feat(theseus/settings): Ariadne — Install / Update / Uninstall alongside Turn on / off

Extends the Ariadne toggle card in Settings > Registries with the three
lifecycle actions the user asked for:

- Install: runs the bundled AriadneResolver-Setup-<ver>.exe silently
  and elevated (/VERYSILENT /SUPPRESSMSGBOXES /NORESTART). Single UAC
  prompt, no wizard.
- Update: same installer, run over the top. Inno Setup detects the
  matching AppId and upgrades in place. Only shown when the bundled
  version is newer than what's installed.
- Uninstall: reads Inno's QuietUninstallString from
  HKLM\...\Uninstall\{7E7A5F1C-...}_is1 and runs it elevated with
  /VERYSILENT /SUPPRESSMSGBOXES /NORESTART.

Status now surfaces the installed version + bundled version so the
user can see what's on disk vs what would be installed. Three new IPC
handlers: ariadne-install / ariadne-update / ariadne-uninstall. Every
button disables during work and shows a busy label; refresh runs
after success OR failure so the UI never lies.

Version compare + registry read live in main; both the WOW6432Node and
native uninstall paths are checked so the query works regardless of
which architecture bit Inno picked.
This commit is contained in:
Local Dev 2026-09-07 23:03:57 +02:00
parent 95d199c2f2
commit 93a9ffcbb3
3 changed files with 151 additions and 37 deletions

122
main.js
View file

