diff --git a/ostp-client/src/tunnel/inbounds/tun.rs b/ostp-client/src/tunnel/inbounds/tun.rs index cbf5d21..26b8049 100644 --- a/ostp-client/src/tunnel/inbounds/tun.rs +++ b/ostp-client/src/tunnel/inbounds/tun.rs @@ -89,6 +89,7 @@ pub async fn run_tun_inbound( let async_fd_shared = std::sync::Arc::new(async_fd); let afd1 = async_fd_shared.clone(); + let m_sent = metrics.clone(); let tun_to_stack = tokio::spawn(async move { let mut frame = vec![0u8; 65535]; loop { @@ -104,6 +105,8 @@ pub async fn run_tun_inbound( } else { Ok(res as isize) } }) { Ok(Ok(n)) if n > 0 => { + // Bytes leaving the device toward the tunnel = upload. + m_sent.bytes_sent.fetch_add(n as u64, Ordering::Relaxed); if let Err(_) = stack_sink.send(frame[..n as usize].to_vec()).await { break; } } Ok(Ok(_)) => break, @@ -114,8 +117,11 @@ pub async fn run_tun_inbound( }); let afd2 = async_fd_shared.clone(); + let m_recv = metrics.clone(); let stack_to_tun = tokio::spawn(async move { while let Some(Ok(frame)) = stack_stream.next().await { + // Bytes arriving from the tunnel toward the device = download. + m_recv.bytes_recv.fetch_add(frame.len() as u64, Ordering::Relaxed); let mut written = 0; while written < frame.len() { let mut guard = match afd2.writable().await { diff --git a/ostp-client/src/tunnel/outbounds/ostp.rs b/ostp-client/src/tunnel/outbounds/ostp.rs index e735f73..9b63f8d 100644 --- a/ostp-client/src/tunnel/outbounds/ostp.rs +++ b/ostp-client/src/tunnel/outbounds/ostp.rs @@ -289,6 +289,7 @@ pub async fn handle_udp( } // Send handshake first + let hs_start = std::time::Instant::now(); if let Ok(action) = machine.on_event(OstpEvent::Start) { handle_udp_action(action, &transport).await; } @@ -300,6 +301,14 @@ pub async fn handle_udp( transport.recv(&mut buf), ).await { Ok(Ok(n)) => { + // Real round-trip to the server over the already-protected transport. + // On Android the separate health-probe socket is NOT VPN-protected + // (it would route into the tunnel and never reach the server), so this + // handshake timing is the only reliable RTT source on mobile. We only + // set rtt here; connection_state stays owned by the health probe. + if let Some(m) = &metrics { + m.rtt_ms.store(hs_start.elapsed().as_millis() as u32, std::sync::atomic::Ordering::Relaxed); + } let _ = machine.on_event(OstpEvent::Inbound(bytes::Bytes::copy_from_slice(&buf[..n]))); } _ => { diff --git a/ostp-flutter/lib/ui/home_screen.dart b/ostp-flutter/lib/ui/home_screen.dart index a4136de..26f7899 100644 --- a/ostp-flutter/lib/ui/home_screen.dart +++ b/ostp-flutter/lib/ui/home_screen.dart @@ -183,7 +183,21 @@ class _HomeScreenState extends State with TickerProviderStateMixin { outbounds.add({"type": "direct", "tag": "direct"}); outbounds.add({"type": "block", "tag": "block"}); + // Exclusions → "direct". Keys MUST match the Rust RoutingRule fields + // (domain_suffix / ip_cidr / process_name). The old "domains/ips/processes" + // keys did not match the struct and were silently dropped, so exclusions + // never applied. Only push a rule that actually has entries — a rule with an + // empty list never matches anyway. + final exDomainsList = exDomains.split('\n').where((s) => s.trim().isNotEmpty).toList(); + final exIpsList = exIps.split('\n').where((s) => s.trim().isNotEmpty).toList(); + final exProcessesList = exProcesses.split('\n').where((s) => s.trim().isNotEmpty).toList(); + final List> routingRules = []; + if (exDomainsList.isNotEmpty) routingRules.add({"domain_suffix": exDomainsList, "outbound": "direct"}); + if (exIpsList.isNotEmpty) routingRules.add({"ip_cidr": exIpsList, "outbound": "direct"}); + if (exProcessesList.isNotEmpty) routingRules.add({"process_name": exProcessesList, "outbound": "direct"}); + final configMap = { + "mode": "client", "version": "0.3.20", "log": { "level": debugMode ? "debug" : "info" @@ -191,14 +205,8 @@ class _HomeScreenState extends State with TickerProviderStateMixin { "inbounds": inbounds, "outbounds": outbounds, "routing": { - "rules": [ - { - "domains": exDomains.split('\n').where((s) => s.trim().isNotEmpty).toList(), - "ips": exIps.split('\n').where((s) => s.trim().isNotEmpty).toList(), - "processes": exProcesses.split('\n').where((s) => s.trim().isNotEmpty).toList(), - "outbound": "direct" - } - ] + "rules": routingRules, + "default_outbound": "proxy" }, "app_rules": { "mode": appRoutingMode, diff --git a/ostp-flutter/lib/ui/settings_screen.dart b/ostp-flutter/lib/ui/settings_screen.dart index 8d0cfbf..416be24 100644 --- a/ostp-flutter/lib/ui/settings_screen.dart +++ b/ostp-flutter/lib/ui/settings_screen.dart @@ -141,6 +141,62 @@ class _SettingsScreenState extends State { } } + String _buildShareLink(OstpProfile p) { + final type = p.transportMode == 'uot' ? 'tcp' : 'udp'; + final key = Uri.encodeComponent(p.accessKey); + final name = Uri.encodeComponent(p.name); + return 'ostp://$key@${p.serverAddr}?type=$type#$name'; + } + + void _shareProfile(OstpProfile p) { + final link = _buildShareLink(p); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: Theme.of(ctx).colorScheme.surface, + title: const Text('Share profile'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: QrImageView( + data: link, + size: 200, + backgroundColor: Colors.white, + ), + ), + const SizedBox(height: 16), + SelectableText( + link, + textAlign: TextAlign.center, + style: const TextStyle(fontFamily: 'monospace', fontSize: 11), + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + Clipboard.setData(ClipboardData(text: link)); + ScaffoldMessenger.of(ctx).showSnackBar( + const SnackBar(content: Text('Copied')), + ); + }, + child: const Text('Copy link'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Close'), + ), + ], + ), + ); + } + void _showAddProfileMenu() { showModalBottomSheet( context: context, @@ -395,9 +451,19 @@ class _SettingsScreenState extends State { ), title: Text(p.name, style: const TextStyle(fontWeight: FontWeight.bold)), subtitle: Text('${p.serverAddr} (${p.transportMode.toUpperCase()})', style: const TextStyle(fontSize: 12)), - trailing: IconButton( - icon: const Icon(Icons.edit, size: 20, color: Colors.white54), - onPressed: () => _showEditProfileDialog(p), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.qr_code_2, size: 20, color: Colors.white54), + tooltip: 'Share', + onPressed: () => _shareProfile(p), + ), + IconButton( + icon: const Icon(Icons.edit, size: 20, color: Colors.white54), + onPressed: () => _showEditProfileDialog(p), + ), + ], ), onTap: () { setState(() { diff --git a/ostp-gui/src-tauri/Cargo.lock b/ostp-gui/src-tauri/Cargo.lock index f36661a..e44e082 100644 --- a/ostp-gui/src-tauri/Cargo.lock +++ b/ostp-gui/src-tauri/Cargo.lock @@ -2665,7 +2665,7 @@ dependencies = [ [[package]] name = "ostp-client" -version = "0.3.18" +version = "0.3.21" dependencies = [ "anyhow", "base64 0.22.1", @@ -2700,7 +2700,7 @@ dependencies = [ [[package]] name = "ostp-core" -version = "0.3.18" +version = "0.3.21" dependencies = [ "anyhow", "byteorder", @@ -2729,6 +2729,7 @@ dependencies = [ "ostp-client", "ostp-core", "portable-atomic", + "qrcode", "rand", "serde", "serde_json", @@ -2742,7 +2743,7 @@ dependencies = [ [[package]] name = "ostp-tun" -version = "0.3.18" +version = "0.3.21" dependencies = [ "anyhow", "libc", @@ -3101,6 +3102,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" + [[package]] name = "quick-xml" version = "0.39.4" diff --git a/ostp-gui/src-tauri/Cargo.toml b/ostp-gui/src-tauri/Cargo.toml index 09201f3..400539e 100644 --- a/ostp-gui/src-tauri/Cargo.toml +++ b/ostp-gui/src-tauri/Cargo.toml @@ -33,4 +33,5 @@ rand = "0.8" chacha20poly1305 = "0.10" sha2 = "0.10" hex = "0.4.3" +qrcode = { version = "0.14", default-features = false, features = ["svg"] } diff --git a/ostp-gui/src-tauri/src/lib.rs b/ostp-gui/src-tauri/src/lib.rs index 7e2c7b4..d2e9194 100644 --- a/ostp-gui/src-tauri/src/lib.rs +++ b/ostp-gui/src-tauri/src/lib.rs @@ -416,6 +416,20 @@ async fn stop_tunnel(state: tauri::State<'_, AppState>) -> Result Ok(true) } +#[tauri::command] +fn generate_qr(text: String) -> Result { + // Render the share link to an SVG QR locally — the access key never leaves + // the device (unlike an online QR service). + let code = qrcode::QrCode::new(text.as_bytes()).map_err(|e| e.to_string())?; + let svg = code + .render::() + .min_dimensions(220, 220) + .dark_color(qrcode::render::svg::Color("#000000")) + .light_color(qrcode::render::svg::Color("#ffffff")) + .build(); + Ok(svg) +} + #[tauri::command] async fn start_tunnel(state: tauri::State<'_, AppState>, app: tauri::AppHandle) -> Result { let mut guard = state.0.lock().await; @@ -888,7 +902,7 @@ pub fn run() { } _ => {} }) - .invoke_handler(tauri::generate_handler![start_tunnel, stop_tunnel, reload_tunnel, get_tunnel_status, get_metrics, get_config, save_config, get_wintun_install_path, set_autostart, get_autostart, list_running_processes, dns_prober::run_dns_prober]) + .invoke_handler(tauri::generate_handler![start_tunnel, stop_tunnel, reload_tunnel, get_tunnel_status, get_metrics, get_config, save_config, get_wintun_install_path, set_autostart, get_autostart, list_running_processes, dns_prober::run_dns_prober, generate_qr]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/ostp-gui/src/main.js b/ostp-gui/src/main.js index a0d6ab8..5c78949 100644 --- a/ostp-gui/src/main.js +++ b/ostp-gui/src/main.js @@ -432,7 +432,16 @@ async function handleToggle() { serverAddr = cfg.server || ''; } catch { serverAddr = ''; } + const activeProfiles = profiles.filter(p => p.active); + if (activeProfiles.length === 0) { + showToast('Select at least one profile in Settings', 'error'); + return; + } + setState('connecting'); + // Rebuild config.json from the current active profiles so a freshly added, + // edited or toggled profile actually takes effect on connect. + await handleSave(true); try { console.log('[OSTP] invoking start_tunnel...'); @@ -482,82 +491,40 @@ async function loadConfigIntoForm() { const c = rawConfig.mode === 'client' ? rawConfig : null; if (!c) return; - if (c.version === '0.3.1' || c.outbounds !== undefined) { - // NEW FORMAT - const ostpOut = (c.outbounds || []).find(o => o.type === 'ostp'); - if (ostpOut) { - inServer.value = ostpOut.server ? `${ostpOut.server}:${ostpOut.port || 50000}` : ''; - inKey.value = ostpOut.access_key || ''; - inTransport.value = ostpOut.transport?.type || 'udp'; - if (inTransport.value === 'dns') { - groupDnsProxy.style.display = 'flex'; - inDnsDomain.value = ostpOut.transport?.domain || ''; - inDnsRegion.value = ostpOut.transport?.resolver || 'Global'; - } else { - groupDnsProxy.style.display = 'none'; - } - inMux.checked = !!ostpOut.multiplex?.enabled; - inMuxSessions.value = ostpOut.multiplex?.sessions || ''; - } - - const tunIn = (c.inbounds || []).find(i => i.type === 'tun'); - if (tunIn) { - inTun.checked = true; - inMtu.value = tunIn.mtu || ''; - } else { - inTun.checked = false; - } - - const socksIn = (c.inbounds || []).find(i => i.type === 'local_proxy'); - if (socksIn) { - inSocks.value = `${socksIn.listen || '127.0.0.1'}:${socksIn.port || 1088}`; - } - - inDns.value = ''; // DNS handling is manual in routing now, ignore here - if (inKillSwitch) inKillSwitch.checked = !!c.gui?.kill_switch; - inDebug.checked = c.log?.level === 'debug'; - - const ex = c.routing?.rules || []; - const doms = new Set(); - const ips = new Set(); - const procs = new Set(); - ex.forEach(r => { - if (r.outbound === 'direct') { - if (r.domain_suffix) r.domain_suffix.forEach(d => doms.add(d)); - if (r.ip_cidr) r.ip_cidr.forEach(ip => ips.add(ip)); - if (r.process_name) r.process_name.forEach(p => procs.add(p)); - } - }); - tagState.domains = doms; - tagState.ips = ips; - tagState.processes = procs; + // Restore the profile list from persisted GUI state. + profiles = rawConfig.gui?.profiles || []; + renderProfiles(); + const tunIn = (c.inbounds || []).find(i => i.type === 'tun'); + if (tunIn) { + inTun.checked = true; + inMtu.value = tunIn.mtu || ''; } else { - // OLD FORMAT - inServer.value = c.server || ''; - inKey.value = c.access_key || ''; - inSocks.value = c.socks5_bind || '127.0.0.1:1088'; - inTransport.value = c.transport?.mode || 'udp'; - if (inTransport.value === 'dns') { - groupDnsProxy.style.display = 'block'; - } else { - groupDnsProxy.style.display = 'none'; - } - - inMtu.value = c.mtu || ''; - inTun.checked = !!c.tun?.enable; - if (inKillSwitch) inKillSwitch.checked = !!c.tun?.kill_switch; - inMux.checked = !!c.mux?.enabled; - inMuxSessions.value = c.mux?.sessions || ''; - - inDns.value = c.tun?.dns || ''; - inDebug.checked = !!c.debug; - - const ex = c.exclude || {}; - tagState.domains = new Set(ex.domains || []); - tagState.ips = new Set(ex.ips || []); - tagState.processes = new Set(ex.processes || []); + inTun.checked = false; } + + const socksIn = (c.inbounds || []).find(i => i.type === 'local_proxy'); + if (socksIn) { + inSocks.value = `${socksIn.listen || '127.0.0.1'}:${socksIn.port || 1088}`; + } + + if (inKillSwitch) inKillSwitch.checked = !!c.gui?.kill_switch; + inDebug.checked = c.log?.level === 'debug'; + + const ex = c.routing?.rules || []; + const doms = new Set(); + const ips = new Set(); + const procs = new Set(); + ex.forEach(r => { + if (r.outbound === 'direct') { + if (r.domain_suffix) r.domain_suffix.forEach(d => doms.add(d)); + if (r.ip_cidr) r.ip_cidr.forEach(ip => ips.add(ip)); + if (r.process_name) r.process_name.forEach(p => procs.add(p)); + } + }); + tagState.domains = doms; + tagState.ips = ips; + tagState.processes = procs; if (inAutoconnect) inAutoconnect.checked = !!c.gui?.autoconnect; if (inLaunchStartup) inLaunchStartup.checked = !!c.gui?.launch_startup; @@ -581,18 +548,15 @@ function scheduleAutoSave() { async function handleSave(silent = false) { if (!rawConfig) rawConfig = { mode: 'client', log_level: 'info' }; - const server = inServer.value.trim(); - const key = inKey.value.trim(); - - if (!server) { if (!silent) showToast(t('err_server_req') || 'Server address required', 'error'); return; } - if (!key) { if (!silent) showToast(t('err_key_req') || 'Access key required', 'error'); return; } - if (inLaunchStartup) { try { await invoke('set_autostart', { enable: inLaunchStartup.checked }); } catch (err) { console.error('autostart error', err); } } - const sHost = server.includes(':') ? server.substring(0, server.lastIndexOf(':')) : server; - const sPort = server.includes(':') ? parseInt(server.substring(server.lastIndexOf(':') + 1), 10) : 50000; + const activeProfiles = profiles.filter(p => p.active); + if (activeProfiles.length === 0) { + if (!silent) showToast('Please select at least one profile in Settings', 'error'); + return; + } const socksStr = inSocks.value.trim() || '127.0.0.1:1088'; const socksHost = socksStr.includes(':') ? socksStr.substring(0, socksStr.lastIndexOf(':')) : '127.0.0.1'; @@ -616,26 +580,42 @@ async function handleSave(silent = false) { }); } - const outbounds = [ - { + const outbounds = []; + const ostpTags = []; + + activeProfiles.forEach((p, i) => { + const tag = activeProfiles.length > 1 ? `proxy-${i}` : 'proxy'; + ostpTags.push(tag); + + const parts = p.serverAddr.split(':'); + const host = parts[0] || '127.0.0.1'; + const port = parseInt(parts[1]) || 50000; + + outbounds.push({ type: "ostp", - tag: "proxy", - server: sHost, - port: sPort, - access_key: key, - transport: { - type: inTransport.value, - domain: inTransport.value === 'dns' ? inDnsDomain.value.trim() : undefined, - resolver: inTransport.value === 'dns' ? inDnsRegion.value : undefined - }, + tag: tag, + server: host, + port: port, + access_key: p.accessKey, + transport: { type: p.transportMode }, multiplex: inMux.checked ? { enabled: true, sessions: parseInt(inMuxSessions.value, 10) || 1 } : { enabled: false, sessions: 1 } - }, - { type: "direct", tag: "direct" }, - { type: "block", tag: "block" } - ]; + }); + }); + + if (activeProfiles.length > 1) { + outbounds.push({ + type: "urltest", + tag: "proxy", + outbounds: ostpTags, + url: "http://cp.cloudflare.com", + interval: "3m" + }); + } + + outbounds.push({ type: "direct", tag: "direct" }, { type: "block", tag: "block" }); const rules = []; if (tagState.domains.size > 0) rules.push({ domain_suffix: Array.from(tagState.domains), outbound: "direct" }); @@ -660,42 +640,36 @@ async function handleSave(silent = false) { if (inAutoconnect) rawConfig.gui.autoconnect = inAutoconnect.checked; if (inLaunchStartup) rawConfig.gui.launch_startup = inLaunchStartup.checked; if (inKillSwitch) rawConfig.gui.kill_switch = inKillSwitch.checked; + // Persist the full profile list (including inactive ones) so it survives restarts. + rawConfig.gui.profiles = profiles; try { const ok = await invoke('save_config', { jsonContent: JSON.stringify(rawConfig, null, 2) }); if (!ok && !silent) { showToast(t('toast_error'), 'error'); } else if (ok && appState === 'connected') { - // Hot-reload exclusions into the running tunnel (no reconnect needed) - try { await invoke('reload_tunnel'); } catch { /* ignore */ } + try { await invoke('reload_tunnel'); } catch { } } } catch (err) { if (!silent) showToast(String(err), 'error'); } } +// Called by profile add/edit/toggle/delete. saveSettings() was referenced but +// never defined — every profile mutation threw ReferenceError, so nothing +// persisted and the list never re-rendered. It rebuilds config.json (outbounds +// from active profiles) and persists the profile list via handleSave. +function saveSettings() { handleSave(true); } + + // ── Import share link ───────────────────────────────────────────────────────── function handleImport() { const raw = importInput.value.trim(); if (!raw) return; try { - if (!raw.startsWith('ostp://')) throw new Error('Link must start with ostp://'); - const url = new URL(raw); - const key = decodeURIComponent(url.username); - const host = url.host; - if (!key || !host) throw new Error('Incomplete link parameters'); - inServer.value = host; - inKey.value = key; - inTransport.value = 'udp'; - groupDnsProxy.style.display = 'none'; - - const type = url.searchParams.get('type'); - if (type === 'tcp' || type === 'http') inTransport.value = 'uot'; - else inTransport.value = 'udp'; - + createProfileFromLink(raw); importInput.value = ''; - showToast(t('toast_imported'), 'ok'); - handleSave(false); + showToast(t('toast_imported') || 'Profile added', 'ok'); } catch (err) { showToast(err.message, 'error'); } @@ -736,10 +710,10 @@ window.addEventListener('DOMContentLoaded', async () => { } catch { /* ignore */ } } - // Auto-connect on startup + // Load persisted config (form fields + profiles) so connect has the right + // inbounds/outbounds even if the user never opens Settings, then auto-connect. try { - const raw = await invoke('get_config'); - rawConfig = parseJsonc(raw); + await loadConfigIntoForm(); if (rawConfig?.gui?.autoconnect) { setTimeout(() => { if (appState === 'disconnected') handleToggle(); @@ -829,8 +803,10 @@ window.addEventListener('DOMContentLoaded', async () => { btnGoSettings.addEventListener('click', () => showScreen('settings')); btnBack.addEventListener('click', () => showScreen('home')); - btnImport.addEventListener('click', handleImport); - btnPeekKey.addEventListener('click', togglePeek); + if (btnImport) btnImport.addEventListener('click', handleImport); + // btn-peek-key / in-key etc. were removed with the old single-server UI. + // Guard so a missing element can't throw and abort the rest of init wiring. + if (btnPeekKey) btnPeekKey.addEventListener('click', togglePeek); // Theme toggle const btnThemeToggle = $('btn-theme-toggle'); @@ -928,23 +904,29 @@ window.addEventListener('DOMContentLoaded', async () => { }); +function escapeHtml(s) { + return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +} + function renderProfiles() { - if (profiles.length === 0) { - profilesList.innerHTML = ''; - profilesEmpty.style.display = 'block'; - } else { - profilesEmpty.style.display = 'none'; - profilesList.innerHTML = profiles.map(p => ` + const hasProfiles = profiles.length > 0; + const rowsHtml = profiles.map(p => `
-
${p.name}
-
${p.serverAddr}
+
${escapeHtml(p.name)}
+
${escapeHtml(p.serverAddr)}
- -
- `).join(''); - } + + + `).join(''); + + // Profiles live on the Settings page. + if (profilesList) profilesList.innerHTML = rowsHtml; + if (profilesEmpty) profilesEmpty.style.display = hasProfiles ? 'none' : 'block'; + // Settings page shows only "Create a new profile" + "+" until a profile exists. + const csb = $('client-settings-block'); + if (csb) csb.style.display = hasProfiles ? '' : 'none'; } window.toggleProfile = function(id) { @@ -964,18 +946,99 @@ window.editProfile = function(id) { profileModal.classList.remove('hidden'); }; -if (btnAddProfile) { - btnAddProfile.addEventListener('click', () => { - editingProfileId = null; - $('profile-modal-title').innerText = 'New Profile'; - inProfName.value = ''; - inProfServer.value = ''; - inProfKey.value = ''; - inProfTransport.value = 'udp'; - btnProfDelete.style.display = 'none'; - profileModal.classList.remove('hidden'); - }); +function openProfileModalNew() { + editingProfileId = null; + $('profile-modal-title').innerText = 'New Profile'; + inProfName.value = ''; + inProfServer.value = ''; + inProfKey.value = ''; + inProfTransport.value = 'udp'; + if (btnProfDelete) btnProfDelete.style.display = 'none'; + profileModal.classList.remove('hidden'); } +if (btnAddProfile) btnAddProfile.addEventListener('click', () => { const m = $('add-menu'); if (m) m.classList.remove('hidden'); }); + +// Build / parse the ostp:// share link (key in userinfo, host:port authority, +// transport in ?type, name in #fragment). +function buildShareLink(p) { + const type = p.transportMode === 'uot' ? 'tcp' : 'udp'; + return `ostp://${encodeURIComponent(p.accessKey)}@${p.serverAddr}?type=${type}#${encodeURIComponent(p.name || '')}`; +} +function createProfileFromLink(raw) { + if (!raw.startsWith('ostp://')) throw new Error('Link must start with ostp://'); + const url = new URL(raw); + const key = decodeURIComponent(url.username || ''); + const host = url.host; + if (!key || !host) throw new Error('Incomplete link'); + const type = url.searchParams.get('type'); + const transportMode = (type === 'tcp' || type === 'http') ? 'uot' : 'udp'; + let name = url.searchParams.get('name'); + if (!name && url.hash) { try { name = decodeURIComponent(url.hash.slice(1)); } catch {} } + profiles.push({ + id: Date.now().toString(), + name: name || host, + serverAddr: host, + accessKey: key, + transportMode, + active: profiles.length === 0, + }); + saveSettings(); + renderProfiles(); +} + +// Share a profile: show its link + a scannable QR (QR rendered by the Rust +// `generate_qr` command so the access key never leaves the device). +window.shareProfile = async function(id) { + const p = profiles.find(x => x.id === id); + if (!p) return; + const link = buildShareLink(p); + const linkInput = $('share-link'); + if (linkInput) linkInput.value = link; + const qrBox = $('share-qr'); + if (qrBox) { + qrBox.innerHTML = '
Generating…
'; + try { + qrBox.innerHTML = await invoke('generate_qr', { text: link }); + } catch { + qrBox.innerHTML = '
QR unavailable — rebuild app
'; + } + } + const m = $('share-modal'); + if (m) m.classList.remove('hidden'); +}; + +// ── Add-profile menu + link/share modal wiring ──────────────────── +(function wireProfileMenus() { + const addMenu = $('add-menu'); + const closeAddMenu = () => { if (addMenu) addMenu.classList.add('hidden'); }; + // Clicking the overlay (outside the card) closes the menu. + if (addMenu) addMenu.addEventListener('click', (e) => { if (e.target === addMenu) closeAddMenu(); }); + if ($('btn-add-cancel')) $('btn-add-cancel').addEventListener('click', closeAddMenu); + + const linkModal = $('link-modal'); + if ($('btn-add-from-link')) $('btn-add-from-link').addEventListener('click', () => { + closeAddMenu(); + if ($('in-link')) $('in-link').value = ''; + if (linkModal) linkModal.classList.remove('hidden'); + }); + if ($('btn-add-manual')) $('btn-add-manual').addEventListener('click', () => { + closeAddMenu(); + openProfileModalNew(); + }); + if ($('btn-link-cancel')) $('btn-link-cancel').addEventListener('click', () => { if (linkModal) linkModal.classList.add('hidden'); }); + if ($('btn-link-import')) $('btn-link-import').addEventListener('click', () => { + const raw = ($('in-link')?.value || '').trim(); + if (!raw) return; + try { createProfileFromLink(raw); if (linkModal) linkModal.classList.add('hidden'); showToast(t('toast_imported') || 'Profile added', 'ok'); } + catch (err) { showToast(err.message, 'error'); } + }); + // Share modal + if ($('btn-share-close')) $('btn-share-close').addEventListener('click', () => { const m = $('share-modal'); if (m) m.classList.add('hidden'); }); + if ($('btn-share-copy')) $('btn-share-copy').addEventListener('click', () => { + const v = $('share-link')?.value || ''; + if (v) { navigator.clipboard?.writeText(v); showToast('Copied', 'ok'); } + }); +})(); if (btnProfCancel) btnProfCancel.addEventListener('click', () => profileModal.classList.add('hidden')); diff --git a/ostp-gui/src/styles.css b/ostp-gui/src/styles.css index e14b4f0..5eb3e37 100644 --- a/ostp-gui/src/styles.css +++ b/ostp-gui/src/styles.css @@ -227,6 +227,8 @@ html[data-theme="light"] .watermark { .screen { position: absolute; inset: 0; + height: 100%; + overflow: hidden; z-index: 2; display: flex; flex-direction: column; @@ -697,15 +699,21 @@ html[data-theme="light"] .watermark { .card.scrollable { flex: 1; - min-height: 0; /* required so overflow-y:auto actually scrolls inside the flex column */ + min-height: 0; overflow-y: auto; overflow-x: hidden; scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.08) transparent; + display: block; /* Force block layout to avoid flexbox nested scrolling issues */ } .card.scrollable > * { flex-shrink: 0; + margin-bottom: 14px; /* Emulate gap from .card */ +} + +.card.scrollable > *:last-child { + margin-bottom: 0; } .card.scrollable::-webkit-scrollbar { width: 3px; }