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:
parent
08beadcf8f
commit
2496e54385
2 changed files with 188 additions and 54 deletions
|
|
@ -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");
|
||||
|
|
|
|||
224
main.js
224
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.<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, {
|
||||
// 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,
|
||||
});
|
||||
} catch { r = 1; }
|
||||
e.returnValue = { handled: true, value: kind === "confirm" ? (r === 0 ? "1" : "0") : "" };
|
||||
}).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();
|
||||
if (!sharedIndex) await ensureIndex();
|
||||
if (!sharedIndex) return null;
|
||||
if (hermesNpGen !== indexBuiltAt || !hermesNpMap) {
|
||||
const H = await loadHermesLib();
|
||||
const idx = await R.buildIndex({ WebSocket });
|
||||
for (const [name, entry] of idx) {
|
||||
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 === pkHex) {
|
||||
hermesState._pkToName.set(pkHex, name);
|
||||
return name;
|
||||
try { const hex = H.parseNpRecord(raw); if (hex && !m.has(hex)) m.set(hex, name); } catch { /* malformed np — skip */ }
|
||||
}
|
||||
} 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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue