diff --git a/bcnr-preload.js b/bcnr-preload.js
index 2861363e..e46171b1 100644
--- a/bcnr-preload.js
+++ b/bcnr-preload.js
@@ -37,3 +37,18 @@ contextBridge.exposeInMainWorld("bcnr", {
// "cancelled"). Pages can feature-detect it: absent on older builds.
installExtension: (id) => ipcRenderer.invoke("bcnr:installExtension", String(id || "")),
});
+
+// Install-as-app relay. Theseus fires a synthetic `beforeinstallprompt` in
+// pages whose manifest is installable (webapps.js); the page's prompt()
+// dispatches a DOM event that this isolated world hears, asks main for the
+// install dialog, and answers with another DOM event. Nothing is exposed
+// on window; the page only ever sees Chrome's event shape. Top frame only.
+try {
+ if (window.top === window) {
+ document.addEventListener("theseus:webapp-prompt", () => {
+ ipcRenderer.invoke("webapp-prompt").then((r) => {
+ document.dispatchEvent(new Event(r === "accepted" ? "theseus:webapp-accepted" : "theseus:webapp-dismissed"));
+ }).catch(() => { try { document.dispatchEvent(new Event("theseus:webapp-dismissed")); } catch {} });
+ });
+ }
+} catch {}
diff --git a/chrome.html b/chrome.html
index d154d3b0..bedb8167 100644
--- a/chrome.html
+++ b/chrome.html
@@ -308,6 +308,10 @@
.regbtn.cand .cv { display: inline; }
.star { border: none; background: transparent; cursor: pointer; color: var(--dim); font-size: 15px; padding: 2px 4px; border-radius: 6px; }
.star:hover { background: var(--line2); color: var(--ink); } .star.on { color: #ffd23d; }
+ .appchip { flex: none; display: inline-flex; align-items: center; height: 24px; padding: 0 4px; border: none; border-radius: 6px; background: transparent; cursor: pointer; color: var(--dim); }
+ .appchip:hover, .appchip.active { background: var(--line2); color: var(--ink); }
+ .appchip svg { width: 17px; height: 17px; }
+ .appchip.on { color: var(--acid); }
/* Update-available chip — shows when the releases manifest names a newer
version than this build. Two buttons: main body opens the download URL
in the system browser; ✕ dismisses for the session. */
@@ -479,6 +483,10 @@
▾
+
+
@@ -1268,6 +1276,20 @@
if (bookmarks.some((b) => b.url === current.url)) T.removeBookmark(current.url);
else T.addBookmark({ title: current.title || current.url, url: current.url, favicon: current.favicon || null });
};
+ // ---- install-as-app chip ----
+ function updateAppChip(wa) {
+ const c = $("appchip"); if (!c) return;
+ c.hidden = !wa;
+ if (!wa) return;
+ c.classList.toggle("on", !!wa.installed);
+ c.title = wa.installed ? `${wa.name} is installed — open it or remove it` : `Install ${wa.name}`;
+ }
+ $("appchip") && ($("appchip").onclick = () => {
+ const c = $("appchip"); const r = c.getBoundingClientRect();
+ c.classList.add("active");
+ T.webappChip({ x: r.left, y: r.top, w: r.width, h: r.height }).finally(() => c.classList.remove("active"));
+ });
+ T.onToolbarMenuClosed && T.onToolbarMenuClosed(() => { const c = $("appchip"); c && c.classList.remove("active"); });
T.getBookmarks().then((b) => { bookmarks = b || []; renderBookmarks(); });
T.onBookmarks((b) => { bookmarks = b || []; renderBookmarks(); });
@@ -1457,6 +1479,7 @@
current.title = active ? active.title : "";
current.favicon = active ? (active.favicon || null) : null;
updateStar();
+ updateAppChip(d.webapp || null);
renderTabStrip($("tabs"), d);
});
// ---- tab strip: keyed reconciliation ------------------------------------
diff --git a/main.js b/main.js
index f1971306..16327985 100644
--- a/main.js
+++ b/main.js
@@ -13,6 +13,7 @@ const tls = require("tls");
const { spawn } = require("child_process");
const fs = require("fs");
const WebSocket = require("ws");
+const webapps = require("./webapps");
// A browser has no business dying because its stdout went away. Launched from
// a shell — a dev run, a test harness — Theseus inherits that shell's pipe;
@@ -1074,6 +1075,7 @@ function onChromeReady() {
(bnsWarm || warmFromSnapshot().catch(() => null)).then(() => {
try { restoreTabs(); }
catch (e) { console.error("restoreTabs failed:", e?.message); try { createTab(); } catch {} }
+ for (const u of pendingTabUrls.splice(0)) { try { createTab(u); } catch {} }
startBnsPolling();
ensureIndex().catch(() => {}); // fallback for first launch without a bundled snapshot
});
@@ -2774,6 +2776,7 @@ function emitTabs() {
canBack: wc ? wc.navigationHistory.canGoBack() : false,
canForward: wc ? wc.navigationHistory.canGoForward() : false,
zoom: zoomPercent(t),
+ webapp: webapps.chipState(t),
});
}
function setLoading(tab, on) { if (tab && tab.loading !== on) { tab.loading = on; emitTabs(); } }
@@ -3022,6 +3025,8 @@ function createTab(initial, opts = {}) {
if (tab.id !== activeId) return;
try { chrome?.webContents.send("find-result", { activeMatchOrdinal: r.activeMatchOrdinal, matches: r.matches, finalUpdate: r.finalUpdate }); } catch {}
});
+ // A new document means a new (or no) web-app manifest; the chip follows.
+ wc.on("did-navigate", () => { tab.webapp = null; });
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); });
// Ctrl+wheel / pinch: Chromium only reports the intent on Windows and
@@ -3029,6 +3034,9 @@ function createTab(initial, opts = {}) {
wc.on("zoom-changed", (_e, dir) => zoomStep(tab, dir === "in" ? 1 : -1));
wc.on("did-start-loading", () => setLoading(tab, true));
wc.on("did-stop-loading", () => setLoading(tab, false));
+ // Installable site? Read its manifest once the document is in; the chip
+ // and the page's own beforeinstallprompt follow from tab.webapp.
+ wc.on("did-finish-load", () => { if (!tab.settings && !tab.addonId) webapps.probeTab(tab).then(() => { if (tab.id === activeId) emitTabs(); }).catch(() => {}); });
// Failed loads: NAME_NOT_RESOLVED, CONNECTION_REFUSED, cert errors, etc.
// Show the branded error page instead of Chromium's default "This site
// can't be reached". Skip subframe errors, our own programmatic loads,
@@ -3198,6 +3206,12 @@ function createTab(initial, opts = {}) {
{ label: "Forward", enabled: wc.navigationHistory.canGoForward(), click: () => wc.navigationHistory.goForward() },
{ label: "Reload", click: () => wc.reload() },
);
+ if (tab.webapp) {
+ const inst = webapps.find(tab.webapp.key);
+ items.push({ type: "separator" }, inst
+ ? { label: `Open ${tab.webapp.name} in its window`, click: () => webapps.open(inst) }
+ : { label: `Install ${tab.webapp.name}…`, click: () => installWebAppFromTab(tab) });
+ }
Menu.buildFromTemplate(items).popup();
});
layout();
@@ -4052,6 +4066,42 @@ async function installExtensionWithConsent(id, requester) {
return r;
} finally { installPromptOpen = false; }
}
+// ---- web apps (PWA install) ----
+function installWebAppFromTab(tab) {
+ if (!tab || !tab.webapp) return Promise.resolve({ ok: false, error: "not installable" });
+ return webapps.install(tab.webapp, { wc: tab.view.webContents, favicon: tab.favicon || null }).then((r) => { emitTabs(); return r; });
+}
+// Address-bar chip: not installed → the install dialog; installed → a menu
+// to open the app window or remove the app.
+ipcMain.handle("webapp-chip", async (e, rect) => {
+ if (chrome && e.sender !== chrome.webContents) throw new Error("webapp-chip: untrusted sender");
+ const t = activeTab(); if (!t || !t.webapp) return false;
+ const inst = webapps.find(t.webapp.key);
+ if (!inst) { await installWebAppFromTab(t); return true; }
+ const menu = Menu.buildFromTemplate([
+ { label: `Open ${inst.name} in its window`, click: () => webapps.open(inst) },
+ { type: "separator" },
+ { label: `Remove ${inst.name} from Theseus…`, click: () => webapps.uninstall(inst.key, true).then(() => emitTabs()) },
+ ]);
+ const pos = rect && Number.isFinite(rect.x) ? { x: Math.round(rect.x), y: Math.round((rect.y || 0) + (rect.h || 0)) } : {};
+ menu.popup({ window: win, ...pos, callback: () => { try { chrome?.webContents.send("toolbar-menu-closed"); } catch {} } });
+ return true;
+});
+// A page's own install chip calls beforeinstallprompt.prompt(); the session
+// preload relays it here. Only the tab that was probed installable, and only
+// while it still shows that origin, gets the dialog. Resolves "accepted" /
+// "dismissed" like Chrome's userChoice.
+ipcMain.handle("webapp-prompt", async (e) => {
+ const tab = tabs.find((t) => t.view?.webContents === e.sender);
+ if (!tab || !tab.webapp) return "dismissed";
+ let origin = ""; try { origin = new URL(e.sender.getURL().replace(/^bns:\/\//, "https://")).origin; } catch {}
+ if (!origin || origin !== tab.webapp.origin) return "dismissed";
+ const r = await installWebAppFromTab(tab);
+ return r && r.ok ? "accepted" : "dismissed";
+});
+ipcMain.handle("webapps-list", () => webapps.list().map((a) => ({ key: a.key, name: a.name, host: a.host, startUrl: a.startUrl, installedAt: a.installedAt })));
+ipcMain.handle("webapps-open", (_e, key) => { const a = webapps.find(String(key || "")); if (a) webapps.open(a); return !!a; });
+ipcMain.handle("webapps-remove", (_e, key) => webapps.uninstall(String(key || ""), true).then((ok) => { emitTabs(); return ok; }));
ipcMain.handle("bcnr:installExtension", (e, id) => {
let requester = null;
try { requester = new URL(e.sender.getURL()).host || null; } catch {}
@@ -5932,7 +5982,29 @@ app.on("web-contents-created", (_event, wc) => {
// THESEUS_NO_AUTOSTART lets a test harness reuse serveBns/resolveHost without
// launching the full UI (see dev/selftest.js). Normal `npm start` is unchanged.
-if (!process.env.THESEUS_NO_AUTOSTART) {
+// Opening a page in a normal tab from an app window: the main window may be
+// gone (closed while app windows stayed up) — bring it back first and let
+// the session restore run before the requested page joins the strip.
+const pendingTabUrls = [];
+function openInMainTab(url) {
+ if (win && !win.isDestroyed()) { createTab(url); try { if (win.isMinimized()) win.restore(); win.focus(); } catch {} return; }
+ pendingTabUrls.push(url);
+ createWindow();
+}
+// A second launch: `Theseus.exe --app=` (an installed app's shortcut)
+// opens that app's window here; a plain launch fronts the browser window,
+// recreating it when only app windows are left.
+function handleLaunch(argv) {
+ const appUrl = webapps.appUrlFromArgv(argv);
+ if (appUrl) { if (webapps.launch(appUrl)) return; openInMainTab(appUrl); return; }
+ if (win && !win.isDestroyed()) { try { if (win.isMinimized()) win.restore(); win.focus(); } catch {} }
+ else createWindow();
+}
+if (!process.env.THESEUS_NO_AUTOSTART && !app.requestSingleInstanceLock()) {
+ // Another Theseus owns this profile; it got our argv via second-instance.
+ app.quit();
+} else if (!process.env.THESEUS_NO_AUTOSTART) {
+ app.on("second-instance", (_e, argv) => { try { handleLaunch(argv); } catch (err) { console.warn("second-instance failed:", err?.message); } });
app.whenReady().then(() => {
Menu.setApplicationMenu(null); // drop the native File/Edit/View/Help menu bar
loadSettings();
@@ -5967,6 +6039,13 @@ if (!process.env.THESEUS_NO_AUTOSTART) {
installDownloadTracker();
migrateExtensionDirs();
initAddons();
+ webapps.init({
+ parentWindow: () => (win && !win.isDestroyed() ? win : undefined),
+ openInTab: openInMainTab,
+ targetUrlFor, isBnsHost,
+ prepareContents: (wc) => { styleScrollbars(wc); try { wc.setWebRTCIPHandlingPolicy(webrtcPolicy()); } catch {} try { wc.setBackgroundThrottling(settings.backgroundThrottle); } catch {} applyFingerprint(wc); },
+ changed: () => emitTabs(),
+ });
// Kick off signed add-on update polling 30 s after boot so it never
// slows launch. Any staged update lands in /addons-updates-
// staged/, and promoteStagedUpdates() picks it up on the NEXT initAddons.
@@ -5979,7 +6058,11 @@ if (!process.env.THESEUS_NO_AUTOSTART) {
// "restart to apply" chip instead of finding out in Settings.
setTimeout(() => pollAddonUpdates("boot"), 30_000);
setInterval(() => pollAddonUpdates("interval"), 4 * 60 * 60 * 1000);
- createWindow();
+ // Launched from an installed app's shortcut: just that app's window; the
+ // browser window comes up on the next plain launch (second-instance).
+ const appUrl = webapps.appUrlFromArgv(process.argv);
+ if (appUrl && webapps.launch(appUrl)) { /* app window only */ }
+ else createWindow();
// Multi-source BNS warm-up so the first .bch page opens near-instantly and
// stays fresh for as long as the browser is running. Every source runs in
// parallel — none of them can block a navigation. Source 1 starts now
diff --git a/preload.js b/preload.js
index 6e02bcdc..b16a082f 100644
--- a/preload.js
+++ b/preload.js
@@ -107,6 +107,9 @@ contextBridge.exposeInMainWorld("theseus", {
// Page zoom on the active tab: step +1 / -1 along Chrome's ladder, or reset to 100 %.
zoomStep: (dir) => ipcRenderer.invoke("zoom-step", dir),
zoomReset: () => ipcRenderer.invoke("zoom-reset"),
+ // Install-as-app chip in the address bar: main decides between the
+ // install dialog and the open/remove menu (anchored at rect).
+ webappChip: (rect) => ipcRenderer.invoke("webapp-chip", rect),
// Chrome listens so it can drop the "active" tint when the native menu
// dismisses (Esc, outside click, item picked — main fires all three).
onToolbarMenuClosed: (cb) => ipcRenderer.on("toolbar-menu-closed", () => cb()),
diff --git a/webapps.js b/webapps.js
new file mode 100644
index 00000000..f888db0c
--- /dev/null
+++ b/webapps.js
@@ -0,0 +1,438 @@
+// 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 , 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
+// /webapps/, turns the manifest icon into an .ico
+// and writes Start Menu / desktop shortcuts that launch
+// Theseus with `--app=`.
+// · 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; }
+ // 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);
+ 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) {
+ 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");
+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 `; 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 { response, checkboxChecked } = await dialog.showMessageBox(parent, {
+ 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; }
+}
+async function uninstall(key, ask = true) {
+ const entry = find(key); if (!entry) return false;
+ if (ask) {
+ const parent = deps.parentWindow ? deps.parentWindow() : undefined;
+ const { response } = await dialog.showMessageBox(parent, {
+ 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;
+ }
+ const w = windows.get(key); if (w && !w.isDestroyed()) { try { w.close(); } 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);
+ 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) },
+ );
+ 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);
+ w.on("closed", () => { if (windows.get(entry.key) === w) windows.delete(entry.key); });
+ 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 };