Theseus: fix the review follow-ups (stale BNS records, POST replay, dialog focus theft, ...)

- Resolved names are re-resolved when a newer index lands and evicted when
  they drop out of it; an edited ip/s3/tls record, a transfer or an expiry
  used to keep serving the old target until restart. The signed-DNS A
  fallback follows its 30 s TTL instead of the first answer it ever saw.
- A cross-host navigation to a host the warm index knows is unregistered is
  left to Chromium: replaying it via loadURL turned form POSTs (OAuth
  form_post, SAML, 3-D Secure) into bodyless GETs. The site badge follows
  navigations Chromium makes on its own.
- A background tab's alert/confirm no longer pulls its tab to the front; it
  waits, marked in the tab strip, until the user switches to it. Dialogs in
  other windows use the async box, so they no longer freeze every tab.
- Messages resolves sender keys from the browser's own index (one map per
  index generation) instead of a full chain walk per unknown sender; the
  dedupe set is bounded.
- Ariadne uninstall reads HKLM only and runs nothing but unins###.exe from
  Program Files, elevated directly rather than via cmd /c.
- Tor and an add-on proxy no longer wipe each other's settings: Tor wins
  while on, the add-on's rules come back when it goes off.
- Profile migration copies beside the target and renames it into place;
  a failed copy keeps the old, complete profile instead of a partial one.
- Reload/DevTools/zoom shortcuts in app and link windows act on that window;
  Ctrl+B stays with web pages (bold) and toggles the sidebar elsewhere.
- quickPanel comment corrected: it shares the default session on purpose.
This commit is contained in:
Local Dev 2026-10-03 09:59:53 +02:00
parent 08beadcf8f
commit 2496e54385
2 changed files with 188 additions and 54 deletions

View file

