Theseus: close the Settings-tab vault leak and the add-on update signer bypass

A preload belongs to the WebContents, not the page: a website loaded into
the Settings tab kept window.cfg and could read every vault password, flip
settings and install extensions without consent. Settings and add-on tabs
now never load web content, and the channels behind settings-preload check
their sender. `navigate` no longer accepts calls from web pages.

Add-on updates trusted any publisherSig, whatever name it carried, even for
bundled add-ons. The trust root is now the installed addon.json (publisher,
or the operator key when there is none); versions must be plain dotted
numbers; a community install can't take over a bundled or foreign id.

Also:
- autofill matches and fills against the live URL, not a stale prov.host
- bns:// forwards the raw request path (..%2F escaped the name's bucket)
- clipboard-read denied, openExternal asks; forged collision choices ignored
- web pages can't window.open file:/chrome:/theseus:; data:/blob: no longer
  go to the search engine; the quick-links panel loses home-preload
- clear-history-on-quit is awaited and removes history.json too
- update helper takes its paths from the environment (non-ASCII profiles)
- electrum poll has a deadline; misses wait at most 2.5 s
- p records go through Tor; add-on proxy credentials are actually used
- whole-folder require-cache bust on add-on version change; failed
  activate() no longer leaks its request filter
- approvals released when the window closes; web-app ids stay on-origin
This commit is contained in:
Local Dev 2026-10-03 09:50:10 +02:00
parent 166e220343
commit 08beadcf8f
7 changed files with 346 additions and 98 deletions

View file

@ -62,6 +62,25 @@ Fix, currently in [settings.html](settings.html):
Chromium also respects `select option { background; color }` in the popup Chromium also respects `select option { background; color }` in the popup
on Windows — a belt-and-braces override alongside `color-scheme`. on Windows — a belt-and-braces override alongside `color-scheme`.
## A preload belongs to the WebContents, not the page
A view's preload runs for every document that view ever loads. Navigating
a Settings tab to a website used to hand that site `window.cfg`, which
covers the vault (`pwGet`), settings and extension installs. Two rules
follow:
- Settings and add-on-file tabs never load anything else. `navigateTab`
and `will-navigate` open the target in a fresh tab instead.
- A new IPC channel exposed through `settings-preload.js` **must** be added
to `SETTINGS_ONLY` (or `SETTINGS_SHARED` if the toolbar's `preload.js`
calls it too) in [main.js](main.js). The wrapper around `ipcMain.handle`
only checks the sender for the channels in those sets. Any other channel
is callable by whatever page ends up in the view.
The same applies to `home-preload.js`, which is in *every* tab. Handlers
behind it check `isHomePageSender` / `isErrorPageSender`, which compare
the sender against the exact shipped file:// URL.
## The resolver in Theseus is `resolver-web.mjs`, not `.js` ## The resolver in Theseus is `resolver-web.mjs`, not `.js`
Packaged builds ship `Argus/src/lib/resolver-web.js` as `resolver-web.mjs` Packaged builds ship `Argus/src/lib/resolver-web.js` as `resolver-web.mjs`

View file

