From 93a9ffcbb32048f49966b771d93475db48d174f2 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Mon, 7 Sep 2026 23:03:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(theseus/settings):=20Ariadne=20=E2=80=94?= =?UTF-8?q?=20Install=20/=20Update=20/=20Uninstall=20alongside=20Turn=20on?= =?UTF-8?q?=20/=20off?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the Ariadne toggle card in Settings > Registries with the three lifecycle actions the user asked for: - Install: runs the bundled AriadneResolver-Setup-.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. --- main.js | 122 ++++++++++++++++++++++++++++++++++++++------ settings-preload.js | 7 ++- settings.html | 59 ++++++++++++++------- 3 files changed, 151 insertions(+), 37 deletions(-) diff --git a/main.js b/main.js index 36251f3..da7846f 100644 --- a/main.js +++ b/main.js @@ -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 diff --git a/settings-preload.js b/settings-preload.js index 0f3b6d8..7893750 100644 --- a/settings-preload.js +++ b/settings-preload.js @@ -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), diff --git a/settings.html b/settings.html index e308cb4..37cbc6a 100644 --- a/settings.html +++ b/settings.html @@ -325,16 +325,18 @@
checking…
-
+
+ + +
@@ -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: running — every browser on this machine resolves BCDN names.'; - arStop.hidden = false; + arStatus.innerHTML = `Status: running${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: stopped — only Theseus resolves BCDN names; other browsers won\'t.'; - arStart.hidden = false; + arStatus.innerHTML = `Status: stopped${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: not installed 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();