vpn 0.1.1 → 0.1.2: server-list dropdown + gateway overlay

Every commercial VPN client stores its server catalog as a JSON on the
backend and lets the panel pick from a dropdown; this pulls that shape
into the extension.

- server-list.json: baked-in default the tarball ships with. Three
  Silent Mode slots (sm-1..sm-3), status "coming-soon" until the VLESS
  URLs land — the toggle stays disabled for any entry whose status is
  not "ready", so a placeholder cannot be selected by accident.
- Gateway overlay: index.js fetches
  https://navigate.st/api/vpn/servers on activation (with a 6-hour TTL
  and a "refresh" button in the panel) and merges by id — remote wins,
  new remote entries append. Cached to per-addon storage so an offline
  boot still has the last-good catalog.
- turnOn now accepts { serverId } or { vless }. Server id is resolved
  through the catalog inside the addon; the panel only sees a public
  view (label, flag, country, ready/coming-soon), never the raw URL.
- Panel: dropdown of servers + a "Custom vless://" option that reveals
  the paste box. Selection persists per-machine, refresh button forces
  a re-fetch, disabled toggle explains why in the hint area.

No behavioural change for anyone with a saved vless:// paste — that
path is now "Custom" in the dropdown and still works identically.
This commit is contained in:
Local Dev 2026-09-22 19:56:01 +02:00
parent b5641b33c8
commit 3d955720cb
4 changed files with 241 additions and 26 deletions

View file

