feat(theseus): install a site as an app, the way Chrome and Edge offer it
Electron ships Chromium's renderer without the browser-side web-app install machinery, so beforeinstallprompt never fires and every site's own "Install our app" chip (coin-spectrum.com's, for one) stays hidden in Theseus. The browser side now exists: - webapps.js reads a page's <link rel="manifest">, accepts it when it names an app with a standalone-style display mode and a start_url on the page's origin, and records the descriptor on the tab. - The address bar shows an install chip for such pages (filled once the app is installed: click then opens or removes it); the page context menu carries the same entry. - Pages get a synthetic beforeinstallprompt whose prompt() routes to the Theseus install dialog and resolves userChoice like Chrome, and an appinstalled event afterwards, so sites' own chips appear and work. - Installing stores the app under <userData>/webapps/, wraps the manifest icon into an .ico, writes a Start Menu (optionally desktop) shortcut that launches Theseus with --app=<start_url>, and opens the app in a chromeless window with its own taskbar identity. The window shares the session, BCNR resolution, fingerprint and add-on bridges with tabs; popups and "open in Theseus" go to the browser window, Alt+arrows / F5 / Ctrl+R cover navigation without a toolbar. - Theseus takes the single-instance lock so a shortcut launch lands in the running browser (second-instance) instead of a second profile owner; launched cold, --app= opens only the app window and a later plain launch brings the browser window back.
This commit is contained in:
parent
718e6712c5
commit
d3787f8a29
5 changed files with 564 additions and 2 deletions
|
|
@ -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 {}
|
||||
|
|
|
|||
23
chrome.html
23
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 @@
|
|||
<span class="cv">▾</span>
|
||||
</button>
|
||||
<button class="pwchip" id="pwchip" title="Fill password for this site" hidden><span id="pwchipCount"></span><svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="6" cy="9" r="3.2" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M8.8 8.6 L14 8.6 M13.2 8.6 L13.2 11 M11.5 8.6 L11.5 10.4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></button>
|
||||
<!-- Install-as-app chip: shown when the page's web-app manifest
|
||||
makes it installable (Chrome's omnibox icon). Filled once the
|
||||
app is installed; click then opens it or removes it. -->
|
||||
<button class="appchip" id="appchip" title="Install app" hidden><svg viewBox="0 0 16 16" aria-hidden="true"><rect x="2" y="2.5" width="12" height="9" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M8 5 V9.5 M6 7.8 L8 9.8 L10 7.8" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M5.5 14 H10.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></button>
|
||||
<button class="star" id="star" title="Save this page">☆</button>
|
||||
</div>
|
||||
<div class="bardrag" id="bardrag" title="Drag to shift the address / search bar ratio"></div>
|
||||
|
|
@ -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 ------------------------------------
|
||||
|
|
|
|||
87
main.js
87
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=<url>` (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 <userData>/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
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
438
webapps.js
Normal file
438
webapps.js
Normal file
|
|
@ -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 <link rel="manifest">, checks it describes
|
||||
// an installable app (a name plus a standalone-style display
|
||||
// mode, start_url on the page's own origin) and records the
|
||||
// descriptor on the tab. main.js then shows the address-bar
|
||||
// chip and fires a synthetic `beforeinstallprompt` so the
|
||||
// site's own chip appears and works too.
|
||||
// · install() asks the user (native dialog), stores the app under
|
||||
// <userData>/webapps/, turns the manifest icon into an .ico
|
||||
// and writes Start Menu / desktop shortcuts that launch
|
||||
// Theseus with `--app=<start_url>`.
|
||||
// · open() the chromeless app window: same session, same BCNR
|
||||
// resolution, same add-on bridges as a tab, own taskbar
|
||||
// identity so it can be pinned like any app.
|
||||
// · launch() what `--app=` resolves to at startup or on a second
|
||||
// instance.
|
||||
//
|
||||
// Windows-first: shortcuts and taskbar identity are Windows APIs; on other
|
||||
// platforms the app still installs and opens, only without shortcuts.
|
||||
const { app, BrowserWindow, dialog, nativeImage, shell, session, Menu, clipboard, nativeTheme } = require("electron");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const crypto = require("crypto");
|
||||
|
||||
// Display modes that mean "this wants to be an app, not a page".
|
||||
const APP_DISPLAYS = new Set(["standalone", "fullscreen", "minimal-ui", "window-controls-overlay", "tabbed"]);
|
||||
// Runs inside the page: find the manifest link and fetch it the way the
|
||||
// page itself would (same-origin cookies unless the link opts into CORS
|
||||
// credentials). Returns { href, text } or null.
|
||||
const PROBE_SRC = `(async () => { try {
|
||||
const l = document.querySelector('link[rel~="manifest"]'); if (!l || !l.href) return null;
|
||||
const r = await fetch(l.href, { credentials: l.crossOrigin === "use-credentials" ? "include" : "same-origin" });
|
||||
if (!r.ok) return null; return { href: l.href, text: await r.text() };
|
||||
} catch (e) { return null; } })()`;
|
||||
// Runs inside the page after a successful probe: the synthetic
|
||||
// beforeinstallprompt. prompt() asks Theseus through a DOM event that the
|
||||
// session preload (bcnr-preload.js) relays over IPC; the answer comes back
|
||||
// as another DOM event, and userChoice resolves with Chrome's shape.
|
||||
const PROMPT_SRC = `(() => { try {
|
||||
if (window.__theseusInstallPrompt) return; window.__theseusInstallPrompt = 1;
|
||||
const e = new Event("beforeinstallprompt", { cancelable: true });
|
||||
e.platforms = ["windows"];
|
||||
let done; e.userChoice = new Promise((r) => { done = r; });
|
||||
document.addEventListener("theseus:webapp-accepted", () => done({ outcome: "accepted", platform: "windows" }), { once: true });
|
||||
document.addEventListener("theseus:webapp-dismissed", () => done({ outcome: "dismissed", platform: "windows" }), { once: true });
|
||||
e.prompt = () => { document.dispatchEvent(new Event("theseus:webapp-prompt")); return e.userChoice; };
|
||||
window.dispatchEvent(e);
|
||||
} catch (err) {} })()`;
|
||||
const INSTALLED_SRC = `try { window.dispatchEvent(new Event("appinstalled")); } catch (e) {}`;
|
||||
|
||||
let deps = {}; // supplied by main.js — see init()
|
||||
let apps = []; // installed apps (persisted)
|
||||
let dir = null, file = null;
|
||||
const windows = new Map(); // key -> BrowserWindow
|
||||
let promptOpen = false;
|
||||
|
||||
function init(d) {
|
||||
deps = d;
|
||||
dir = path.join(app.getPath("userData"), "webapps");
|
||||
file = path.join(dir, "apps.json");
|
||||
try { apps = JSON.parse(fs.readFileSync(file, "utf8")); } catch { apps = []; }
|
||||
if (!Array.isArray(apps)) apps = [];
|
||||
}
|
||||
function save() {
|
||||
try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(file, JSON.stringify(apps, null, 2)); } catch (e) { console.warn("[webapps] save failed:", e?.message); }
|
||||
}
|
||||
const list = () => apps.slice();
|
||||
const find = (key) => apps.find((a) => a.key === key) || null;
|
||||
const str = (v) => (typeof v === "string" ? v.trim() : "");
|
||||
// Pages on BNS names load as bns://host/…; the app is stored under its
|
||||
// https form (what the user sees, what the shortcut carries). targetUrlFor
|
||||
// turns it back into bns:// at launch when the resolver says so.
|
||||
const norm = (u) => String(u || "").replace(/^bns:\/\//i, "https://");
|
||||
function originOf(u) { try { return new URL(norm(u)).origin; } catch { return ""; } }
|
||||
const keyFor = (id) => crypto.createHash("sha1").update(id).digest("hex").slice(0, 16);
|
||||
const aumidFor = (key) => `st.silentmode.theseus.app.${key}`;
|
||||
|
||||
// ---- manifest → descriptor ----------------------------------------------
|
||||
function parseSizes(s) {
|
||||
let best = 0;
|
||||
for (const part of String(s || "").split(/\s+/)) {
|
||||
if (part === "any") return Infinity;
|
||||
const m = /^(\d+)x(\d+)$/i.exec(part); if (m) best = Math.max(best, Math.min(+m[1], +m[2]));
|
||||
}
|
||||
return best;
|
||||
}
|
||||
function pickIcon(icons, base) {
|
||||
if (!Array.isArray(icons)) return null;
|
||||
let best = null;
|
||||
for (const ic of icons) {
|
||||
if (!ic || typeof ic !== "object" || !str(ic.src)) continue;
|
||||
const type = str(ic.type).toLowerCase();
|
||||
const svg = type === "image/svg+xml" || /\.svg(\?|$)/i.test(ic.src);
|
||||
if (svg) continue; // nativeImage can't rasterise SVG; the favicon fallback covers it
|
||||
if (type && !/^image\/(png|jpeg|jpg|webp|x-icon|vnd\.microsoft\.icon)$/.test(type)) continue;
|
||||
const purpose = str(ic.purpose).toLowerCase().split(/\s+/).filter(Boolean);
|
||||
const any = purpose.length === 0 || purpose.includes("any");
|
||||
const size = parseSizes(ic.sizes);
|
||||
let href; try { href = new URL(ic.src, base).href; } catch { continue; }
|
||||
// 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 <appdir>`; packaged builds are just the exe.
|
||||
const args = (app.isPackaged ? "" : `"${app.getAppPath()}" `) + `--app=${entry.startUrl}`;
|
||||
return { target, args };
|
||||
}
|
||||
function shortcutName(entry) {
|
||||
const base = entry.name.replace(/[\\/:*?"<>|]+/g, " ").replace(/\s+/g, " ").trim().slice(0, 60) || entry.host;
|
||||
// Two different apps with the same name: the second gets its host appended.
|
||||
const clash = apps.some((a) => a.key !== entry.key && a.shortcutBase === base);
|
||||
return clash ? `${base} (${entry.host})` : base;
|
||||
}
|
||||
function writeShortcuts(entry, desktop) {
|
||||
const paths = [];
|
||||
if (process.platform !== "win32") return paths;
|
||||
const { target, args } = launchSpec(entry);
|
||||
const name = shortcutName(entry);
|
||||
entry.shortcutBase = name;
|
||||
const dirs = [path.join(app.getPath("appData"), "Microsoft", "Windows", "Start Menu", "Programs")];
|
||||
if (desktop) { try { dirs.push(app.getPath("desktop")); } catch {} }
|
||||
for (const d of dirs) {
|
||||
const lnk = path.join(d, `${name}.lnk`);
|
||||
try {
|
||||
fs.mkdirSync(d, { recursive: true });
|
||||
const ok = shell.writeShortcutLink(lnk, "create", {
|
||||
target, args, cwd: path.dirname(target), icon: icoPath(entry), iconIndex: 0,
|
||||
appUserModelId: aumidFor(entry.key), description: `${entry.name} — runs in Theseus Navigator`,
|
||||
});
|
||||
if (ok) paths.push(lnk);
|
||||
} catch (e) { console.warn("[webapps] shortcut failed:", lnk, e?.message); }
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
function removeShortcuts(entry) {
|
||||
for (const p of entry.shortcuts || []) { try { fs.unlinkSync(p); } catch {} }
|
||||
}
|
||||
|
||||
// ---- install / uninstall --------------------------------------------------------
|
||||
// Asks the user, then installs. `desc` comes from probeTab; `ctx` names the
|
||||
// tab's webContents (for the appinstalled event) and favicon. Resolves
|
||||
// { ok, entry } or { ok:false, error } ("cancelled" when the user said no).
|
||||
async function install(desc, ctx = {}) {
|
||||
if (!desc || !desc.key) return { ok: false, error: "not installable" };
|
||||
const have = find(desc.key);
|
||||
if (have) { open(have); return { ok: true, entry: have, alreadyInstalled: true }; }
|
||||
if (promptOpen) return { ok: false, error: "another install prompt is open" };
|
||||
promptOpen = true;
|
||||
try {
|
||||
const parent = deps.parentWindow ? deps.parentWindow() : undefined;
|
||||
let icon; // best effort preview in the dialog
|
||||
try { const raw = await fetchBytes(desc.iconUrl || ""); const i = nativeImage.createFromBuffer(raw); if (!i.isEmpty()) icon = i.resize({ width: 64, height: 64 }); } catch {}
|
||||
const { 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 };
|
||||
Loading…
Add table
Reference in a new issue