diff options
Diffstat (limited to 'cmd/client/frontend/dist')
| -rw-r--r-- | cmd/client/frontend/dist/index.html | 538 |
1 files changed, 538 insertions, 0 deletions
diff --git a/cmd/client/frontend/dist/index.html b/cmd/client/frontend/dist/index.html new file mode 100644 index 0000000..6a77f85 --- /dev/null +++ b/cmd/client/frontend/dist/index.html @@ -0,0 +1,538 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>vpnem</title> + <style> +:root { + --bg: #191919; + --surface: #222; + --surface2: #2a2a2a; + --border: #333; + --border-focus: #555; + --text: #c8c4bd; + --text-dim: #706c64; + --text-faint: #4a4740; + --accent: #c9885a; + --accent-hover: #dda070; + --on: #7aad6a; + --on-dim: #3a5032; + --off: #b05050; + --off-dim: #4a2828; + --warn: #c9a84a; + --mono: 'Consolas', 'SF Mono', 'Menlo', monospace; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } +html, body { height: 100%; } +body { + font: 14px/1.5 'Segoe UI', 'Inter', system-ui, sans-serif; + background: var(--bg); color: var(--text); + user-select: none; overflow: hidden; +} +::-webkit-scrollbar { width:6px; } +::-webkit-scrollbar-track { background:transparent; } +::-webkit-scrollbar-thumb { background:#383838; border-radius:3px; } +::-webkit-scrollbar-thumb:hover { background:#484848; } + +/* Layout */ +.app { display: flex; flex-direction: column; height: 100vh; } + +/* Header */ +.header { + display: flex; align-items: center; justify-content: space-between; + padding: 10px 14px; border-bottom: 1px solid var(--border); + background: var(--surface); -webkit-app-region: drag; +} +.header .brand { font: 700 16px var(--mono); color: var(--accent); letter-spacing: 1.5px; text-transform: uppercase; } +.header .conn-info { flex: 1; text-align: center; font: 12px var(--mono); color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0 10px; transition: color .3s; } +.header .conn-info.on { color: var(--text-dim); } +.header .status { display: flex; align-items: center; gap: 8px; font-size: 13px; } +.header .status .indicator { + width: 8px; height: 8px; border-radius: 50%; background: var(--off); + transition: background .3s; +} +.header .status .indicator.on { background: var(--on); } +.header .status .label { color: var(--text-dim); transition: color .3s; } +.header .status .label.on { color: var(--on); } + +/* Toast notifications */ +.toast { + position: fixed; bottom: 40px; left: 50%; transform: translateX(-50%); + padding: 8px 18px; font: 13px var(--mono); border-radius: 3px; + background: var(--surface2); border: 1px solid var(--border); color: var(--text); + opacity: 0; transition: opacity .3s; pointer-events: none; z-index: 100; +} +.toast.show { opacity: 1; } +.toast.ok { border-color: var(--on); color: var(--on); } +.toast.err { border-color: var(--off); color: var(--off); } + +/* Nav */ +nav { + display: flex; border-bottom: 1px solid var(--border); background: var(--surface); +} +nav button { + flex: 1; padding: 10px 0; font: 600 12px/1 inherit; text-transform: uppercase; + letter-spacing: 1px; color: var(--text-dim); background: none; + border: none; border-bottom: 2px solid transparent; cursor: pointer; + transition: color .15s, border-color .15s; +} +nav button:hover { color: var(--text); } +nav button.active { color: var(--accent); border-bottom-color: var(--accent); } + +/* Content area */ +.content { flex: 1; overflow-y: auto; padding: 16px 18px; } +.panel { display: none; } +.panel.active { display: block; } + +/* Form elements */ +label.field { display: block; margin-bottom: 14px; } +label.field .lbl { display: block; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: var(--text-dim); margin-bottom: 6px; } +select, input[type=text] { + width: 100%; padding: 9px 12px; font: 14px inherit; color: var(--text); + background: var(--surface); border: 1px solid var(--border); border-radius: 3px; + outline: none; cursor: pointer; transition: border-color .15s; +} +select:focus, input[type=text]:focus { border-color: var(--border-focus); } +input[type=text] { cursor: text; } + +/* Primary action */ +.btn-primary { + width: 100%; padding: 12px; margin-top: 6px; font: 700 14px inherit; + text-transform: uppercase; letter-spacing: 1.5px; border: 1px solid var(--on); + border-radius: 3px; background: var(--on-dim); color: var(--on); cursor: pointer; + transition: all .15s; +} +.btn-primary:active { transform: scale(.98); } +.btn-primary:hover { background: var(--on); color: var(--bg); } +.btn-primary.disconnect { border-color: var(--off); background: var(--off-dim); color: var(--off); } +.btn-primary.disconnect:hover { background: var(--off); color: var(--bg); } +.btn-primary:disabled { opacity: .5; cursor: default; } + +/* Secondary buttons */ +.btn { display: inline-block; padding: 5px 12px; font: 11px inherit; color: var(--text-dim); background: var(--surface2); border: 1px solid var(--border); border-radius: 3px; cursor: pointer; } +.btn:hover { color: var(--text); border-color: var(--border-focus); } + +/* Exit IP line */ +.meta { margin-top: 10px; font: 13px var(--mono); color: var(--text-dim); text-align: center; } +.meta em { font-style: normal; color: var(--accent); } + +/* Checkbox row */ +.check-row { display: flex; align-items: center; gap: 8px; margin-top: 12px; font-size: 13px; color: var(--text-dim); } +.check-row input[type=checkbox] { accent-color: var(--accent); width: 15px; height: 15px; cursor: pointer; } + +/* Toggle list rows */ +.row { + display: flex; align-items: center; gap: 8px; + padding: 6px 8px; border-bottom: 1px solid var(--border); + font-size: 12px; +} +.row:last-child { border-bottom: none; } +.row .name { flex: 1; font-family: var(--mono); font-size: 13px; color: var(--text); } +.row .desc { flex: 2; color: var(--text-dim); font-size: 12px; } +.row .badge { font: 600 9px var(--mono); text-transform: uppercase; letter-spacing: 0.5px; padding: 2px 5px; border-radius: 2px; } +.row .badge.on { background: var(--on-dim); color: var(--on); } +.row .badge.always { background: #333; color: var(--text-dim); } + +/* Toggle switch */ +.sw { position: relative; width: 32px; height: 18px; flex-shrink: 0; } +.sw input { display: none; } +.sw span { + position: absolute; inset: 0; background: #3a3a3a; border-radius: 9px; + cursor: pointer; transition: .2s; +} +.sw span::after { + content: ''; position: absolute; width: 12px; height: 12px; + left: 3px; top: 3px; background: #666; border-radius: 50%; transition: .2s; +} +.sw input:checked + span { background: var(--on-dim); } +.sw input:checked + span::after { transform: translateX(14px); background: var(--on); } + +/* Process items */ +.proc { display: flex; align-items: center; padding: 6px 8px; font: 13px var(--mono); border-bottom: 1px solid var(--border); transition: background .1s; } +.proc:hover { background: var(--surface); } +.proc .pname { flex: 1; } +.proc.builtin .pname { color: var(--text-dim); } +.proc .x { background: none; border: none; color: var(--off); cursor: pointer; font-size: 13px; padding: 0 4px; } +.proc .x:hover { color: #e06060; } +.add-row { display: flex; gap: 4px; margin-top: 6px; } +.add-row input { flex: 1; font-size: 12px; padding: 5px 8px; } +.add-row .btn { padding: 5px 14px; } + +/* Latency rows */ +.lat { display: flex; padding: 6px 8px; font: 13px var(--mono); border-bottom: 1px solid var(--border); transition: background .1s; } +.lat:hover { background: var(--surface); } +.lat .tag { flex: 1; } +.lat .ms { min-width: 55px; text-align: right; } +.lat .ms.g { color: var(--on); } +.lat .ms.m { color: var(--warn); } +.lat .ms.b { color: var(--off); } +.lat .ms.d { color: var(--text-faint); } + +/* Log viewer */ +.logview { + background: #111; border: 1px solid var(--border); border-radius: 3px; + padding: 8px 10px; max-height: 220px; overflow-y: auto; + font: 11px/1.7 var(--mono); color: var(--text-dim); white-space: pre-wrap; word-break: break-all; +} + +/* Section headers inside panels */ +.sh { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: var(--text-faint); margin: 14px 0 8px; } +.sh:first-child { margin-top: 0; } + +/* Footer */ +.footer { + display: flex; align-items: center; justify-content: space-between; + padding: 8px 16px; border-top: 1px solid var(--border); + background: var(--surface); font-size: 12px; color: var(--text-faint); +} +.footer .update { color: var(--accent); cursor: pointer; } + </style> +</head> +<body> +<div class="app"> + <div class="header"> + <span class="brand">vpnem</span> + <span class="conn-info" id="connInfo"></span> + <div class="status"> + <div class="indicator" id="ind"></div> + <span class="label" id="statusLabel">offline</span> + </div> + </div> + <div class="toast" id="toast"></div> + + <nav> + <button class="active" onclick="tab('vpn')">vpn</button> + <button onclick="tab('rules')">rules</button> + <button onclick="tab('bypass')">bypass</button> + <button onclick="tab('diag')">diag</button> + </nav> + + <div class="content"> + <!-- VPN panel --> + <div id="p-vpn" class="panel active"> + <label class="field"> + <span class="lbl">Server</span> + <select id="selServer"></select> + </label> + <label class="field"> + <span class="lbl">Routing mode</span> + <select id="selMode"></select> + </label> + <button class="btn-primary" id="btnConn" onclick="doConnect()">Connect</button> + <div class="meta" id="exitIp"></div> + <div class="check-row"> + <input type="checkbox" id="chkAuto" onchange="window.go.main.App.SetAutoConnect(this.checked)"> + <span>Auto-connect on launch</span> + </div> + </div> + + <!-- Rules panel --> + <div id="p-rules" class="panel"> + <div class="sh">Rule sets</div> + <div id="rsList"></div> + </div> + + <!-- Bypass panel --> + <div id="p-bypass" class="panel"> + <div class="sh">Built-in exclusions</div> + <div id="procDefault"></div> + <div class="sh">Custom exclusions</div> + <div id="procCustom"></div> + <div class="add-row"> + <input type="text" id="inpProc" placeholder="process.exe" onkeydown="if(event.key==='Enter')addProc()"> + <button class="btn" onclick="addProc()">Add</button> + </div> + </div> + + <!-- Diag panel --> + <div id="p-diag" class="panel"> + <div class="sh">Latency <button class="btn" onclick="measureLat()" style="margin-left:6px">Measure</button></div> + <div id="latList"></div> + <div class="sh" style="margin-top:14px">Log <button class="btn" onclick="loadLog()" style="margin-left:6px">Refresh</button></div> + <div class="logview" id="logBox">No logs</div> + </div> + </div> + + <div class="footer"> + <span id="syncInfo">--</span> + <span> + <span id="updInfo" style="display:none" class="update"></span> + <button id="updBtn" style="display:none" class="btn" onclick="doUpdate()">update</button> + <button class="btn" onclick="doSync()">sync</button> + </span> + </div> +</div> + +<script> +let on = false; +let toastTimer = null; +const $ = s => document.getElementById(s); +const A = () => window.go.main.App; + +function toast(msg, type) { + const t = $('toast'); + t.textContent = msg; + t.className = 'toast show' + (type ? ' ' + type : ''); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => t.className = 'toast', 3000); +} + +function tab(name) { + document.querySelectorAll('nav button').forEach((b,i) => b.classList.toggle('active', b.textContent.trim() === name)); + document.querySelectorAll('.panel').forEach(p => p.classList.remove('active')); + $('p-' + name).classList.add('active'); + if (name === 'rules') loadRules(); + if (name === 'bypass') loadProcs(); + if (name === 'diag') loadLog(); +} + +async function init() { + for (let i = 0; i < 50; i++) { + if (window.go && window.go.main && window.go.main.App) break; + await new Promise(r => setTimeout(r, 100)); + } + if (!A()) { $('syncInfo').textContent = 'runtime error'; return; } + + try { + // Load modes (always available, static) + const modes = await A().GetModes(); + const sel = $('selMode'); + (modes||[]).forEach(m => { const o = document.createElement('option'); o.value = m; o.textContent = m; sel.appendChild(o); }); + + // Wait for sync to complete (may still be running in background) + let servers = await A().GetServers(); + if (!servers || !servers.length) { + $('syncInfo').textContent = 'syncing...'; + // Wait up to 10s for background sync + for (let i = 0; i < 20; i++) { + await new Promise(r => setTimeout(r, 500)); + servers = await A().GetServers(); + if (servers && servers.length) break; + } + if (!servers || !servers.length) { + // Force sync + await A().Sync(); + servers = await A().GetServers(); + } + } + + const status = await A().GetStatus(); + + fillServers(servers || []); + + // Set saved or random NL server + if (status.server) { + $('selServer').value = status.server; + } else { + try { + const rnd = await A().RandomNLServer(); + if (rnd) $('selServer').value = rnd; + } catch(e) {} + } + + // Set saved mode or default to last (Combo) + if (status.mode) { + $('selMode').value = status.mode; + } else if (modes && modes.length) { + $('selMode').value = modes[modes.length - 1]; + } + if (status.connected) { setOn(true); setTimeout(getIP, 2000); } + $('chkAuto').checked = !!status.autoConnect; + $('syncInfo').textContent = (servers||[]).length + ' servers'; + } catch(e) { $('syncInfo').textContent = String(e); } + + setTimeout(checkUpd, 5000); + + // Listen for backend sync events and refresh UI + if (window.runtime && window.runtime.EventsOn) { + window.runtime.EventsOn('connected', (serverTag) => { + $('selServer').value = serverTag; + setOn(true, serverTag); + }); + window.runtime.EventsOn('synced', async () => { + try { + const servers = await A().GetServers(); + const status = await A().GetStatus(); + $('selServer').textContent = ''; + fillServers(servers || []); + if (status.server) { + $('selServer').value = status.server; + } else { + const rnd = await A().RandomNLServer(); + if (rnd) $('selServer').value = rnd; + } + if (status.mode) $('selMode').value = status.mode; + $('syncInfo').textContent = (servers||[]).length + ' servers'; + } catch(e) {} + }); + } +} + +function fillServers(list) { + const sel = $('selServer'); + const groups = {}; + list.forEach(s => { (groups[s.region] = groups[s.region]||[]).push(s); }); + for (const [r, ss] of Object.entries(groups)) { + const g = document.createElement('optgroup'); g.label = r; + ss.forEach(s => { const o = document.createElement('option'); o.value = s.tag; o.textContent = s.tag + ' \u00b7 ' + s.type; g.appendChild(o); }); + sel.appendChild(g); + } +} + +async function doConnect() { + $('btnConn').disabled = true; + $('btnConn').textContent = on ? 'Disconnecting...' : 'Connecting...'; + try { + if (on) { + await A().Disconnect(); + setOn(false); + } else { + const srv = $('selServer').value; + const mode = $('selMode').value; + if (!srv) { toast('Select a server', 'err'); $('btnConn').disabled = false; $('btnConn').textContent = 'Connect'; return; } + await A().Connect(srv, mode); + setOn(true); + setTimeout(getIP, 2500); + } + } catch(e) { + setOn(false); + toast(String(e).substring(0, 80), 'err'); + } + $('btnConn').disabled = false; +} + +function setOn(state, serverTag) { + const wasOn = on; + on = state; + $('ind').className = 'indicator' + (state ? ' on' : ''); + $('statusLabel').textContent = state ? 'connected' : 'offline'; + $('statusLabel').className = 'label' + (state ? ' on' : ''); + const b = $('btnConn'); + b.textContent = state ? 'Disconnect' : 'Connect'; + b.className = 'btn-primary' + (state ? ' disconnect' : ''); + if (state) { + const srv = serverTag || $('selServer').value; + const mode = $('selMode').value; + $('connInfo').textContent = srv + ' \u00b7 ' + mode; + $('connInfo').className = 'conn-info on'; + if (!wasOn) toast('Connected: ' + srv, 'ok'); + } else { + $('connInfo').textContent = ''; + $('connInfo').className = 'conn-info'; + $('exitIp').textContent = ''; + if (wasOn) toast('Disconnected', 'err'); + } +} + +async function getIP() { + try { + const ip = await A().GetExitIP(); + if (ip) { $('exitIp').textContent = ''; const t = document.createTextNode('exit '); $('exitIp').appendChild(t); const e = document.createElement('em'); e.textContent = ip.trim(); $('exitIp').appendChild(e); } + } catch(e) {} +} + +async function doSync() { + $('syncInfo').textContent = 'syncing\u2026'; + try { await A().Sync(); const s = await A().GetServers(); $('selServer').textContent = ''; fillServers(s||[]); $('syncInfo').textContent = (s||[]).length + ' servers'; } + catch(e) { $('syncInfo').textContent = 'error'; } +} + +// Rules +async function loadRules() { + const c = $('rsList'); c.textContent = ''; + try { + const rs = await A().GetRuleSets(); + (rs||[]).forEach(r => { + const row = document.createElement('div'); row.className = 'row'; + const nm = document.createElement('span'); nm.className = 'name'; nm.textContent = r.tag; row.appendChild(nm); + const ds = document.createElement('span'); ds.className = 'desc'; ds.textContent = r.description; row.appendChild(ds); + if (r.optional) { + const sw = document.createElement('label'); sw.className = 'sw'; + const inp = document.createElement('input'); inp.type = 'checkbox'; inp.checked = r.enabled; + inp.onchange = () => A().SetRuleSetEnabled(r.tag, inp.checked); + const sp = document.createElement('span'); + sw.appendChild(inp); sw.appendChild(sp); row.appendChild(sw); + } else { + const b = document.createElement('span'); b.className = 'badge always'; b.textContent = 'active'; row.appendChild(b); + } + c.appendChild(row); + }); + } catch(e) {} +} + +// Bypass processes +async function loadProcs() { + try { + const d = await A().GetBypassProcesses(); + const df = $('procDefault'); df.textContent = ''; + (d.default||[]).forEach(p => { const r = document.createElement('div'); r.className = 'proc builtin'; const n = document.createElement('span'); n.className = 'pname'; n.textContent = p; r.appendChild(n); df.appendChild(r); }); + const cf = $('procCustom'); cf.textContent = ''; + (d.custom||[]).forEach(p => { + const r = document.createElement('div'); r.className = 'proc'; + const n = document.createElement('span'); n.className = 'pname'; n.textContent = p; r.appendChild(n); + const x = document.createElement('button'); x.className = 'x'; x.textContent = '\u00d7'; x.onclick = async()=>{ await A().RemoveBypassProcess(p); loadProcs(); }; r.appendChild(x); + cf.appendChild(r); + }); + } catch(e) {} +} + +async function addProc() { + const v = $('inpProc').value.trim(); if (!v) return; + await A().AddBypassProcess(v); $('inpProc').value = ''; loadProcs(); +} + +// Latency +async function measureLat() { + const c = $('latList'); c.textContent = 'measuring\u2026'; + try { + const res = await A().MeasureLatency(); c.textContent = ''; + res.forEach(r => { + const row = document.createElement('div'); row.className = 'lat'; + const t = document.createElement('span'); t.className = 'tag'; t.textContent = r.tag; row.appendChild(t); + const m = document.createElement('span'); m.className = 'ms'; + if (r.latency_ms < 0) { m.textContent = '\u2014'; m.classList.add('d'); } + else if (r.latency_ms < 80) { m.textContent = r.latency_ms + 'ms'; m.classList.add('g'); } + else if (r.latency_ms < 200) { m.textContent = r.latency_ms + 'ms'; m.classList.add('m'); } + else { m.textContent = r.latency_ms + 'ms'; m.classList.add('b'); } + row.appendChild(m); c.appendChild(row); + }); + } catch(e) { c.textContent = String(e); } +} + +async function loadLog() { + try { const l = await A().GetLogs(); $('logBox').textContent = (l||[]).join('\n') || 'empty'; $('logBox').scrollTop = $('logBox').scrollHeight; } catch(e) {} +} + +async function checkUpd() { + try { + const i = await A().CheckUpdate(); + if (i && i.available) { + $('updInfo').style.display = 'inline'; + $('updInfo').textContent = 'v' + i.version + ' available'; + $('updBtn').style.display = 'inline-block'; + } + } catch(e) {} +} + +async function doUpdate() { + $('updBtn').disabled = true; + $('updBtn').textContent = 'updating...'; + $('updInfo').textContent = 'downloading, app will restart'; + try { + await A().DownloadUpdate(); + // If we get here, restart failed — shouldn't normally happen + $('updInfo').textContent = 'restart manually'; + } catch(e) { + $('updBtn').textContent = 'failed'; + $('updInfo').textContent = String(e).substring(0, 40); + setTimeout(() => { $('updBtn').textContent = 'retry'; $('updBtn').disabled = false; }, 3000); + } +} + +setInterval(async()=>{ try { const s = await A().GetStatus(); if (s.connected !== on) { setOn(s.connected); if (s.connected) getIP(); } } catch(e) {} }, 5000); +init(); +</script> +</body> +</html> |