@ -1,7 +1,7 @@
{
"id": "vpn",
"name": "VPN",
"version": "0.1.1",
"version": "0.1.2",
"description": "Route Theseus's traffic through a Silent Mode VPN endpoint. Runs sing-box locally, exits at one of our servers, and switches on with one click. Paste any vless:// URL to point it at your own endpoint instead.",
"author": "Silent Mode",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDJMNCA1djdjMCA1IDMuNSA5LjIgOCAxMCA0LjUtLjggOC01IDgtMTBWNWwtOC0zeiIgZmlsbD0iIzBhYzE4ZSIvPjxwYXRoIGQ9Ik05IDEybDIgMiA0LTQiIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4=",

View file

@ -22,9 +22,12 @@ const crypto = require("node:crypto");
const { spawn } = require("node:child_process");
const BINARY_MANIFEST_FILE = "binary-manifest.json";
const SERVER_LIST_FILE = "server-list.json";
const SERVER_LIST_URL = "https://navigate.st/api/vpn/servers";
const CACHE_SUBDIR = "bin";
const CONFIG_SUBDIR = "run";
const DOWNLOAD_TIMEOUT_MS = 5 * 60_000;
const SERVER_LIST_TTL_MS = 6 * 60 * 60_000;
function platformKey() {
const p = os.platform(); // "win32" | "linux" | "darwin"
@ -191,6 +194,64 @@ module.exports = {
manifest = { platforms: {} };
}
// Server catalog — the dropdown of pre-configured Silent Mode endpoints.
// Read the baked-in list first so a first-run panel has something to show,
// then refresh from the gateway in the background. Entries with the same
// `id` in the gateway response replace the baked-in copy; a user's saved
// selection persists by id so a re-keyed server keeps its slot in the
// dropdown.
function readBundledServers() {
try {
const j = JSON.parse(fs.readFileSync(path.join(api.folder, SERVER_LIST_FILE), "utf8"));
return Array.isArray(j?.servers) ? j.servers : [];
} catch (e) { api.log("bundled server-list unreadable:", e?.message); return []; }
}
let serverList = readBundledServers();
let serverListSource = "bundled";
let serverListFetchedAt = 0;
async function refreshServerList() {
const now = Date.now();
if (now - serverListFetchedAt < SERVER_LIST_TTL_MS) return serverList;
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), 15_000);
try {
const r = await fetch(SERVER_LIST_URL, { signal: controller.signal, redirect: "follow" });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const j = await r.json();
const remote = Array.isArray(j?.servers) ? j.servers : [];
// Overlay by id: remote replaces bundled, new remote entries append.
const byId = new Map(readBundledServers().map((s) => [s.id, s]));
for (const s of remote) if (s && s.id) byId.set(s.id, s);
serverList = [...byId.values()];
serverListSource = "gateway";
serverListFetchedAt = now;
try { await api.storage.set("__serverListCache", { at: now, servers: serverList }); } catch {}
api.log(`server list refreshed: ${serverList.length} entries from gateway`);
} catch (e) {
api.log(`server list refresh failed (${e?.message || e}), keeping ${serverListSource}`);
} finally {
clearTimeout(t);
}
return serverList;
}
// On activation, hydrate from the last cached response if the gateway is
// currently unreachable; then kick off a background refresh.
(async () => {
try {
const cached = await api.storage.get("__serverListCache", null);
if (cached && Array.isArray(cached.servers) && cached.servers.length) {
// Overlay cached on top of bundled the same way.
const byId = new Map(readBundledServers().map((s) => [s.id, s]));
for (const s of cached.servers) if (s && s.id) byId.set(s.id, s);
serverList = [...byId.values()];
serverListSource = "cache";
serverListFetchedAt = cached.at || Date.now();
}
} catch {}
refreshServerList().catch(() => {});
})();
function entryForCurrentPlatform() {
const e = manifest.platforms && manifest.platforms[state.platform];
if (!e) throw new Error(`no VPN binary published for ${state.platform}`);
@ -235,9 +296,25 @@ module.exports = {
return file;
}
async function turnOn(vlessUrl) {
// Resolve either a raw vless:// URL or a serverId lookup into the URL to
// hand to sing-box. The panel usually sends { serverId } for a preset and
// { vless } for a custom paste; either shape is accepted.
function resolveEndpoint({ vless, serverId }) {
if (vless && /^vless:\/\//i.test(vless)) return vless;
if (serverId) {
const hit = serverList.find((s) => s.id === serverId);
if (!hit) throw new Error(`no server with id ${serverId} in the catalog`);
if (!hit.vless || hit.status === "coming-soon") {
throw new Error(`${hit.label || serverId} is not yet configured (${hit.status || "no vless URL"})`);
}
return hit.vless;
}
throw new Error("no endpoint — pass { vless } or { serverId }");
}
async function turnOn(payload) {
if (state.running) return snapshot();
if (!vlessUrl) throw new Error("no VLESS endpoint configured");
const vlessUrl = resolveEndpoint(payload || {});
const vless = parseVless(vlessUrl);
// The binary must exist and match its pinned sha256 BEFORE spawn.
let bin;
@ -297,6 +374,16 @@ module.exports = {
}
function snapshot() {
// Trim the server catalog for the panel: never leak the raw vless URL
// (it's a credential in the free-tier model). The panel only needs the
// label, flag, country and whether the entry is usable.
const publicServers = serverList.map((s) => ({
id: s.id,
label: s.label || s.id,
flag: s.flag || "🌐",
country: s.country || "",
status: s.vless && s.status !== "coming-soon" ? "ready" : (s.status || "coming-soon"),
}));
return {
running: state.running,
platform: state.platform,
@ -312,15 +399,23 @@ module.exports = {
availablePlatforms: Object.entries(manifest.platforms || {})
.filter(([, v]) => v && v.sha256 && v.sha256 !== "PENDING")
.map(([k]) => k),
servers: publicServers,
serverListSource,
serverListFetchedAt,
};
}
// Message handlers ------------------------------------------------------
api.onMessage("status", () => snapshot());
api.onMessage("prepareBinary", () => ensureBinary().then(() => snapshot()));
api.onMessage("turnOn", (p) => turnOn(String(p && p.vless || "")));
api.onMessage("turnOff", () => turnOff());
api.onMessage("status", () => snapshot());
api.onMessage("prepareBinary", () => ensureBinary().then(() => snapshot()));
api.onMessage("turnOn", (p) => turnOn(p || {}));
api.onMessage("turnOff", () => turnOff());
api.onMessage("refreshServers", async () => {
serverListFetchedAt = 0; // force
await refreshServerList();
return snapshot();
});
api.onMessage("clearCache", () => {
try {
for (const f of fs.readdirSync(cacheDir)) fs.unlinkSync(path.join(cacheDir, f));

View file

@ -90,15 +90,23 @@ button.btn.small { padding: 4px 9px; font-size: 11.5px; }
</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>
<h2>Server</h2>
<p>Pick a Silent Mode endpoint, or point at your own. Server credentials stay on this device;
the panel only sees which one you picked.</p>
<div class="row">
<textarea id="vless" rows="3" placeholder="vless://uuid@host:443?type=tcp&security=reality&pbk=…&sni=…"></textarea>
<select id="serverSel" style="flex:1;background:var(--bg);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:8px 10px;font:inherit"></select>
<button class="btn small" id="refreshServers" title="Fetch the latest server list"></button>
</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 class="hint" id="serverHint" style="text-align:left"></div>
<div id="customBlock" hidden>
<div class="row" style="margin-top:8px">
<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>
</div>
@ -144,16 +152,67 @@ const dSocks = $("d-socks");
let pending = false;
let currentVless = "";
let selectedServerId = "";
let serverCatalog = [];
async function loadEndpoint() {
try { currentVless = await SM().storage.get("vless", ""); } catch { currentVless = ""; }
const CUSTOM_ID = "__custom";
async function loadSelection() {
try { selectedServerId = await SM().storage.get("serverId", "") || ""; } catch { selectedServerId = ""; }
try { currentVless = await SM().storage.get("vless", "") || ""; } catch { currentVless = ""; }
$("vless").value = currentVless;
}
async function saveSelection() {
try { await SM().storage.set("serverId", selectedServerId); } catch {}
}
async function saveEndpoint() {
currentVless = $("vless").value.trim();
try { await SM().storage.set("vless", currentVless); } catch {}
render(await SM().invoke("status", {}));
}
function renderServerDropdown(servers) {
serverCatalog = Array.isArray(servers) ? servers : [];
const sel = $("serverSel");
const prev = sel.value || selectedServerId;
sel.innerHTML = "";
if (serverCatalog.length) {
const group = document.createElement("optgroup");
group.label = "Silent Mode servers";
for (const s of serverCatalog) {
const opt = document.createElement("option");
opt.value = s.id;
opt.textContent = `${s.flag || "🌐"} ${s.label || s.id}${s.status === "ready" ? "" : " · " + s.status}`;
opt.disabled = s.status !== "ready";
group.appendChild(opt);
}
sel.appendChild(group);
}
const custom = document.createElement("option");
custom.value = CUSTOM_ID;
custom.textContent = "⚙️ Custom vless:// URL";
sel.appendChild(custom);
// Restore the previous selection if it survived the refresh.
if ([...sel.options].some((o) => o.value === prev)) sel.value = prev;
else if (currentVless) sel.value = CUSTOM_ID;
else if (serverCatalog.find((s) => s.status === "ready")) sel.value = serverCatalog.find((s) => s.status === "ready").id;
selectedServerId = sel.value;
onSelectionChanged();
}
function onSelectionChanged() {
const isCustom = selectedServerId === CUSTOM_ID;
$("customBlock").hidden = !isCustom;
const hit = serverCatalog.find((s) => s.id === selectedServerId);
const hint = $("serverHint");
if (isCustom) {
hint.className = "hint"; hint.textContent = currentVless ? "" : "Paste a vless:// URL below.";
} else if (hit && hit.status !== "ready") {
hint.className = "hint err"; hint.textContent = `${hit.label || hit.id}: ${hit.status}`;
} else {
hint.className = "hint"; hint.textContent = "";
}
}
async function loadAuto() {
try { $("autoOn").checked = !!(await SM().storage.get("autoOn", false)); } catch {}
}
@ -169,6 +228,9 @@ function render(s) {
? `sing-box ${s.binaryVersion || "?"} (cached)`
: (s.binaryError || "not downloaded");
dSocks.textContent = s.running ? `127.0.0.1:${s.socksPort}` : "off";
// Refresh the dropdown from the latest catalog snapshot. renderServerDropdown
// is a no-op when the id list hasn't changed since the last paint.
if (Array.isArray(s.servers)) renderServerDropdown(s.servers);
if (s.running) {
statusEl.className = "pill on"; statusTx.textContent = "connected";
@ -189,9 +251,17 @@ function render(s) {
} else {
statusEl.className = "pill"; statusTx.textContent = "off";
toggle.className = "toggle off"; toggle.textContent = "Turn on";
toggle.disabled = pending || !currentVless;
const readyServer = serverCatalog.find((sv) => sv.id === selectedServerId && sv.status === "ready");
const canRun = (selectedServerId === CUSTOM_ID && !!currentVless) || !!readyServer;
toggle.disabled = pending || !canRun;
hint.className = "hint";
hint.textContent = currentVless ? "" : "Paste a vless:// URL below to enable the toggle.";
if (!canRun) {
hint.textContent = selectedServerId === CUSTOM_ID
? "Paste a vless:// URL below to enable the toggle."
: "Pick a server (or Custom) to enable the toggle.";
} else {
hint.textContent = "";
}
progressBar.hidden = true;
}
@ -211,9 +281,11 @@ toggle.addEventListener("click", async () => {
try {
if (s.running) {
render(await SM().invoke("turnOff", {}));
} else {
if (!currentVless) throw new Error("no endpoint saved");
} else if (selectedServerId === CUSTOM_ID) {
if (!currentVless) throw new Error("paste a vless:// URL first");
render(await SM().invoke("turnOn", { vless: currentVless }));
} else {
render(await SM().invoke("turnOn", { serverId: selectedServerId }));
}
} catch (e) {
hint.className = "hint err"; hint.textContent = e?.message || String(e);
@ -225,6 +297,18 @@ toggle.addEventListener("click", async () => {
}
});
$("serverSel").addEventListener("change", async () => {
selectedServerId = $("serverSel").value;
onSelectionChanged();
await saveSelection();
render(await SM().invoke("status", {}));
});
$("refreshServers").addEventListener("click", async () => {
hint.className = "hint"; hint.textContent = "refreshing catalog…";
try { render(await SM().invoke("refreshServers", {})); }
catch (e) { hint.className = "hint err"; hint.textContent = e?.message || String(e); }
});
$("save").addEventListener("click", saveEndpoint);
$("paste").addEventListener("click", async () => {
try { $("vless").value = await navigator.clipboard.readText(); } catch {}
@ -252,14 +336,20 @@ try {
} catch {}
(async () => {
await loadEndpoint();
await loadSelection();
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); }
// Auto-on: if it was on before, try to bring it back with the last saved
// choice — server id if any, else custom vless. Silent failure is fine,
// the toggle stays in "off" and the hint carries the reason.
if ($("autoOn").checked) {
try {
if (selectedServerId === CUSTOM_ID && currentVless) {
render(await SM().invoke("turnOn", { vless: currentVless }));
} else if (selectedServerId && selectedServerId !== CUSTOM_ID) {
render(await SM().invoke("turnOn", { serverId: selectedServerId }));
}
} 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.

View file

@ -0,0 +1,30 @@
{
"schema": 1,
"note": "Baked-in default server list. The addon fetches https://navigate.st/api/vpn/servers on activation and overlays that on top of this — any entry with a matching id replaces the baked-in one, new entries append. Users' saved selection persists by id, so a server can be re-parameterised (Reality key rotated, new host) without the panel losing its state.",
"servers": [
{
"id": "sm-1",
"label": "Silent Mode · 1",
"flag": "🌐",
"country": "",
"vless": "",
"status": "coming-soon"
},
{
"id": "sm-2",
"label": "Silent Mode · 2",
"flag": "🌐",
"country": "",
"vless": "",
"status": "coming-soon"
},
{
"id": "sm-3",
"label": "Silent Mode · 3",
"flag": "🌐",
"country": "",
"vless": "",
"status": "coming-soon"
}
]
}