Ships the extension small (~50 KB tarball). No binaries in it — the
platform-matched sing-box is downloaded on first "Turn on" from
bns/theseus.x/vpn-binaries/<platform>/, sha256-verified against the
manifest that ships inside this operator-signed tarball, and cached
under extensions-data/vpn/bin/. Every subsequent launch re-verifies
before spawning; a mismatch redownloads rather than trusts what is on
disk.
Config generator produces a sing-box config from a vless:// URL (the
shape a 3x-UI VLESS+Reality inbound produces), plus a SOCKS5 inbound
on 127.0.0.1:<ephemeral>. api.setSessionProxy points every Theseus
request at that port while the tunnel is up; child.on("exit") clears
it if sing-box dies. Off again clears the proxy back to whatever the
browser had.
Panel is a big on/off toggle with a status pill, a paste-and-save
endpoint box, and an Advanced disclosure with "auto-on at browser
start", "re-download binary" and "clear cache". Any user with a
vless:// URL can flip it on today; the free tier and the Silent Mode
exit inbound are the server-side half, documented under DESIGN.md.
Binary manifest ships with PENDING sha256s until the binaries are
uploaded to Sia — ensureBinary refuses to activate on a platform whose
sha256 is PENDING, so a user cannot flip it on against an unverified
download.
270 lines
11 KiB
HTML
270 lines
11 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>VPN</title>
|
|
<style>
|
|
:root { color-scheme: light dark;
|
|
--bg:#0e131c; --panel:#141a24; --panel2:#191f2b; --line:rgba(255,255,255,.09);
|
|
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#0ac18e; --danger:#ff5b5b; }
|
|
@media (prefers-color-scheme: light) {
|
|
:root { --bg:#f8faff; --panel:#ffffff; --panel2:#eff3fb; --line:rgba(0,0,0,.10);
|
|
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5; }
|
|
}
|
|
* { box-sizing: border-box; }
|
|
html, body { margin: 0; height: 100%; }
|
|
body { background: var(--bg); color: var(--ink); font: 13px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; display: flex; flex-direction: column; }
|
|
header { display: flex; align-items: center; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--line); background: var(--panel); }
|
|
header h1 { margin: 0; font-size: 14px; font-weight: 600; }
|
|
header .plat { margin-left: auto; color: var(--dim); font-size: 11px; }
|
|
|
|
.body { flex: 1; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 12px; }
|
|
|
|
.big {
|
|
border: 1px solid var(--line); background: var(--panel); border-radius: 14px;
|
|
padding: 18px; display: flex; flex-direction: column; align-items: center; gap: 10px;
|
|
}
|
|
.pill { display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px; border-radius: 999px;
|
|
font-size: 11px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
|
|
background: rgba(139,152,169,.14); color: var(--mut); }
|
|
.pill.on { color: #0b0e14; background: var(--acid); }
|
|
.pill.err { color: #fff; background: var(--danger); }
|
|
.pill .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
|
|
|
.toggle {
|
|
border: none; padding: 14px 28px; border-radius: 12px; font: inherit; font-weight: 700; font-size: 15px;
|
|
cursor: pointer; letter-spacing: .04em;
|
|
background: var(--acid); color: #0b0e14; min-width: 190px;
|
|
}
|
|
.toggle:disabled { opacity: .5; cursor: default; }
|
|
.toggle.off { background: var(--panel2); color: var(--ink); border: 1px solid var(--line); }
|
|
|
|
.progress { width: 100%; height: 4px; background: var(--panel2); border-radius: 999px; overflow: hidden; margin-top: 4px; }
|
|
.progress > span { display: block; height: 100%; background: var(--acid); width: 0%; transition: width .18s ease; }
|
|
.hint { color: var(--dim); font-size: 11.5px; text-align: center; }
|
|
.hint.err { color: var(--danger); }
|
|
|
|
.card { border: 1px solid var(--line); background: var(--panel); border-radius: 12px; padding: 14px; display: flex; flex-direction: column; gap: 10px; }
|
|
.card h2 { margin: 0 0 4px; font-size: 12.5px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--dim); }
|
|
.card p { margin: 0; color: var(--mut); font-size: 12px; }
|
|
|
|
.row { display: flex; gap: 8px; align-items: center; }
|
|
.row textarea, .row input[type=text] {
|
|
flex: 1; border: 1px solid var(--line); background: var(--bg); color: var(--ink);
|
|
border-radius: 8px; padding: 8px 10px; font: 12px/1.5 ui-monospace, monospace; resize: vertical;
|
|
}
|
|
button.btn { border: 1px solid var(--line); background: var(--panel2); color: var(--ink);
|
|
border-radius: 8px; padding: 7px 12px; font: inherit; font-size: 12px; cursor: pointer; }
|
|
button.btn:hover { border-color: rgb(from var(--acid) r g b / .55); }
|
|
button.btn.small { padding: 4px 9px; font-size: 11.5px; }
|
|
|
|
.opt { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 0; }
|
|
.opt label { color: var(--ink); font-size: 12.5px; }
|
|
.opt .sub { color: var(--dim); font-size: 11.5px; }
|
|
.opt input[type=checkbox] { transform: scale(1.15); accent-color: var(--acid); }
|
|
|
|
.kv { display: grid; grid-template-columns: 100px 1fr; gap: 4px 12px; font-size: 11.5px; }
|
|
.kv b { font-weight: 600; color: var(--mut); }
|
|
.kv code { font: 11px ui-monospace, monospace; color: var(--ink); overflow-wrap: anywhere; }
|
|
|
|
.disclosure { border: 1px solid var(--line); background: var(--panel); border-radius: 12px; padding: 10px 12px; font-size: 12px; }
|
|
.disclosure summary { cursor: pointer; color: var(--mut); font-weight: 500; list-style: none; }
|
|
.disclosure summary::-webkit-details-marker { display: none; }
|
|
.disclosure[open] { padding-bottom: 14px; }
|
|
.disclosure[open] > *:not(summary) { margin-top: 10px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>VPN</h1>
|
|
<span class="plat" id="platform">—</span>
|
|
</header>
|
|
|
|
<div class="body">
|
|
|
|
<div class="big">
|
|
<span class="pill" id="status"><span class="dot"></span><span id="statusText">loading…</span></span>
|
|
<button class="toggle off" id="toggle" disabled>Turn on</button>
|
|
<div class="progress" id="progressBar" hidden><span id="progress"></span></div>
|
|
<div class="hint" id="hint"></div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Endpoint</h2>
|
|
<p>Paste a <code>vless://</code> URL from Silent Mode or another operator. The URL stays on this device
|
|
— it's used to build a sing-box config on the fly and never leaves the machine.</p>
|
|
<div class="row">
|
|
<textarea id="vless" rows="3" placeholder="vless://uuid@host:443?type=tcp&security=reality&pbk=…&sni=…"></textarea>
|
|
</div>
|
|
<div class="row" style="justify-content:flex-end">
|
|
<button class="btn small" id="paste">Paste</button>
|
|
<button class="btn small" id="save">Save</button>
|
|
</div>
|
|
</div>
|
|
|
|
<details class="disclosure">
|
|
<summary>Advanced</summary>
|
|
|
|
<div class="kv" id="details">
|
|
<b>Platform</b><code id="d-platform">—</code>
|
|
<b>Engine</b><code id="d-engine">sing-box</code>
|
|
<b>Binary</b><code id="d-binary">—</code>
|
|
<b>SOCKS5</b><code id="d-socks">—</code>
|
|
</div>
|
|
|
|
<div class="opt" style="margin-top:10px">
|
|
<div>
|
|
<label for="autoOn">Turn on at browser start</label>
|
|
<div class="sub">Restore the VPN as soon as Theseus launches, if it was on when you last quit.</div>
|
|
</div>
|
|
<input type="checkbox" id="autoOn">
|
|
</div>
|
|
|
|
<div class="row" style="justify-content:flex-end;margin-top:6px">
|
|
<button class="btn small" id="refreshBinary">Re-download binary</button>
|
|
<button class="btn small" id="clearCache">Clear cache</button>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
|
|
<script>
|
|
const SM = () => window.silentmode;
|
|
const $ = (id) => document.getElementById(id);
|
|
|
|
const toggle = $("toggle");
|
|
const statusEl = $("status");
|
|
const statusTx = $("statusText");
|
|
const hint = $("hint");
|
|
const progress = $("progress");
|
|
const progressBar = $("progressBar");
|
|
const platEl = $("platform");
|
|
const dPlat = $("d-platform");
|
|
const dBinary = $("d-binary");
|
|
const dSocks = $("d-socks");
|
|
|
|
let pending = false;
|
|
let currentVless = "";
|
|
|
|
async function loadEndpoint() {
|
|
try { currentVless = await SM().storage.get("vless", ""); } catch { currentVless = ""; }
|
|
$("vless").value = currentVless;
|
|
}
|
|
async function saveEndpoint() {
|
|
currentVless = $("vless").value.trim();
|
|
try { await SM().storage.set("vless", currentVless); } catch {}
|
|
render(await SM().invoke("status", {}));
|
|
}
|
|
async function loadAuto() {
|
|
try { $("autoOn").checked = !!(await SM().storage.get("autoOn", false)); } catch {}
|
|
}
|
|
async function saveAuto() {
|
|
try { await SM().storage.set("autoOn", $("autoOn").checked); } catch {}
|
|
}
|
|
|
|
function render(s) {
|
|
if (!s) return;
|
|
platEl.textContent = s.platform;
|
|
dPlat.textContent = s.platform;
|
|
dBinary.textContent = s.binaryReady
|
|
? `sing-box ${s.binaryVersion || "?"} (cached)`
|
|
: (s.binaryError || "not downloaded");
|
|
dSocks.textContent = s.running ? `127.0.0.1:${s.socksPort}` : "off";
|
|
|
|
if (s.running) {
|
|
statusEl.className = "pill on"; statusTx.textContent = "connected";
|
|
toggle.className = "toggle"; toggle.textContent = "Turn off"; toggle.disabled = pending;
|
|
hint.className = "hint"; hint.textContent = "";
|
|
progressBar.hidden = true;
|
|
} else if (s.downloadPct > 0 && s.downloadPct < 100 && pending) {
|
|
statusEl.className = "pill"; statusTx.textContent = "downloading";
|
|
toggle.textContent = "Preparing…"; toggle.disabled = true;
|
|
progressBar.hidden = false;
|
|
progress.style.width = `${s.downloadPct}%`;
|
|
hint.className = "hint"; hint.textContent = `sing-box · ${s.downloadPct}%`;
|
|
} else if (s.binaryError) {
|
|
statusEl.className = "pill err"; statusTx.textContent = "unavailable";
|
|
toggle.className = "toggle off"; toggle.textContent = "Turn on"; toggle.disabled = true;
|
|
hint.className = "hint err"; hint.textContent = s.binaryError;
|
|
progressBar.hidden = true;
|
|
} else {
|
|
statusEl.className = "pill"; statusTx.textContent = "off";
|
|
toggle.className = "toggle off"; toggle.textContent = "Turn on";
|
|
toggle.disabled = pending || !currentVless;
|
|
hint.className = "hint";
|
|
hint.textContent = currentVless ? "" : "Paste a vless:// URL below to enable the toggle.";
|
|
progressBar.hidden = true;
|
|
}
|
|
|
|
if (s.lastError && !s.running) {
|
|
hint.className = "hint err"; hint.textContent = s.lastError;
|
|
}
|
|
}
|
|
|
|
async function tick() {
|
|
try { render(await SM().invoke("status", {})); } catch {}
|
|
}
|
|
|
|
toggle.addEventListener("click", async () => {
|
|
if (pending) return;
|
|
pending = true;
|
|
const s = await SM().invoke("status", {});
|
|
try {
|
|
if (s.running) {
|
|
render(await SM().invoke("turnOff", {}));
|
|
} else {
|
|
if (!currentVless) throw new Error("no endpoint saved");
|
|
render(await SM().invoke("turnOn", { vless: currentVless }));
|
|
}
|
|
} catch (e) {
|
|
hint.className = "hint err"; hint.textContent = e?.message || String(e);
|
|
statusEl.className = "pill err"; statusTx.textContent = "failed";
|
|
progressBar.hidden = true; toggle.disabled = false;
|
|
toggle.className = "toggle off"; toggle.textContent = "Turn on";
|
|
} finally {
|
|
pending = false;
|
|
}
|
|
});
|
|
|
|
$("save").addEventListener("click", saveEndpoint);
|
|
$("paste").addEventListener("click", async () => {
|
|
try { $("vless").value = await navigator.clipboard.readText(); } catch {}
|
|
});
|
|
$("autoOn").addEventListener("change", saveAuto);
|
|
|
|
$("refreshBinary").addEventListener("click", async () => {
|
|
pending = true;
|
|
try {
|
|
hint.className = "hint"; hint.textContent = "downloading…";
|
|
await SM().invoke("clearCache", {});
|
|
await SM().invoke("prepareBinary", {});
|
|
render(await SM().invoke("status", {}));
|
|
} catch (e) {
|
|
hint.className = "hint err"; hint.textContent = e?.message || String(e);
|
|
} finally { pending = false; }
|
|
});
|
|
$("clearCache").addEventListener("click", async () => {
|
|
render(await SM().invoke("clearCache", {}));
|
|
});
|
|
|
|
// Live updates from index.js — download progress, spawn crashes, etc.
|
|
try {
|
|
window.silentmode.on?.("state", (s) => render(s));
|
|
} catch {}
|
|
|
|
(async () => {
|
|
await loadEndpoint();
|
|
await loadAuto();
|
|
render(await SM().invoke("status", {}));
|
|
// Auto-on: if it was on before, try to bring it back — silent failure is
|
|
// fine, the toggle stays in "off" and the hint carries the reason.
|
|
if ($("autoOn").checked && currentVless) {
|
|
try { render(await SM().invoke("turnOn", { vless: currentVless })); }
|
|
catch (e) { hint.className = "hint err"; hint.textContent = e?.message || String(e); }
|
|
}
|
|
// Periodic refresh for slow status changes (download bytes, unexpected
|
|
// sing-box exit). Cheap; runs only while the panel is visible.
|
|
setInterval(tick, 800);
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|