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(); }