feat(theseus/ariadne): settings panel — policy + per-source toggles + status report

Ariadne 0.1.13 exposed /api/status and per-source enable flags in
policy.json. Theseus's Plug-ins > Ariadne's Thread sub-page now wires those
into a full UI, no daemon restart, no UAC.

Added to the plugins-ariadne sub-page (after Status, before Remove):

  Collision policy   -- radio group (BCNR-first / ICANN-first) writes
                        C:\ProgramData\Ariadne\policy.json.policy; hot-reloaded
                        by the daemon within 5 s.
  Sources            -- 3-column grid, one row per source (snapshotHttps,
                        electrumWss, perQueryLookup, diskCache, localApi):
                        enable checkbox + last-state summary
                        (last success / last error / hit-miss counters /
                        disk-cache size+mtime). Toggle writes
                        policy.json.sources.<name>.enabled and re-polls after
                        the 5-s hot-reload tick so the state text catches up.
  Status report      -- <pre> JSON dump of GET http://127.0.0.1/api/status
                        with Copy report + Refresh report buttons. This is
                        the paste-me-into-support artefact for any diagnosis.

IPC wiring:
  main.js
    ariadne-get-status  -> GET http://127.0.0.1/api/status  ({ok, status|error})
    ariadne-get-policy  -> read C:\ProgramData\Ariadne\policy.json (or {})
    ariadne-set-policy  -> merge {policy}, write back (validates enum)
    ariadne-set-source  -> merge {sources.<name>.enabled}, write back
                          (validates against the known 5 names)
  settings-preload.js
    ariadneGetStatus, ariadneGetPolicy, ariadneSetPolicy, ariadneSetSource

All four handlers write policy.json as the local user; no UAC. Works because
install.ps1 grants BUILTIN\Users Modify on the file (0.1.7+).

Sub-page auto-refreshes state every time it opens (listens on the existing
'section' custom event dispatched by showSection).

Not building/shipping Theseus here -- this rides the next Theseus release.
Panel gracefully handles: daemon down (shows 'Daemon unreachable' with a
pointer to the Status toggle), localApi disabled (daemon returns 503, panel
shows the error), missing policy.json (all sources default to true).
This commit is contained in:
Local Dev 2026-10-01 00:51:50 +02:00
parent a3fb8917c3
commit 596ea09fc7
3 changed files with 195 additions and 0 deletions

60
main.js
View file