@ -32,6 +32,14 @@ const SIG_DOMAIN = "silentmode.addon-update-v1";
const MAX_MANIFEST_BYTES = 128 * 1024; // updates.json shouldn't exceed 128 KB const MAX_MANIFEST_BYTES = 128 * 1024; // updates.json shouldn't exceed 128 KB
const MAX_TARBALL_BYTES = 16 * 1024 * 1024; // an add-on payload above 16 MB is suspicious const MAX_TARBALL_BYTES = 16 * 1024 * 1024; // an add-on payload above 16 MB is suspicious
const MAX_REDIRECTS = 3; const MAX_REDIRECTS = 3;
// A channel's version string ends up in staging paths and temp-file names, so
// only plain dotted numbers are accepted ("1.2.3", not "9/../../x").
const VERSION_RE = /^\d{1,6}(?:\.\d{1,6}){0,3}$/;
// Windows resolves a bare "tar" against the current directory before PATH;
// use the system copy (Win10 1803+) explicitly.
const TAR = process.platform === "win32"
? path.join(process.env.SystemRoot || "C:\\Windows", "System32", "tar.exe")
: "tar";
function cmpVer(a, b) { function cmpVer(a, b) {
const A = String(a || "").split(".").map((n) => parseInt(n, 10) || 0); const A = String(a || "").split(".").map((n) => parseInt(n, 10) || 0);
@ -149,11 +157,15 @@ function verifySignature(id, version, tarballSha256, sigB64, pubkeysHex) {
} }
// ---------- single-add-on staging ---------------------------------------- // ---------- single-add-on staging ----------------------------------------
// Read a channel manifest and pick its best entry. An entry is trusted when // Read a channel manifest and pick its best entry. The trust root comes from
// EITHER the operator signed it (Ed25519 `sig`, bundled add-ons) OR the // the caller (the installed addon.json, or the catalog card), never from the
// publisher signed it (`publisherSig`, community extensions — verified by the // channel itself: with `publisher` set, the entry must name that publisher and
// caller-supplied verifyPublisher against the publisher name's NFT owner). // carry its `publisherSig` (verified by verifyPublisher against the name's NFT
async function pickChannelEntry({ id, currentVer, updateURL, pubkeysHex, verifyPublisher, log, timeoutMs }) { // owner); without it, only the operator's Ed25519 `sig` counts. Otherwise
// whoever can write a channel could sign a bundled add-on's update with any
// name they own.
async function pickChannelEntry({ id, currentVer, updateURL, pubkeysHex, verifyPublisher, publisher, log, timeoutMs }) {
const pub = publisher ? String(publisher).toLowerCase() : null;
let manifestBuf; let manifestBuf;
try { manifestBuf = await httpGet(updateURL, { timeoutMs, maxBytes: MAX_MANIFEST_BYTES }); } try { manifestBuf = await httpGet(updateURL, { timeoutMs, maxBytes: MAX_MANIFEST_BYTES }); }
catch (e) { log(`updates: fetch ${id} failed:`, e.message); return { status: "fetch-failed", detail: e.message }; } catch (e) { log(`updates: fetch ${id} failed:`, e.message); return { status: "fetch-failed", detail: e.message }; }
@ -163,14 +175,16 @@ async function pickChannelEntry({ id, currentVer, updateURL, pubkeysHex, verifyP
const addons = Array.isArray(manifest?.addons) ? manifest.addons : []; const addons = Array.isArray(manifest?.addons) ? manifest.addons : [];
let best = null; let best = null;
for (const e of addons) { for (const e of addons) {
if (!e?.version || !e?.url || !e?.sha256 || !(e?.sig || e?.publisherSig)) continue; if (!e?.version || !e?.url || !e?.sha256 || !(pub ? e?.publisherSig : e?.sig)) continue;
if (!VERSION_RE.test(String(e.version))) continue;
if (pub && String(e.publisher || "").toLowerCase() !== pub) continue;
if (currentVer && cmpVer(e.version, currentVer) <= 0) continue; if (currentVer && cmpVer(e.version, currentVer) <= 0) continue;
if (!best || cmpVer(e.version, best.version) > 0) best = e; if (!best || cmpVer(e.version, best.version) > 0) best = e;
} }
if (!best) return { status: "up-to-date" }; if (!best) return { status: "up-to-date" };
let trusted = false; let trusted = false;
if (best.sig && Array.isArray(pubkeysHex) && pubkeysHex.length) trusted = verifySignature(id, best.version, best.sha256, best.sig, pubkeysHex); if (!pub && Array.isArray(pubkeysHex) && pubkeysHex.length) trusted = verifySignature(id, best.version, best.sha256, best.sig, pubkeysHex);
if (!trusted && best.publisherSig && typeof verifyPublisher === "function") { if (pub && typeof verifyPublisher === "function") {
try { trusted = !!(await verifyPublisher({ ...best, id })); } catch (e) { log(`updates: publisher verify ${id}@${best.version} threw:`, e.message); } try { trusted = !!(await verifyPublisher({ ...best, id })); } catch (e) { log(`updates: publisher verify ${id}@${best.version} threw:`, e.message); }
} }
if (!trusted) { if (!trusted) {
@ -219,10 +233,10 @@ async function fetchVerifiedPackage({ id, version, url, sha256, log }) {
const posix = (p) => p.replace(/\\/g, "/"); const posix = (p) => p.replace(/\\/g, "/");
const baseArgs = ["-x", "-z", "-f", posix(tmpFile), "-C", posix(tmpDir)]; const baseArgs = ["-x", "-z", "-f", posix(tmpFile), "-C", posix(tmpDir)];
try { try {
execFileSync("tar", baseArgs, { stdio: "ignore" }); execFileSync(TAR, baseArgs, { stdio: "ignore" });
} catch (e1) { } catch (e1) {
try { try {
execFileSync("tar", ["--force-local", ...baseArgs], { stdio: "ignore" }); execFileSync(TAR, ["--force-local", ...baseArgs], { stdio: "ignore" });
} catch (e2) { } catch (e2) {
// Surface the first error; --force-local retry is opportunistic. // Surface the first error; --force-local retry is opportunistic.
throw e1; throw e1;
@ -258,8 +272,8 @@ function placeDir(from, to) {
catch { fs.cpSync(from, to, { recursive: true }); fs.rmSync(from, { recursive: true, force: true }); } catch { fs.cpSync(from, to, { recursive: true }); fs.rmSync(from, { recursive: true, force: true }); }
} }
async function stageOne({ id, currentVer, updateURL, stagedDir, pubkeysHex, verifyPublisher, log, timeoutMs }) { async function stageOne({ id, currentVer, updateURL, stagedDir, pubkeysHex, verifyPublisher, publisher, log, timeoutMs }) {
const picked = await pickChannelEntry({ id, currentVer, updateURL, pubkeysHex, verifyPublisher, log, timeoutMs }); const picked = await pickChannelEntry({ id, currentVer, updateURL, pubkeysHex, verifyPublisher, publisher, log, timeoutMs });
if (picked.status !== "ok") return picked; if (picked.status !== "ok") return picked;
const best = picked.best; const best = picked.best;
@ -285,11 +299,19 @@ async function stageOne({ id, currentVer, updateURL, stagedDir, pubkeysHex, veri
// checks it against the name's NFT owner); a prior copy is kept in backupsDir // checks it against the name's NFT owner); a prior copy is kept in backupsDir
// like every other add-on swap. The installed addon.json gets `updateURL` // like every other add-on swap. The installed addon.json gets `updateURL`
// and `publisher` so the regular update check covers it from then on. // and `publisher` so the regular update check covers it from then on.
async function installCommunity({ id, updatesUrl, addonsDir, backupsDir, verifyPublisher, log = () => {}, timeoutMs = 15000 }) { async function installCommunity({ id, updatesUrl, publisher, addonsDir, backupsDir, verifyPublisher, log = () => {}, timeoutMs = 15000 }) {
if (typeof verifyPublisher !== "function") return { ok: false, error: "no publisher verifier" }; if (typeof verifyPublisher !== "function") return { ok: false, error: "no publisher verifier" };
if (!publisher) return { ok: false, error: "catalog entry has no publisher" };
const dest = path.join(addonsDir, id); const dest = path.join(addonsDir, id);
const currentVer = readAddonJson(dest)?.version || null; const current = readAddonJson(dest);
const picked = await pickChannelEntry({ id, currentVer, updateURL: updatesUrl, pubkeysHex: [], verifyPublisher, log, timeoutMs }); // An id that is already installed belongs to whoever signed it — an
// operator-signed add-on (no publisher) or another publisher's extension
// can't be replaced by a catalog entry that reuses its id.
if (current && String(current.publisher || "").toLowerCase() !== String(publisher).toLowerCase()) {
return { ok: false, error: `"${id}" is already installed from another publisher` };
}
const currentVer = current?.version || null;
const picked = await pickChannelEntry({ id, currentVer, updateURL: updatesUrl, pubkeysHex: [], verifyPublisher, publisher, log, timeoutMs });
if (picked.status === "up-to-date") return { ok: false, error: currentVer ? `already installed (v${currentVer})` : "channel has no installable version" }; if (picked.status === "up-to-date") return { ok: false, error: currentVer ? `already installed (v${currentVer})` : "channel has no installable version" };
if (picked.status !== "ok") return { ok: false, error: picked.status + (picked.detail ? ": " + picked.detail : ""), status: picked.status }; if (picked.status !== "ok") return { ok: false, error: picked.status + (picked.detail ? ": " + picked.detail : ""), status: picked.status };
const best = picked.best; const best = picked.best;
@ -300,6 +322,8 @@ async function installCommunity({ id, updatesUrl, addonsDir, backupsDir, verifyP
const mf = { ...pkg.manifest }; const mf = { ...pkg.manifest };
if (!mf.updateURL) mf.updateURL = updatesUrl; if (!mf.updateURL) mf.updateURL = updatesUrl;
mf.publisher = best.publisher; mf.publisher = best.publisher;
// First-party plug-in placement is not something a package can claim.
delete mf.category; delete mf.absorbs;
fs.writeFileSync(path.join(pkg.tmpDir, "addon.json"), JSON.stringify(mf, null, 2)); fs.writeFileSync(path.join(pkg.tmpDir, "addon.json"), JSON.stringify(mf, null, 2));
fs.mkdirSync(addonsDir, { recursive: true }); fs.mkdirSync(addonsDir, { recursive: true });
if (fs.existsSync(dest)) { if (fs.existsSync(dest)) {
@ -351,6 +375,7 @@ async function checkAndStageUpdates({ addonsDir, stagedDir, pubkeysHex, verifyPu
id: manifest.id, id: manifest.id,
currentVer: manifest.version, currentVer: manifest.version,
updateURL: manifest.updateURL, updateURL: manifest.updateURL,
publisher: manifest.publisher || null,
stagedDir, pubkeysHex, verifyPublisher, log, timeoutMs, stagedDir, pubkeysHex, verifyPublisher, log, timeoutMs,
}) })
.then((r) => report.push({ id: manifest.id, currentVer: manifest.version, updateURL: manifest.updateURL, ...r })) .then((r) => report.push({ id: manifest.id, currentVer: manifest.version, updateURL: manifest.updateURL, ...r }))

View file

@ -371,14 +371,24 @@ class AddonHost {
_activateOne(manifest, folder) { _activateOne(manifest, folder) {
const mainPath = path.join(folder, manifest.main); const mainPath = path.join(folder, manifest.main);
if (this._active.has(manifest.id)) {
throw new Error(`duplicate add-on id "${manifest.id}" (already loaded from ${this._active.get(manifest.id).folder})`);
}
// require() from a folder outside asar is fine — Electron just uses Node's // require() from a folder outside asar is fine — Electron just uses Node's
// resolver. This is where the trust decision lives: we're loading arbitrary // resolver. This is where the trust decision lives: we're loading arbitrary
// JS into the main process with full API access. // JS into the main process with full API access.
let mod; let mod;
try { try {
// Bust the require cache so a manual reload (future feature) picks up // Bust the require cache so a manual reload picks up edits — cheap since
// edits — cheap since add-ons are small. // add-ons are small. When the version changed (a hot-applied update) the
delete require.cache[require.resolve(mainPath)]; // whole folder goes, or the new index.js would run against the previous
// version's lib/*.js; a plain re-activation keeps its submodules.
this._loadedVersions = this._loadedVersions || new Map();
if (this._loadedVersions.get(folder) !== manifest.version) {
const root = path.resolve(folder).toLowerCase() + path.sep;
for (const k of Object.keys(require.cache)) if (k.toLowerCase().startsWith(root)) delete require.cache[k];
} else delete require.cache[require.resolve(mainPath)];
this._loadedVersions.set(folder, manifest.version);
mod = require(mainPath); mod = require(mainPath);
} catch (e) { } catch (e) {
throw new Error(`require() failed: ${e?.message || e}`); throw new Error(`require() failed: ${e?.message || e}`);
@ -398,8 +408,16 @@ class AddonHost {
active.inject = { source, matchers: manifest.pageInject.matchers, origins: manifest.pageInject.origins }; active.inject = { source, matchers: manifest.pageInject.matchers, origins: manifest.pageInject.origins };
} }
const api = this._makeApi(active); const api = this._makeApi(active);
try { mod.activate(api); } // Request filters and tab listeners register as activate() runs; if it
catch (e) { throw new Error(`activate() threw: ${e?.message || e}`); } // fails the add-on never reaches _active, so _deactivateAll can't undo them.
const cleanup = () => {
try { if (this._requestFilter) this._requestFilter.clear(manifest.id); } catch {}
for (const off of active.tabListeners) { try { off(); } catch {} }
};
try {
const r = mod.activate(api);
if (r && typeof r.then === "function") r.catch((e) => this.log(`[${manifest.id}] activate() rejected: ${e?.message || e}`));
} catch (e) { cleanup(); throw new Error(`activate() threw: ${e?.message || e}`); }
this._active.set(manifest.id, active); this._active.set(manifest.id, active);
this.log(`activated ${manifest.id} v${manifest.version}`); this.log(`activated ${manifest.id} v${manifest.version}`);
} }

View file

@ -23,6 +23,8 @@
// Batch specifics: `timeout` refuses to run without a console, so sleeps are // Batch specifics: `timeout` refuses to run without a console, so sleeps are
// `ping -n <n+1> 127.0.0.1`; the setup is launched with `start "" /wait` // `ping -n <n+1> 127.0.0.1`; the setup is launched with `start "" /wait`
// so the script blocks until the installer exits. The script deletes itself. // so the script blocks until the installer exits. The script deletes itself.
const ENV_SETUP = "THESEUS_UPDATE_SETUP";
const ENV_DIR = "THESEUS_UPDATE_DIR";
function buildUpdateHelperCmd({ pid, setupPath, installDir, args = ["/S", "--force-run"], graceSec = 2, maxWaitSec = 120 }) { function buildUpdateHelperCmd({ pid, setupPath, installDir, args = ["/S", "--force-run"], graceSec = 2, maxWaitSec = 120 }) {
if (!Number.isInteger(pid) || pid <= 0) throw new Error("pid required"); if (!Number.isInteger(pid) || pid <= 0) throw new Error("pid required");
if (typeof setupPath !== "string" || !setupPath || /["\r\n%]/.test(setupPath)) throw new Error("setupPath required (no quotes, percent signs or newlines)"); if (typeof setupPath !== "string" || !setupPath || /["\r\n%]/.test(setupPath)) throw new Error("setupPath required (no quotes, percent signs or newlines)");
@ -46,8 +48,12 @@ function buildUpdateHelperCmd({ pid, setupPath, installDir, args = ["/S", "--for
return [ return [
"@echo off", "@echo off",
"setlocal", "setlocal",
`set "SETUP=${setupPath}"`, // The paths arrive through the environment (updateHelperEnv), not as text
`set "ASAR=${installDir}\\resources\\app.asar"`, // in this file: cmd.exe reads a batch file in the OEM code page, so a
// UTF-8 "C:\Users\Иван\…" written here would point nowhere. The
// environment block is UTF-16 and survives intact.
`set "SETUP=%${ENV_SETUP}%"`,
`set "ASAR=%${ENV_DIR}%\\resources\\app.asar"`,
`"${PS}" -NoProfile -NonInteractive -Command "Wait-Process -Id ${pid} -Timeout ${maxIter} -ErrorAction SilentlyContinue"`, `"${PS}" -NoProfile -NonInteractive -Command "Wait-Process -Id ${pid} -Timeout ${maxIter} -ErrorAction SilentlyContinue"`,
`"${S32}\\ping.exe" -n ${grace} 127.0.0.1 >nul`, `"${S32}\\ping.exe" -n ${grace} 127.0.0.1 >nul`,
`"%SETUP%" ${argStr}`, `"%SETUP%" ${argStr}`,
@ -64,4 +70,9 @@ function buildUpdateHelperCmd({ pid, setupPath, installDir, args = ["/S", "--for
].join("\r\n"); ].join("\r\n");
} }
module.exports = { buildUpdateHelperCmd }; // Environment for the cmd.exe that runs the script above.
function updateHelperEnv({ setupPath, installDir }) {
return { [ENV_SETUP]: setupPath, [ENV_DIR]: installDir };
}
module.exports = { buildUpdateHelperCmd, updateHelperEnv };

317
main.js
View file

@ -742,20 +742,50 @@ async function refreshRemoteHomeCards() {
console.log(`[home-cards] refreshed from ${HOME_CARDS_URL}: ${clean.length} cards`); console.log(`[home-cards] refreshed from ${HOME_CARDS_URL}: ${clean.length} cards`);
} catch (e) { /* silent */ } } catch (e) { /* silent */ }
} }
// Sender validation — only accept IPC from our own home.html file:// URL. // Sender validation — only accept IPC from our own app pages. Compared against
// Rejects third-party pages that see the API shape via the preload. // the exact file:// URL of the shipped page, so a downloaded
function isHomePageSender(sender) { // file:///…/Downloads/home.html (or …/x.html#home.html) doesn't pass.
try { const appPageUrl = (name) => url.pathToFileURL(path.join(__dirname, name)).href.toLowerCase();
const u = sender.getURL() || ""; function isAppPage(sender, name) {
return u.startsWith("file://") && /home\.html(?:$|\?|#)/i.test(u); try { return String(sender.getURL() || "").split(/[?#]/)[0].toLowerCase() === appPageUrl(name); }
} catch { return false; } catch { return false; }
} }
// Rejects third-party pages that see the API shape via the preload.
function isHomePageSender(sender) { return isAppPage(sender, "home.html"); }
// Same origin-gating pattern for the branded error page. // Same origin-gating pattern for the branded error page.
function isErrorPageSender(sender) { function isErrorPageSender(sender) { return isAppPage(sender, "error.html"); }
try { // settings-preload is the only bridge to these channels, but a preload belongs
const u = sender.getURL() || ""; // to the WebContents, not the page — so the caller is checked as well: it must
return u.startsWith("file://") && /error\.html(?:$|\?|#)/i.test(u); // be a Settings tab currently showing our settings.html. SETTINGS_SHARED are
} catch { return false; } // also called by the toolbar (preload.js).
const SETTINGS_ONLY = new Set([
"addon-invoke", "addons-check-updates", "addons-community-catalog", "addons-install-community",
"addons-list", "addons-open-dir", "addons-reload", "addons-remove", "addons-reveal", "addons-set-enabled",
"ariadne-get-policy", "ariadne-get-status", "ariadne-install", "ariadne-set-policy", "ariadne-set-source",
"ariadne-state", "ariadne-toggle", "ariadne-uninstall", "ariadne-update",
"clear-browsing-data", "collision-reset", "collision-set-policy",
"password-add", "password-generate", "password-get", "password-list", "password-lock", "password-remove",
"password-setup", "password-status", "password-unlock", "password-update",
"recheck-update", "remove-from-list", "set-engine-enabled", "set-engine-order",
"settings-open-panel", "settings-section", "tor-state",
]);
const SETTINGS_SHARED = new Set([
"add-engine", "addons-apply-staged", "addons-list-staged", "app-restart", "collision-state",
"remove-engine", "settings-get", "settings-set", "toggle-tor",
]);
function isSettingsPage(sender) {
return tabs.some((t) => t.settings && t.view?.webContents === sender) && isAppPage(sender, "settings.html");
}
{
const handle = ipcMain.handle.bind(ipcMain);
ipcMain.handle = (channel, fn) => handle(channel,
SETTINGS_ONLY.has(channel) || SETTINGS_SHARED.has(channel)
? (e, ...args) => {
const ok = isSettingsPage(e.sender) || (SETTINGS_SHARED.has(channel) && chrome && e.sender === chrome.webContents);
if (!ok) throw new Error(`${channel}: settings only`);
return fn(e, ...args);
}
: fn);
} }
// ---- address-bar history (userData/history.json) -------------------------- // ---- address-bar history (userData/history.json) --------------------------
@ -1161,7 +1191,9 @@ const sessionFile = () => path.join(app.getPath("userData"), "session.json");
// without any tab having to load first — background tabs stay DORMANT (no // without any tab having to load first — background tabs stay DORMANT (no
// loadURL, no renderer activity) and come to life only when activated. // loadURL, no renderer activity) and come to life only when activated.
let sessionSavedAtClose = false; let sessionSavedAtClose = false;
let sessionDroppedForQuit = false; // clear-history-on-quit already deleted it; the window's close must not rewrite it
function saveSession() { function saveSession() {
if (sessionDroppedForQuit) return;
if (sessionSavedAtClose && !winAlive()) return; // already captured when the window closed if (sessionSavedAtClose && !winAlive()) return; // already captured when the window closed
try { try {
const live = tabs.filter((t) => !t.settings && (t.url || t.pending?.url)); const live = tabs.filter((t) => !t.settings && (t.url || t.pending?.url));
@ -1290,6 +1322,23 @@ function applyThrottle() {
// media-device labels/ids from enumerateDevices. Handlers read settings live. // media-device labels/ids from enumerateDevices. Handlers read settings live.
// Device permissions with no legitimate need here — always denied. // Device permissions with no legitimate need here — always denied.
const SENSITIVE_DEVICE = new Set(["hid", "serial", "usb", "bluetooth", "midi", "midiSysex"]); const SENSITIVE_DEVICE = new Set(["hid", "serial", "usb", "bluetooth", "midi", "midiSysex"]);
// Silent clipboard reads (seeds, WIFs, copied vault passwords live there), idle
// detection and multi-screen layout have no place being auto-granted either.
const DENIED_PERMISSIONS = new Set(["clipboard-read", "idle-detection", "window-management"]);
// A page navigating to an unknown scheme (search-ms:, ms-msdt:, …) asks for
// "openExternal"; hand it to the OS only after the user says so.
async function confirmOpenExternal(wc, externalURL) {
let scheme = "";
try { scheme = new URL(externalURL).protocol; } catch { return false; }
if (scheme === "mailto:" || scheme === "tel:") return true;
const parent = BrowserWindow.fromWebContents(wc) || win;
const r = await dialog.showMessageBox(parent, {
type: "question", buttons: ["Open", "Cancel"], defaultId: 1, cancelId: 1, noLink: true,
message: "Open an external application?",
detail: `This page wants to open:\n${String(externalURL).slice(0, 300)}`,
}).catch(() => ({ response: 1 }));
return r.response === 0;
}
// A "media" request may ask for audio, video, or both — allow only if none blocked. // A "media" request may ask for audio, video, or both — allow only if none blocked.
function mediaAllowed(kinds) { function mediaAllowed(kinds) {
if (kinds.includes("video") && settings.blockCamera) return false; if (kinds.includes("video") && settings.blockCamera) return false;
@ -1301,7 +1350,8 @@ function applyPermissions() {
ses.setPermissionRequestHandler((_wc, permission, callback, details) => { ses.setPermissionRequestHandler((_wc, permission, callback, details) => {
if (permission === "media") return callback(mediaAllowed(details?.mediaTypes || [])); if (permission === "media") return callback(mediaAllowed(details?.mediaTypes || []));
if (permission === "geolocation") return callback(effLocation() !== "deny"); // allow unless "hide" if (permission === "geolocation") return callback(effLocation() !== "deny"); // allow unless "hide"
if (SENSITIVE_DEVICE.has(permission)) return callback(false); if (SENSITIVE_DEVICE.has(permission) || DENIED_PERMISSIONS.has(permission)) return callback(false);
if (permission === "openExternal") { confirmOpenExternal(_wc, details?.externalURL).then(callback, () => callback(false)); return; }
callback(true); // benign UX permissions (fullscreen, pointerLock, …) callback(true); // benign UX permissions (fullscreen, pointerLock, …)
}); });
ses.setPermissionCheckHandler((_wc, permission, _origin, details) => { ses.setPermissionCheckHandler((_wc, permission, _origin, details) => {
@ -1311,7 +1361,7 @@ function applyPermissions() {
return !(settings.blockCamera && settings.blockMicrophone); return !(settings.blockCamera && settings.blockMicrophone);
} }
if (permission === "geolocation") return effLocation() !== "deny"; if (permission === "geolocation") return effLocation() !== "deny";
if (SENSITIVE_DEVICE.has(permission)) return false; if (SENSITIVE_DEVICE.has(permission) || DENIED_PERMISSIONS.has(permission)) return false;
return true; return true;
}); });
} }
@ -1365,6 +1415,9 @@ async function startTor() {
torProc.stdout.on("data", (d) => { if (/Bootstrapped 100%/.test(d.toString())) torReady(); }); torProc.stdout.on("data", (d) => { if (/Bootstrapped 100%/.test(d.toString())) torReady(); });
torProc.stderr.on("data", () => {}); torProc.stderr.on("data", () => {});
torProc.on("exit", () => { torProc = null; if (torState !== "off") torOff(); }); torProc.on("exit", () => { torProc = null; if (torState !== "off") torOff(); });
// A missing/quarantined tor.exe emits "error" and never "exit" — without this
// torProc stays set and startTor() is a no-op until restart.
torProc.on("error", (e) => { console.warn("tor spawn failed:", e?.message); torProc = null; torOff(); });
} }
function torReady() { function torReady() {
torState = "on"; torState = "on";
@ -1397,15 +1450,18 @@ function nodeRequest(urlStr, { method = "GET", headers = {}, agent } = {}) {
const req = lib.request(u, { method, headers, agent }, (res) => { const req = lib.request(u, { method, headers, agent }, (res) => {
const chunks = []; const chunks = [];
res.on("data", (c) => chunks.push(c)); res.on("data", (c) => chunks.push(c));
res.on("end", () => resolve({ status: res.statusCode, contentType: res.headers["content-type"], buffer: Buffer.concat(chunks) })); res.on("end", () => resolve({ status: res.statusCode, contentType: res.headers["content-type"], location: res.headers.location || null, buffer: Buffer.concat(chunks) }));
res.on("error", reject);
}); });
// An upstream that accepts and never answers would leave the tab spinning.
req.setTimeout(60000, () => req.destroy(new Error("upstream timeout")));
req.on("error", reject); req.end(); req.on("error", reject); req.end();
}); });
} }
async function contentFetch(url, init = {}) { async function contentFetch(url, init = {}) {
if (torState === "on") { await loadSocks(); return nodeRequest(url, { ...init, agent: new SocksProxyAgent(`socks5h://127.0.0.1:${TOR_PORT}`) }); } if (torState === "on") { await loadSocks(); return nodeRequest(url, { ...init, agent: new SocksProxyAgent(`socks5h://127.0.0.1:${TOR_PORT}`) }); }
const r = await fetch(url, init); const r = await fetch(url, init);
return { status: r.status, contentType: r.headers.get("content-type"), buffer: Buffer.from(await r.arrayBuffer()) }; return { status: r.status, contentType: r.headers.get("content-type"), location: r.headers.get("location"), buffer: Buffer.from(await r.arrayBuffer()) };
} }
// BNS `ip`-record fetch. The default `fetch` fails here for two reasons: // BNS `ip`-record fetch. The default `fetch` fails here for two reasons:
@ -1458,8 +1514,10 @@ async function httpGetByIp(ip, reqPath, hostHeader) {
const req = http.request(opts, (res) => { const req = http.request(opts, (res) => {
const chunks = []; const chunks = [];
res.on("data", (c) => chunks.push(c)); res.on("data", (c) => chunks.push(c));
res.on("end", () => resolve({ status: res.statusCode, contentType: res.headers["content-type"], buffer: Buffer.concat(chunks) })); res.on("end", () => resolve({ status: res.statusCode, contentType: res.headers["content-type"], location: res.headers.location || null, buffer: Buffer.concat(chunks) }));
res.on("error", reject);
}); });
req.setTimeout(60000, () => req.destroy(new Error("upstream timeout")));
req.on("error", reject); req.end(); req.on("error", reject); req.end();
}); });
} }
@ -1635,9 +1693,14 @@ let pollInFlight = null;
let pollTimer = null; let pollTimer = null;
let pollAttempts = 0, pollLastError = null; let pollAttempts = 0, pollLastError = null;
// Neither the electrum connect nor its WebSocket handshake has a timeout of
// its own; a server that accepts TCP and then stalls kept pollInFlight set
// forever, wedging the poll loop until restart.
const POLL_DEADLINE_MS = 45_000;
async function pollAndMerge() { async function pollAndMerge() {
if (pollInFlight) return pollInFlight; if (pollInFlight) return pollInFlight;
pollInFlight = (async () => { let conn = null, deadline;
const work = (async () => {
pollAttempts++; pollAttempts++;
try { try {
const R = await getResolver(); const R = await getResolver();
@ -1656,7 +1719,7 @@ async function pollAndMerge() {
|| readSnapshotFrom(SNAPSHOT_BUNDLED) || readSnapshotFrom(SNAPSHOT_BUNDLED)
|| { beacon: R.BEACON_SCRIPTHASH, history: [], txs: {} }; || { beacon: R.BEACON_SCRIPTHASH, history: [], txs: {} };
const el = await R.connectElectrum({ const el = conn = await R.connectElectrum({
electrum: electrumPool, WebSocket: currentWS(), directIP: true, electrum: electrumPool, WebSocket: currentWS(), directIP: true,
}); });
try { try {
@ -1697,9 +1760,21 @@ async function pollAndMerge() {
pollLastError = e && e.message || String(e); pollLastError = e && e.message || String(e);
// Silent — the browser stays usable via sharedIndex (last-known-good) or // Silent — the browser stays usable via sharedIndex (last-known-good) or
// the ensureIndex fallback on the next navigation. // the ensureIndex fallback on the next navigation.
} finally { pollInFlight = null; } }
})(); })();
return pollInFlight; const timeout = new Promise((resolve) => {
deadline = setTimeout(() => {
pollLastError = `poll timed out after ${POLL_DEADLINE_MS / 1000}s`;
try { conn?.close(); } catch {}
resolve(null);
}, POLL_DEADLINE_MS);
});
const p = Promise.race([work, timeout]).finally(() => {
clearTimeout(deadline);
if (pollInFlight === p) pollInFlight = null;
});
pollInFlight = p;
return p;
} }
function startBnsPolling() { function startBnsPolling() {
@ -1737,6 +1812,9 @@ async function ensureIndex(force = false) {
indexBuilding = buildIndex({ WebSocket: currentWS(), directIP: true, electrum: electrumPool }) indexBuilding = buildIndex({ WebSocket: currentWS(), directIP: true, electrum: electrumPool })
.then((idx) => { sharedIndex = idx; indexBuiltAt = Date.now(); refreshBcnrTlds(idx); return idx; }) .then((idx) => { sharedIndex = idx; indexBuiltAt = Date.now(); refreshBcnrTlds(idx); return idx; })
.finally(() => { indexBuilding = null; }); .finally(() => { indexBuilding = null; });
// When a stale index is served below nobody awaits the rebuild; a failed one
// (routine offline) would surface as an unhandled rejection.
indexBuilding.catch(() => {});
// If we have a stale index, don't block on the rebuild — serve stale, refresh async. // If we have a stale index, don't block on the rebuild — serve stale, refresh async.
return (sharedIndex && !force) ? sharedIndex : indexBuilding; return (sharedIndex && !force) ? sharedIndex : indexBuilding;
} }
@ -1756,7 +1834,11 @@ async function resolveHost(host) {
if (!entry && Date.now() - indexBuiltAt > 8_000) { if (!entry && Date.now() - indexBuiltAt > 8_000) {
const R = await getResolver(); const R = await getResolver();
if (R.connectElectrum && R.buildIndexFromSnapshot) { if (R.connectElectrum && R.buildIndexFromSnapshot) {
await pollAndMerge(); // Every dotted host the web uses lands here on a miss, so the wait is
// bounded: with electrum unreachable or stalling, an ordinary website
// must not sit behind a TCP timeout per server. The poll carries on in
// the background and the next lookup sees its result.
await Promise.race([pollAndMerge(), new Promise((r) => setTimeout(r, 2500))]);
entry = sharedIndex?.get(key) ?? null; entry = sharedIndex?.get(key) ?? null;
} else { } else {
idx = await ensureIndex(true); idx = await ensureIndex(true);
@ -1774,11 +1856,25 @@ const MIME = { html: "text/html; charset=utf-8", htm: "text/html; charset=utf-8"
json: "application/json", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", svg: "image/svg+xml", json: "application/json", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", svg: "image/svg+xml",
ico: "image/x-icon", webp: "image/webp", woff2: "font/woff2", woff: "font/woff", txt: "text/plain", wasm: "application/wasm" }; ico: "image/x-icon", webp: "image/webp", woff2: "font/woff2", woff: "font/woff", txt: "text/plain", wasm: "application/wasm" };
const guessType = (p) => MIME[p.split(".").pop()?.toLowerCase()] || "application/octet-stream"; const guessType = (p) => MIME[p.split(".").pop()?.toLowerCase()] || "application/octet-stream";
// Response() throws on a body with a null-body status, which turned an
// upstream 204/304 into the 502 page.
const NULL_BODY_STATUS = new Set([101, 204, 205, 304]);
function upstreamResponse(up, contentType) {
const headers = { "content-type": contentType };
if (up.location && up.status >= 300 && up.status < 400) headers.location = up.location;
return new Response(NULL_BODY_STATUS.has(up.status) ? null : up.buffer, { status: up.status, headers });
}
async function serveBns(request) { async function serveBns(request) {
const url = new URL(request.url); const url = new URL(request.url);
const host = url.hostname.toLowerCase(); const host = url.hostname.toLowerCase();
const reqPath = decodeURIComponent(url.pathname) || "/"; // Upstream requests carry the path exactly as the URL has it (percent-
// encoded). Decoding first broke file names with spaces/non-Latin-1 on `ip`
// records, turned %23/%3F into #/?, and let `..%2F` climb out of the
// name's own bucket on the gateway (bns://a.bch/..%2Fb.bch%2Fx). The
// decoded form is only used for MIME guessing.
const rawPath = url.pathname || "/";
let reqPath; try { reqPath = decodeURIComponent(rawPath); } catch { reqPath = rawPath; }
// (bns://collision-choose/ is handled by the will-navigate listener attached // (bns://collision-choose/ is handled by the will-navigate listener attached
// to each tab — it fires BEFORE the request reaches this protocol handler.) // to each tab — it fires BEFORE the request reaches this protocol handler.)
@ -1799,8 +1895,8 @@ async function serveBns(request) {
// record when available, HTTP fallback when not. Fixes serving BNS names // record when available, HTTP fallback when not. Fixes serving BNS names
// whose server redirects :80→:443 (the plain-fetch path chokes on the // whose server redirects :80→:443 (the plain-fetch path chokes on the
// redirect target because it isn't in ICANN DNS). // redirect target because it isn't in ICANN DNS).
const up = await ipRequest(r.ip, reqPath + url.search, host, r.tls); const up = await ipRequest(r.ip, rawPath + url.search, host, r.tls);
return new Response(up.buffer, { status: up.status, headers: { "content-type": up.contentType || guessType(reqPath) } }); return upstreamResponse(up, up.contentType || guessType(reqPath));
}; };
// `p` — reverse-proxy the request to a full upstream URL. Address bar stays // `p` — reverse-proxy the request to a full upstream URL. Address bar stays
// on the BNS host; unlike `ip`, uses the upstream's own DNS + public CA and // on the BNS host; unlike `ip`, uses the upstream's own DNS + public CA and
@ -1811,11 +1907,10 @@ async function serveBns(request) {
const serveP = async () => { const serveP = async () => {
const base = new URL(r.p); const base = new URL(r.p);
const prefix = base.pathname === "/" ? "" : base.pathname.replace(/\/$/, ""); const prefix = base.pathname === "/" ? "" : base.pathname.replace(/\/$/, "");
const target = base.origin + prefix + reqPath + url.search; const target = base.origin + prefix + rawPath + url.search;
const up = await fetch(target, { redirect: "manual" }); // contentFetch so Tor covers this too (a plain fetch leaked the real IP).
const body = Buffer.from(await up.arrayBuffer()); const up = await contentFetch(target, { redirect: "manual" });
const ct = up.headers.get("content-type") || guessType(reqPath); return upstreamResponse(up, up.contentType || guessType(reqPath));
return new Response(body, { status: up.status, headers: { "content-type": ct } });
}; };
try { try {
// A subdomain the owner has ruled on: blocked, or sent elsewhere. The // A subdomain the owner has ruled on: blocked, or sent elsewhere. The
@ -1832,7 +1927,7 @@ async function serveBns(request) {
// Secret-free: fetch Sia content from the public gateway (it holds the // Secret-free: fetch Sia content from the public gateway (it holds the
// keys and owns the subfolder mapping) instead of signing S3 requests // keys and owns the subfolder mapping) instead of signing S3 requests
// with credentials that must never ship in a public build. // with credentials that must never ship in a public build.
const up = await contentFetch(`${GATEWAY}/bns/${host}${reqPath}${url.search}`, {}); const up = await contentFetch(`${GATEWAY}/bns/${host}${rawPath}${url.search}`, {});
const ct = up.contentType && up.contentType !== "application/octet-stream" const ct = up.contentType && up.contentType !== "application/octet-stream"
? up.contentType : guessType(reqPath === "/" ? "index.html" : reqPath); ? up.contentType : guessType(reqPath === "/" ? "index.html" : reqPath);
let body = up.buffer; let body = up.buffer;
@ -1841,7 +1936,7 @@ async function serveBns(request) {
// resolve against the bns:// origin, not back through the relay. // resolve against the bns:// origin, not back through the relay.
body = Buffer.from(body.toString("utf8").replace(/<base\s+href="\/bns\/[^"]*">/i, ""), "utf8"); body = Buffer.from(body.toString("utf8").replace(/<base\s+href="\/bns\/[^"]*">/i, ""), "utf8");
} }
return new Response(body, { status: up.status, headers: { "content-type": ct } }); return upstreamResponse({ ...up, buffer: body }, ct);
} }
if (r.ip) return await serveIp(); if (r.ip) return await serveIp();
if (!isSubdomain && r.p) return await serveP(); if (!isSubdomain && r.p) return await serveP();
@ -1851,8 +1946,8 @@ async function serveBns(request) {
// semantics as an `ip` record (and the on-chain `tls` pin still applies). // semantics as an `ip` record (and the on-chain `tls` pin still applies).
const dnsIp = await dnsAddressFor(rec.entry); const dnsIp = await dnsAddressFor(rec.entry);
if (dnsIp) { if (dnsIp) {
const up = await ipRequest(dnsIp, reqPath + url.search, host, r.tls); const up = await ipRequest(dnsIp, rawPath + url.search, host, r.tls);
return new Response(up.buffer, { status: up.status, headers: { "content-type": up.contentType || guessType(reqPath) } }); return upstreamResponse(up, up.contentType || guessType(reqPath));
} }
return new Response(JSON.stringify(rec.entry, null, 2), { headers: { "content-type": "application/json" } }); return new Response(JSON.stringify(rec.entry, null, 2), { headers: { "content-type": "application/json" } });
} catch (e) { } catch (e) {
@ -1947,11 +2042,10 @@ function sidebarMaxWidth() {
// The add-on host is the single point of truth for what's installed and // The add-on host is the single point of truth for what's installed and
// active. Populated by initAddons() at app-ready time. // active. Populated by initAddons() at app-ready time.
let addonHost = null; let addonHost = null;
// One-shot proxy-login handler installed by setSessionProxy when the // Proxy credentials supplied by an add-on via setSessionProxy, answered from
// extension provided credentials. Removed and re-installed on every // app#login (Session has no "login" event). Replaced on every setSessionProxy
// setSessionProxy call so the current credentials always match the // call so the current credentials always match the current proxy.
// current proxy. let proxyAuth = null;
let proxyLoginHandler = null;
const { AddonHost } = require("./addons-host.js"); const { AddonHost } = require("./addons-host.js");
const addonUpdater = require("./addon-updater.js"); const addonUpdater = require("./addon-updater.js");
const { PUBKEYS_HEX: ADDON_UPDATE_PUBKEYS } = require("./addon-update-pubkeys.js"); const { PUBKEYS_HEX: ADDON_UPDATE_PUBKEYS } = require("./addon-update-pubkeys.js");
@ -2142,7 +2236,7 @@ function initAddons() {
setSessionProxy: async (rules, addonId) => { setSessionProxy: async (rules, addonId) => {
const ses = session.defaultSession; const ses = session.defaultSession;
// Always clear any prior proxy-login handler before swapping. // Always clear any prior proxy-login handler before swapping.
if (proxyLoginHandler) { ses.off("login", proxyLoginHandler); proxyLoginHandler = 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`);
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); }
@ -2161,16 +2255,8 @@ function initAddons() {
} }
const publicRules = opts.proxyRules; // never log the password const publicRules = opts.proxyRules; // never log the password
console.log(`[addons] [${addonId}] setting session proxy:`, publicRules, auth ? "(auth pending)" : ""); console.log(`[addons] [${addonId}] setting session proxy:`, publicRules, auth ? "(auth pending)" : "");
if (auth) { // Chromium fires app#login with authInfo.isProxy when the proxy asks for creds.
// Chromium fires session#login with `authenticationResponseDetails.isProxy === true` if (auth) proxyAuth = auth;
// when the proxy asks for creds. Answer once per session.
proxyLoginHandler = (event, _details, authInfo, callback) => {
if (!authInfo || !authInfo.isProxy) return;
event.preventDefault();
callback(auth.username, auth.password);
};
ses.on("login", proxyLoginHandler);
}
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
@ -2331,6 +2417,7 @@ function initAddons() {
addonsDir: addonsUserDir(), addonsDir: addonsUserDir(),
stagedDir, stagedDir,
pubkeysHex: ADDON_UPDATE_PUBKEYS, pubkeysHex: ADDON_UPDATE_PUBKEYS,
verifyPublisher: verifyPublisherEntry,
logger: (...a) => console.log("[addons]", ...a), logger: (...a) => console.log("[addons]", ...a),
}); });
const staged = listStagedAddons(stagedDir); const staged = listStagedAddons(stagedDir);
@ -2952,11 +3039,21 @@ function pwMatchesForHost(host) {
.filter((e) => e.domain === h) .filter((e) => e.domain === h)
.map((e) => ({ id: e.id, domain: e.domain, username: e.username || "" })); .map((e) => ({ id: e.id, domain: e.domain, username: e.username || "" }));
} }
// The host of what the tab is showing right now. t.prov.host is set by our own
// navigations only, so it goes stale on Back/Forward and server redirects —
// matching credentials against it offered (and filled) bank.com's login on
// whatever page was actually loaded.
function liveHost(t) {
try {
const u = new URL(t.view.webContents.getURL());
return u.protocol === "http:" || u.protocol === "https:" || u.protocol === "bns:" ? u.hostname.toLowerCase() : "";
} catch { return ""; }
}
// Emit the current tab's match count to chrome so the toolbar chip can // Emit the current tab's match count to chrome so the toolbar chip can
// show/hide + display the count. Cheap; called on nav + vault unlock/lock. // show/hide + display the count. Cheap; called on nav + vault unlock/lock.
function emitPwAvailability() { function emitPwAvailability() {
const t = activeTab(); const t = activeTab();
const host = t?.prov?.host || ""; const host = t ? liveHost(t) : "";
const count = pwMatchesForHost(host).length; const count = pwMatchesForHost(host).length;
try { chrome?.webContents.send("pw-availability", { host, count }); } catch {} try { chrome?.webContents.send("pw-availability", { host, count }); } catch {}
} }
@ -2966,6 +3063,8 @@ function emitPwAvailability() {
// (user clicks the chip; nothing runs on page load). // (user clicks the chip; nothing runs on page load).
async function pwFillIntoActiveTab(entry) { async function pwFillIntoActiveTab(entry) {
const t = activeTab(); if (!t) return false; const t = activeTab(); if (!t) return false;
// Re-check at fill time: the page may have navigated since the picker opened.
if (!entry.domain || liveHost(t) !== entry.domain) return { ok: false, why: "origin-changed" };
const wc = t.view.webContents; const wc = t.view.webContents;
const script = `(() => { const script = `(() => {
const visible = (el) => { const r = el.getBoundingClientRect(); return r.width > 4 && r.height > 4; }; const visible = (el) => { const r = el.getBoundingClientRect(); return r.width > 4 && r.height > 4; };
@ -3054,6 +3153,11 @@ function installDownloadTracker() {
try { item.setSavePath(dst); } catch {} try { item.setSavePath(dst); } catch {}
updateDownloadTotal = item.getTotalBytes() || 0; updateDownloadTotal = item.getTotalBytes() || 0;
updateDownloadReceived = 0; updateDownloadReceived = 0;
// The hash this download must match, taken now: a manifest refresh while
// it runs would otherwise compare it against the next release's hash.
// Only the installer is armable — the portable build can't go through
// the NSIS helper, so it has no expected hash here.
const expectedHash = url === updateAvailable.setupUrl ? String(updateAvailable.setupHash || "").toLowerCase() : "";
item.on("updated", () => { item.on("updated", () => {
updateDownloadReceived = item.getReceivedBytes(); updateDownloadReceived = item.getReceivedBytes();
updateDownloadTotal = item.getTotalBytes() || updateDownloadTotal; updateDownloadTotal = item.getTotalBytes() || updateDownloadTotal;
@ -3073,7 +3177,7 @@ function installDownloadTracker() {
// and 0.3.31's in-app updater then spawned a half-file as setup — // and 0.3.31's in-app updater then spawned a half-file as setup —
// NSIS integrity check failed silently and the browser was gone. // NSIS integrity check failed silently and the browser was gone.
const savedPath = item.getSavePath() || dst; const savedPath = item.getSavePath() || dst;
const expected = String(updateAvailable && updateAvailable.setupHash || "").toLowerCase(); const expected = expectedHash;
if (!expected) { if (!expected) {
updateDownloadState = "failed"; updateDownloadState = "failed";
console.warn(`[update] no manifest hash for ${savedPath} — refusing to arm install`); console.warn(`[update] no manifest hash for ${savedPath} — refusing to arm install`);
@ -3261,9 +3365,9 @@ ipcMain.handle("auth-answer", (e, id, creds) => {
settle(creds && typeof creds.username === "string" ? { username: creds.username, password: String(creds.password || "") } : null); settle(creds && typeof creds.username === "string" ? { username: creds.username, password: String(creds.password || "") } : null);
}); });
app.on("login", (event, _wc, details, authInfo, callback) => { app.on("login", (event, _wc, details, authInfo, callback) => {
// Proxy auth configured by an add-on is answered by its own handler.
if (authInfo?.isProxy && proxyLoginHandler) return;
event.preventDefault(); event.preventDefault();
// Proxy auth configured by an add-on is answered with its credentials.
if (authInfo?.isProxy && proxyAuth) return callback(proxyAuth.username, proxyAuth.password);
const host = authInfo?.host || ""; const host = authInfo?.host || "";
const port = authInfo?.port; const port = authInfo?.port;
const origin = (authInfo?.isProxy ? "" : (String(details?.url || "").startsWith("http:") ? "http://" : "https://")) const origin = (authInfo?.isProxy ? "" : (String(details?.url || "").startsWith("http:") ? "http://" : "https://"))
@ -3453,7 +3557,7 @@ 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); });
wc.on("did-navigate", () => { if (tab.id === activeId) notifyTabChange(); }); 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
// Linux, the zoom itself is up to us. // Linux, the zoom itself is up to us.
@ -3491,12 +3595,21 @@ function createTab(initial, opts = {}) {
installExtensionWithConsent(installId, requester); installExtensionWithConsent(installId, requester);
return; return;
} }
// Same rule as navigateTab: privileged tabs hand any non-file target to a new tab.
if ((tab.settings || tab.addonId) && !/^file:/i.test(u)) {
e.preventDefault();
navigateTab(id, u);
return;
}
const parsed = new URL(u); const parsed = new URL(u);
// Intercept the collision-choose posted by the in-tab "Open with…" page, // Intercept the collision-choose posted by the in-tab "Open with…" page,
// apply the remember flag, set a one-shot transient override so loadBns // apply the remember flag, set a one-shot transient override so loadBns
// doesn't re-prompt, and route via navigateTab so chrome/prov stay in sync. // doesn't re-prompt, and route via navigateTab so chrome/prov stay in sync.
if (parsed.protocol === "bns:" && parsed.hostname === "collision-choose") { if (parsed.protocol === "bns:" && parsed.hostname === "collision-choose") {
e.preventDefault(); e.preventDefault();
// Only our interstitial may post a choice — any page could otherwise
// persist "always ICANN"/"always BCNR" for a name or a whole TLD.
if (!isAppPage(wc, "collision.html")) return;
const p = parsed.searchParams; const p = parsed.searchParams;
const target = String(p.get("host") || "").toLowerCase(); const target = String(p.get("host") || "").toLowerCase();
const cTld = String(p.get("tld") || "").toLowerCase(); const cTld = String(p.get("tld") || "").toLowerCase();
@ -3568,7 +3681,16 @@ function createTab(initial, opts = {}) {
if (installId) { if (installId) {
let requester = null; try { requester = new URL(wc.getURL()).host || null; } catch {} let requester = null; try { requester = new URL(wc.getURL()).host || null; } catch {}
installExtensionWithConsent(installId, requester); installExtensionWithConsent(installId, requester);
} else if (url && url !== "about:blank") createTab(url, { background: disposition === "background-tab", after: tab.id }); } else if (url && url !== "about:blank") {
// Chromium won't let a web page navigate to file://, chrome:// or
// theseus://, but createTab → loadURL runs from main and would. Web
// pages get web schemes only; our own file:// pages keep the rest.
let opener = ""; try { opener = new URL(wc.getURL()).protocol; } catch {}
let target = ""; try { target = new URL(url).protocol; } catch {}
if (opener === "file:" || ["http:", "https:", "bns:"].includes(target)) {
createTab(url, { background: disposition === "background-tab", after: tab.id });
}
}
return { action: "deny" }; return { action: "deny" };
}); });
// Right-click context menu. // Right-click context menu.
@ -3814,7 +3936,14 @@ function createWindow() {
// hidden via activeQuickLinkId. Separate browsing context from any tab, // hidden via activeQuickLinkId. Separate browsing context from any tab,
// so a Facebook sidebar visit doesn't share cookies with a Facebook tab // so a Facebook sidebar visit doesn't share cookies with a Facebook tab
// the user opened — matching how Opera's sidebar panels feel. // the user opened — matching how Opera's sidebar panels feel.
quickPanel = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "home-preload.js") } }); // 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.
quickPanel = new WebContentsView();
quickPanel.webContents.setWindowOpenHandler(({ url }) => {
if (url && /^(?:https?|bns):/i.test(url)) createTab(url);
return { action: "deny" };
});
try { quickPanel.setBackgroundColor(nativeTheme.shouldUseDarkColors ? "#0b0e14" : "#ffffff"); } catch {} try { quickPanel.setBackgroundColor(nativeTheme.shouldUseDarkColors ? "#0b0e14" : "#ffffff"); } catch {}
win.contentView.addChildView(quickPanel); win.contentView.addChildView(quickPanel);
styleScrollbars(quickPanel.webContents); styleScrollbars(quickPanel.webContents);
@ -3865,6 +3994,10 @@ function createWindow() {
win.on("closed", () => { win.on("closed", () => {
win = null; chrome = null; popover = null; enginePicker = null; downloadsPop = null; win = null; chrome = null; popover = null; enginePicker = null; downloadsPop = null;
dismissJsDialogFor(null); dismissJsDialogFor(null);
// Same for wallet approvals: a request left current would block
// pumpApproval (and every later dapp request) until a full restart.
if (approvalCurrent) { const c = approvalCurrent; approvalCurrent = null; try { c.resolve("cancel"); } catch {} }
for (const q of approvalQueue.splice(0)) { try { q.resolve("cancel"); } catch {} }
addressPicker = null; pwFillPop = null; linkStatus = null; sidebar = null; approvalPop = null; jsDialogPop = null; addressPicker = null; pwFillPop = null; linkStatus = null; sidebar = null; approvalPop = null; jsDialogPop = null;
linkStatusVisible = false; linkStatusVisible = false;
}); });
@ -3882,9 +4015,24 @@ async function navigateTab(id, input) {
// every Settings page. // every Settings page.
const settingsLink = /^theseus:\/\/settings(?:\/([a-z0-9-]{1,32}(?:\/[a-z0-9-]{1,32})?))?\/?$/i.exec(q); const settingsLink = /^theseus:\/\/settings(?:\/([a-z0-9-]{1,32}(?:\/[a-z0-9-]{1,32})?))?\/?$/i.exec(q);
if (settingsLink) { openSettingsTab(settingsLink[1] || ""); return; } if (settingsLink) { openSettingsTab(settingsLink[1] || ""); return; }
// A Settings or add-on tab keeps its privileged preload for the life of its
// WebContents, so it never loads anything else: the target opens in a fresh
// tab in its place.
if (t.settings || t.addonId) { createTab(q, { after: id }); closeTab(id); return; }
// Local paths open as files — never BCNR, never a search. // Local paths open as files — never BCNR, never a search.
const fileUrl = localFileUrl(q); const fileUrl = localFileUrl(q);
if (fileUrl) return loadLocalFile(t, id, fileUrl); if (fileUrl) return loadLocalFile(t, id, fileUrl);
// data:/blob: (e.g. "Open image in new tab" on an inline image) aren't
// scheme:// URLs, so they used to fall through to the search below — which
// sent the whole image to the search engine. Load them as they are.
if (/^(?:data|blob):/i.test(q)) {
t.url = q; t.prov = { host: "", kind: "web" };
await t.view.webContents.loadURL(q).catch(() => {});
if (id === activeId) pushNav(t.prov);
emitTabs();
return;
}
if (/^javascript:/i.test(q)) return;
// Address bar doubles as a search box: anything that isn't a URL/hostname // Address bar doubles as a search box: anything that isn't a URL/hostname
// (a bare word, or a phrase with spaces) becomes a web search. // (a bare word, or a phrase with spaces) becomes a web search.
if (!looksLikeUrl(q)) q = SEARCH(q); if (!looksLikeUrl(q)) q = SEARCH(q);
@ -4018,7 +4166,14 @@ async function loadBns(t, id, host, rest, tld) {
emitTabs(); emitTabs();
} }
ipcMain.handle("navigate", (_e, input) => navigateTab(activeId, input)); // The toolbar and our own home page only — home-preload is in every tab, and a
// web page must not steer the active tab (or a Settings tab) from the background.
ipcMain.handle("navigate", (e, input) => {
if (chrome && e.sender === chrome.webContents) return navigateTab(activeId, input);
if (!isHomePageSender(e.sender)) return;
const t = tabs.find((x) => x.view.webContents === e.sender);
return navigateTab(t ? t.id : activeId, input);
});
ipcMain.handle("search", (_e, q) => navigateTab(activeId, SEARCH(q))); ipcMain.handle("search", (_e, q) => navigateTab(activeId, SEARCH(q)));
ipcMain.handle("new-tab", () => createTab()); ipcMain.handle("new-tab", () => createTab());
ipcMain.handle("close-tab", (_e, id) => closeTab(id)); ipcMain.handle("close-tab", (_e, id) => closeTab(id));
@ -4607,8 +4762,9 @@ async function installCommunityById(id) {
try { try {
const card = (await fetchCommunityCatalog()).find((e) => e.id === id); const card = (await fetchCommunityCatalog()).find((e) => e.id === id);
if (!card) return { ok: false, error: "not in the catalog" }; if (!card) return { ok: false, error: "not in the catalog" };
if (fs.existsSync(path.join(bundledAddonsDir(), id, "addon.json"))) return { ok: false, error: "id belongs to a built-in add-on" };
const r = await addonUpdater.installCommunity({ const r = await addonUpdater.installCommunity({
id, updatesUrl: card.updatesUrl, id, updatesUrl: card.updatesUrl, publisher: card.publisher,
addonsDir: addonsUserDir(), backupsDir: addonsBackupDir(), addonsDir: addonsUserDir(), backupsDir: addonsBackupDir(),
verifyPublisher: verifyPublisherEntry, verifyPublisher: verifyPublisherEntry,
log: (...a) => console.log("[addons]", ...a), log: (...a) => console.log("[addons]", ...a),
@ -4662,7 +4818,7 @@ async function installExtensionWithConsent(id, requester) {
: `Install ${card.name || id} ${card.latest}?`, : `Install ${card.name || id} ${card.latest}?`,
detail: `${from}Published by ${card.publisher}. Theseus verifies the package signature against ${card.publisher}'s current owner in its own chain index before anything is written.\n\n` detail: `${from}Published by ${card.publisher}. Theseus verifies the package signature against ${card.publisher}'s current owner in its own chain index before anything is written.\n\n`
+ `A community extension runs with the same access as any add-on — treat it like a program from that publisher.`, + `A community extension runs with the same access as any add-on — treat it like a program from that publisher.`,
buttons: ["Install", "Cancel"], defaultId: 0, cancelId: 1, noLink: true, buttons: ["Install", "Cancel"], defaultId: 1, cancelId: 1, noLink: true,
}); });
if (response !== 0) return { ok: false, error: "cancelled" }; if (response !== 0) return { ok: false, error: "cancelled" };
const r = await installCommunityById(id); const r = await installCommunityById(id);
@ -5287,13 +5443,17 @@ app.on("will-quit", () => {
const setupPath = pendingInstallerPath; const setupPath = pendingInstallerPath;
pendingInstallerPath = null; pendingInstallerPath = null;
try { try {
const { buildUpdateHelperCmd } = require("./lib/update-helper.cjs"); const { buildUpdateHelperCmd, updateHelperEnv } = require("./lib/update-helper.cjs");
const cmdPath = path.join(app.getPath("userData"), "update-helper.cmd"); const cmdPath = path.join(app.getPath("userData"), "update-helper.cmd");
fs.writeFileSync(cmdPath, buildUpdateHelperCmd({ pid: process.pid, setupPath, installDir: path.dirname(process.execPath) })); const installDir = path.dirname(process.execPath);
fs.writeFileSync(cmdPath, buildUpdateHelperCmd({ pid: process.pid, setupPath, installDir }));
// A detached cmd.exe outlives this process (verified) and is in no job // A detached cmd.exe outlives this process (verified) and is in no job
// of ours; the batch file itself waits for our PID to disappear. // of ours; the batch file itself waits for our PID to disappear.
const helper = spawn("cmd.exe", [`/d /c "${cmdPath}"`], // /s + doubled quotes: a plain /c "<path>" loses its quotes when the path
{ detached: true, stdio: "ignore", windowsHide: true, windowsVerbatimArguments: true }); // holds & ( ) and the like.
const helper = spawn("cmd.exe", [`/d /s /c ""${cmdPath}""`],
{ detached: true, stdio: "ignore", windowsHide: true, windowsVerbatimArguments: true,
env: { ...process.env, ...updateHelperEnv({ setupPath, installDir }) } });
helper.unref(); helper.unref();
console.log(`[update] helper armed for ${setupPath}`); console.log(`[update] helper armed for ${setupPath}`);
} catch (e) { console.warn("[update] helper spawn failed:", e?.message); } } catch (e) { console.warn("[update] helper spawn failed:", e?.message); }
@ -6172,7 +6332,7 @@ ipcMain.handle("address-pick", (_e, url) => {
// active tab. Whole flow is user-initiated; no page-load DOM watchers yet. // active tab. Whole flow is user-initiated; no page-load DOM watchers yet.
ipcMain.handle("toggle-pw-fill", async (_e, rect) => { ipcMain.handle("toggle-pw-fill", async (_e, rect) => {
if (pwfVisible) return showPwFill(false); if (pwfVisible) return showPwFill(false);
const t = activeTab(); const host = t?.prov?.host || ""; const t = activeTab(); const host = t ? liveHost(t) : "";
const matches = pwMatchesForHost(host); const matches = pwMatchesForHost(host);
if (!matches.length) return showPwFill(false); if (!matches.length) return showPwFill(false);
if (rect) pwfPos = { x: Math.round(rect.x), y: Math.round(rect.y) }; if (rect) pwfPos = { x: Math.round(rect.x), y: Math.round(rect.y) };
@ -6188,7 +6348,8 @@ ipcMain.handle("pw-fill-resize", (_e, h) => {
pwfH = Math.max(60, Math.min(300, Math.round(h) || 80)); pwfH = Math.max(60, Math.min(300, Math.round(h) || 80));
if (pwfVisible) positionPwFill(); if (pwfVisible) positionPwFill();
}); });
ipcMain.handle("pw-fill-pick", async (_e, id) => { ipcMain.handle("pw-fill-pick", async (e, id) => {
if (!pwFillPop || e.sender !== pwFillPop.webContents) return { ok: false, err: "picker only" };
showPwFill(false); showPwFill(false);
if (!vaultState) return { ok: false, err: "locked" }; if (!vaultState) return { ok: false, err: "locked" };
try { try {
@ -6196,7 +6357,7 @@ ipcMain.handle("pw-fill-pick", async (_e, id) => {
const entry = vaultState.entries.find((x) => x.id === id); const entry = vaultState.entries.find((x) => x.id === id);
if (!entry) return { ok: false, err: "no such entry" }; if (!entry) return { ok: false, err: "no such entry" };
const password = await v.resolvePassword(vaultState, id); const password = await v.resolvePassword(vaultState, id);
return await pwFillIntoActiveTab({ username: entry.username, password }); return await pwFillIntoActiveTab({ domain: entry.domain, username: entry.username, password });
} catch (e) { return { ok: false, err: e?.message || String(e) }; } } catch (e) { return { ok: false, err: e?.message || String(e) }; }
}); });
// The chrome sends arrow-up/down/enter through so the picker can move its // The chrome sends arrow-up/down/enter through so the picker can move its
@ -6679,7 +6840,7 @@ async function openLinkWindow(input) {
// Popups from a page here go to the main window's tabs when it exists — // Popups from a page here go to the main window's tabs when it exists —
// one place for tabs — otherwise to another plain window. // one place for tabs — otherwise to another plain window.
wc.setWindowOpenHandler(({ url }) => { wc.setWindowOpenHandler(({ url }) => {
if (url && url !== "about:blank") { if (url && /^(?:https?|bns):/i.test(url)) { // web schemes only — see the tab handler
if (win && !win.isDestroyed()) { createTab(url); try { win.focus(); } catch {} } if (win && !win.isDestroyed()) { createTab(url); try { win.focus(); } catch {} }
else openLinkWindow(url); else openLinkWindow(url);
} }
@ -7079,7 +7240,14 @@ if (!process.env.THESEUS_NO_AUTOSTART && !app.requestSingleInstanceLock()) {
setInterval(() => refreshRemoteHomeCards().catch(() => {}), HOME_CARDS_REFRESH_MS); setInterval(() => refreshRemoteHomeCards().catch(() => {}), HOME_CARDS_REFRESH_MS);
app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); });
}); });
app.on("before-quit", async (e) => { // Electron doesn't wait for an async before-quit listener, so the quit is
// held until the clear finishes (bounded) and then re-issued.
let quitCleared = false, quitClearing = false;
app.on("before-quit", (e) => {
if (quitCleared) return;
e.preventDefault();
if (quitClearing) return;
quitClearing = true;
// Auto-clear per user settings. saveSession() runs first so restoreSession // Auto-clear per user settings. saveSession() runs first so restoreSession
// still works UNLESS the user asked to drop history — in which case we // still works UNLESS the user asked to drop history — in which case we
// wipe the session file too so the next launch is genuinely blank. // wipe the session file too so the next launch is genuinely blank.
@ -7087,16 +7255,17 @@ if (!process.env.THESEUS_NO_AUTOSTART && !app.requestSingleInstanceLock()) {
stopTor(); stopTor();
stopBnsPolling(); // silence the background delta refresh before exit stopBnsPolling(); // silence the background delta refresh before exit
vaultState = null; // drop the in-memory vault key + purposeRoot vaultState = null; // drop the in-memory vault key + purposeRoot
try { const clear = (async () => {
await clearBrowsingData({ await clearBrowsingData({
cookies: settings.clearCookiesOnQuit, cookies: settings.clearCookiesOnQuit,
cache: settings.clearCacheOnQuit, cache: settings.clearCacheOnQuit,
storage: settings.clearStorageOnQuit, storage: settings.clearStorageOnQuit,
}); });
if (settings.clearHistoryOnQuit) { // Session file AND the address-bar history (history.json).
try { fs.unlinkSync(sessionFile()); } catch {} if (settings.clearHistoryOnQuit) { await clearHistoryNow(); sessionDroppedForQuit = true; }
} })().catch((err) => console.error("before-quit clear failed:", err?.message));
} catch (err) { console.error("before-quit clear failed:", err?.message); } Promise.race([clear, new Promise((r) => setTimeout(r, 5000))])
.finally(() => { quitCleared = true; app.quit(); });
}); });
app.on("window-all-closed", () => { stopTor(); if (process.platform !== "darwin") app.quit(); }); app.on("window-all-closed", () => { stopTor(); if (process.platform !== "darwin") app.quit(); });
} }

View file

@ -1917,7 +1917,7 @@
return; return;
} }
pwListEl.innerHTML = entries.map((e) => `<div class="eng" data-id="${esc(e.id)}">` + pwListEl.innerHTML = entries.map((e) => `<div class="eng" data-id="${esc(e.id)}">` +
`<span class="eic"><img class="ei" src="https://icons.duckduckgo.com/ip3/${esc(e.domain)}.ico" onerror="this.replaceWith(Object.assign(document.createElement('span'),{className:'es',textContent:'🔑'}))"></span>` + `<span class="eic"><span class="es">🔑</span></span>` +
`<span class="enm"><b>${esc(e.domain)}</b> <span class="pmuted">· ${esc(e.username || "—")}</span> <span class="pmuted" style="font-size:11px">· ${e.kind === "generated" ? "generated" : "pasted"}</span></span>` + `<span class="enm"><b>${esc(e.domain)}</b> <span class="pmuted">· ${esc(e.username || "—")}</span> <span class="pmuted" style="font-size:11px">· ${e.kind === "generated" ? "generated" : "pasted"}</span></span>` +
`<button class="cx pwShow" title="Show + copy">👁</button>` + `<button class="cx pwShow" title="Show + copy">👁</button>` +
`<button class="cx pwDel" title="Remove">✕</button>` + `<button class="cx pwDel" title="Remove">✕</button>` +

View file

@ -102,6 +102,8 @@ function pickIcon(icons, base) {
const any = purpose.length === 0 || purpose.includes("any"); const any = purpose.length === 0 || purpose.includes("any");
const size = parseSizes(ic.sizes); const size = parseSizes(ic.sizes);
let href; try { href = new URL(ic.src, base).href; } catch { continue; } let href; try { href = new URL(ic.src, base).href; } catch { continue; }
// Fetched from main — never let a manifest point that at file: or other schemes.
if (!/^(?:https?|bns):/i.test(href)) continue;
// Rank: "any"-purpose over maskable-only, then the largest size up to // Rank: "any"-purpose over maskable-only, then the largest size up to
// 512 (bigger is only downscaled), then anything larger. // 512 (bigger is only downscaled), then anything larger.
const rank = (any ? 1e6 : 0) + (size === Infinity ? 512 : size <= 512 ? size : 512 - (size - 512) / 1e4); const rank = (any ? 1e6 : 0) + (size === Infinity ? 512 : size <= 512 ? size : 512 - (size - 512) / 1e4);
@ -128,6 +130,9 @@ function describe(pageUrl, manifestHref, text) {
if (!scope || originOf(scope) !== origin || !startUrl.startsWith(scope)) scope = startUrl.replace(/[?#].*$/, "").replace(/[^/]*$/, ""); if (!scope || originOf(scope) !== origin || !startUrl.startsWith(scope)) scope = startUrl.replace(/[?#].*$/, "").replace(/[^/]*$/, "");
let id; try { id = new URL(str(m.id) || startUrl, startUrl).href; } catch { id = startUrl; } let id; try { id = new URL(str(m.id) || startUrl, startUrl).href; } catch { id = startUrl; }
id = norm(id); id = norm(id);
// Per spec an id on another origin is ignored — otherwise evil.com could
// claim bank.com's app slot (same key) before bank.com is ever installed.
if (originOf(id) !== origin) id = startUrl;
const icon = pickIcon(m.icons, manifestHref); const icon = pickIcon(m.icons, manifestHref);
return { return {
key: keyFor(id), id, name: name.slice(0, 80), shortName: (str(m.short_name) || name).slice(0, 40), key: keyFor(id), id, name: name.slice(0, 80), shortName: (str(m.short_name) || name).slice(0, 40),
@ -174,6 +179,7 @@ function pngToIco(png) {
return Buffer.concat([hdr, ent, png]); return Buffer.concat([hdr, ent, png]);
} }
async function fetchBytes(u) { async function fetchBytes(u) {
if (!/^(?:https?|bns|data):/i.test(String(u))) throw new Error("unsupported icon URL");
const r = await session.defaultSession.fetch(u, { cache: "force-cache" }); const r = await session.defaultSession.fetch(u, { cache: "force-cache" });
if (!r.ok) throw new Error("HTTP " + r.status); if (!r.ok) throw new Error("HTTP " + r.status);
return Buffer.from(await r.arrayBuffer()); return Buffer.from(await r.arrayBuffer());