@ -2556,13 +2556,18 @@ ipcMain.handle("toolbar-menu-popup", async (e, addonId, rect) => {
const id = String(addonId || "");
const menu = addonHost.getToolbarMenus().find((m) => m.addonId === id);
if (!menu) throw new Error(`toolbar-menu-popup: no menu for "${id}"`);
// Capture the clicked item id here; dispatch runs from the popup's
// `callback` below, AFTER the menu is torn down. Firing synchronously
// in the click handler catches the parent window still non-foreground
// (the OS menu popup is on top), which leaves Chromium's occlusion
// tracker marking the tab view as hidden — WebContents.capturePage()
// then snapshots a blank frame at the correct dimensions (not a 0x0
// that our retry could catch). Deferring until after callback lets
// focus return to the parent so the compositor is live at capture time.
let picked = null;
const template = menu.items.map((it) => ({
label: (it.icon ? String(it.icon) + " " : "") + String(it.label || it.id),
click: () => {
// Fire-and-forget dispatch; chrome doesn't need the return value.
addonHost.dispatch(id, "menu-select", { id: it.id }, { from: "toolbar-menu" })
.catch((err) => console.warn(`[addons] menu-select ${id}.${it.id} failed:`, err?.message || err));
},
click: () => { picked = it.id; },
}));
const popup = Menu.buildFromTemplate(template);
const chromeBounds = chrome ? chrome.getBounds() : { x: 0, y: 0 };
@ -2570,6 +2575,16 @@ ipcMain.handle("toolbar-menu-popup", async (e, addonId, rect) => {
const y = Math.max(0, Math.round(chromeBounds.y + (rect?.y || 0)));
popup.popup({ window: win, x, y, callback: () => {
try { chrome?.webContents.send("toolbar-menu-closed"); } catch {}
if (!picked) return; // user hit Escape or clicked outside
// Small settle before the handler runs. Electron fires this callback
// as the popup closes, but Windows takes a few frames to restore
// foreground state to the parent window; without the delay the
// compositor is still throttled when captureTab hits capturePage().
const iid = picked;
setTimeout(() => {
addonHost.dispatch(id, "menu-select", { id: iid }, { from: "toolbar-menu" })
.catch((err) => console.warn(`[addons] menu-select ${id}.${iid} failed:`, err?.message || err));
}, 120);
}});
return true;
});
@ -2947,35 +2962,63 @@ ipcMain.handle("collision-reset", () => { collisions = { byName: {}, byTld: {} }
// unaffected. Toggling requires admin (tasks run as SYSTEM) — start/stop go
// through an elevated powershell that UAC-prompts once per action.
const ARIADNE_TASKS = ["BNS Resolver Daemon", "BNS Sia Bridge"];
// Inno Setup's AppId + "_is1" is the uninstall registry key. Check both
// native and WOW6432 in case Inno installed either way.
const ARIADNE_UNINSTALL_KEYS = [
'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{7E7A5F1C-3B4E-4C8A-9E1D-ARIADNERSLVR}_is1',
'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{7E7A5F1C-3B4E-4C8A-9E1D-ARIADNERSLVR}_is1',
];
function ariadneBundledInstaller() {
// Bundled with Theseus; the file name tracks the version we ship.
const dir = app.isPackaged ? process.resourcesPath : path.join(__dirname, "build");
try {
const hit = fs.readdirSync(dir).find((f) => /^AriadneResolver-Setup-.*\.exe$/i.test(f));
if (hit) return { path: path.join(dir, hit), version: (hit.match(/-Setup-(.+)\.exe$/i) || [])[1] || null };
} catch {}
return { path: null, version: null };
}
function ariadneQueryState() {
return new Promise((resolve) => {
const { spawn } = require("child_process");
// Get-ScheduledTask returns Ready | Running | Disabled | (missing → error).
// Print state or "MISSING" for each task; MISSING on the primary task
// means Ariadne isn't installed at all.
// One shell round-trip for both bits of info:
// - task states for BNS Resolver Daemon + BNS Sia Bridge
// - installed version + quiet-uninstall string from Inno's registry key
const ps = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command",
"$names=@('BNS Resolver Daemon','BNS Sia Bridge');" +
"$names | ForEach-Object { $t = Get-ScheduledTask -TaskName $_ -ErrorAction SilentlyContinue;" +
" if ($t) { \"$_=$($t.State)\" } else { \"$_=MISSING\" } }"], { windowsHide: true });
" if ($t) { \"$_=$($t.State)\" } else { \"$_=MISSING\" } };" +
"$keys=@(" + ARIADNE_UNINSTALL_KEYS.map((k) => "'" + k + "'").join(",") + ");" +
"foreach ($k in $keys) { if (Test-Path $k) { $r=Get-ItemProperty $k; \"__VER__=$($r.DisplayVersion)\"; \"__UNINSTALL__=$($r.QuietUninstallString)\"; break } }"
], { windowsHide: true });
let out = "";
ps.stdout.on("data", (d) => { out += d; });
ps.on("close", () => {
const lines = out.trim().split(/\r?\n/).filter(Boolean);
const map = Object.fromEntries(lines.map((l) => { const i = l.lastIndexOf("="); return [l.slice(0, i), l.slice(i + 1)]; }));
const primary = map["BNS Resolver Daemon"];
if (!primary || primary === "MISSING") return resolve({ state: "not-installed" });
if (primary === "Running") return resolve({ state: "running" });
return resolve({ state: "stopped" });
const installedVersion = map.__VER__ || null;
const quietUninstall = map.__UNINSTALL__ || null;
const bundled = ariadneBundledInstaller();
const canUpdate = installedVersion && bundled.version && cmpVersions(bundled.version, installedVersion) > 0;
let state;
if (!primary || primary === "MISSING") state = installedVersion ? "stopped" : "not-installed";
else if (primary === "Running") state = "running";
else state = "stopped";
resolve({ state, installedVersion, bundledVersion: bundled.version, canUpdate, hasUninstaller: !!quietUninstall });
});
ps.on("error", () => resolve({ state: "not-installed" }));
ps.on("error", () => resolve({ state: "not-installed", installedVersion: null, bundledVersion: null, canUpdate: false, hasUninstaller: false }));
});
}
function cmpVersions(a, b) {
const pa = String(a).split(".").map((n) => parseInt(n, 10) || 0);
const pb = String(b).split(".").map((n) => parseInt(n, 10) || 0);
const n = Math.max(pa.length, pb.length);
for (let i = 0; i < n; i++) { const d = (pa[i] || 0) - (pb[i] || 0); if (d) return d < 0 ? -1 : 1; }
return 0;
}
function ariadneSetState(on) {
return new Promise((resolve, reject) => {
const { spawn } = require("child_process");
// Elevated PowerShell — triggers UAC prompt. -Verb RunAs starts a separate
// elevated process; we can't easily capture its stdout, but we don't need
// to (re-query state afterwards).
const verb = on ? "Start-ScheduledTask" : "Stop-ScheduledTask";
const cmd = `foreach ($t in 'BNS Resolver Daemon','BNS Sia Bridge') { try { ${verb} -TaskName $t -ErrorAction Stop } catch {} }`;
const ps = spawn("powershell.exe", ["-NoProfile", "-Command",
@ -2984,8 +3027,53 @@ function ariadneSetState(on) {
ps.on("error", (e) => reject(e));
});
}
ipcMain.handle("ariadne-state", () => ariadneQueryState().catch(() => ({ state: "not-installed" })));
// Run the bundled Ariadne installer silently, elevated. Inno Setup with
// /VERYSILENT /SUPPRESSMSGBOXES /NORESTART finishes without user interaction
// after the initial UAC prompt.
function ariadneInstall() {
const bundled = ariadneBundledInstaller();
if (!bundled.path) return Promise.reject(new Error("bundled installer not found"));
const { spawn } = require("child_process");
return new Promise((resolve, reject) => {
const ps = spawn("powershell.exe", ["-NoProfile", "-Command",
`Start-Process -FilePath '${bundled.path.replace(/'/g, "''")}' -ArgumentList '/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART' -Verb RunAs -Wait`
], { windowsHide: true });
ps.on("close", (code) => code === 0 ? resolve(true) : reject(new Error("installer exited " + code)));
ps.on("error", reject);
});
}
// Run Inno's own quiet uninstaller. Reads the QuietUninstallString from the
// registry (already ends in / VERYSILENT) and spawns it elevated.
function ariadneUninstall() {
const { spawn } = require("child_process");
return new Promise((resolve, reject) => {
// Read the uninstall string fresh — a stale cache could point at a moved
// file. Extract the path (may be quoted) + any trailing args.
const cmd = `$keys=@(${ARIADNE_UNINSTALL_KEYS.map((k) => "'" + k + "'").join(",")});foreach ($k in $keys){if(Test-Path $k){$r=Get-ItemProperty $k;Write-Host $r.QuietUninstallString;break}}`;
const ps = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", cmd], { windowsHide: true });
let out = "";
ps.stdout.on("data", (d) => { out += d; });
ps.on("close", () => {
const uninstallCmd = out.trim();
if (!uninstallCmd) return reject(new Error("Ariadne uninstaller not registered"));
// uninstallCmd typically: "C:\Program Files (x86)\...\unins000.exe" /VERYSILENT
// Spawn it elevated. Inno's silent uninstall respects /VERYSILENT so no UI.
const runCmd = `Start-Process -FilePath 'cmd.exe' -ArgumentList '/c ${uninstallCmd.replace(/'/g, "''")} /SUPPRESSMSGBOXES /NORESTART' -Verb RunAs -Wait`;
const ps2 = spawn("powershell.exe", ["-NoProfile", "-Command", runCmd], { windowsHide: true });
ps2.on("close", (code) => code === 0 ? resolve(true) : reject(new Error("uninstaller exited " + code)));
ps2.on("error", reject);
});
ps.on("error", reject);
});
}
// Update = run the bundled installer over the top. Inno Setup detects the
// same AppId and upgrades in place. Same UAC dance as install.
const ariadneUpdate = ariadneInstall;
ipcMain.handle("ariadne-state", () => ariadneQueryState().catch(() => ({ state: "not-installed", installedVersion: null, bundledVersion: null, canUpdate: false, hasUninstaller: false })));
ipcMain.handle("ariadne-toggle", (_e, on) => ariadneSetState(!!on).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-uninstall", () => ariadneUninstall().then(() => ({ ok: true })).catch((e) => ({ ok: false, error: e?.message || String(e) })));
// 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

View file

@ -31,8 +31,11 @@ contextBridge.exposeInMainWorld("cfg", {
collisionState: () => ipcRenderer.invoke("collision-state"),
setCollisionPolicy: (p) => ipcRenderer.invoke("collision-set-policy", p),
resetCollisions: () => ipcRenderer.invoke("collision-reset"),
ariadneState: () => ipcRenderer.invoke("ariadne-state"),
ariadneToggle: (on) => ipcRenderer.invoke("ariadne-toggle", !!on),
ariadneState: () => ipcRenderer.invoke("ariadne-state"),
ariadneToggle: (on) => ipcRenderer.invoke("ariadne-toggle", !!on),
ariadneInstall: () => ipcRenderer.invoke("ariadne-install"),
ariadneUpdate: () => ipcRenderer.invoke("ariadne-update"),
ariadneUninstall: () => ipcRenderer.invoke("ariadne-uninstall"),
// Add-ons management (Settings > Add-ons tab).
listAddons: () => ipcRenderer.invoke("addons-list"),
setAddonEnabled: (id, enabled) => ipcRenderer.invoke("addons-set-enabled", id, !!enabled),

View file

@ -325,16 +325,18 @@
</div>
</div>
<div id="ariadneStatus" class="pmuted" style="font-size:12.5px">checking…</div>
<div style="display:flex;gap:8px">
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button id="ariadneStart" class="btn" hidden>Turn on</button>
<button id="ariadneStop" class="btn" hidden>Turn off</button>
<button id="ariadneInstall" class="btn" hidden>Install</button>
<button id="ariadneUpdate" class="btn" hidden>Update</button>
<button id="ariadneUninstall" class="btn" hidden style="color:#f6768a;border-color:rgba(246,118,138,.35)">Uninstall</button>
<button id="ariadneRefresh" class="btn">Refresh</button>
</div>
<div id="ariadneMissing" class="pmuted" style="font-size:12px" hidden>
Ariadne's Thread isn't installed on this machine. If you skipped it during
Theseus setup, you can install it separately from
<a href="https://silentmode.st/tools/">silentmode.st/tools</a> — grab
<code>AriadneResolver-Setup</code>.
Ariadne's Thread isn't installed on this machine. Click <b>Install</b> above
to run the bundled installer, or grab it manually from
<a href="https://silentmode.st/tools/">silentmode.st/tools</a>.
</div>
</div>
</section>
@ -919,30 +921,51 @@
// Ariadne runs as two elevated Scheduled Tasks ("BNS Resolver Daemon" +
// "BNS Sia Bridge"). Toggling requires admin — main spawns an elevated
// powershell for start/stop, which prompts UAC once per action.
const arStatus = document.getElementById("ariadneStatus");
const arStart = document.getElementById("ariadneStart");
const arStop = document.getElementById("ariadneStop");
const arRefresh = document.getElementById("ariadneRefresh");
const arMissing = document.getElementById("ariadneMissing");
const arStatus = document.getElementById("ariadneStatus");
const arStart = document.getElementById("ariadneStart");
const arStop = document.getElementById("ariadneStop");
const arInstall = document.getElementById("ariadneInstall");
const arUpdate = document.getElementById("ariadneUpdate");
const arUninstall = document.getElementById("ariadneUninstall");
const arRefresh = document.getElementById("ariadneRefresh");
const arMissing = document.getElementById("ariadneMissing");
async function refreshAriadne() {
arStatus.textContent = "checking…";
arStart.hidden = true; arStop.hidden = true; arMissing.hidden = true;
arStart.hidden = arStop.hidden = arInstall.hidden = arUpdate.hidden = arUninstall.hidden = arMissing.hidden = true;
try {
const r = await C.ariadneState();
const verSuffix = r.installedVersion ? ` (v${r.installedVersion})` : "";
const bundledSuffix = r.bundledVersion ? ` (bundled v${r.bundledVersion})` : "";
if (r.state === "running") {
arStatus.innerHTML = 'Status: <b style="color:var(--acid)">running</b> — every browser on this machine resolves BCDN names.';
arStop.hidden = false;
arStatus.innerHTML = `Status: <b style="color:var(--acid)">running</b>${verSuffix} — every browser on this machine resolves BCDN names.`;
arStop.hidden = false; arUninstall.hidden = !r.hasUninstaller;
if (r.canUpdate) arUpdate.hidden = false;
} else if (r.state === "stopped") {
arStatus.innerHTML = 'Status: <b>stopped</b> — only Theseus resolves BCDN names; other browsers won\'t.';
arStart.hidden = false;
arStatus.innerHTML = `Status: <b>stopped</b>${verSuffix} — only Theseus resolves BCDN names; other browsers won't.`;
arStart.hidden = false; arUninstall.hidden = !r.hasUninstaller;
if (r.canUpdate) arUpdate.hidden = false;
} else {
arStatus.textContent = "Status: not installed on this machine.";
arStatus.innerHTML = `Status: <b>not installed</b> on this machine.${bundledSuffix}`;
arInstall.hidden = false;
arMissing.hidden = false;
}
} catch (e) { arStatus.textContent = "Status check failed: " + (e?.message || e); }
}
arStart.onclick = async () => { arStart.disabled = true; try { await C.ariadneToggle(true); } finally { arStart.disabled = false; refreshAriadne(); } };
arStop.onclick = async () => { arStop.disabled = true; try { await C.ariadneToggle(false); } finally { arStop.disabled = false; refreshAriadne(); } };
// Wrap each async action so the button disables during work + always
// refreshes state after (success OR failure), so the UI never lies.
function wire(btn, action, busyLabel) {
btn.onclick = async () => {
const orig = btn.textContent;
btn.disabled = true; btn.textContent = busyLabel;
try { await action(); } catch {}
finally { btn.disabled = false; btn.textContent = orig; refreshAriadne(); }
};
}
wire(arStart, () => C.ariadneToggle(true), "Starting…");
wire(arStop, () => C.ariadneToggle(false), "Stopping…");
wire(arInstall, () => C.ariadneInstall(), "Installing… (accept the UAC prompt)");
wire(arUpdate, () => C.ariadneUpdate(), "Updating… (accept the UAC prompt)");
wire(arUninstall, () => C.ariadneUninstall(), "Uninstalling… (accept the UAC prompt)");
arRefresh.onclick = refreshAriadne;
refreshAriadne();