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:
parent
b5641b33c8
commit
3d955720cb
4 changed files with 241 additions and 26 deletions
|
|
@ -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=",
|
||||
|
|
|
|||
|
|
@ -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,6 +399,9 @@ module.exports = {
|
|||
availablePlatforms: Object.entries(manifest.platforms || {})
|
||||
.filter(([, v]) => v && v.sha256 && v.sha256 !== "PENDING")
|
||||
.map(([k]) => k),
|
||||
servers: publicServers,
|
||||
serverListSource,
|
||||
serverListFetchedAt,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -319,8 +409,13 @@ module.exports = {
|
|||
|
||||
api.onMessage("status", () => snapshot());
|
||||
api.onMessage("prepareBinary", () => ensureBinary().then(() => snapshot()));
|
||||
api.onMessage("turnOn", (p) => turnOn(String(p && p.vless || "")));
|
||||
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));
|
||||
|
|
|
|||
|
|
@ -90,10 +90,17 @@ 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">
|
||||
<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="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">
|
||||
|
|
@ -101,6 +108,7 @@ button.btn.small { padding: 4px 9px; font-size: 11.5px; }
|
|||
<button class="btn small" id="save">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="disclosure">
|
||||
<summary>Advanced</summary>
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
30
bundled-addons/vpn/server-list.json
Normal file
30
bundled-addons/vpn/server-list.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue