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