diff --git a/chrome.html b/chrome.html
index dad59d93..76af064f 100644
--- a/chrome.html
+++ b/chrome.html
@@ -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 = `๐โ`;
+ el.innerHTML = `๐ฌ๐โ`;
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
is
// only touched when the URL actually changes, so it never reloads.
const ico = el.querySelector(".ico");
diff --git a/main.js b/main.js
index 84416057..9632e433 100644
--- a/main.js
+++ b/main.js
@@ -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.).
+ 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();
}