theseus/webapps.js
Local Dev 3dbbc452cd fix(theseus): an installed app can be closed and removed; Settings lists apps
An app whose page has a beforeunload handler (CoinSpectrum) could not be
closed: tabs answer the page's "stay?" request, app windows had nothing
listening, and Electron reads silence as a veto. Closing the window is now
always the user's call; a reload or navigation inside the app asks, as a
tab does.

Removing it looked dead for a related reason. The confirmation was drawn
as Theseus's sheet in the main window, where the user was not looking (or
nowhere, with the main window closed), and the removal then closed the app
window with the same call the page vetoes. The question is now asked on
the app window that asked, and removal destroys the window.

Installed apps were only reachable from the address-bar chip while on the
site. Settings gets an Apps page, at theseus://settings/apps, that lists
them with Open and Remove and follows installs, removals and open windows.
The three list calls it uses are settings-only now.
2026-10-04 19:15:11 +02:00

479 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// webapps.js — install a site as an app ("PWA install"), the way Chrome and
// Edge offer it. Electron ships Chromium's renderer without the browser-side
// web-app machinery, so `beforeinstallprompt` never fires in an Electron app
// and every site's own "Install our app" chip stays hidden. This module fills
// that gap on the browser side:
//
// · probeTab() reads a page's <link rel="manifest">, checks it describes
// an installable app (a name plus a standalone-style display
// mode, start_url on the page's own origin) and records the
// descriptor on the tab. main.js then shows the address-bar
// chip and fires a synthetic `beforeinstallprompt` so the
// site's own chip appears and works too.
// · install() asks the user (native dialog), stores the app under
// <userData>/webapps/, turns the manifest icon into an .ico
// and writes Start Menu / desktop shortcuts that launch
// Theseus with `--app=<start_url>`.
// · open() the chromeless app window: same session, same BCNR
// resolution, same add-on bridges as a tab, own taskbar
// identity so it can be pinned like any app.
// · launch() what `--app=` resolves to at startup or on a second
// instance.
//
// Windows-first: shortcuts and taskbar identity are Windows APIs; on other
// platforms the app still installs and opens, only without shortcuts.
const { app, BrowserWindow, dialog, nativeImage, shell, session, Menu, clipboard, nativeTheme } = require("electron");
const path = require("path");
const fs = require("fs");
const crypto = require("crypto");
// Display modes that mean "this wants to be an app, not a page".
const APP_DISPLAYS = new Set(["standalone", "fullscreen", "minimal-ui", "window-controls-overlay", "tabbed"]);
// Runs inside the page: find the manifest link and fetch it the way the
// page itself would (same-origin cookies unless the link opts into CORS
// credentials). Returns { href, text } or null.
const PROBE_SRC = `(async () => { try {
const l = document.querySelector('link[rel~="manifest"]'); if (!l || !l.href) return null;
const r = await fetch(l.href, { credentials: l.crossOrigin === "use-credentials" ? "include" : "same-origin" });
if (!r.ok) return null; return { href: l.href, text: await r.text() };
} catch (e) { return null; } })()`;
// Runs inside the page after a successful probe: the synthetic
// beforeinstallprompt. prompt() asks Theseus through a DOM event that the
// session preload (bcnr-preload.js) relays over IPC; the answer comes back
// as another DOM event, and userChoice resolves with Chrome's shape.
const PROMPT_SRC = `(() => { try {
if (window.__theseusInstallPrompt) return; window.__theseusInstallPrompt = 1;
const e = new Event("beforeinstallprompt", { cancelable: true });
e.platforms = ["windows"];
let done; e.userChoice = new Promise((r) => { done = r; });
document.addEventListener("theseus:webapp-accepted", () => done({ outcome: "accepted", platform: "windows" }), { once: true });
document.addEventListener("theseus:webapp-dismissed", () => done({ outcome: "dismissed", platform: "windows" }), { once: true });
e.prompt = () => { document.dispatchEvent(new Event("theseus:webapp-prompt")); return e.userChoice; };
window.dispatchEvent(e);
} catch (err) {} })()`;
const INSTALLED_SRC = `try { window.dispatchEvent(new Event("appinstalled")); } catch (e) {}`;
let deps = {}; // supplied by main.js — see init()
let apps = []; // installed apps (persisted)
let dir = null, file = null;
const windows = new Map(); // key -> BrowserWindow
let promptOpen = false;
function init(d) {
deps = d;
dir = path.join(app.getPath("userData"), "webapps");
file = path.join(dir, "apps.json");
try { apps = JSON.parse(fs.readFileSync(file, "utf8")); } catch { apps = []; }
if (!Array.isArray(apps)) apps = [];
}
function save() {
try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(file, JSON.stringify(apps, null, 2)); } catch (e) { console.warn("[webapps] save failed:", e?.message); }
}
const list = () => apps.slice();
const find = (key) => apps.find((a) => a.key === key) || null;
const str = (v) => (typeof v === "string" ? v.trim() : "");
// Pages on BNS names load as bns://host/…; the app is stored under its
// https form (what the user sees, what the shortcut carries). targetUrlFor
// turns it back into bns:// at launch when the resolver says so.
const norm = (u) => String(u || "").replace(/^bns:\/\//i, "https://");
function originOf(u) { try { return new URL(norm(u)).origin; } catch { return ""; } }
const keyFor = (id) => crypto.createHash("sha1").update(id).digest("hex").slice(0, 16);
const aumidFor = (key) => `st.silentmode.theseus.app.${key}`;
// ---- manifest → descriptor ----------------------------------------------
function parseSizes(s) {
let best = 0;
for (const part of String(s || "").split(/\s+/)) {
if (part === "any") return Infinity;
const m = /^(\d+)x(\d+)$/i.exec(part); if (m) best = Math.max(best, Math.min(+m[1], +m[2]));
}
return best;
}
function pickIcon(icons, base) {
if (!Array.isArray(icons)) return null;
let best = null;
for (const ic of icons) {
if (!ic || typeof ic !== "object" || !str(ic.src)) continue;
const type = str(ic.type).toLowerCase();
const svg = type === "image/svg+xml" || /\.svg(\?|$)/i.test(ic.src);
if (svg) continue; // nativeImage can't rasterise SVG; the favicon fallback covers it
if (type && !/^image\/(png|jpeg|jpg|webp|x-icon|vnd\.microsoft\.icon)$/.test(type)) continue;
const purpose = str(ic.purpose).toLowerCase().split(/\s+/).filter(Boolean);
const any = purpose.length === 0 || purpose.includes("any");
const size = parseSizes(ic.sizes);
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
// 512 (bigger is only downscaled), then anything larger.
const rank = (any ? 1e6 : 0) + (size === Infinity ? 512 : size <= 512 ? size : 512 - (size - 512) / 1e4);
if (!best || rank > best.rank) best = { href, size, rank, any };
}
return best;
}
// Turn a page URL + fetched manifest into an app descriptor, or null when the
// manifest doesn't describe an installable app.
function describe(pageUrl, manifestHref, text) {
let m; try { m = JSON.parse(text); } catch { return null; }
if (!m || typeof m !== "object" || Array.isArray(m)) return null;
const name = str(m.name) || str(m.short_name);
if (!name) return null;
const display = APP_DISPLAYS.has(str(m.display)) ||
(Array.isArray(m.display_override) && m.display_override.some((d) => APP_DISPLAYS.has(str(d))));
if (!display) return null;
let startRaw; try { startRaw = new URL(str(m.start_url) || ".", manifestHref).href; } catch { return null; }
const startUrl = norm(startRaw).replace(/#.*$/, "");
const origin = originOf(pageUrl);
if (!origin || originOf(startUrl) !== origin) return null;
let scope;
try { scope = norm(new URL(str(m.scope) || ".", startRaw).href).replace(/[?#].*$/, ""); } catch { scope = ""; }
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; }
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);
return {
key: keyFor(id), id, name: name.slice(0, 80), shortName: (str(m.short_name) || name).slice(0, 40),
startUrl, scope, origin, host: (() => { try { return new URL(origin).host; } catch { return ""; } })(),
manifestUrl: manifestHref,
iconUrl: icon ? icon.href : null, iconSize: icon ? icon.size : 0,
themeColor: /^#[0-9a-f]{6}$/i.test(str(m.theme_color)) ? str(m.theme_color) : null,
backgroundColor: /^#[0-9a-f]{6}$/i.test(str(m.background_color)) ? str(m.background_color) : null,
};
}
function probeable(url) { return /^(https?|bns):\/\//i.test(String(url || "")); }
// Read the active document's manifest and record the app descriptor (or
// null) on the tab. Returns the descriptor. Fires the synthetic
// beforeinstallprompt when the app isn't installed yet.
async function probeTab(tab) {
const wc = tab?.view?.webContents;
if (!wc || wc.isDestroyed()) return null;
const pageUrl = wc.getURL();
if (!probeable(pageUrl)) { tab.webapp = null; return null; }
let res = null;
try { res = await wc.executeJavaScript(PROBE_SRC, true); } catch {}
if (wc.isDestroyed() || wc.getURL() !== pageUrl) return tab.webapp || null; // navigated away meanwhile
const desc = res && res.href && typeof res.text === "string" ? describe(pageUrl, res.href, res.text) : null;
tab.webapp = desc;
if (desc && !find(desc.key)) { try { await wc.executeJavaScript(PROMPT_SRC, true); } catch {} }
return desc;
}
// Chrome-shaped summary for the toolbar.
function chipState(tab) {
const d = tab?.webapp; if (!d) return null;
return { key: d.key, name: d.name, installed: !!find(d.key) };
}
// ---- icon ------------------------------------------------------------------
// A .ico that simply wraps one PNG (valid since Vista; what most modern .ico
// files are). Windows scales it for every shell size.
function pngToIco(png) {
const w = png.readUInt32BE(16), h = png.readUInt32BE(20);
const hdr = Buffer.alloc(6); hdr.writeUInt16LE(0, 0); hdr.writeUInt16LE(1, 2); hdr.writeUInt16LE(1, 4);
const ent = Buffer.alloc(16);
ent[0] = w >= 256 ? 0 : w; ent[1] = h >= 256 ? 0 : h; ent[2] = 0; ent[3] = 0;
ent.writeUInt16LE(1, 4); ent.writeUInt16LE(32, 6); ent.writeUInt32LE(png.length, 8); ent.writeUInt32LE(22, 12);
return Buffer.concat([hdr, ent, png]);
}
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" });
if (!r.ok) throw new Error("HTTP " + r.status);
return Buffer.from(await r.arrayBuffer());
}
function bundledIcon() {
return app.isPackaged ? path.join(process.resourcesPath, "icon.ico") : path.join(__dirname, "build", "icon.ico");
}
// Manifest icon → PNG (≤ 256 px, square) + ICO on disk. Falls back to the
// page favicon, then to the Theseus icon, so an install never fails on art.
async function writeIcon(desc, faviconUrl, appDir) {
const candidates = [desc.iconUrl, faviconUrl].filter(Boolean);
for (const u of candidates) {
try {
const raw = await fetchBytes(u);
if (raw.length > 4 && raw.readUInt16LE(0) === 0 && raw.readUInt16LE(2) === 1) { // already an .ico
fs.writeFileSync(path.join(appDir, "icon.ico"), raw);
const img = nativeImage.createFromPath(path.join(appDir, "icon.ico"));
if (!img.isEmpty()) fs.writeFileSync(path.join(appDir, "icon.png"), img.toPNG());
return true;
}
let img = nativeImage.createFromBuffer(raw);
if (img.isEmpty()) continue;
const { width, height } = img.getSize();
if (width > 256 || height > 256 || width !== height) {
const s = Math.min(256, Math.max(width, height));
img = img.resize({ width: s, height: s, quality: "best" });
}
const png = img.toPNG();
fs.writeFileSync(path.join(appDir, "icon.png"), png);
fs.writeFileSync(path.join(appDir, "icon.ico"), pngToIco(png));
return true;
} catch {}
}
try { fs.copyFileSync(bundledIcon(), path.join(appDir, "icon.ico")); } catch {}
return false;
}
const icoPath = (entry) => path.join(dir, entry.key, "icon.ico");
const pngPath = (entry) => path.join(dir, entry.key, "icon.png");
// For Settings › Apps: the app's icon as a small data URL, and whether its window is open.
function iconDataUrl(entry) {
try { const i = nativeImage.createFromPath(pngPath(entry)); if (!i.isEmpty()) return i.resize({ width: 64, height: 64 }).toDataURL(); } catch {}
return null;
}
const isOpen = (key) => { const w = windows.get(key); return !!(w && !w.isDestroyed()); };
function windowIcon(entry) {
for (const p of [pngPath(entry), icoPath(entry)]) { try { const i = nativeImage.createFromPath(p); if (!i.isEmpty()) return i; } catch {} }
return bundledIcon();
}
// ---- shortcuts ---------------------------------------------------------------
function launchSpec(entry) {
const target = process.execPath;
// Dev runs are `electron.exe <appdir>`; packaged builds are just the exe.
const args = (app.isPackaged ? "" : `"${app.getAppPath()}" `) + `--app=${entry.startUrl}`;
return { target, args };
}
function shortcutName(entry) {
const base = entry.name.replace(/[\\/:*?"<>|]+/g, " ").replace(/\s+/g, " ").trim().slice(0, 60) || entry.host;
// Two different apps with the same name: the second gets its host appended.
const clash = apps.some((a) => a.key !== entry.key && a.shortcutBase === base);
return clash ? `${base} (${entry.host})` : base;
}
function writeShortcuts(entry, desktop) {
const paths = [];
if (process.platform !== "win32") return paths;
const { target, args } = launchSpec(entry);
const name = shortcutName(entry);
entry.shortcutBase = name;
const dirs = [path.join(app.getPath("appData"), "Microsoft", "Windows", "Start Menu", "Programs")];
if (desktop) { try { dirs.push(app.getPath("desktop")); } catch {} }
for (const d of dirs) {
const lnk = path.join(d, `${name}.lnk`);
try {
fs.mkdirSync(d, { recursive: true });
const ok = shell.writeShortcutLink(lnk, "create", {
target, args, cwd: path.dirname(target), icon: icoPath(entry), iconIndex: 0,
appUserModelId: aumidFor(entry.key), description: `${entry.name} — runs in Theseus Navigator`,
});
if (ok) paths.push(lnk);
} catch (e) { console.warn("[webapps] shortcut failed:", lnk, e?.message); }
}
return paths;
}
function removeShortcuts(entry) {
for (const p of entry.shortcuts || []) { try { fs.unlinkSync(p); } catch {} }
}
// ---- install / uninstall --------------------------------------------------------
// Asks the user, then installs. `desc` comes from probeTab; `ctx` names the
// tab's webContents (for the appinstalled event) and favicon. Resolves
// { ok, entry } or { ok:false, error } ("cancelled" when the user said no).
async function install(desc, ctx = {}) {
if (!desc || !desc.key) return { ok: false, error: "not installable" };
const have = find(desc.key);
if (have) { open(have); return { ok: true, entry: have, alreadyInstalled: true }; }
if (promptOpen) return { ok: false, error: "another install prompt is open" };
promptOpen = true;
try {
const parent = deps.parentWindow ? deps.parentWindow() : undefined;
let icon; // best effort preview in the dialog
try { const raw = await fetchBytes(desc.iconUrl || ""); const i = nativeImage.createFromBuffer(raw); if (!i.isEmpty()) icon = i.resize({ width: 64, height: 64 }); } catch {}
const ask = (o) => (deps.ask ? deps.ask(o) : dialog.showMessageBox(parent, o));
const { response, checkboxChecked } = await ask({
type: "question", title: "Install app", icon,
message: `Install ${desc.name}?`,
detail: `${desc.host}\n\nIt opens in its own window and gets a Start Menu entry. It keeps running inside Theseus, with your names, add-ons and settings.`,
buttons: ["Install", "Cancel"], defaultId: 0, cancelId: 1, noLink: true,
checkboxLabel: process.platform === "win32" ? "Also add a desktop shortcut" : undefined,
checkboxChecked: process.platform === "win32",
});
if (response !== 0) return { ok: false, error: "cancelled" };
const entry = {
key: desc.key, id: desc.id, name: desc.name, shortName: desc.shortName,
startUrl: desc.startUrl, scope: desc.scope, origin: desc.origin, host: desc.host,
manifestUrl: desc.manifestUrl, themeColor: desc.themeColor, backgroundColor: desc.backgroundColor,
installedAt: new Date().toISOString(), shortcuts: [], bounds: null,
};
const appDir = path.join(dir, entry.key);
fs.mkdirSync(appDir, { recursive: true });
entry.hasIcon = await writeIcon(desc, ctx.favicon || null, appDir);
entry.shortcuts = writeShortcuts(entry, !!checkboxChecked);
apps = apps.filter((a) => a.key !== entry.key); apps.push(entry); save();
try { const wc = ctx.wc; if (wc && !wc.isDestroyed()) wc.executeJavaScript(INSTALLED_SRC, true).catch(() => {}); } catch {}
deps.changed && deps.changed();
open(entry);
return { ok: true, entry };
} catch (e) {
console.warn("[webapps] install failed:", e?.message);
return { ok: false, error: e?.message || "install failed" };
} finally { promptOpen = false; }
}
// `from` is the app window the request came from, if any; the question is then
// asked on that window. Theseus's own prompt sheet lives in the main window,
// so asked from an app window it appeared where the user was not looking (or
// nowhere at all with the main window closed) and the removal looked dead.
async function uninstall(key, ask = true, from = null) {
const entry = find(key); if (!entry) return false;
if (ask) {
const parent = deps.parentWindow ? deps.parentWindow() : undefined;
const fromWin = from && !from.isDestroyed() ? from : null;
const ask = (o) => (fromWin ? dialog.showMessageBox(fromWin, o) : deps.ask ? deps.ask(o) : dialog.showMessageBox(parent, o));
const { response } = await ask({
type: "question", title: "Remove app", message: `Remove ${entry.name} from Theseus?`,
detail: "Its window and shortcuts go away. The site itself and your data on it are untouched.",
buttons: ["Remove", "Cancel"], defaultId: 0, cancelId: 1, noLink: true,
});
if (response !== 0) return false;
}
// destroy(), not close(): a page's beforeunload handler can veto a close, and
// it has no say in the app being removed.
const w = windows.get(key); if (w && !w.isDestroyed()) { try { w.destroy(); } catch {} }
removeShortcuts(entry);
try { fs.rmSync(path.join(dir, entry.key), { recursive: true, force: true }); } catch {}
apps = apps.filter((a) => a.key !== key); save();
deps.changed && deps.changed();
return true;
}
// ---- the app window ----------------------------------------------------------
function open(entry, urlOverride) {
if (!entry) return null;
const had = windows.get(entry.key);
if (had && !had.isDestroyed()) {
if (urlOverride) had.webContents.loadURL(urlOverride).catch(() => {});
if (had.isMinimized()) had.restore();
had.focus();
return had;
}
const dark = nativeTheme.shouldUseDarkColors;
const b = entry.bounds && typeof entry.bounds === "object" ? entry.bounds : {};
const w = new BrowserWindow({
width: b.width || 1100, height: b.height || 760,
...(Number.isFinite(b.x) && Number.isFinite(b.y) ? { x: b.x, y: b.y } : {}),
title: entry.name, show: false,
backgroundColor: entry.backgroundColor || (dark ? "#0b0e14" : "#ffffff"),
icon: windowIcon(entry),
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
});
windows.set(entry.key, w);
deps.changed && deps.changed(); // Settings › Apps shows which apps are open
w.setMenuBarVisibility(false);
if (process.platform === "win32") {
// Own taskbar identity: pinning the button pins the app, not Theseus,
// and relaunching from the pin comes back through --app=.
const { target, args } = launchSpec(entry);
try { w.setAppDetails({ appId: aumidFor(entry.key), appIconPath: icoPath(entry), appIconIndex: 0, relaunchCommand: `"${target}" ${args}`, relaunchDisplayName: entry.name }); } catch {}
}
const wc = w.webContents;
deps.prepareContents && deps.prepareContents(wc);
// Page title in the title bar, suffixed with the app name unless the page
// already carries it (most do).
wc.on("page-title-updated", (e, title) => {
e.preventDefault();
const t = String(title || "").trim();
const has = t && (t.toLowerCase().includes(entry.shortName.toLowerCase()) || t.toLowerCase().includes(entry.name.toLowerCase()));
try { w.setTitle(!t ? entry.name : has ? t : `${t} — ${entry.shortName}`); } catch {}
});
// Cross-host navigations inside the window stay BCNR-first, like tabs.
wc.on("will-navigate", (e, u) => {
try {
const p = new URL(u);
if (p.protocol !== "http:" && p.protocol !== "https:") return;
if (!deps.isBnsHost || !deps.isBnsHost(p.hostname)) return;
let cur = ""; try { cur = new URL(wc.getURL()).hostname; } catch {}
if (cur === p.hostname) return;
e.preventDefault();
deps.targetUrlFor(u).then((t) => { if (t) wc.loadURL(t).catch(() => {}); });
} catch {}
});
// Popups go to Theseus tabs — one place for tabs, and an app window has
// no tab strip to hold them.
wc.setWindowOpenHandler(({ url }) => {
if (url && url !== "about:blank") deps.openInTab(url);
return { action: "deny" };
});
// No toolbar, so the keyboard carries navigation: Alt+←/→, F5, Ctrl+R.
wc.on("before-input-event", (e, input) => {
if (input.type !== "keyDown") return;
const nav = wc.navigationHistory;
if (input.alt && input.key === "ArrowLeft" && nav.canGoBack()) { nav.goBack(); e.preventDefault(); }
else if (input.alt && input.key === "ArrowRight" && nav.canGoForward()) { nav.goForward(); e.preventDefault(); }
else if (input.key === "F5" || (input.control && (input.key === "r" || input.key === "R"))) { input.shift || (input.control && input.key === "F5") ? wc.reloadIgnoringCache() : wc.reload(); e.preventDefault(); }
else if (input.key === "F12" || (input.control && input.shift && (input.key === "I" || input.key === "i"))) { wc.isDevToolsOpened() ? wc.closeDevTools() : wc.openDevTools({ mode: "bottom" }); e.preventDefault(); }
});
wc.on("context-menu", (_e, p) => {
const items = [];
if (p.linkURL) {
items.push(
{ label: "Open link in Theseus", click: () => deps.openInTab(p.linkURL) },
{ label: "Copy link address", click: () => clipboard.writeText(p.linkURL) },
{ type: "separator" },
);
}
if (p.isEditable) items.push({ role: "cut" }, { role: "copy" }, { role: "paste" }, { type: "separator" });
else if (p.selectionText) items.push({ role: "copy" }, { type: "separator" });
const nav = wc.navigationHistory;
items.push(
{ label: "Back", enabled: nav.canGoBack(), click: () => nav.goBack() },
{ label: "Forward", enabled: nav.canGoForward(), click: () => nav.goForward() },
{ label: "Reload", click: () => wc.reload() },
{ type: "separator" },
{ label: "Open this page in Theseus", click: () => deps.openInTab(wc.getURL()) },
{ label: "Copy page address", click: () => clipboard.writeText(norm(wc.getURL())) },
{ type: "separator" },
{ label: `Remove ${entry.shortName} from Theseus…`, click: () => uninstall(entry.key, true, w) },
);
Menu.buildFromTemplate(items).popup({ window: w });
});
const remember = () => { try { if (!w.isMinimized()) { entry.bounds = w.getNormalBounds(); save(); } } catch {} };
w.on("resize", remember); w.on("move", remember);
// A page with a beforeunload handler asks to keep the user ("unsaved
// changes"). With nobody listening Electron takes that as a veto, silently:
// an installed app whose page has such a handler could not be closed at all
// (CoinSpectrum, 2026-10-04). Closing the window is the user's decision and
// always goes through; a reload or navigation inside the app asks, as a tab does.
let closing = false;
w.on("close", () => { closing = true; });
wc.on("will-prevent-unload", (e) => {
if (closing) { e.preventDefault(); return; } // preventDefault here means "leave anyway"
let choice = 1;
try {
choice = dialog.showMessageBoxSync(w, {
type: "question", buttons: ["Stay on page", "Leave anyway"], defaultId: 0, cancelId: 0, noLink: true,
title: entry.name, message: "This page is asking you to stay.",
detail: "You may have unsaved changes that will be lost if you leave.",
});
} catch {}
if (choice === 1) e.preventDefault();
});
w.on("closed", () => { if (windows.get(entry.key) === w) windows.delete(entry.key); deps.changed && deps.changed(); });
w.once("ready-to-show", () => { try { w.show(); } catch {} });
Promise.resolve(deps.targetUrlFor ? deps.targetUrlFor(urlOverride || entry.startUrl) : (urlOverride || entry.startUrl))
.then((t) => wc.loadURL(t || urlOverride || entry.startUrl))
.catch(() => {});
return w;
}
const openWindows = () => [...windows.values()].filter((w) => !w.isDestroyed());
// ---- --app= launches --------------------------------------------------------------
function appUrlFromArgv(argv) {
for (const a of argv || []) {
const m = /^--app=(.+)$/.exec(String(a));
if (m && /^https?:\/\//i.test(m[1])) return m[1];
}
return null;
}
// Open the installed app that owns `url` (start_url match first, then any
// app whose scope contains it). A URL no app owns is returned as `null` so
// the caller can fall back to a normal tab.
function launch(url) {
const u = norm(url);
const entry = apps.find((a) => a.startUrl === u) || apps.find((a) => u.startsWith(a.scope));
if (!entry) return null;
return open(entry, entry.startUrl === u ? undefined : u);
}
module.exports = { init, list, find, probeTab, chipState, install, uninstall, open, openWindows, launch, appUrlFromArgv, describe, iconDataUrl, isOpen };