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