// ── Tauri invoke shim ───────────────────────────────────────────────── let invoke = () => Promise.resolve(null); if (window.__TAURI__?.core) { invoke = window.__TAURI__.core.invoke; } // ── Theme: apply saved theme ASAP (before first paint) to avoid a flash ─ if (localStorage.getItem('ostp_theme') === 'light') { document.documentElement.classList.add('light'); } // ── PROFILE STORE ───────────────────────────────────────────────────── // Profiles are stored in localStorage only — the core never knows about them. // Only the active profile is compiled into a config and passed to Tauri. // // Profile shape: // { id: string, name: string, server: string, key: string, transport: 'udp'|'uot' } const PROFILES_KEY = 'ostp_profiles_v1'; const ACTIVE_KEY = 'ostp_active_profile'; const SETTINGS_KEY = 'ostp_client_settings'; function loadProfiles() { try { return JSON.parse(localStorage.getItem(PROFILES_KEY) || '[]'); } catch { return []; } } function saveProfiles(profiles) { localStorage.setItem(PROFILES_KEY, JSON.stringify(profiles)); } function loadActiveId() { return localStorage.getItem(ACTIVE_KEY) || null; } function saveActiveId(id) { localStorage.setItem(ACTIVE_KEY, id || ''); } function loadClientSettings() { try { return JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}'); } catch { return {}; } } function saveClientSettings(s) { localStorage.setItem(SETTINGS_KEY, JSON.stringify(s)); } function genId() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 6); } // ── APP STATE ───────────────────────────────────────────────────────── let appState = 'disconnected'; // 'disconnected'|'connecting'|'connected' let pollTimer = null; let uptimeSecs = 0; let uptimeTimer = null; // for throughput calc let prevBytesRecv = 0, prevBytesSent = 0; // profiles let profiles = loadProfiles(); let activeId = loadActiveId(); // editor state let editingProfileId = null; // null = new profile // ── DOM ─────────────────────────────────────────────────────────────── const $ = id => document.getElementById(id); const homeScreen = $('home-screen'); const settingsScreen = $('settings-screen'); const brandDot = $('brand-dot'); const orbitWrap = $('orbit-wrap'); const btnConnect = $('btn-connect'); const statusText = $('status-text'); const uptimeText = $('uptime-text'); const errorBanner = $('error-banner'); const connInfo = $('connection-info'); const serverBadge = $('server-badge-text'); const liveRtt = $('live-rtt'); const liveDown = $('live-down-speed'); const liveUp = $('live-up-speed'); const metricDown = $('metric-down'); const metricUp = $('metric-up'); const toast = $('toast'); const btnGoSettings = $('btn-go-settings'); const btnAutoConnect = $('btn-auto-connect'); const btnBack = $('btn-back'); const btnAddProfile = $('btn-add-profile'); const addMenu = $('add-menu'); const profileList = $('profile-list'); const profileEmpty = $('profile-empty'); // add menu const addFromLink = $('add-from-link'); const addFromClipboard = $('add-from-clipboard'); const addManually = $('add-manually'); // link modal const linkModal = $('link-modal'); const linkInput = $('link-input'); const btnLinkCancel = $('btn-link-cancel'); const btnLinkImport = $('btn-link-import'); // profile editor modal const profileModal = $('profile-modal'); const profileModalTitle = $('profile-modal-title'); const pmName = $('pm-name'); const pmServer = $('pm-server'); const pmKey = $('pm-key'); const pmTransport = $('pm-transport'); const pmTcpFrag = $('pm-tcp-frag'); const pmFragChunk = $('pm-frag-chunk'); const pmFragSleep = $('pm-frag-sleep'); const pmJunkPcMin = $('pm-junk-pc-min'); const pmJunkPcMax = $('pm-junk-pc-max'); const pmJunkPsMin = $('pm-junk-ps-min'); const pmJunkPsMax = $('pm-junk-ps-max'); const pmTcpSettings = $('pm-tcp-settings'); const pmFragDetails = $('pm-frag-details'); const btnProfileCancel = $('btn-profile-cancel'); const btnProfileSave = $('btn-profile-save'); const btnProfileDelete = $('btn-profile-delete'); const btnPeekPm = $('btn-peek-pm'); // share modal const shareModal = $('share-modal'); const shareQr = $('share-qr'); const shareLink = $('share-link'); const btnShareClose = $('btn-share-close'); const btnShareCopy = $('btn-share-copy'); // wintun modal const wintunModal = $('wintun-modal'); const wintunPath = $('wintun-install-path'); const btnWintunCancel = $('btn-wintun-cancel'); const btnWintunOpen = $('btn-wintun-open'); // client settings const inTun = $('in-tun-mode'); const inKillSwitch = $('in-kill-switch'); const inMux = $('in-mux-mode'); const inMuxSessions = $('in-mux-sessions'); const inMtu = $('in-mtu'); const inDns = $('in-dns'); const inSocks = $('in-socks'); const inExDomains = $('in-ex-domains'); const inExIps = $('in-ex-ips'); const inExProcs = $('in-ex-procs'); const inAutoconnect = $('in-autoconnect'); const inLaunchStartup = $('in-launch-startup'); const inDebug = $('in-debug'); const inShowRtt = $('in-show-rtt'); const inShowSpeed = $('in-show-speed'); const groupKillSwitch = $('group-kill-switch'); const groupMuxSessions = $('group-mux-sessions'); const inJunkEnabled = $('cs-junk-enabled'); const btnJunkSettings = $('btn-junk-settings'); const junkModal = $('junk-modal'); const inJunkPcMin = $('cs-junk-pc-min'); const inJunkPcMax = $('cs-junk-pc-max'); const inJunkPsMin = $('cs-junk-ps-min'); const inJunkPsMax = $('cs-junk-ps-max'); const btnJunkDone = $('btn-junk-done'); const inTcpFrag = $('cs-tcp-frag'); const btnFragSettings = $('btn-frag-settings'); const fragModal = $('frag-modal'); const inFragChunk = $('cs-frag-chunk'); const inFragSleep = $('cs-frag-sleep'); const btnFragDone = $('btn-frag-done'); // ── UTILITIES ───────────────────────────────────────────────────────── function fmtBytes(b) { if (!b || b === 0) return '0 B'; const units = ['B', 'KB', 'MB', 'GB']; const i = Math.min(Math.floor(Math.log2(b) / 10), 3); return (b / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1) + ' ' + units[i]; } function fmtTime(s) { const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60; const p = n => String(n).padStart(2, '0'); return h > 0 ? `${h}:${p(m)}:${p(sec)}` : `${p(m)}:${p(sec)}`; } let toastTimer = null; function showToast(msg, variant = '') { toast.textContent = msg; toast.className = 'toast show' + (variant ? ' is-' + variant : ''); clearTimeout(toastTimer); toastTimer = setTimeout(() => toast.classList.remove('show'), 2600); } function showError(msg) { errorBanner.textContent = msg; errorBanner.classList.remove('hidden'); btnConnect.classList.add('error'); setTimeout(() => { errorBanner.classList.add('hidden'); btnConnect.classList.remove('error'); }, 5000); } // ── STATE MACHINE ───────────────────────────────────────────────────── function setState(next) { if (appState === next) return; appState = next; btnConnect.className = 'power-btn'; orbitWrap.className = 'orbit-wrap'; brandDot.className = 'brand-dot'; statusText.className = 'status-label'; if (next === 'disconnected') { statusText.textContent = 'Disconnected'; uptimeText.textContent = 'Tap to protect your traffic'; connInfo.classList.add('hidden'); metricDown.textContent = liveDown.textContent = '0 B'; metricUp.textContent = liveUp.textContent = '0 B'; liveRtt.textContent = '--'; liveRtt.className = 'live-stat-value'; prevBytesRecv = prevBytesSent = 0; clearInterval(pollTimer); pollTimer = null; clearInterval(uptimeTimer); uptimeTimer = null; uptimeSecs = 0; } else if (next === 'connecting') { btnConnect.classList.add('connecting'); orbitWrap.classList.add('connecting'); brandDot.classList.add('connecting'); statusText.classList.add('is-connecting'); statusText.textContent = 'Connecting…'; uptimeText.textContent = 'Establishing secure tunnel'; connInfo.classList.add('hidden'); clearInterval(uptimeTimer); uptimeTimer = null; uptimeSecs = 0; } else if (next === 'connected') { btnConnect.classList.add('connected'); orbitWrap.classList.add('connected'); brandDot.classList.add('connected'); statusText.classList.add('is-connected'); statusText.textContent = 'Connected'; const active = profiles.find(p => p.id === activeId); if (active) { serverBadge.textContent = active.server; connInfo.classList.remove('hidden'); } uptimeSecs = 0; statusText.textContent = 'Connected'; uptimeTimer = setInterval(() => { uptimeSecs++; uptimeText.textContent = fmtTime(uptimeSecs); }, 1000); } } // ── POLLING ─────────────────────────────────────────────────────────── async function poll() { if (!pollTimer) return; try { const code = await invoke('get_tunnel_status'); if (!pollTimer) return; if (code === 0) { setState('disconnected'); return; } else if (code === 1) setState('connecting'); else if (code === 2) setState('connected'); const metrics = await invoke('get_metrics'); if (metrics && pollTimer) { const recv = metrics.bytes_recv || 0; const sent = metrics.bytes_sent || 0; const rtt = metrics.rtt_ms || 0; // Total bytes metricDown.textContent = fmtBytes(recv); metricUp.textContent = fmtBytes(sent); // Throughput (delta per second) const dRecv = Math.max(0, recv - prevBytesRecv); const dSent = Math.max(0, sent - prevBytesSent); prevBytesRecv = recv; prevBytesSent = sent; liveDown.textContent = fmtBytes(dRecv) + '/s'; liveUp.textContent = fmtBytes(dSent) + '/s'; // RTT coloring if (rtt > 0) { liveRtt.textContent = rtt + ' ms'; liveRtt.className = 'live-stat-value ' + (rtt < 100 ? 'rtt-good' : rtt < 250 ? 'rtt-warn' : 'rtt-bad'); } } } catch (err) { console.error('[OSTP] poll error:', err); if (pollTimer) setState('disconnected'); } } function startPolling() { clearInterval(pollTimer); poll(); pollTimer = setInterval(poll, 1000); } // ── BUILD CONFIG from active profile + client settings ──────────────── function buildConfig() { const active = profiles.find(p => p.id === activeId); if (!active) return null; const s = loadClientSettings(); const cfg = { mode: 'client', server: active.server, access_key: active.key, socks5_bind: s.socks || null, debug: !!s.debug, transport: { mode: active.transport || 'udp', tcp_fragmentation: s.tcpFrag || !!active.tcp_fragmentation, frag_chunk: s.tcpFrag ? (s.fragChunk || 2) : (active.frag_chunk || 2), frag_sleep: s.tcpFrag ? (!isNaN(parseInt(s.fragSleep)) ? s.fragSleep : 2) : (active.frag_sleep !== undefined ? active.frag_sleep : 2), junk_pc: s.junkEnabled ? [s.junkPcMin || 2, s.junkPcMax || 5] : (active.junk_pc || [2, 5]), junk_ps: s.junkEnabled ? [s.junkPsMin || 100, s.junkPsMax || 1000] : (active.junk_ps || [100, 1000]) }, tun: { enable: !!s.tun, wintun_path: './wintun.dll', ipv4_address: '10.1.0.2/24', stack: 'ostp', dns: s.dns || null, kill_switch: !!s.killSwitch, }, exclude: { domains: s.exDomains ? s.exDomains.split(/[\n,]+/).map(x => x.trim()).filter(Boolean) : [], ips: s.exIps ? s.exIps.split(/[\n,]+/).map(x => x.trim()).filter(Boolean) : [], processes: s.exProcs ? s.exProcs.split(/[\n,]+/).map(x => x.trim()).filter(Boolean) : [], }, mux: s.mux ? { enabled: true, sessions: parseInt(s.muxSessions, 10) || 2 } : undefined, gui: { autoconnect: !!s.autoconnect, launch_startup: !!s.launchStartup, }, }; if (s.mtu) cfg.mtu = parseInt(s.mtu, 10); return cfg; } // ── CONNECT / DISCONNECT ────────────────────────────────────────────── async function handleToggle() { if (appState !== 'disconnected') { setState('disconnected'); try { await invoke('stop_tunnel'); } catch { /* ignore */ } showToast('Disconnected'); return; } if (!activeId || !profiles.find(p => p.id === activeId)) { showToast('Select a profile first', 'error'); return; } const cfg = buildConfig(); if (!cfg) { showToast('Active profile invalid', 'error'); return; } setState('connecting'); errorBanner.classList.add('hidden'); try { await invoke('save_config', { jsonContent: JSON.stringify(cfg, null, 2) }); const ok = await invoke('start_tunnel'); if (ok) { startPolling(); } else { setState('disconnected'); showError('Failed to start tunnel. Check the log file.'); } } catch (err) { setState('disconnected'); const msg = String(err); if (msg.includes('WINTUN_MISSING')) { wintunModal.classList.remove('hidden'); } else { showError(msg); showToast(msg, 'error'); } } } // ── AUTO-CONNECT ────────────────────────────────────────────────────── async function handleAutoConnect() { if (appState !== 'disconnected') { showToast('Disconnect first', 'error'); return; } if (!activeId || !profiles.find(p => p.id === activeId)) { showToast('Select a profile first', 'error'); return; } const modes = ['udp', 'uot']; const mtus = [1500, 1350, 1280]; showToast('Auto-connect: scanning…'); for (const transport of modes) { for (const mtu of mtus) { showToast(`Testing ${transport.toUpperCase()} · MTU ${mtu}`); const active = profiles.find(p => p.id === activeId); const tmpCfg = buildConfig(); if (!tmpCfg) return; tmpCfg.transport.mode = transport; tmpCfg.mtu = mtu; try { await invoke('save_config', { jsonContent: JSON.stringify(tmpCfg, null, 2) }); setState('connecting'); const ok = await invoke('start_tunnel'); if (ok) { await new Promise(r => setTimeout(r, 3000)); const metrics = await invoke('get_metrics'); if (metrics?.rtt_ms > 0) { startPolling(); showToast(`✓ ${transport.toUpperCase()} · MTU ${mtu}`, 'ok'); return; } await invoke('stop_tunnel'); setState('disconnected'); } } catch { setState('disconnected'); } } } showToast('No working config found', 'error'); } // ── SCREEN NAVIGATION ───────────────────────────────────────────────── function showScreen(name) { if (name === 'settings') { loadSettingsIntoForm(); homeScreen.classList.remove('active'); settingsScreen.classList.add('active'); } else { settingsScreen.classList.remove('active'); homeScreen.classList.add('active'); } } // ── PROFILE RENDERING ───────────────────────────────────────────────── function renderProfiles() { // Remove all cards but keep empty-state node Array.from(profileList.querySelectorAll('.profile-card')).forEach(n => n.remove()); if (profiles.length === 0) { profileEmpty.style.display = ''; return; } profileEmpty.style.display = 'none'; profiles.forEach(p => { const card = document.createElement('div'); card.className = 'profile-card' + (p.id === activeId ? ' active' : ''); card.dataset.id = p.id; card.innerHTML = `
${escHtml(p.name || p.server)}
${escHtml(p.server)}
${escHtml(p.transport || 'udp')}
`; // Select profile on card click (not on action buttons) card.addEventListener('click', e => { if (e.target.closest('.profile-action-btn')) return; activeId = p.id; saveActiveId(activeId); renderProfiles(); // Auto-save into config const cfg = buildConfig(); if (cfg) invoke('save_config', { jsonContent: JSON.stringify(cfg, null, 2) }).catch(() => {}); }); card.querySelector('.btn-edit-profile').addEventListener('click', e => { e.stopPropagation(); openProfileEditor(p.id); }); card.querySelector('.btn-share-profile').addEventListener('click', e => { e.stopPropagation(); openShare(p.id); }); profileList.appendChild(card); }); } function escHtml(str) { return String(str).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } // ── PROFILE EDITOR ──────────────────────────────────────────────────── function openProfileEditor(id) { editingProfileId = id || null; if (id) { const p = profiles.find(p => p.id === id); if (!p) return; profileModalTitle.textContent = 'Edit Profile'; pmName.value = p.name || ''; pmServer.value = p.server || ''; pmKey.value = p.key || ''; pmTransport.value = p.transport || 'udp'; pmTcpFrag.checked = !!p.tcp_fragmentation; pmFragChunk.value = p.frag_chunk || 2; pmFragSleep.value = p.frag_sleep || 2; pmJunkPcMin.value = p.junk_pc ? p.junk_pc[0] : 2; pmJunkPcMax.value = p.junk_pc ? p.junk_pc[1] : 5; pmJunkPsMin.value = p.junk_ps ? p.junk_ps[0] : 100; pmJunkPsMax.value = p.junk_ps ? p.junk_ps[1] : 1000; btnProfileDelete.style.display = ''; } else { profileModalTitle.textContent = 'New Profile'; pmName.value = pmServer.value = pmKey.value = ''; pmTransport.value = 'udp'; pmTcpFrag.checked = false; pmFragChunk.value = 2; pmFragSleep.value = 2; pmJunkPcMin.value = 2; pmJunkPcMax.value = 5; pmJunkPsMin.value = 100; pmJunkPsMax.value = 1000; btnProfileDelete.style.display = 'none'; } pmKey.type = 'password'; profileModal.classList.remove('hidden'); pmTransport.dispatchEvent(new Event('change')); pmTcpFrag.dispatchEvent(new Event('change')); setTimeout(() => pmName.focus(), 80); } function saveProfileFromEditor() { const server = pmServer.value.trim(); const key = pmKey.value.trim(); if (!server) { showToast('Server address required', 'error'); return; } if (!key) { showToast('Access key required', 'error'); return; } if (editingProfileId) { const idx = profiles.findIndex(p => p.id === editingProfileId); if (idx >= 0) { profiles[idx] = { ...profiles[idx], name: pmName.value.trim() || server, server, key, transport: pmTransport.value, tcp_fragmentation: pmTcpFrag.checked, frag_chunk: parseInt(pmFragChunk.value) || 2, frag_sleep: parseInt(pmFragSleep.value) || 2, junk_pc: [parseInt(pmJunkPcMin.value)||2, parseInt(pmJunkPcMax.value)||5], junk_ps: [parseInt(pmJunkPsMin.value)||100, parseInt(pmJunkPsMax.value)||1000], }; } } else { const p = { id: genId(), name: pmName.value.trim() || server, server, key, transport: pmTransport.value, tcp_fragmentation: pmTcpFrag.checked, frag_chunk: parseInt(pmFragChunk.value) || 2, frag_sleep: parseInt(pmFragSleep.value) || 2, junk_pc: [parseInt(pmJunkPcMin.value)||2, parseInt(pmJunkPcMax.value)||5], junk_ps: [parseInt(pmJunkPsMin.value)||100, parseInt(pmJunkPsMax.value)||1000], }; profiles.push(p); if (!activeId) { activeId = p.id; saveActiveId(activeId); } } saveProfiles(profiles); profileModal.classList.add('hidden'); renderProfiles(); showToast('Profile saved', 'ok'); // Persist config if this is the active profile const cfg = buildConfig(); if (cfg) invoke('save_config', { jsonContent: JSON.stringify(cfg, null, 2) }).catch(() => {}); } function deleteEditingProfile() { if (!editingProfileId) return; profiles = profiles.filter(p => p.id !== editingProfileId); if (activeId === editingProfileId) { activeId = profiles[0]?.id || null; saveActiveId(activeId); } saveProfiles(profiles); profileModal.classList.add('hidden'); renderProfiles(); showToast('Profile deleted'); } // ── PARSE ostp:// link ───────────────────────────────────────────────── function parseOstpLink(raw) { raw = raw.trim(); if (!raw.startsWith('ostp://')) throw new Error('Must start with ostp://'); const url = new URL(raw); const key = decodeURIComponent(url.username); // host includes port const server = url.host; if (!key || !server) throw new Error('Incomplete link'); const type = url.searchParams.get('type') || 'udp'; const transport = (type === 'tcp' || type === 'http' || type === 'uot') ? 'uot' : 'udp'; const name = url.searchParams.get('name') || server; return { name, server, key, transport }; } function importFromLink(raw) { try { const parsed = parseOstpLink(raw); // Pre-fill editor editingProfileId = null; profileModalTitle.textContent = 'New Profile'; pmName.value = parsed.name; pmServer.value = parsed.server; pmKey.value = parsed.key; pmTransport.value = parsed.transport; btnProfileDelete.style.display = 'none'; profileModal.classList.remove('hidden'); showToast('Link imported — tap Save', 'ok'); } catch (err) { showToast(err.message, 'error'); } } // ── SHARE ───────────────────────────────────────────────────────────── function buildShareLink(p) { const params = []; if (p.transport && p.transport !== 'udp') params.push(`type=${p.transport}`); if (p.name) params.push(`name=${encodeURIComponent(p.name)}`); const qs = params.length ? '?' + params.join('&') : ''; return `ostp://${encodeURIComponent(p.key)}@${p.server}${qs}`; } async function openShare(id) { const p = profiles.find(p => p.id === id); if (!p) return; const link = buildShareLink(p); shareLink.value = link; shareQr.innerHTML = ''; try { const svg = await invoke('generate_qr', { text: link }); if (svg) shareQr.innerHTML = svg; } catch { /* QR optional */ } shareModal.classList.remove('hidden'); } // ── CLIENT SETTINGS ─────────────────────────────────────────────────── function loadSettingsIntoForm() { const s = loadClientSettings(); inTun.checked = !!s.tun; inKillSwitch.checked = !!s.killSwitch; inMux.checked = !!s.mux; inMuxSessions.value = s.muxSessions || '2'; inMtu.value = s.mtu || ''; inDns.value = s.dns || ''; inSocks.value = s.socks || ''; inExDomains.value = s.exDomains || ''; inExIps.value = s.exIps || ''; inExProcs.value = s.exProcs || ''; inAutoconnect.checked = !!s.autoconnect; inLaunchStartup.checked = !!s.launchStartup; inDebug.checked = !!s.debug; inShowRtt.checked = s.showRtt !== false; inShowSpeed.checked = s.showSpeed !== false; inJunkEnabled.checked = !!s.junkEnabled; inJunkPcMin.value = s.junkPcMin || 2; inJunkPcMax.value = s.junkPcMax || 5; inJunkPsMin.value = s.junkPsMin || 100; inJunkPsMax.value = s.junkPsMax || 1000; inTcpFrag.checked = !!s.tcpFrag; inFragChunk.value = s.fragChunk || 2; inFragSleep.value = !isNaN(parseInt(s.fragSleep)) ? s.fragSleep : 2; updateClientVisibility(); } function collectAndSaveSettings() { const s = { tun: inTun.checked, killSwitch: inKillSwitch.checked, mux: inMux.checked, muxSessions: inMuxSessions.value.trim(), mtu: inMtu.value.trim(), dns: inDns.value.trim(), socks: inSocks.value.trim(), exDomains: inExDomains.value.trim(), exIps: inExIps.value.trim(), exProcs: inExProcs.value.trim(), autoconnect: inAutoconnect.checked, launchStartup: inLaunchStartup.checked, debug: inDebug.checked, showRtt: inShowRtt.checked, showSpeed: inShowSpeed.checked, junkEnabled: inJunkEnabled.checked, junkPcMin: parseInt(inJunkPcMin.value) || 2, junkPcMax: parseInt(inJunkPcMax.value) || 5, junkPsMin: parseInt(inJunkPsMin.value) || 100, junkPsMax: parseInt(inJunkPsMax.value) || 1000, tcpFrag: inTcpFrag.checked, fragChunk: parseInt(inFragChunk.value) || 2, fragSleep: !isNaN(parseInt(inFragSleep.value)) ? parseInt(inFragSleep.value) : 2, }; saveClientSettings(s); updateClientVisibility(); // Set autostart invoke('set_autostart', { enable: s.launchStartup }).catch(() => {}); // Hot-reload exclusions if connected if (appState === 'connected') { const cfg = buildConfig(); if (cfg) { invoke('save_config', { jsonContent: JSON.stringify(cfg, null, 2) }) .then(() => invoke('reload_tunnel')) .catch(() => {}); } } } function updateClientVisibility() { groupKillSwitch.style.display = inTun.checked ? 'flex' : 'none'; groupMuxSessions.style.display = inMux.checked ? 'flex' : 'none'; const showRtt = inShowRtt.checked; const showSpeed = inShowSpeed.checked; const rttBox = $('stat-rtt-box'); const downBox = $('stat-down-box'); const upBox = $('stat-up-box'); const sep1 = $('stat-sep-1'); const sep2 = $('stat-sep-2'); const container = $('live-stats-container'); if (rttBox) rttBox.style.display = showRtt ? 'flex' : 'none'; if (downBox) downBox.style.display = showSpeed ? 'flex' : 'none'; if (upBox) upBox.style.display = showSpeed ? 'flex' : 'none'; if (sep1) sep1.style.display = (showRtt && showSpeed) ? 'block' : 'none'; if (sep2) sep2.style.display = showSpeed ? 'block' : 'none'; if (container) container.style.display = (showRtt || showSpeed) ? 'flex' : 'none'; } // ── INIT ────────────────────────────────────────────────────────────── window.addEventListener('DOMContentLoaded', async () => { // Render profiles renderProfiles(); // Restore tunnel state if already running try { const code = await invoke('get_tunnel_status'); if (code > 0) { setState(code === 1 ? 'connecting' : 'connected'); startPolling(); } } catch { /* not in Tauri */ } // Wintun path try { const p = await invoke('get_wintun_install_path'); if (p && wintunPath) wintunPath.textContent = p; } catch { /* ignore */ } // Tauri events if (window.__TAURI__?.event) { const { listen } = window.__TAURI__.event; listen('tunnel-error', evt => { setState('disconnected'); showError(String(evt.payload)); }); listen('tray_connect', () => { if (appState === 'disconnected') handleToggle(); }); listen('tray_disconnect', () => { if (appState !== 'disconnected') handleToggle(); }); } // Auto-connect on startup try { const s = loadClientSettings(); if (s.autoconnect && appState === 'disconnected') { setTimeout(() => { if (appState === 'disconnected') handleToggle(); }, 800); } } catch { /* ignore */ } // ── Event wiring ────────────────────────────────────────────────── btnConnect.addEventListener('click', handleToggle); btnAutoConnect.addEventListener('click', handleAutoConnect); btnGoSettings.addEventListener('click', () => showScreen('settings')); btnBack.addEventListener('click', () => showScreen('home')); // Theme toggle (dark ⇄ light), persisted in localStorage const btnTheme = $('btn-theme'); if (btnTheme) btnTheme.addEventListener('click', () => { const isLight = document.documentElement.classList.toggle('light'); localStorage.setItem('ostp_theme', isLight ? 'light' : 'dark'); }); // GUI version shown at the bottom of Settings const appVersionEl = $('app-version'); if (appVersionEl) { const setV = v => { appVersionEl.textContent = 'OSTP GUI v' + v; }; if (window.__TAURI__?.app?.getVersion) { window.__TAURI__.app.getVersion().then(setV).catch(() => setV('0.4.1')); } else { setV('0.4.1'); } } // Add-profile button → dropdown btnAddProfile.addEventListener('click', e => { e.stopPropagation(); addMenu.classList.toggle('hidden'); }); document.addEventListener('click', e => { if (!addMenu.classList.contains('hidden') && !addMenu.contains(e.target) && e.target !== btnAddProfile) { addMenu.classList.add('hidden'); } }); addFromLink.addEventListener('click', () => { addMenu.classList.add('hidden'); linkModal.classList.remove('hidden'); setTimeout(() => linkInput.focus(), 80); linkInput.value = ''; }); addFromClipboard.addEventListener('click', async () => { addMenu.classList.add('hidden'); try { const text = await navigator.clipboard.readText(); if (text.startsWith('ostp://')) { importFromLink(text); } else { showToast('No ostp:// link in clipboard', 'error'); } } catch { showToast('Cannot read clipboard', 'error'); } }); addManually.addEventListener('click', () => { addMenu.classList.add('hidden'); openProfileEditor(null); }); // Link modal btnLinkCancel.addEventListener('click', () => linkModal.classList.add('hidden')); btnLinkImport.addEventListener('click', () => { importFromLink(linkInput.value); linkModal.classList.add('hidden'); }); linkInput.addEventListener('keydown', e => { if (e.key === 'Enter') btnLinkImport.click(); }); linkModal.addEventListener('click', e => { if (e.target === linkModal) linkModal.classList.add('hidden'); }); // Profile editor modal btnProfileCancel.addEventListener('click', () => profileModal.classList.add('hidden')); btnProfileSave.addEventListener('click', saveProfileFromEditor); btnProfileDelete.addEventListener('click', deleteEditingProfile); pmTransport.addEventListener('change', () => { pmTcpSettings.style.display = pmTransport.value === 'uot' ? 'block' : 'none'; }); pmTcpFrag.addEventListener('change', () => { pmFragDetails.style.display = pmTcpFrag.checked ? 'block' : 'none'; }); btnPeekPm.addEventListener('click', () => { pmKey.type = pmKey.type === 'password' ? 'text' : 'password'; }); profileModal.addEventListener('click', e => { if (e.target === profileModal) profileModal.classList.add('hidden'); }); // Share modal btnShareClose.addEventListener('click', () => shareModal.classList.add('hidden')); btnShareCopy.addEventListener('click', async () => { try { await navigator.clipboard.writeText(shareLink.value); showToast('Link copied', 'ok'); } catch { shareLink.select(); document.execCommand('copy'); showToast('Link copied', 'ok'); } }); shareModal.addEventListener('click', e => { if (e.target === shareModal) shareModal.classList.add('hidden'); }); // Wintun modal btnWintunCancel.addEventListener('click', () => wintunModal.classList.add('hidden')); if (btnWintunOpen && window.__TAURI__) { btnWintunOpen.addEventListener('click', e => { e.preventDefault(); const opener = window.__TAURI__?.opener || window.__TAURI__?.shell; if (opener?.open) opener.open('https://www.wintun.net'); else window.open('https://www.wintun.net', '_blank'); }); } wintunModal.addEventListener('click', e => { if (e.target === wintunModal) wintunModal.classList.add('hidden'); }); // Client settings — wire all inputs [inTun, inKillSwitch, inMux, inAutoconnect, inLaunchStartup, inDebug, inShowRtt, inShowSpeed, inJunkEnabled, inTcpFrag] .forEach(el => el.addEventListener('change', collectAndSaveSettings)); [inMuxSessions, inMtu, inDns, inSocks, inExDomains, inExIps, inExProcs, inJunkPcMin, inJunkPcMax, inJunkPsMin, inJunkPsMax, inFragChunk, inFragSleep] .forEach(el => { el.addEventListener('input', () => { clearTimeout(el._saveTimer); el._saveTimer = setTimeout(collectAndSaveSettings, 400); }); }); // Junk and Frag modals btnJunkSettings.addEventListener('click', () => junkModal.classList.remove('hidden')); btnJunkDone.addEventListener('click', () => { collectAndSaveSettings(); junkModal.classList.add('hidden'); }); junkModal.addEventListener('click', e => { if (e.target === junkModal) junkModal.classList.add('hidden'); }); btnFragSettings.addEventListener('click', () => fragModal.classList.remove('hidden')); btnFragDone.addEventListener('click', () => { collectAndSaveSettings(); fragModal.classList.add('hidden'); }); fragModal.addEventListener('click', e => { if (e.target === fragModal) fragModal.classList.add('hidden'); }); // ── Global Keyboard Shortcuts (TUI emulation) ───────────────────── window.addEventListener('keydown', async e => { // Ignore if typing in an input or textarea if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return; if (e.code === 'Space') { e.preventDefault(); handleToggle(); } else if (e.code === 'Tab') { e.preventDefault(); if (profiles.length > 0) { let idx = profiles.findIndex(p => p.id === activeId); idx = (idx + 1) % profiles.length; activeId = profiles[idx].id; saveActiveId(activeId); renderProfiles(); const cfg = buildConfig(); if (cfg) invoke('save_config', { jsonContent: JSON.stringify(cfg, null, 2) }).catch(() => {}); showToast('Profile: ' + profiles[idx].name); } } else if (e.key === 'b' || e.key === 'B') { // Hide to tray (background) try { invoke('hide_window'); } catch { window.close(); } } else if (e.key === 'q' || e.key === 'Q' || e.key === 'Escape') { // Quit if (e.key === 'Escape') { // if modals are open, don't quit if (!linkModal.classList.contains('hidden') || !profileModal.classList.contains('hidden') || !shareModal.classList.contains('hidden') || !wintunModal.classList.contains('hidden') || !junkModal.classList.contains('hidden') || !fragModal.classList.contains('hidden') || !addMenu.classList.contains('hidden')) { return; } } try { invoke('close_window'); } catch { window.close(); } } }); });