vpn 0.1.2 → 0.1.3: subscription import + landing page mockup
Addon: - Paste any https:// URL that returns a list of vless:// (either newline-separated or base64) and the extension fetches, decodes, parses, and adds every server to the dropdown. The full vless URL never leaves the panel — the addon holds it in its own storage and passes an opaque "sub-<hash>" id back for selection. - Subscription CRUD on the addon side (listSubscriptions, addSubscription, refreshSubscription, removeSubscription). A refresh is a no-op inside the 6-hour TTL to avoid pounding the provider. - Merges subscription servers with the baked-in three and gateway overlay by id; the dropdown groups them under one banner. Site: - silentmode.st/vpn landing page: three-plan grid (Free, Pro at $1/mo BCH, Max at $4/mo BCH), how-it-works four-step block, "the three servers" strip with per-tier availability, why-this-VPN cards, FAQ. Priced in USD, paid in BCH via the oracle at pay-time — same pattern as the marketplace's USD-listing covenant, no reintroduction of fiat/card processors.
This commit is contained in:
parent
1505f766c2
commit
806a976647
3 changed files with 232 additions and 5 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"id": "vpn",
|
"id": "vpn",
|
||||||
"name": "VPN",
|
"name": "VPN",
|
||||||
"version": "0.1.2",
|
"version": "0.1.3",
|
||||||
"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.",
|
"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",
|
"author": "Silent Mode",
|
||||||
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDJMNCA1djdjMCA1IDMuNSA5LjIgOCAxMCA0LjUtLjggOC01IDgtMTBWNWwtOC0zeiIgZmlsbD0iIzBhYzE4ZSIvPjxwYXRoIGQ9Ik05IDEybDIgMiA0LTQiIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4=",
|
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDJMNCA1djdjMCA1IDMuNSA5LjIgOCAxMCA0LjUtLjggOC01IDgtMTBWNWwtOC0zeiIgZmlsbD0iIzBhYzE4ZSIvPjxwYXRoIGQ9Ik05IDEybDIgMiA0LTQiIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4=",
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ const CACHE_SUBDIR = "bin";
|
||||||
const CONFIG_SUBDIR = "run";
|
const CONFIG_SUBDIR = "run";
|
||||||
const DOWNLOAD_TIMEOUT_MS = 5 * 60_000;
|
const DOWNLOAD_TIMEOUT_MS = 5 * 60_000;
|
||||||
const SERVER_LIST_TTL_MS = 6 * 60 * 60_000;
|
const SERVER_LIST_TTL_MS = 6 * 60 * 60_000;
|
||||||
|
const SUBSCRIPTION_TTL_MS = 6 * 60 * 60_000;
|
||||||
|
const SUBSCRIPTION_FETCH_TIMEOUT_MS = 20_000;
|
||||||
|
|
||||||
function platformKey() {
|
function platformKey() {
|
||||||
const p = os.platform(); // "win32" | "linux" | "darwin"
|
const p = os.platform(); // "win32" | "linux" | "darwin"
|
||||||
|
|
@ -235,6 +237,121 @@ module.exports = {
|
||||||
}
|
}
|
||||||
return serverList;
|
return serverList;
|
||||||
}
|
}
|
||||||
|
// Subscriptions — user pastes an HTTPS URL that returns a list of vless
|
||||||
|
// URLs (either newline-separated or base64-encoded newline-separated,
|
||||||
|
// whichever the operator publishes). We fetch, parse, and merge each
|
||||||
|
// entry into the server list on activation and on demand.
|
||||||
|
//
|
||||||
|
// Every subscription entry gets a stable id derived from its URL so a
|
||||||
|
// refresh does not duplicate servers when the same subscription is
|
||||||
|
// re-fetched. The full vless URL for a subscription server lives in the
|
||||||
|
// add-on's private storage — never leaves this device.
|
||||||
|
function subServerId(subLabel, index) {
|
||||||
|
// 8-char prefix of a sha256 of "<subLabel>|<index>" so the id is
|
||||||
|
// deterministic across refreshes but does not leak the subscription URL.
|
||||||
|
const h = crypto.createHash("sha256").update(`${subLabel}|${index}`).digest("hex").slice(0, 8);
|
||||||
|
return `sub-${h}`;
|
||||||
|
}
|
||||||
|
function decodeSubscriptionBody(text) {
|
||||||
|
// Two shapes are common in the wild. Try the plain-text form first —
|
||||||
|
// whitespace-separated URLs starting with vless://. If that yields
|
||||||
|
// nothing, try base64 (both standard and URL-safe alphabets, with or
|
||||||
|
// without padding).
|
||||||
|
const trimmed = String(text || "").trim();
|
||||||
|
const isPlain = /\bvless:\/\//i.test(trimmed);
|
||||||
|
if (isPlain) return trimmed;
|
||||||
|
try {
|
||||||
|
const b64 = trimmed.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const padded = b64 + "=".repeat((4 - b64.length % 4) % 4);
|
||||||
|
const decoded = Buffer.from(padded, "base64").toString("utf8");
|
||||||
|
if (/\bvless:\/\//i.test(decoded)) return decoded;
|
||||||
|
} catch {}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
function parseSubscription(subLabel, body) {
|
||||||
|
const decoded = decodeSubscriptionBody(body);
|
||||||
|
if (!decoded) return [];
|
||||||
|
const urls = decoded.split(/\r?\n|[\t ]+/).map((s) => s.trim()).filter((s) => /^vless:\/\//i.test(s));
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < urls.length; i++) {
|
||||||
|
const url = urls[i];
|
||||||
|
let label;
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
label = decodeURIComponent(u.hash.replace(/^#/, "")) || u.hostname;
|
||||||
|
} catch { continue; }
|
||||||
|
out.push({
|
||||||
|
id: subServerId(subLabel, i),
|
||||||
|
label,
|
||||||
|
flag: "🔗",
|
||||||
|
country: "",
|
||||||
|
status: "ready",
|
||||||
|
vless: url,
|
||||||
|
_sub: subLabel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
async function fetchSubscription(url) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const t = setTimeout(() => controller.abort(), SUBSCRIPTION_FETCH_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const r = await fetch(url, { signal: controller.signal, redirect: "follow" });
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||||
|
return await r.text();
|
||||||
|
} finally { clearTimeout(t); }
|
||||||
|
}
|
||||||
|
// Subscriptions the user has added, keyed by their URL. Value is
|
||||||
|
// { label, addedAt, fetchedAt, servers[] } — servers[].vless is what
|
||||||
|
// resolveEndpoint() reads when the user clicks Turn on for a sub server.
|
||||||
|
async function readSubscriptions() {
|
||||||
|
try { return (await api.storage.get("subscriptions", null)) || {}; }
|
||||||
|
catch { return {}; }
|
||||||
|
}
|
||||||
|
async function writeSubscriptions(subs) {
|
||||||
|
try { await api.storage.set("subscriptions", subs); } catch {}
|
||||||
|
}
|
||||||
|
async function addSubscription({ url, label }) {
|
||||||
|
const clean = String(url || "").trim();
|
||||||
|
if (!/^https?:\/\//i.test(clean)) throw new Error("subscription URL must be http:// or https://");
|
||||||
|
const body = await fetchSubscription(clean);
|
||||||
|
const nice = String(label || new URL(clean).host).trim();
|
||||||
|
const parsed = parseSubscription(nice, body);
|
||||||
|
if (!parsed.length) throw new Error("no vless:// entries in that subscription");
|
||||||
|
const subs = await readSubscriptions();
|
||||||
|
subs[clean] = { label: nice, url: clean, addedAt: (subs[clean]?.addedAt) || Date.now(), fetchedAt: Date.now(), servers: parsed };
|
||||||
|
await writeSubscriptions(subs);
|
||||||
|
api.log(`subscription "${nice}" imported: ${parsed.length} servers`);
|
||||||
|
return { count: parsed.length, label: nice };
|
||||||
|
}
|
||||||
|
async function refreshSubscription(url) {
|
||||||
|
const subs = await readSubscriptions();
|
||||||
|
const entry = subs[url];
|
||||||
|
if (!entry) throw new Error("no such subscription");
|
||||||
|
if (Date.now() - (entry.fetchedAt || 0) < SUBSCRIPTION_TTL_MS) return { skipped: true };
|
||||||
|
const body = await fetchSubscription(url);
|
||||||
|
const parsed = parseSubscription(entry.label, body);
|
||||||
|
entry.servers = parsed;
|
||||||
|
entry.fetchedAt = Date.now();
|
||||||
|
await writeSubscriptions(subs);
|
||||||
|
return { count: parsed.length };
|
||||||
|
}
|
||||||
|
async function removeSubscription(url) {
|
||||||
|
const subs = await readSubscriptions();
|
||||||
|
if (!subs[url]) return false;
|
||||||
|
delete subs[url];
|
||||||
|
await writeSubscriptions(subs);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
async function subscriptionServers() {
|
||||||
|
const subs = await readSubscriptions();
|
||||||
|
const out = [];
|
||||||
|
for (const sub of Object.values(subs)) {
|
||||||
|
for (const s of (sub.servers || [])) out.push(s);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// On activation, hydrate from the last cached response if the gateway is
|
// On activation, hydrate from the last cached response if the gateway is
|
||||||
// currently unreachable; then kick off a background refresh.
|
// currently unreachable; then kick off a background refresh.
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|
@ -249,6 +366,15 @@ module.exports = {
|
||||||
serverListFetchedAt = cached.at || Date.now();
|
serverListFetchedAt = cached.at || Date.now();
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
// Merge subscription servers on top so a fresh-boot panel shows them.
|
||||||
|
try {
|
||||||
|
const subServers = await subscriptionServers();
|
||||||
|
if (subServers.length) {
|
||||||
|
const byId = new Map(serverList.map((s) => [s.id, s]));
|
||||||
|
for (const s of subServers) byId.set(s.id, s);
|
||||||
|
serverList = [...byId.values()];
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
refreshServerList().catch(() => {});
|
refreshServerList().catch(() => {});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
@ -298,10 +424,18 @@ module.exports = {
|
||||||
|
|
||||||
// Resolve either a raw vless:// URL or a serverId lookup into the URL to
|
// 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
|
// hand to sing-box. The panel usually sends { serverId } for a preset and
|
||||||
// { vless } for a custom paste; either shape is accepted.
|
// { vless } for a custom paste; either shape is accepted. A serverId of
|
||||||
function resolveEndpoint({ vless, serverId }) {
|
// the form "sub-<hash>" refers to a subscription-imported server whose
|
||||||
|
// vless URL lives in the addon's private storage, never in the panel.
|
||||||
|
async function resolveEndpoint({ vless, serverId }) {
|
||||||
if (vless && /^vless:\/\//i.test(vless)) return vless;
|
if (vless && /^vless:\/\//i.test(vless)) return vless;
|
||||||
if (serverId) {
|
if (serverId) {
|
||||||
|
if (serverId.startsWith("sub-")) {
|
||||||
|
const subServers = await subscriptionServers();
|
||||||
|
const hit = subServers.find((s) => s.id === serverId);
|
||||||
|
if (!hit || !hit.vless) throw new Error(`no subscription server with id ${serverId}`);
|
||||||
|
return hit.vless;
|
||||||
|
}
|
||||||
const hit = serverList.find((s) => s.id === serverId);
|
const hit = serverList.find((s) => s.id === serverId);
|
||||||
if (!hit) throw new Error(`no server with id ${serverId} in the catalog`);
|
if (!hit) throw new Error(`no server with id ${serverId} in the catalog`);
|
||||||
if (!hit.vless || hit.status === "coming-soon") {
|
if (!hit.vless || hit.status === "coming-soon") {
|
||||||
|
|
@ -314,7 +448,7 @@ module.exports = {
|
||||||
|
|
||||||
async function turnOn(payload) {
|
async function turnOn(payload) {
|
||||||
if (state.running) return snapshot();
|
if (state.running) return snapshot();
|
||||||
const vlessUrl = resolveEndpoint(payload || {});
|
const vlessUrl = await resolveEndpoint(payload || {});
|
||||||
const vless = parseVless(vlessUrl);
|
const vless = parseVless(vlessUrl);
|
||||||
// The binary must exist and match its pinned sha256 BEFORE spawn.
|
// The binary must exist and match its pinned sha256 BEFORE spawn.
|
||||||
let bin;
|
let bin;
|
||||||
|
|
@ -373,16 +507,32 @@ module.exports = {
|
||||||
return snapshot();
|
return snapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cached subscription-server list so snapshot() stays synchronous. The
|
||||||
|
// storage read for the current subscription set is refreshed after every
|
||||||
|
// add/remove/refresh op.
|
||||||
|
let subServerCache = [];
|
||||||
|
async function refreshSubServerCache() {
|
||||||
|
try { subServerCache = await subscriptionServers(); } catch { subServerCache = []; }
|
||||||
|
}
|
||||||
|
// Warm the cache on activation once storage is ready. Not awaited on
|
||||||
|
// purpose — the panel polls status() and will pick up entries once loaded.
|
||||||
|
refreshSubServerCache();
|
||||||
|
|
||||||
function snapshot() {
|
function snapshot() {
|
||||||
// Trim the server catalog for the panel: never leak the raw vless URL
|
// 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
|
// (it's a credential in the free-tier model). The panel only needs the
|
||||||
// label, flag, country and whether the entry is usable.
|
// label, flag, country and whether the entry is usable.
|
||||||
const publicServers = serverList.map((s) => ({
|
const combined = [
|
||||||
|
...serverList,
|
||||||
|
...subServerCache.filter((s) => !serverList.find((x) => x.id === s.id)),
|
||||||
|
];
|
||||||
|
const publicServers = combined.map((s) => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
label: s.label || s.id,
|
label: s.label || s.id,
|
||||||
flag: s.flag || "🌐",
|
flag: s.flag || "🌐",
|
||||||
country: s.country || "",
|
country: s.country || "",
|
||||||
status: s.vless && s.status !== "coming-soon" ? "ready" : (s.status || "coming-soon"),
|
status: s.vless && s.status !== "coming-soon" ? "ready" : (s.status || "coming-soon"),
|
||||||
|
sub: s._sub || null,
|
||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
running: state.running,
|
running: state.running,
|
||||||
|
|
@ -414,8 +564,32 @@ module.exports = {
|
||||||
api.onMessage("refreshServers", async () => {
|
api.onMessage("refreshServers", async () => {
|
||||||
serverListFetchedAt = 0; // force
|
serverListFetchedAt = 0; // force
|
||||||
await refreshServerList();
|
await refreshServerList();
|
||||||
|
await refreshSubServerCache();
|
||||||
return snapshot();
|
return snapshot();
|
||||||
});
|
});
|
||||||
|
api.onMessage("listSubscriptions", async () => {
|
||||||
|
const subs = await readSubscriptions();
|
||||||
|
return Object.values(subs).map((s) => ({
|
||||||
|
url: s.url, label: s.label,
|
||||||
|
addedAt: s.addedAt, fetchedAt: s.fetchedAt,
|
||||||
|
count: (s.servers || []).length,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
api.onMessage("addSubscription", async (p) => {
|
||||||
|
const r = await addSubscription(p || {});
|
||||||
|
await refreshSubServerCache();
|
||||||
|
return { ...r, snapshot: snapshot() };
|
||||||
|
});
|
||||||
|
api.onMessage("refreshSubscription", async (p) => {
|
||||||
|
const r = await refreshSubscription(String(p && p.url || ""));
|
||||||
|
await refreshSubServerCache();
|
||||||
|
return { ...r, snapshot: snapshot() };
|
||||||
|
});
|
||||||
|
api.onMessage("removeSubscription", async (p) => {
|
||||||
|
const ok = await removeSubscription(String(p && p.url || ""));
|
||||||
|
await refreshSubServerCache();
|
||||||
|
return { ok, snapshot: snapshot() };
|
||||||
|
});
|
||||||
api.onMessage("clearCache", () => {
|
api.onMessage("clearCache", () => {
|
||||||
try {
|
try {
|
||||||
for (const f of fs.readdirSync(cacheDir)) fs.unlinkSync(path.join(cacheDir, f));
|
for (const f of fs.readdirSync(cacheDir)) fs.unlinkSync(path.join(cacheDir, f));
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,18 @@ button.btn.small { padding: 4px 9px; font-size: 11.5px; }
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Subscription</h2>
|
||||||
|
<p>Paste an HTTPS URL that returns a list of <code>vless://</code> servers.
|
||||||
|
The URL and its servers stay on this device.</p>
|
||||||
|
<div class="row">
|
||||||
|
<input id="subUrl" type="url" placeholder="https://provider.example/subscribe/…" style="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"/>
|
||||||
|
<button class="btn small" id="subAdd">Add</button>
|
||||||
|
</div>
|
||||||
|
<div id="subList" class="kv" style="grid-template-columns:1fr auto auto;gap:6px 8px;font-size:12px"></div>
|
||||||
|
<div class="hint" id="subHint" style="text-align:left"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<details class="disclosure">
|
<details class="disclosure">
|
||||||
<summary>Advanced</summary>
|
<summary>Advanced</summary>
|
||||||
|
|
||||||
|
|
@ -309,6 +321,46 @@ $("refreshServers").addEventListener("click", async () => {
|
||||||
catch (e) { hint.className = "hint err"; hint.textContent = e?.message || String(e); }
|
catch (e) { hint.className = "hint err"; hint.textContent = e?.message || String(e); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- subscription import ----
|
||||||
|
async function renderSubList() {
|
||||||
|
const subs = await SM().invoke("listSubscriptions", {});
|
||||||
|
const host = document.getElementById("subList");
|
||||||
|
if (!subs.length) { host.innerHTML = ""; return; }
|
||||||
|
host.innerHTML = subs.map((s) => {
|
||||||
|
const url = s.url.replace(/"/g, """);
|
||||||
|
return `<div style="overflow-wrap:anywhere"><b style="color:var(--ink)">${s.label}</b><div style="color:var(--dim);font-size:11px">${s.count} servers · ${s.url}</div></div>
|
||||||
|
<button class="btn small" data-sub-refresh="${url}">↻</button>
|
||||||
|
<button class="btn small" data-sub-remove="${url}">✕</button>`;
|
||||||
|
}).join("");
|
||||||
|
host.querySelectorAll("[data-sub-refresh]").forEach((b) => b.addEventListener("click", async () => {
|
||||||
|
const subHint = document.getElementById("subHint");
|
||||||
|
subHint.className = "hint"; subHint.textContent = "refreshing…";
|
||||||
|
try {
|
||||||
|
const r = await SM().invoke("refreshSubscription", { url: b.dataset.subRefresh });
|
||||||
|
subHint.textContent = r.skipped ? "recent enough, skipped" : `${r.count} servers`;
|
||||||
|
render(r.snapshot);
|
||||||
|
renderSubList();
|
||||||
|
} catch (e) { subHint.className = "hint err"; subHint.textContent = e?.message || String(e); }
|
||||||
|
}));
|
||||||
|
host.querySelectorAll("[data-sub-remove]").forEach((b) => b.addEventListener("click", async () => {
|
||||||
|
const r = await SM().invoke("removeSubscription", { url: b.dataset.subRemove });
|
||||||
|
render(r.snapshot); renderSubList();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
document.getElementById("subAdd").addEventListener("click", async () => {
|
||||||
|
const url = document.getElementById("subUrl").value.trim();
|
||||||
|
const subHint = document.getElementById("subHint");
|
||||||
|
if (!url) { subHint.className = "hint err"; subHint.textContent = "paste a URL first"; return; }
|
||||||
|
subHint.className = "hint"; subHint.textContent = "fetching…";
|
||||||
|
try {
|
||||||
|
const r = await SM().invoke("addSubscription", { url });
|
||||||
|
document.getElementById("subUrl").value = "";
|
||||||
|
subHint.className = "hint"; subHint.textContent = `added ${r.label} — ${r.count} servers`;
|
||||||
|
render(r.snapshot);
|
||||||
|
renderSubList();
|
||||||
|
} catch (e) { subHint.className = "hint err"; subHint.textContent = e?.message || String(e); }
|
||||||
|
});
|
||||||
|
|
||||||
$("save").addEventListener("click", saveEndpoint);
|
$("save").addEventListener("click", saveEndpoint);
|
||||||
$("paste").addEventListener("click", async () => {
|
$("paste").addEventListener("click", async () => {
|
||||||
try { $("vless").value = await navigator.clipboard.readText(); } catch {}
|
try { $("vless").value = await navigator.clipboard.readText(); } catch {}
|
||||||
|
|
@ -339,6 +391,7 @@ try {
|
||||||
await loadSelection();
|
await loadSelection();
|
||||||
await loadAuto();
|
await loadAuto();
|
||||||
render(await SM().invoke("status", {}));
|
render(await SM().invoke("status", {}));
|
||||||
|
renderSubList();
|
||||||
// Auto-on: if it was on before, try to bring it back with the last saved
|
// 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,
|
// choice — server id if any, else custom vless. Silent failure is fine,
|
||||||
// the toggle stays in "off" and the hint carries the reason.
|
// the toggle stays in "off" and the hint carries the reason.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue