// 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 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 };