From 596ea09fc753090b32385a8130ebe274d7e611d2 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Thu, 1 Oct 2026 00:51:50 +0200 Subject: [PATCH] =?UTF-8?q?feat(theseus/ariadne):=20settings=20panel=20?= =?UTF-8?q?=E2=80=94=20policy=20+=20per-source=20toggles=20+=20status=20re?= =?UTF-8?q?port?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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..enabled and re-polls after the 5-s hot-reload tick so the state text catches up. Status report --
 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..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).
---
 main.js             |  60 +++++++++++++++++++++
 settings-preload.js |   7 +++
 settings.html       | 128 ++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 195 insertions(+)

diff --git a/main.js b/main.js
index 71022fff..a7ddce83 100644
--- a/main.js
+++ b/main.js
@@ -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-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) })));
+
+// ---- 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.
 // ---- Password vault -------------------------------------------------------
 // The vault lives at userData/passwords.vault (encrypted). Unlock state is
diff --git a/settings-preload.js b/settings-preload.js
index ff7bb4a7..84bed9da 100644
--- a/settings-preload.js
+++ b/settings-preload.js
@@ -72,6 +72,13 @@ contextBridge.exposeInMainWorld("cfg", {
   ariadneInstall:   () => ipcRenderer.invoke("ariadne-install"),
   ariadneUpdate:    () => ipcRenderer.invoke("ariadne-update"),
   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-
   // fetches the release manifest. Returns { updateAvailable, currentVersion }.
   recheckUpdate: () => ipcRenderer.invoke("recheck-update"),
diff --git a/settings.html b/settings.html
index 0e19ec4c..e3cac5db 100644
--- a/settings.html
+++ b/settings.html
@@ -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:hover{background:#141c28}
   .polrow input{accent-color:var(--acid)}
+  /* Ariadne sources table -- 3-col grid instead of  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)}
   .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}
@@ -217,6 +227,12 @@
        hover tints keyed to BCH dark for consistent depth. */
     .polrow{ background:#f4f8fb; color:#253A49; }
     .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: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; }
@@ -398,6 +414,28 @@
         
 
+      

Collision policy

+

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.

+
+ + +
+ +

Sources

+

Each index source can be toggled independently to isolate a path or measure without it. Changes hot-reload every 5 s via C:\ProgramData\Ariadne\policy.json — no restart. State column shows the last attempt result for each.

+
+
SourceEnabledState
+
+
+ +

Status report

+

Full snapshot of what the daemon currently sees — daemon version, permitted TLDs, index size, last fetch per source. Paste into a support conversation.

+
+ + +
+
Loading…
+

Remove

Uninstall the resolver from this machine. Theseus itself keeps working — its built-in resolver isn't affected.

@@ -1547,6 +1585,96 @@ arRefreshSub.onclick = 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/<host> 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 = '
Daemon unreachable — start it from Status above.
'; + 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 = `${d.label}${d.hint}` + + `` + + `${s.text}`; + 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 —————————————————————————————————————————————————————————— // Bundled add-on shipped OTA. Same addons-check-updates IPC the // Extensions page uses, filtered for the aegis id. Legacy id