@ -5623,6 +5623,66 @@ ipcMain.handle("ariadne-toggle", (_e, on) => ariadneSetState(!!on).catch((e) =>
ipcMain.handle("ariadne-install", () => ariadneInstall().then(() => ({ ok: true })).catch((e) => ({ ok: false, error: e?.message || String(e) }))); ipcMain.handle("ariadne-install", () => ariadneInstall().then(() => ({ ok: true })).catch((e) => ({ ok: false, error: e?.message || String(e) })));
ipcMain.handle("ariadne-update", () => ariadneUpdate().then(() => ({ ok: true })).catch((e) => ({ ok: false, error: e?.message || String(e) }))); ipcMain.handle("ariadne-update", () => ariadneUpdate().then(() => ({ ok: true })).catch((e) => ({ ok: false, error: e?.message || String(e) })));
ipcMain.handle("ariadne-uninstall", () => ariadneUninstall().then(() => ({ ok: true })).catch((e) => ({ ok: false, error: e?.message || String(e) }))); ipcMain.handle("ariadne-uninstall", () => ariadneUninstall().then(() => ({ ok: true })).catch((e) => ({ ok: false, error: e?.message || String(e) })));
// ---- Ariadne 0.1.13+ settings + status wiring --------------------------
// The daemon's own read/write surface lives at http://127.0.0.1/api/status
// and C:\ProgramData\Ariadne\policy.json. The policy file is ACL'd user-
// writable by install.ps1, so all four of these run WITHOUT UAC.
const ARIADNE_POLICY_FILE = (() => {
const dir = process.env.PROGRAMDATA || "C:\\ProgramData";
return path.join(dir, "Ariadne", "policy.json");
})();
function ariadneReadPolicyFile() {
try {
if (!fs.existsSync(ARIADNE_POLICY_FILE)) return {};
const raw = fs.readFileSync(ARIADNE_POLICY_FILE, "utf8").replace(/^\uFEFF/, "");
return JSON.parse(raw) || {};
} catch { return {}; }
}
function ariadneWritePolicyFile(obj) {
try {
fs.mkdirSync(path.dirname(ARIADNE_POLICY_FILE), { recursive: true });
fs.writeFileSync(ARIADNE_POLICY_FILE, JSON.stringify(obj, null, 2), "utf8");
return { ok: true };
} catch (e) { return { ok: false, error: e?.message || String(e) }; }
}
// GET http://127.0.0.1/api/status against the local daemon. Returns the full
// status document, or {ok:false, error} on any failure (daemon down, port
// blocked, localApi turned off in policy.json -> the daemon returns 503).
function ariadneFetchStatus() {
return new Promise((resolve) => {
const req = http.request({ host: "127.0.0.1", port: 80, path: "/api/status", method: "GET", headers: { "user-agent": "TheseusNavigator/ariadne-panel" } }, (r) => {
const chunks = [];
r.on("data", (c) => chunks.push(c));
r.on("end", () => {
if (r.statusCode !== 200) return resolve({ ok: false, error: `daemon HTTP ${r.statusCode}`, body: Buffer.concat(chunks).toString("utf8").slice(0, 400) });
try { resolve({ ok: true, status: JSON.parse(Buffer.concat(chunks).toString("utf8")) }); }
catch (e) { resolve({ ok: false, error: "malformed JSON from daemon: " + e.message }); }
});
});
req.setTimeout(4000, () => req.destroy(new Error("daemon timeout on 127.0.0.1/api/status")));
req.on("error", (e) => resolve({ ok: false, error: e.message }));
req.end();
});
}
ipcMain.handle("ariadne-get-status", () => ariadneFetchStatus());
ipcMain.handle("ariadne-get-policy", () => ({ ok: true, policy: ariadneReadPolicyFile() }));
ipcMain.handle("ariadne-set-policy", (_e, value) => {
const v = String(value || "").toLowerCase();
if (!["bcnr-first", "icann-first"].includes(v)) return { ok: false, error: `invalid policy '${value}' (expected bcnr-first or icann-first)` };
const cur = ariadneReadPolicyFile();
cur.policy = v;
return ariadneWritePolicyFile(cur);
});
ipcMain.handle("ariadne-set-source", (_e, name, enabled) => {
const known = ["snapshotHttps", "electrumWss", "perQueryLookup", "diskCache", "localApi"];
if (!known.includes(String(name || ""))) return { ok: false, error: `unknown source '${name}' (known: ${known.join(", ")})` };
const cur = ariadneReadPolicyFile();
if (!cur.sources || typeof cur.sources !== "object") cur.sources = {};
if (!cur.sources[name] || typeof cur.sources[name] !== "object") cur.sources[name] = {};
cur.sources[name].enabled = !!enabled;
return ariadneWritePolicyFile(cur);
});
// Storage: clear right now (any subset). "history" also drops the saved-session file. // Storage: clear right now (any subset). "history" also drops the saved-session file.
// ---- Password vault ------------------------------------------------------- // ---- Password vault -------------------------------------------------------
// The vault lives at userData/passwords.vault (encrypted). Unlock state is // The vault lives at userData/passwords.vault (encrypted). Unlock state is

View file

@ -72,6 +72,13 @@ contextBridge.exposeInMainWorld("cfg", {
ariadneInstall: () => ipcRenderer.invoke("ariadne-install"), ariadneInstall: () => ipcRenderer.invoke("ariadne-install"),
ariadneUpdate: () => ipcRenderer.invoke("ariadne-update"), ariadneUpdate: () => ipcRenderer.invoke("ariadne-update"),
ariadneUninstall: () => ipcRenderer.invoke("ariadne-uninstall"), ariadneUninstall: () => ipcRenderer.invoke("ariadne-uninstall"),
// Local BNS daemon settings + status (0.1.13+). All read/write against
// C:\ProgramData\Ariadne\policy.json (user-writable ACL) and the daemon's
// own /api/status endpoint on 127.0.0.1. No UAC required for any of these.
ariadneGetStatus: () => ipcRenderer.invoke("ariadne-get-status"),
ariadneGetPolicy: () => ipcRenderer.invoke("ariadne-get-policy"),
ariadneSetPolicy: (policy) => ipcRenderer.invoke("ariadne-set-policy", String(policy || "")),
ariadneSetSource: (name, enabled) => ipcRenderer.invoke("ariadne-set-source", String(name || ""), !!enabled),
// Manual "Check for updates" — un-dismisses any existing chip and re- // Manual "Check for updates" — un-dismisses any existing chip and re-
// fetches the release manifest. Returns { updateAvailable, currentVersion }. // fetches the release manifest. Returns { updateAvailable, currentVersion }.
recheckUpdate: () => ipcRenderer.invoke("recheck-update"), recheckUpdate: () => ipcRenderer.invoke("recheck-update"),

View file

@ -105,6 +105,16 @@
.polrow{display:flex;align-items:center;gap:10px;background:#10151f;border:1px solid var(--line);border-radius:8px;padding:8px 12px;font-size:13px;cursor:pointer} .polrow{display:flex;align-items:center;gap:10px;background:#10151f;border:1px solid var(--line);border-radius:8px;padding:8px 12px;font-size:13px;cursor:pointer}
.polrow:hover{background:#141c28} .polrow:hover{background:#141c28}
.polrow input{accent-color:var(--acid)} .polrow input{accent-color:var(--acid)}
/* Ariadne sources table -- 3-col grid instead of <table> so it inherits our row styling and reflows nicely. */
.ariadne-sources{display:flex;flex-direction:column;gap:1px;background:var(--line);border:1px solid var(--line);border-radius:8px;overflow:hidden;font-size:13px}
.ariadne-sources .asrc-head, .ariadne-sources .asrc-row{display:grid;grid-template-columns:1fr 70px minmax(180px,2fr);align-items:center;gap:12px;padding:8px 12px;background:#10151f}
.ariadne-sources .asrc-head{font-weight:600;color:var(--muted);background:#0c1119;font-size:12px;text-transform:uppercase;letter-spacing:.4px}
.ariadne-sources .asrc-row .aname{color:var(--ink);font-weight:500}
.ariadne-sources .asrc-row .aname small{display:block;color:var(--muted);font-weight:400;margin-top:2px;font-size:11.5px}
.ariadne-sources .asrc-row .astate{color:var(--muted);font-size:11.5px;font-family:ui-monospace,'SF Mono',Menlo,Consolas,monospace}
.ariadne-sources .asrc-row .astate.ok{color:var(--acid-text)}
.ariadne-sources .asrc-row .astate.err{color:#f6768a}
.ariadne-sources input[type=checkbox]{accent-color:var(--acid);width:18px;height:18px;cursor:pointer}
.pmuted{color:var(--mut)} .pmuted{color:var(--mut)}
.ceng{display:flex;align-items:center;gap:8px;background:#10151f;border:1px solid var(--line);border-radius:8px;padding:6px 10px;margin-bottom:6px;font-size:13px} .ceng{display:flex;align-items:center;gap:8px;background:#10151f;border:1px solid var(--line);border-radius:8px;padding:6px 10px;margin-bottom:6px;font-size:13px}
.ceng .cs{font-size:14px;flex:none} .ceng .cs{font-size:14px;flex:none}
@ -217,6 +227,12 @@
hover tints keyed to BCH dark for consistent depth. */ hover tints keyed to BCH dark for consistent depth. */
.polrow{ background:#f4f8fb; color:#253A49; } .polrow{ background:#f4f8fb; color:#253A49; }
.polrow:hover{ background:#eaf0f5; } .polrow:hover{ background:#eaf0f5; }
.ariadne-sources{background:#e6ecf1;border-color:#e6ecf1}
.ariadne-sources .asrc-head, .ariadne-sources .asrc-row{background:#f4f8fb;color:#253A49}
.ariadne-sources .asrc-head{background:#eaf0f5;color:#5a7080}
.ariadne-sources .asrc-row .aname{color:#253A49}
.ariadne-sources .asrc-row .aname small{color:#5a7080}
#ariadneStatusJson{background:#f4f8fb !important;color:#253A49}
.polrow input:checked ~ span, .polrow input:checked ~ span,
.polrow:has(input:checked){ background:rgb(from var(--acid) r g b / .10); border-color:color-mix(in srgb, var(--acid) 35%, transparent); } .polrow:has(input:checked){ background:rgb(from var(--acid) r g b / .10); border-color:color-mix(in srgb, var(--acid) 35%, transparent); }
.segseg{ background:#eef1f6; } .segseg{ background:#eef1f6; }
@ -398,6 +414,28 @@
<button id="ariadneRefreshSub" class="btn">Refresh</button> <button id="ariadneRefreshSub" class="btn">Refresh</button>
</div> </div>
<h2 class="sub">Collision policy</h2>
<p class="subd">When a name exists on both BCNR and ICANN, which one wins? Applies machine-wide — every browser sees the same answer. Change is picked up within 5 seconds; no daemon restart, no UAC.</p>
<div class="row" style="flex-direction:column;align-items:stretch;gap:6px">
<label class="polrow"><input type="radio" name="ariadnePolicy" value="bcnr-first"><span><b>BCNR first</b> <span class="pmuted">— use BCNR when the name is on chain; forward to ICANN on miss. Default.</span></span></label>
<label class="polrow"><input type="radio" name="ariadnePolicy" value="icann-first"><span><b>ICANN first</b> <span class="pmuted">— forward to ICANN; use BCNR only when ICANN returns NXDOMAIN.</span></span></label>
</div>
<h2 class="sub">Sources</h2>
<p class="subd">Each index source can be toggled independently to isolate a path or measure without it. Changes hot-reload every 5 s via <code>C:\ProgramData\Ariadne\policy.json</code> — no restart. State column shows the last attempt result for each.</p>
<div class="ariadne-sources" role="table" aria-label="Ariadne index sources">
<div class="asrc-head" role="row"><span>Source</span><span>Enabled</span><span>State</span></div>
<div id="ariadneSourcesBody"></div>
</div>
<h2 class="sub">Status report</h2>
<p class="subd">Full snapshot of what the daemon currently sees — daemon version, permitted TLDs, index size, last fetch per source. Paste into a support conversation.</p>
<div class="row" style="justify-content:flex-end;gap:8px">
<button id="ariadneStatusCopy" class="btn">Copy report</button>
<button id="ariadneStatusRefresh" class="btn">Refresh report</button>
</div>
<pre id="ariadneStatusJson" style="max-height:320px;overflow:auto;background:rgba(0,0,0,.25);padding:12px;border-radius:6px;font-size:11.5px;font-family:ui-monospace,'SF Mono',Menlo,Consolas,monospace;line-height:1.45;margin:8px 0 0">Loading…</pre>
<h2 class="sub">Remove</h2> <h2 class="sub">Remove</h2>
<p class="subd">Uninstall the resolver from this machine. Theseus itself keeps working — its built-in resolver isn't affected.</p> <p class="subd">Uninstall the resolver from this machine. Theseus itself keeps working — its built-in resolver isn't affected.</p>
<div class="row"> <div class="row">
@ -1547,6 +1585,96 @@
arRefreshSub.onclick = refreshAriadne; arRefreshSub.onclick = refreshAriadne;
refreshAriadne(); refreshAriadne();
// ---- Ariadne 0.1.13+ sub-page: policy + sources + status report -----
// All read/write against the daemon's /api/status (over 127.0.0.1) and
// C:\ProgramData\Ariadne\policy.json (user-writable ACL) -- no UAC.
const arPolicyRadios = document.querySelectorAll('input[name="ariadnePolicy"]');
const arSourcesBody = q("ariadneSourcesBody");
const arStatusJson = q("ariadneStatusJson");
const arStatusCopy = q("ariadneStatusCopy");
const arStatusRefr = q("ariadneStatusRefresh");
const SOURCE_DESCRIPTIONS = {
snapshotHttps: { label: "HTTPS snapshot (VPS + Sia)", hint: "dl.silentmode.st + s3.silentmode.st, polled every 30 s with ETag" },
electrumWss: { label: "Electrum WSS (chipnet)", hint: "direct chain read via WebSocket; secondary refresh" },
perQueryLookup: { label: "Per-query indexer fallback", hint: "navigate.st/api/name/&lt;host&gt; on index miss, 30-s LRU" },
diskCache: { label: "Disk cache", hint: "C:\\ProgramData\\Ariadne\\bns-name-snapshot.json — read at boot, written on any HTTPS win" },
localApi: { label: "Local BNS indexer API", hint: "http://127.0.0.1/api/tlds, /api/name/:n, /api/registry, /api/status" },
};
function fmtTime(iso) {
if (!iso) return "never";
try { const d = new Date(iso); const s = Math.round((Date.now() - d.getTime()) / 1000); return s < 60 ? `${s} s ago` : s < 3600 ? `${Math.round(s/60)} m ago` : d.toLocaleString(); } catch { return String(iso); }
}
function sourceState(name, st) {
if (!st) return { text: "no state yet", ok: false, err: false };
if (name === "diskCache") return { text: st.exists ? `${(st.size||0).toLocaleString()} B · ${fmtTime(st.mtime)}` : "no file yet", ok: !!st.exists, err: false };
if (name === "localApi") return { text: st.enabled ? "listening" : "disabled", ok: !!st.enabled, err: false };
if (name === "perQueryLookup") return { text: `${st.hits||0} hit · ${st.misses||0} miss · ${st.errors||0} err · last ${fmtTime(st.lastAttempt)}`, ok: (st.hits||0) > 0 || !st.lastAttempt, err: (st.errors||0) > 0 };
if (st.lastError) return { text: `error: ${st.lastError.slice(0, 80)} · last ${fmtTime(st.lastAttempt)}`, ok: false, err: true };
if (st.lastSuccess) return { text: `ok · last success ${fmtTime(st.lastSuccess)}` + (st.refreshes ? ` · ${st.refreshes} refresh${st.refreshes>1?"es":""}` : ""), ok: true, err: false };
return { text: `waiting · last try ${fmtTime(st.lastAttempt)}`, ok: false, err: false };
}
let arLastStatus = null;
async function refreshAriadneStatus() {
arStatusJson.textContent = "Loading…";
arSourcesBody.innerHTML = "";
const r = await C.ariadneGetStatus();
if (!r || !r.ok) {
arStatusJson.textContent = `Cannot reach the daemon: ${r?.error || "unknown error"}\n\nIs Ariadne's Thread running? Turn it on from the Status section above.`;
arSourcesBody.innerHTML = '<div class="asrc-row"><span class="aname" style="grid-column:1 / -1;color:var(--muted)">Daemon unreachable — start it from Status above.</span></div>';
return;
}
arLastStatus = r.status;
arStatusJson.textContent = JSON.stringify(r.status, null, 2);
const sources = r.status.sources || {};
arSourcesBody.innerHTML = "";
for (const name of Object.keys(SOURCE_DESCRIPTIONS)) {
const d = SOURCE_DESCRIPTIONS[name];
const st = sources[name] || {};
const s = sourceState(name, st);
const row = document.createElement("div");
row.className = "asrc-row";
row.innerHTML = `<span class="aname">${d.label}<small>${d.hint}</small></span>` +
`<span><input type="checkbox" data-src="${name}" ${st.enabled ? "checked" : ""}></span>` +
`<span class="astate ${s.ok ? "ok" : s.err ? "err" : ""}">${s.text}</span>`;
arSourcesBody.appendChild(row);
}
// Wire the enable checkboxes AFTER they're in the DOM.
arSourcesBody.querySelectorAll('input[type="checkbox"][data-src]').forEach((cb) => {
cb.addEventListener("change", async () => {
cb.disabled = true;
const w = await C.ariadneSetSource(cb.dataset.src, cb.checked);
cb.disabled = false;
if (!w?.ok) { alert(`Could not save: ${w?.error || "unknown error"}`); cb.checked = !cb.checked; return; }
// Poll again after the daemon's 5-s hot-reload tick so state text catches up.
setTimeout(refreshAriadneStatus, 6000);
});
});
}
async function loadAriadnePolicy() {
const r = await C.ariadneGetPolicy();
const p = (r?.policy?.policy || "bcnr-first").toLowerCase();
arPolicyRadios.forEach((rb) => { rb.checked = (rb.value === p); });
}
arPolicyRadios.forEach((rb) => {
rb.addEventListener("change", async () => {
if (!rb.checked) return;
const w = await C.ariadneSetPolicy(rb.value);
if (!w?.ok) alert(`Could not save policy: ${w?.error || "unknown error"}`);
});
});
arStatusRefr.onclick = refreshAriadneStatus;
arStatusCopy.onclick = async () => {
if (!arLastStatus) return;
try { await navigator.clipboard.writeText(JSON.stringify(arLastStatus, null, 2)); arStatusCopy.textContent = "Copied"; setTimeout(() => (arStatusCopy.textContent = "Copy report"), 1500); }
catch { arStatusCopy.textContent = "Copy failed"; setTimeout(() => (arStatusCopy.textContent = "Copy report"), 1500); }
};
// Load once on script boot; also every time the user navigates INTO the
// Ariadne sub-page (fresh state, no cached-stale JSON on re-entry).
loadAriadnePolicy(); refreshAriadneStatus();
document.addEventListener("section", (e) => {
if (e.detail === "plugins/ariadne") { loadAriadnePolicy(); refreshAriadneStatus(); }
});
// AEGIS —————————————————————————————————————————————————————————— // AEGIS ——————————————————————————————————————————————————————————
// Bundled add-on shipped OTA. Same addons-check-updates IPC the // Bundled add-on shipped OTA. Same addons-check-updates IPC the
// Extensions page uses, filtered for the aegis id. Legacy id // Extensions page uses, filtered for the aegis id. Legacy id