@ -87,6 +87,7 @@
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } }
.tab .x { opacity: .5; cursor: pointer; padding: 0 2px; border-radius: 4px; } .tab .x { opacity: .5; cursor: pointer; padding: 0 2px; border-radius: 4px; }
.tab .mute { font-size: 10px; opacity: .8; margin-right: 2px; } .tab .mute { font-size: 10px; opacity: .8; margin-right: 2px; }
.tab .ask { font-size: 10px; margin-right: 2px; }
/* Tab group visual: a colored dot before the title, plus a matching top /* Tab group visual: a colored dot before the title, plus a matching top
accent stripe on the tab itself so a whole group reads as one cluster accent stripe on the tab itself so a whole group reads as one cluster
even when tabs are next to each other. */ even when tabs are next to each other. */
@ -1578,7 +1579,7 @@
function makeTab(id) { function makeTab(id) {
const el = document.createElement("div"); const el = document.createElement("div");
el.className = "tab"; el.dataset.id = String(id); el.dataset.key = "tab:" + id; el.draggable = true; el.className = "tab"; el.dataset.id = String(id); el.dataset.key = "tab:" + id; el.draggable = true;
el.innerHTML = `<span class="ico"></span><span class="t"></span><span class="mute" title="Muted" hidden>🔇</span><span class="x" data-close="${id}">✕</span>`; el.innerHTML = `<span class="ico"></span><span class="t"></span><span class="ask" title="This page is waiting for you" hidden>💬</span><span class="mute" title="Muted" hidden>🔇</span><span class="x" data-close="${id}">✕</span>`;
el.onclick = (e) => { if (e.target.dataset.close) T.closeTab(id); else T.switchTab(id); }; el.onclick = (e) => { if (e.target.dataset.close) T.closeTab(id); else T.switchTab(id); };
// Right-click → native OS menu popped from main.js (a DOM menu forced the // Right-click → native OS menu popped from main.js (a DOM menu forced the
// chrome view taller and opened a gap under the toolbar). // chrome view taller and opened a gap under the toolbar).
@ -1611,6 +1612,7 @@
const label = el.querySelector(".t"); const txt = t.title || "New Tab"; const label = el.querySelector(".t"); const txt = t.title || "New Tab";
if (label.textContent !== txt) label.textContent = txt; if (label.textContent !== txt) label.textContent = txt;
el.querySelector(".mute").hidden = !t.muted; el.querySelector(".mute").hidden = !t.muted;
el.querySelector(".ask").hidden = !t.asking;
// Icon slot: spinner while loading, otherwise the favicon. The <img> is // Icon slot: spinner while loading, otherwise the favicon. The <img> is
// only touched when the URL actually changes, so it never reloads. // only touched when the URL actually changes, so it never reloads.
const ico = el.querySelector(".ico"); const ico = el.querySelector(".ico");

238
main.js
View file

@ -88,8 +88,21 @@ function relocateProfile(appData) {
if (!fs.existsSync(oldDir)) continue; if (!fs.existsSync(oldDir)) continue;
try { fs.renameSync(oldDir, newDir); return newDir; } try { fs.renameSync(oldDir, newDir); return newDir; }
catch { catch {
try { fs.cpSync(oldDir, newDir, { recursive: true }); return newDir; } // Copy beside the target and rename it into place only once complete.
catch (e) { console.warn(`[profile] relocate ${candidate} failed:`, e?.message); } // Copying straight into newDir left a partial profile behind on failure
// (a file locked by a running old instance, disk full) — and since
// newDir then existed, the migration never ran again.
const tmp = newDir + ".migrating";
try {
fs.rmSync(tmp, { recursive: true, force: true });
fs.cpSync(oldDir, tmp, { recursive: true });
fs.renameSync(tmp, newDir);
return newDir;
} catch (e) {
console.warn(`[profile] relocate ${candidate} failed, staying on it this run:`, e?.message);
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {}
return oldDir; // complete, just not moved yet — next launch retries
}
} }
} }
return newDir; return newDir;
@ -382,7 +395,13 @@ function dnsRecordKinds(entry) {
// because there is nothing else to serve. // because there is nothing else to serve.
async function dnsAddressFor(entry) { async function dnsAddressFor(entry) {
if (!entry?.name) return null; if (!entry?.name) return null;
const v = entry.dns !== undefined ? entry.dns : await fetchDnsRecords(entry.name); // entry.dns was attached once and never refreshed. Go through the cache so
// the TTL holds: a stale answer is served while it revalidates, and only a
// name with no answer at all waits for the fetch.
const c = dnsRecordsCache.get(entry.name);
let v;
if (c && c.at > 0) { v = c.value; if (Date.now() - c.at >= DNS_RECORDS_TTL) fetchDnsRecords(entry.name).catch(() => {}); }
else v = await fetchDnsRecords(entry.name);
const a = v?.dns?.A; const a = v?.dns?.A;
const ip = Array.isArray(a) ? a.find((x) => typeof x === "string" && /^\d{1,3}(\.\d{1,3}){3}$/.test(x)) : null; const ip = Array.isArray(a) ? a.find((x) => typeof x === "string" && /^\d{1,3}(\.\d{1,3}){3}$/.test(x)) : null;
return ip || null; return ip || null;
@ -1388,6 +1407,16 @@ protocol.registerSchemesAsPrivileged([
{ scheme: "bns", privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true } }, { scheme: "bns", privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true } },
]); ]);
// True only when we can say for sure, without waiting, that `host` has no
// BCNR registration: the resolver is loaded, an index exists and the name is
// in neither it nor the resolved-entries cache.
function knownUnregistered(host) {
if (!resolver || !sharedIndex) return false;
const h = String(host || "").toLowerCase();
if (entries.has(h)) return false;
let key; try { key = resolver.normalizeName(h); } catch { return false; }
return sharedIndex.get(key) == null;
}
let resolver; let resolver;
async function getResolver() { async function getResolver() {
if (!resolver) resolver = await import(`file://${RESOLVER.replace(/\\/g, "/")}`); if (!resolver) resolver = await import(`file://${RESOLVER.replace(/\\/g, "/")}`);
@ -1426,9 +1455,13 @@ function torReady() {
applyWebRTCPolicy(); applyWebRTCPolicy();
sendTor(); sendTor();
} }
// The session proxy has two owners: Tor and an add-on (VPN). Tor wins while
// it is on; the add-on's rules are remembered and come back when Tor goes
// off, instead of the two silently wiping each other's settings.
let addonProxyOpts = null;
function torOff() { function torOff() {
torState = "off"; torWsAgent = null; torState = "off"; torWsAgent = null;
session.defaultSession.setProxy({ proxyRules: "" }); session.defaultSession.setProxy(addonProxyOpts || { proxyRules: "" });
applyWebRTCPolicy(); applyWebRTCPolicy();
sendTor(); sendTor();
} }
@ -1591,6 +1624,9 @@ function maybeRefreshElectrum() {
refreshElectrumPool(); // fire-and-forget refreshElectrumPool(); // fire-and-forget
} }
// host -> { entry, host, gen }. `gen` is the indexBuiltAt of the index the
// entry came from; serveBns re-resolves when a newer index has landed, so an
// edited ip/s3/tls record, a transfer or an expiry shows up without a restart.
const entries = new Map(); const entries = new Map();
// Cached chain index. Building it (connect + fetch every beacon tx) is the slow // Cached chain index. Building it (connect + fetch every beacon tx) is the slow
// part, and it was happening on EVERY navigation. Build once, reuse for lookups, // part, and it was happening on EVERY navigation. Build once, reuse for lookups,
@ -1845,7 +1881,8 @@ async function resolveHost(host) {
entry = idx.get(key) ?? null; entry = idx.get(key) ?? null;
} }
} }
if (entry) entries.set(host.toLowerCase(), { entry, host: host.toLowerCase() }); if (entry) entries.set(host.toLowerCase(), { entry, host: host.toLowerCase(), gen: indexBuiltAt });
else entries.delete(host.toLowerCase()); // no longer registered — stop serving the old record
// Signed DNS records ride alongside the on-chain answer — started here, // Signed DNS records ride alongside the on-chain answer — started here,
// never awaited (see attachDnsRecords). // never awaited (see attachDnsRecords).
if (entry) attachDnsRecords(entry); if (entry) attachDnsRecords(entry);
@ -1880,7 +1917,9 @@ async function serveBns(request) {
// to each tab — it fires BEFORE the request reaches this protocol handler.) // to each tab — it fires BEFORE the request reaches this protocol handler.)
let rec = entries.get(host); let rec = entries.get(host);
if (!rec) { try { await resolveHost(host); } catch {} rec = entries.get(host); } // A lookup that throws (resolver unavailable) keeps the last good record;
// one that answers "not registered" has already evicted it.
if (!rec || rec.gen !== indexBuiltAt) { try { await resolveHost(host); rec = entries.get(host); } catch {} }
if (!rec) return new Response("NXDOMAIN: " + host, { status: 404, headers: { "content-type": "text/plain" } }); if (!rec) return new Response("NXDOMAIN: " + host, { status: 404, headers: { "content-type": "text/plain" } });
const r = rec.entry.records; const r = rec.entry.records;
// Subdomain inheritance: `checkers.game.x` collapses to `game.x` in the // Subdomain inheritance: `checkers.game.x` collapses to `game.x` in the
@ -2239,6 +2278,8 @@ function initAddons() {
proxyAuth = null; proxyAuth = null;
if (rules == null || rules === "") { if (rules == null || rules === "") {
console.log(`[addons] [${addonId}] clearing session proxy`); console.log(`[addons] [${addonId}] clearing session proxy`);
addonProxyOpts = null;
if (torState !== "off") return; // Tor owns the session proxy right now
try { await ses.setProxy({ proxyRules: "" }); } catch (e) { console.warn("proxy clear failed:", e?.message); } try { await ses.setProxy({ proxyRules: "" }); } catch (e) { console.warn("proxy clear failed:", e?.message); }
return; return;
} }
@ -2257,6 +2298,8 @@ function initAddons() {
console.log(`[addons] [${addonId}] setting session proxy:`, publicRules, auth ? "(auth pending)" : ""); console.log(`[addons] [${addonId}] setting session proxy:`, publicRules, auth ? "(auth pending)" : "");
// Chromium fires app#login with authInfo.isProxy when the proxy asks for creds. // Chromium fires app#login with authInfo.isProxy when the proxy asks for creds.
if (auth) proxyAuth = auth; if (auth) proxyAuth = auth;
addonProxyOpts = opts;
if (torState !== "off") { console.log(`[addons] [${addonId}] Tor is on — proxy kept for when it goes off`); return; }
try { await ses.setProxy(opts); } catch (e) { console.warn("proxy set failed:", e?.message); } try { await ses.setProxy(opts); } catch (e) { console.warn("proxy set failed:", e?.message); }
}, },
// vault-derive capability. Resolves once the vault is unlocked (the // vault-derive capability. Resolves once the vault is unlocked (the
@ -3285,7 +3328,7 @@ function emitTabs() {
const t = activeTab(); const t = activeTab();
const wc = t?.view.webContents; const wc = t?.view.webContents;
chrome?.webContents.send("tabs", { chrome?.webContents.send("tabs", {
tabs: tabs.map((x) => ({ id: x.id, title: x.title || "New Tab", active: x.id === activeId, loading: !!x.loading, favicon: x.favicon || null, muted: !!x.muted, group: x.group || null, url: x.url || "" })), tabs: tabs.map((x) => ({ id: x.id, title: x.title || "New Tab", active: x.id === activeId, loading: !!x.loading, favicon: x.favicon || null, muted: !!x.muted, group: x.group || null, url: x.url || "", asking: tabHasPendingDialog(x.id) })),
collapsedGroups: [...tabGroupCollapsed], collapsedGroups: [...tabGroupCollapsed],
url: t?.url || "", url: t?.url || "",
loading: !!t?.loading, loading: !!t?.loading,
@ -3557,6 +3600,17 @@ function createTab(initial, opts = {}) {
}); });
wc.on("did-navigate", () => { refreshTabUrl(tab); emitTabs(); historyAdd(tab.url, tab.title); }); wc.on("did-navigate", () => { refreshTabUrl(tab); emitTabs(); historyAdd(tab.url, tab.title); });
wc.on("did-navigate-in-page", () => { refreshTabUrl(tab); emitTabs(); historyAdd(tab.url, tab.title); }); wc.on("did-navigate-in-page", () => { refreshTabUrl(tab); emitTabs(); historyAdd(tab.url, tab.title); });
// Navigations Chromium makes on its own (Back/Forward, redirects, links to
// unregistered hosts) never pass through navigateTab, which is what sets
// prov — keep the site badge on the host actually loaded.
wc.on("did-navigate", () => {
let u; try { u = new URL(wc.getURL()); } catch { return; }
if (u.protocol !== "http:" && u.protocol !== "https:") return;
const host = u.hostname.toLowerCase();
if (tab.prov && tab.prov.host === host) return;
tab.prov = { host, kind: "web" };
if (tab.id === activeId) pushNav(tab.prov);
});
wc.on("did-navigate", () => { if (tab.id === activeId) { notifyTabChange(); emitPwAvailability(); } }); wc.on("did-navigate", () => { if (tab.id === activeId) { notifyTabChange(); emitPwAvailability(); } });
wc.on("did-navigate-in-page", () => { if (tab.id === activeId) notifyTabChange(); }); wc.on("did-navigate-in-page", () => { if (tab.id === activeId) notifyTabChange(); });
// Ctrl+wheel / pinch: Chromium only reports the intent on Windows and // Ctrl+wheel / pinch: Chromium only reports the intent on Windows and
@ -3643,6 +3697,11 @@ function createTab(initial, opts = {}) {
let currentHost = ""; let currentHost = "";
try { currentHost = new URL(wc.getURL()).hostname; } catch {} try { currentHost = new URL(wc.getURL()).hostname; } catch {}
if (currentHost === parsed.hostname) return; if (currentHost === parsed.hostname) return;
// Cross-host too: when the warm index already says the target isn't a
// BCNR name, there is nothing for navigateTab to add — and replaying
// it via loadURL would turn a form POST into a bodyless GET (OAuth
// form_post, SAML, 3-D Secure, login forms posting to auth.<site>).
if (knownUnregistered(parsed.hostname)) return;
// Preserve query + fragment. Dropping them broke every search engine // Preserve query + fragment. Dropping them broke every search engine
// that submits via a classic form GET (Google's /search?q=foo lost // that submits via a classic form GET (Google's /search?q=foo lost
// the ?q=, so the results page opened blank). // the ?q=, so the results page opened blank).
@ -3933,9 +3992,11 @@ function createWindow() {
quicklinks.webContents.loadFile("quicklinks.html"); quicklinks.webContents.loadFile("quicklinks.html");
// Dedicated mini-view that renders the currently-active quick-link in its // Dedicated mini-view that renders the currently-active quick-link in its
// own narrow column, Opera-style. Lives permanently in the window; shown / // own narrow column, Opera-style. Lives permanently in the window; shown /
// hidden via activeQuickLinkId. Separate browsing context from any tab, // hidden via activeQuickLinkId. It shares the default session with the
// so a Facebook sidebar visit doesn't share cookies with a Facebook tab // tabs (and so their cookies) on purpose: Tor/proxy, the permission
// the user opened — matching how Opera's sidebar panels feel. // handlers, the request filter and the bns:// protocol are all installed
// on session.defaultSession, and a separate partition would silently go
// without every one of them.
// Third-party sites only — no preload (home-preload's navigate/cards API // Third-party sites only — no preload (home-preload's navigate/cards API
// has no business here), and its popups become ordinary tabs instead of // has no business here), and its popups become ordinary tabs instead of
// bare Electron windows outside every tab protection. // bare Electron windows outside every tab protection.
@ -5072,16 +5133,27 @@ function jsDialogReply(item, ok, value) {
const out = kind === "confirm" ? (ok ? "1" : "0") : kind === "prompt" ? (ok ? String(value ?? "") : null) : ""; const out = kind === "confirm" ? (ok ? "1" : "0") : kind === "prompt" ? (ok ? String(value ?? "") : null) : "";
try { item.e.returnValue = { handled: true, value: out }; } catch {} try { item.e.returnValue = { handled: true, value: out }; } catch {}
} }
// Does this tab have a page dialog waiting for the user to come back to it?
function tabHasPendingDialog(tabId) {
return jsDialogQueue.some((q) => q.tabId === tabId) || (!!jsDialogCurrent && jsDialogCurrent.tabId === tabId && tabId !== activeId);
}
function pumpJsDialog() { function pumpJsDialog() {
if (jsDialogCurrent || !jsDialogQueue.length) return; if (jsDialogCurrent || !jsDialogQueue.length) return;
if (!jsDialogPop || !winAlive()) { for (const it of jsDialogQueue.splice(0)) jsDialogReply(it, false, null); return; } if (!jsDialogPop || !winAlive()) { for (const it of jsDialogQueue.splice(0)) jsDialogReply(it, false, null); return; }
const next = jsDialogQueue.shift(); for (let i = jsDialogQueue.length - 1; i >= 0; i--) {
if (next.tabId != null && !tabById(next.tabId)) { jsDialogReply(next, false, null); return pumpJsDialog(); } const q = jsDialogQueue[i];
if (q.tabId != null && !tabById(q.tabId)) jsDialogReply(jsDialogQueue.splice(i, 1)[0], false, null);
}
// A dialog shows only over the tab that asked (or, for a panel's dialog,
// over whatever is current). A background tab's dialog waits — marked in
// the tab strip — until the user switches to it; it never pulls its tab to
// the front, which let any page in the background jump over what the user
// was doing with alert().
const i = jsDialogQueue.findIndex((q) => q.tabId == null || q.tabId === activeId);
emitTabs();
if (i < 0) return;
const next = jsDialogQueue.splice(i, 1)[0];
jsDialogCurrent = next; jsDialogCurrent = next;
// The dialog belongs to the tab that asked: bring that tab forward so
// the sheet never appears over an unrelated page. (A panel's dialog has
// no tab and shows over whatever is current.)
if (next.tabId != null && next.tabId !== activeId) { try { setActive(next.tabId); } catch {} }
overlayReady(jsDialogPop).then(() => { overlayReady(jsDialogPop).then(() => {
if (jsDialogCurrent !== next) return; if (jsDialogCurrent !== next) return;
try { try {
@ -5102,11 +5174,22 @@ function pumpJsDialog() {
// (and above any tab added since) when its tab returns. A panel's sheet has // (and above any tab added since) when its tab returns. A panel's sheet has
// no tab and stays. // no tab and stays.
function syncJsDialogVisibility() { function syncJsDialogVisibility() {
if (!jsDialogPop || !jsDialogCurrent || !winAlive()) return; if (!jsDialogPop || !winAlive()) return;
if (!jsDialogCurrent) { pumpJsDialog(); return; }
const show = jsDialogCurrent.tabId == null || jsDialogCurrent.tabId === activeId; const show = jsDialogCurrent.tabId == null || jsDialogCurrent.tabId === activeId;
if (!show) {
// Its tab went to the background: put it back in line (still answering
// nothing — the page stays blocked in its sendSync) so the tab now in
// front can show its own dialog, if any.
try { jsDialogPop.setVisible(false); } catch {}
jsDialogQueue.unshift(jsDialogCurrent);
jsDialogCurrent = null;
pumpJsDialog();
return;
}
try { try {
jsDialogPop.setVisible(show); jsDialogPop.setVisible(true);
if (show) { win.contentView.removeChildView(jsDialogPop); win.contentView.addChildView(jsDialogPop); jsDialogPop.webContents.focus(); } win.contentView.removeChildView(jsDialogPop); win.contentView.addChildView(jsDialogPop); jsDialogPop.webContents.focus();
} catch {} } catch {}
} }
function finishJsDialog(ok, value) { function finishJsDialog(ok, value) {
@ -5164,15 +5247,15 @@ ipcMain.on("js-dialog", (e, raw) => {
if (!inMain || !jsDialogPop) { if (!inMain || !jsDialogPop) {
if (kind === "prompt") { e.returnValue = { handled: false }; return; } if (kind === "prompt") { e.returnValue = { handled: false }; return; }
let parent; try { parent = BrowserWindow.fromWebContents(e.sender) || undefined; } catch {} let parent; try { parent = BrowserWindow.fromWebContents(e.sender) || undefined; } catch {}
let r = 0; // Async box: the page stays blocked in its sendSync until returnValue is
try { // set, but the main process (every tab, bns://, downloads) keeps running —
r = dialog.showMessageBoxSync(parent, { // the sync variant froze the whole browser while the box was up.
type: kind === "confirm" ? "question" : "info", title: "Theseus Navigator", const answer = (r) => { try { e.returnValue = { handled: true, value: kind === "confirm" ? (r === 0 ? "1" : "0") : "" }; } catch {} };
message: `${who.label} says`, detail: message, dialog.showMessageBox(parent, {
buttons: kind === "confirm" ? ["OK", "Cancel"] : ["OK"], defaultId: 0, cancelId: 1, noLink: true, type: kind === "confirm" ? "question" : "info", title: "Theseus Navigator",
}); message: `${who.label} says`, detail: message,
} catch { r = 1; } buttons: kind === "confirm" ? ["OK", "Cancel"] : ["OK"], defaultId: 0, cancelId: 1, noLink: true,
e.returnValue = { handled: true, value: kind === "confirm" ? (r === 0 ? "1" : "0") : "" }; }).then(({ response }) => answer(response), () => answer(1));
return; return;
} }
jsDialogQueue.push({ e, tabId: tab ? tab.id : null, req: { reqId: ++jsDialogSeq, kind, message, def, who } }); jsDialogQueue.push({ e, tabId: tab ? tab.id : null, req: { reqId: ++jsDialogSeq, kind, message, def, who } });
@ -5675,8 +5758,11 @@ const ARIADNE_TASKS = ["BNS Resolver Daemon", "BNS Sia Bridge"];
// closing brace), so the literal key path Theseus used to look for never // closing brace), so the literal key path Theseus used to look for never
// matched — the panel showed no version, no Update, no Uninstall. Matching // matched — the panel showed no version, no Update, no Uninstall. Matching
// on the name also survives a future AppId fix. // on the name also survives a future AppId fix.
// HKLM only: the installer is PrivilegesRequired=admin, and HKCU is writable
// by any process the user runs — a planted "Ariadne Resolver" entry there
// would have its uninstall string run elevated by ariadneUninstall.
const ARIADNE_REG_LOOKUP = const ARIADNE_REG_LOOKUP =
"$r=$null;foreach($root in 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall','HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall','HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall'){" + "$r=$null;foreach($root in 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall','HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'){" +
"if($r){break};foreach($k in (Get-ChildItem $root -ErrorAction SilentlyContinue)){$p=Get-ItemProperty $k.PSPath -ErrorAction SilentlyContinue;" + "if($r){break};foreach($k in (Get-ChildItem $root -ErrorAction SilentlyContinue)){$p=Get-ItemProperty $k.PSPath -ErrorAction SilentlyContinue;" +
"if($p.DisplayName -like 'Ariadne Resolver*' -and $p.QuietUninstallString){$r=$p;break}}};"; "if($p.DisplayName -like 'Ariadne Resolver*' -and $p.QuietUninstallString){$r=$p;break}}};";
// Same manifest the Theseus updater reads (UPDATE_MANIFEST_URL). The copy on // Same manifest the Theseus updater reads (UPDATE_MANIFEST_URL). The copy on
@ -5884,9 +5970,17 @@ function ariadneUninstall() {
ps.on("close", () => { ps.on("close", () => {
const uninstallCmd = out.trim(); const uninstallCmd = out.trim();
if (!uninstallCmd) return reject(new Error("Ariadne uninstaller not registered")); if (!uninstallCmd) return reject(new Error("Ariadne uninstaller not registered"));
// uninstallCmd typically: "C:\Program Files (x86)\...\unins000.exe" /VERYSILENT // uninstallCmd typically: "C:\Program Files\Ariadne Resolver\unins000.exe" /SILENT
// Spawn it elevated. Inno's silent uninstall respects /VERYSILENT so no UI. // Only the Inno uninstaller itself is run elevated — never the registry
const runCmd = `$p = Start-Process -FilePath 'cmd.exe' -ArgumentList '/c ${uninstallCmd.replace(/'/g, "''")} /SUPPRESSMSGBOXES /NORESTART' -Verb RunAs -Wait -PassThru; exit $p.ExitCode`; // string through cmd /c (the UAC prompt would only name cmd.exe).
const m = /^\s*"([^"]+)"|^\s*(\S+)/.exec(uninstallCmd);
const exe = path.resolve((m && (m[1] || m[2])) || "");
const roots = [process.env.ProgramFiles, process.env["ProgramFiles(x86)"]].filter(Boolean).map((r) => path.resolve(r).toLowerCase() + path.sep);
if (!/^unins\d{3}\.exe$/i.test(path.basename(exe)) || !roots.some((r) => exe.toLowerCase().startsWith(r))) {
return reject(new Error("unexpected Ariadne uninstaller path: " + exe));
}
// Inno's silent uninstall respects /VERYSILENT so no UI.
const runCmd = `$p = Start-Process -FilePath '${exe.replace(/'/g, "''")}' -ArgumentList '/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART' -Verb RunAs -Wait -PassThru; exit $p.ExitCode`;
const ps2 = spawn("powershell.exe", ["-NoProfile", "-Command", runCmd], { windowsHide: true }); 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("close", (code) => code === 0 ? resolve(true) : reject(new Error("uninstaller exited " + code)));
ps2.on("error", reject); ps2.on("error", reject);
@ -6587,28 +6681,28 @@ function hermesEmitStatus() {
// Populates senderName in the inbox so incoming DMs render as // Populates senderName in the inbox so incoming DMs render as
// "alice.bch · 12ab…9f" instead of a naked hex string. Cache is per-hermesState // "alice.bch · 12ab…9f" instead of a naked hex string. Cache is per-hermesState
// (dropped on hermes-close) so a re-init starts clean. // (dropped on hermes-close) so a re-init starts clean.
// pubkey → name, from the browser's own warm index. Built once per index
// generation: this used to run a full buildIndex() (an electrum walk) per
// unknown sender, and anyone can send to a published np key — a stream of
// wraps from fresh keys was a stream of full chain walks.
let hermesNpMap = null, hermesNpGen = -1;
async function hermesReverseResolve(pkHex) { async function hermesReverseResolve(pkHex) {
if (!hermesState) return null; if (!hermesState) return null;
if (!hermesState._pkToName) hermesState._pkToName = new Map();
if (hermesState._pkToName.has(pkHex)) return hermesState._pkToName.get(pkHex);
try { try {
const R = await getResolver(); if (!sharedIndex) await ensureIndex();
const H = await loadHermesLib(); if (!sharedIndex) return null;
const idx = await R.buildIndex({ WebSocket }); if (hermesNpGen !== indexBuiltAt || !hermesNpMap) {
for (const [name, entry] of idx) { const H = await loadHermesLib();
const raw = entry && entry.records && entry.records.np; const m = new Map();
if (typeof raw !== "string" || !raw.trim()) continue; for (const [name, entry] of sharedIndex) {
try { const raw = entry && entry.records && entry.records.np;
const hex = H.parseNpRecord(raw); if (typeof raw !== "string" || !raw.trim()) continue;
if (hex === pkHex) { try { const hex = H.parseNpRecord(raw); if (hex && !m.has(hex)) m.set(hex, name); } catch { /* malformed np — skip */ }
hermesState._pkToName.set(pkHex, name); }
return name; hermesNpMap = m; hermesNpGen = indexBuiltAt;
}
} catch { /* malformed np — skip */ }
} }
} catch { /* chain unreachable — leave unresolved this round */ } return hermesNpMap.get(pkHex) ?? null;
hermesState._pkToName.set(pkHex, null); // negative-cache so we don't re-scan every message } catch { return null; } // chain unreachable — leave unresolved this round
return null;
} }
// One receive subscription per relay. If the socket dies we resurrect it on a // One receive subscription per relay. If the socket dies we resurrect it on a
@ -6636,7 +6730,12 @@ async function hermesConnectRelay(url) {
// on (senderPkHex, text, createdAt) which is sufficient for MVP. // on (senderPkHex, text, createdAt) which is sufficient for MVP.
const key = opened.senderPkHex + "\0" + opened.createdAt + "\0" + opened.text; const key = opened.senderPkHex + "\0" + opened.createdAt + "\0" + opened.text;
if (hermesState._seen && hermesState._seen.has(key)) return; if (hermesState._seen && hermesState._seen.has(key)) return;
hermesState._seen && hermesState._seen.add(key); if (hermesState._seen) {
hermesState._seen.add(key);
// Bounded: drop the oldest half once it grows past the cap (a Set
// iterates in insertion order).
if (hermesState._seen.size > 4000) { let n = 2000; for (const k of hermesState._seen) { if (n-- <= 0) break; hermesState._seen.delete(k); } }
}
// Reverse-resolve pk → name off the wrap decrypt path (fire-and-forget // Reverse-resolve pk → name off the wrap decrypt path (fire-and-forget
// wouldn't work — we need the name in the record we push). Await here; // wouldn't work — we need the name in the record we push). Await here;
// the cache short-circuits after the first miss per pk. // the cache short-circuits after the first miss per pk.
@ -7036,6 +7135,15 @@ ipcMain.handle("bcnr:getRecordVersion", async (_e, name) => {
// Reload/hard-reload always target the active tab, regardless of which // Reload/hard-reload always target the active tab, regardless of which
// view received the key (URL bar focused, overlay focused, etc.), so the // view received the key (URL bar focused, overlay focused, etc.), so the
// user's mental model matches every browser they've ever used. // user's mental model matches every browser they've ever used.
// Is this webContents part of the browser window (toolbar, a tab, a panel or
// overlay)? Views may report no owner; another window reports itself.
function inMainWindow(wc) {
if (!win || win.isDestroyed()) return false;
if (chrome && wc === chrome.webContents) return true;
if (tabs.some((t) => t.view?.webContents === wc)) return true;
let owner = null; try { owner = BrowserWindow.fromWebContents(wc); } catch {}
return !owner || owner === win;
}
app.on("web-contents-created", (_event, wc) => { app.on("web-contents-created", (_event, wc) => {
wc.on("before-input-event", (e, input) => { wc.on("before-input-event", (e, input) => {
if (input.type !== "keyDown") return; if (input.type !== "keyDown") return;
@ -7044,9 +7152,33 @@ app.on("web-contents-created", (_event, wc) => {
openHermesWindow(); openHermesWindow();
return e.preventDefault(); return e.preventDefault();
} }
// App windows, link windows, Messages: the keys act on the page that got
// them. The browser-window shortcuts below used to reach the main
// window's active tab from here (F5 reloaded a tab the user wasn't
// looking at, F12 inspected it, Ctrl+F opened a hidden find bar).
if (!inMainWindow(wc)) {
const k = input.key, code = input.code;
const isR = k === "R" || k === "r", isI = k === "I" || k === "i";
try {
if (k === "F5" || (input.control && isR)) {
if ((input.control && input.shift && isR) || (input.control && k === "F5")) wc.reloadIgnoringCache(); else wc.reload();
return e.preventDefault();
}
if (k === "F12" || (input.control && input.shift && isI)) { wc.toggleDevTools(); return e.preventDefault(); }
if (input.control && !input.alt) {
if (k === "+" || k === "=" || code === "NumpadAdd") { wc.setZoomLevel(Math.min(wc.getZoomLevel() + 0.5, 9)); return e.preventDefault(); }
if (k === "-" || k === "_" || code === "NumpadSubtract") { wc.setZoomLevel(Math.max(wc.getZoomLevel() - 0.5, -8)); return e.preventDefault(); }
if ((k === "0" || code === "Numpad0") && !input.shift) { wc.setZoomLevel(0); return e.preventDefault(); }
}
} catch {}
return;
}
// Ctrl+B -> toggle add-on sidebar (matches the VS Code convention). // Ctrl+B -> toggle add-on sidebar (matches the VS Code convention).
// Silently no-ops if no add-on has registered a sidebar panel yet. // Silently no-ops if no add-on has registered a sidebar panel yet. Not
if (input.control && !input.shift && !input.alt && (input.key === "B" || input.key === "b")) { // taken from a web page in a tab: there it is "bold" in every editor
// (Docs, Notion, webmail), and the toolbar button still toggles the panel.
const inWebTab = tabs.some((t) => t.view?.webContents === wc && !t.settings && !t.addonId);
if (input.control && !input.shift && !input.alt && (input.key === "B" || input.key === "b") && !inWebTab) {
toggleSidebar(); toggleSidebar();
return e.preventDefault(); return e.preventDefault();
} }