// ── Tauri invoke shim ───────────────────────────────────────────────── let invoke = () => Promise.resolve(null); if (window.__TAURI__?.core) { invoke = window.__TAURI__.core.invoke; } // ── 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 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 groupKillSwitch = $('group-kill-switch'); const groupMuxSessions = $('group-mux-sessions'); // ── 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', }, 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 = `