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); } }
.tab .x { opacity: .5; cursor: pointer; padding: 0 2px; border-radius: 4px; }
.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
accent stripe on the tab itself so a whole group reads as one cluster
even when tabs are next to each other. */
@ -1578,7 +1579,7 @@
function makeTab(id) {
const el = document.createElement("div");
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); };
// Right-click → native OS menu popped from main.js (a DOM menu forced the
// 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";
if (label.textContent !== txt) label.textContent = txt;
el.querySelector(".mute").hidden = !t.muted;
el.querySelector(".ask").hidden = !t.asking;
// Icon slot: spinner while loading, otherwise the favicon. The <img> is
// only touched when the URL actually changes, so it never reloads.
const ico = el.querySelector(".ico");

238
main.js
View file

@ -88,8 +88,21 @@ function relocateProfile(appData) {
if (!fs.existsSync(oldDir)) continue;
try { fs.renameSync(oldDir, newDir); return newDir; }
catch {
try { fs.cpSync(oldDir, newDir, { recursive: true }); return newDir; }
catch (e) { console.warn(`[profile] relocate ${candidate} failed:`, e?.message); }
// Copy beside the target and rename it into place only once complete.
// 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;
@ -382,7 +395,13 @@ function dnsRecordKinds(entry) {
// because there is nothing else to serve.
async function dnsAddressFor(entry) {
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 ip = Array.isArray(a) ? a.find((x) => typeof x === "string" && /^\d{1,3}(\.\d{1,3}){3}$/.test(x)) : null;
return ip || null;
@ -1388,6 +1407,16 @@ protocol.registerSchemesAsPrivileged([
{ 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;
async function getResolver() {
if (!resolver) resolver = await import(`file://${RESOLVER.replace(/\\/g, "/")}`);
@ -1426,9 +1455,13 @@ function torReady() {
applyWebRTCPolicy();
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() {
torState = "off"; torWsAgent = null;
session.defaultSession.setProxy({ proxyRules: "" });
session.defaultSession.setProxy(addonProxyOpts || { proxyRules: "" });
applyWebRTCPolicy();
sendTor();
}
@ -1591,6 +1624,9 @@ function maybeRefreshElectrum() {
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();
// 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,
@ -1845,7 +1881,8 @@ async function resolveHost(host) {
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,
// never awaited (see attachDnsRecords).
if (entry) attachDnsRecords(entry);
@ -1880,7 +1917,9 @@ async function serveBns(request) {
// to each tab — it fires BEFORE the request reaches this protocol handler.)
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" } });
const r = rec.entry.records;
// Subdomain inheritance: `checkers.game.x` collapses to `game.x` in the
@ -2239,6 +2278,8 @@ function initAddons() {
proxyAuth = null;
if (rules == null || rules === "") {
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); }
return;
}
@ -2257,6 +2298,8 @@ function initAddons() {
console.log(`[addons] [${addonId}] setting session proxy:`, publicRules, auth ? "(auth pending)" : "");
// Chromium fires app#login with authInfo.isProxy when the proxy asks for creds.
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); }
},
// vault-derive capability. Resolves once the vault is unlocked (the
@ -3285,7 +3328,7 @@ function emitTabs() {
const t = activeTab();
const wc = t?.view.webContents;
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],
url: t?.url || "",
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-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-in-page", () => { if (tab.id === activeId) notifyTabChange(); });
// Ctrl+wheel / pinch: Chromium only reports the intent on Windows and
@ -3643,6 +3697,11 @@ function createTab(initial, opts = {}) {
let currentHost = "";
try { currentHost = new URL(wc.getURL()).hostname; } catch {}
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
// that submits via a classic form GET (Google's /search?q=foo lost
// the ?q=, so the results page opened blank).
@ -3933,9 +3992,11 @@ function createWindow() {
quicklinks.webContents.loadFile("quicklinks.html");
// Dedicated mini-view that renders the currently-active quick-link in its
// own narrow column, Opera-style. Lives permanently in the window; shown /
// hidden via activeQuickLinkId. Separate browsing context from any tab,
// so a Facebook sidebar visit doesn't share cookies with a Facebook tab
// the user opened — matching how Opera's sidebar panels feel.
// hidden via activeQuickLinkId. It shares the default session with the
// tabs (and so their cookies) on purpose: Tor/proxy, the permission
// 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
// has no business here), and its popups become ordinary tabs instead of
// 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) : "";
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() {
if (jsDialogCurrent || !jsDialogQueue.length) return;
if (!jsDialogPop || !winAlive()) { for (const it of jsDialogQueue.splice(0)) jsDialogReply(it, false, null); return; }
const next = jsDialogQueue.shift();
if (next.tabId != null && !tabById(next.tabId)) { jsDialogReply(next, false, null); return pumpJsDialog(); }
for (let i = jsDialogQueue.length - 1; i >= 0; i--) {
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;
// 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(() => {
if (jsDialogCurrent !== next) return;
try {
@ -5102,11 +5174,22 @@ function pumpJsDialog() {
// (and above any tab added since) when its tab returns. A panel's sheet has
// no tab and stays.
function syncJsDialogVisibility() {
if (!jsDialogPop || !jsDialogCurrent || !winAlive()) return;
if (!jsDialogPop || !winAlive()) return;
if (!jsDialogCurrent) { pumpJsDialog(); return; }
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 {
jsDialogPop.setVisible(show);
if (show) { win.contentView.removeChildView(jsDialogPop); win.contentView.addChildView(jsDialogPop); jsDialogPop.webContents.focus(); }
jsDialogPop.setVisible(true);
win.contentView.removeChildView(jsDialogPop); win.contentView.addChildView(jsDialogPop); jsDialogPop.webContents.focus();
} catch {}
}
function finishJsDialog(ok, value) {
@ -5164,15 +5247,15 @@ ipcMain.on("js-dialog", (e, raw) => {
if (!inMain || !jsDialogPop) {
if (kind === "prompt") { e.returnValue = { handled: false }; return; }
let parent; try { parent = BrowserWindow.fromWebContents(e.sender) || undefined; } catch {}
let r = 0;
try {
r = dialog.showMessageBoxSync(parent, {
type: kind === "confirm" ? "question" : "info", title: "Theseus Navigator",
message: `${who.label} says`, detail: message,
buttons: kind === "confirm" ? ["OK", "Cancel"] : ["OK"], defaultId: 0, cancelId: 1, noLink: true,
});
} catch { r = 1; }
e.returnValue = { handled: true, value: kind === "confirm" ? (r === 0 ? "1" : "0") : "" };
// Async box: the page stays blocked in its sendSync until returnValue is
// set, but the main process (every tab, bns://, downloads) keeps running —
// the sync variant froze the whole browser while the box was up.
const answer = (r) => { try { e.returnValue = { handled: true, value: kind === "confirm" ? (r === 0 ? "1" : "0") : "" }; } catch {} };
dialog.showMessageBox(parent, {
type: kind === "confirm" ? "question" : "info", title: "Theseus Navigator",
message: `${who.label} says`, detail: message,
buttons: kind === "confirm" ? ["OK", "Cancel"] : ["OK"], defaultId: 0, cancelId: 1, noLink: true,
}).then(({ response }) => answer(response), () => answer(1));
return;
}
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
// matched — the panel showed no version, no Update, no Uninstall. Matching
// 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 =
"$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($p.DisplayName -like 'Ariadne Resolver*' -and $p.QuietUninstallString){$r=$p;break}}};";
// Same manifest the Theseus updater reads (UPDATE_MANIFEST_URL). The copy on
@ -5884,9 +5970,17 @@ function ariadneUninstall() {
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 = `$p = Start-Process -FilePath 'cmd.exe' -ArgumentList '/c ${uninstallCmd.replace(/'/g, "''")} /SUPPRESSMSGBOXES /NORESTART' -Verb RunAs -Wait -PassThru; exit $p.ExitCode`;
// uninstallCmd typically: "C:\Program Files\Ariadne Resolver\unins000.exe" /SILENT
// Only the Inno uninstaller itself is run elevated — never the registry
// 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 });
ps2.on("close", (code) => code === 0 ? resolve(true) : reject(new Error("uninstaller exited " + code)));
ps2.on("error", reject);
@ -6587,28 +6681,28 @@ function hermesEmitStatus() {
// Populates senderName in the inbox so incoming DMs render as
// "alice.bch · 12ab…9f" instead of a naked hex string. Cache is per-hermesState
// (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) {
if (!hermesState) return null;
if (!hermesState._pkToName) hermesState._pkToName = new Map();
if (hermesState._pkToName.has(pkHex)) return hermesState._pkToName.get(pkHex);
try {
const R = await getResolver();
const H = await loadHermesLib();
const idx = await R.buildIndex({ WebSocket });
for (const [name, entry] of idx) {
const raw = entry && entry.records && entry.records.np;
if (typeof raw !== "string" || !raw.trim()) continue;
try {
const hex = H.parseNpRecord(raw);
if (hex === pkHex) {
hermesState._pkToName.set(pkHex, name);
return name;
}
} catch { /* malformed np — skip */ }
if (!sharedIndex) await ensureIndex();
if (!sharedIndex) return null;
if (hermesNpGen !== indexBuiltAt || !hermesNpMap) {
const H = await loadHermesLib();
const m = new Map();
for (const [name, entry] of sharedIndex) {
const raw = entry && entry.records && entry.records.np;
if (typeof raw !== "string" || !raw.trim()) continue;
try { const hex = H.parseNpRecord(raw); if (hex && !m.has(hex)) m.set(hex, name); } catch { /* malformed np — skip */ }
}
hermesNpMap = m; hermesNpGen = indexBuiltAt;
}
} catch { /* chain unreachable — leave unresolved this round */ }
hermesState._pkToName.set(pkHex, null); // negative-cache so we don't re-scan every message
return null;
return hermesNpMap.get(pkHex) ?? null;
} catch { return null; } // chain unreachable — leave unresolved this round
}
// 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.
const key = opened.senderPkHex + "\0" + opened.createdAt + "\0" + opened.text;
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
// wouldn't work — we need the name in the record we push). Await here;
// 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
// view received the key (URL bar focused, overlay focused, etc.), so the
// 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) => {
wc.on("before-input-event", (e, input) => {
if (input.type !== "keyDown") return;
@ -7044,9 +7152,33 @@ app.on("web-contents-created", (_event, wc) => {
openHermesWindow();
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).
// Silently no-ops if no add-on has registered a sidebar panel yet.
if (input.control && !input.shift && !input.alt && (input.key === "B" || input.key === "b")) {
// Silently no-ops if no add-on has registered a sidebar panel yet. Not
// 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();
return e.preventDefault();
}