// 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; }
// 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");
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 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; }
}
async function uninstall(key, ask = true) {
const entry = find(key); if (!entry) return false;
if (ask) {
const parent = deps.parentWindow ? deps.parentWindow() : undefined;
const ask = (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;
}
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 };