A preload belongs to the WebContents, not the page: a website loaded into the Settings tab kept window.cfg and could read every vault password, flip settings and install extensions without consent. Settings and add-on tabs now never load web content, and the channels behind settings-preload check their sender. `navigate` no longer accepts calls from web pages. Add-on updates trusted any publisherSig, whatever name it carried, even for bundled add-ons. The trust root is now the installed addon.json (publisher, or the operator key when there is none); versions must be plain dotted numbers; a community install can't take over a bundled or foreign id. Also: - autofill matches and fills against the live URL, not a stale prov.host - bns:// forwards the raw request path (..%2F escaped the name's bucket) - clipboard-read denied, openExternal asks; forged collision choices ignored - web pages can't window.open file:/chrome:/theseus:; data:/blob: no longer go to the search engine; the quick-links panel loses home-preload - clear-history-on-quit is awaited and removes history.json too - update helper takes its paths from the environment (non-ASCII profiles) - electrum poll has a deadline; misses wait at most 2.5 s - p records go through Tor; add-on proxy credentials are actually used - whole-folder require-cache bust on add-on version change; failed activate() no longer leaks its request filter - approvals released when the window closes; web-app ids stay on-origin
446 lines
24 KiB
JavaScript
446 lines
24 KiB
JavaScript
// 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; }
|
|
// Fetched from main — never let a manifest point that at file: or other schemes.
|
|
if (!/^(?:https?|bns):/i.test(href)) continue;
|
|
// Rank: "any"-purpose over maskable-only, then the largest size up to
|
|
// 512 (bigger is only downscaled), then anything larger.
|
|
const rank = (any ? 1e6 : 0) + (size === Infinity ? 512 : size <= 512 ? size : 512 - (size - 512) / 1e4);
|
|
if (!best || rank > best.rank) best = { href, size, rank, any };
|
|
}
|
|
return best;
|
|
}
|
|
// Turn a page URL + fetched manifest into an app descriptor, or null when the
|
|
// manifest doesn't describe an installable app.
|
|
function describe(pageUrl, manifestHref, text) {
|
|
let m; try { m = JSON.parse(text); } catch { return null; }
|
|
if (!m || typeof m !== "object" || Array.isArray(m)) return null;
|
|
const name = str(m.name) || str(m.short_name);
|
|
if (!name) return null;
|
|
const display = APP_DISPLAYS.has(str(m.display)) ||
|
|
(Array.isArray(m.display_override) && m.display_override.some((d) => APP_DISPLAYS.has(str(d))));
|
|
if (!display) return null;
|
|
let startRaw; try { startRaw = new URL(str(m.start_url) || ".", manifestHref).href; } catch { return null; }
|
|
const startUrl = norm(startRaw).replace(/#.*$/, "");
|
|
const origin = originOf(pageUrl);
|
|
if (!origin || originOf(startUrl) !== origin) return null;
|
|
let scope;
|
|
try { scope = norm(new URL(str(m.scope) || ".", startRaw).href).replace(/[?#].*$/, ""); } catch { scope = ""; }
|
|
if (!scope || originOf(scope) !== origin || !startUrl.startsWith(scope)) scope = startUrl.replace(/[?#].*$/, "").replace(/[^/]*$/, "");
|
|
let id; try { id = new URL(str(m.id) || startUrl, startUrl).href; } catch { id = startUrl; }
|
|
id = norm(id);
|
|
// Per spec an id on another origin is ignored — otherwise evil.com could
|
|
// claim bank.com's app slot (same key) before bank.com is ever installed.
|
|
if (originOf(id) !== origin) id = startUrl;
|
|
const icon = pickIcon(m.icons, manifestHref);
|
|
return {
|
|
key: keyFor(id), id, name: name.slice(0, 80), shortName: (str(m.short_name) || name).slice(0, 40),
|
|
startUrl, scope, origin, host: (() => { try { return new URL(origin).host; } catch { return ""; } })(),
|
|
manifestUrl: manifestHref,
|
|
iconUrl: icon ? icon.href : null, iconSize: icon ? icon.size : 0,
|
|
themeColor: /^#[0-9a-f]{6}$/i.test(str(m.theme_color)) ? str(m.theme_color) : null,
|
|
backgroundColor: /^#[0-9a-f]{6}$/i.test(str(m.background_color)) ? str(m.background_color) : null,
|
|
};
|
|
}
|
|
function probeable(url) { return /^(https?|bns):\/\//i.test(String(url || "")); }
|
|
|
|
// Read the active document's manifest and record the app descriptor (or
|
|
// null) on the tab. Returns the descriptor. Fires the synthetic
|
|
// beforeinstallprompt when the app isn't installed yet.
|
|
async function probeTab(tab) {
|
|
const wc = tab?.view?.webContents;
|
|
if (!wc || wc.isDestroyed()) return null;
|
|
const pageUrl = wc.getURL();
|
|
if (!probeable(pageUrl)) { tab.webapp = null; return null; }
|
|
let res = null;
|
|
try { res = await wc.executeJavaScript(PROBE_SRC, true); } catch {}
|
|
if (wc.isDestroyed() || wc.getURL() !== pageUrl) return tab.webapp || null; // navigated away meanwhile
|
|
const desc = res && res.href && typeof res.text === "string" ? describe(pageUrl, res.href, res.text) : null;
|
|
tab.webapp = desc;
|
|
if (desc && !find(desc.key)) { try { await wc.executeJavaScript(PROMPT_SRC, true); } catch {} }
|
|
return desc;
|
|
}
|
|
// Chrome-shaped summary for the toolbar.
|
|
function chipState(tab) {
|
|
const d = tab?.webapp; if (!d) return null;
|
|
return { key: d.key, name: d.name, installed: !!find(d.key) };
|
|
}
|
|
|
|
// ---- icon ------------------------------------------------------------------
|
|
// A .ico that simply wraps one PNG (valid since Vista; what most modern .ico
|
|
// files are). Windows scales it for every shell size.
|
|
function pngToIco(png) {
|
|
const w = png.readUInt32BE(16), h = png.readUInt32BE(20);
|
|
const hdr = Buffer.alloc(6); hdr.writeUInt16LE(0, 0); hdr.writeUInt16LE(1, 2); hdr.writeUInt16LE(1, 4);
|
|
const ent = Buffer.alloc(16);
|
|
ent[0] = w >= 256 ? 0 : w; ent[1] = h >= 256 ? 0 : h; ent[2] = 0; ent[3] = 0;
|
|
ent.writeUInt16LE(1, 4); ent.writeUInt16LE(32, 6); ent.writeUInt32LE(png.length, 8); ent.writeUInt32LE(22, 12);
|
|
return Buffer.concat([hdr, ent, png]);
|
|
}
|
|
async function fetchBytes(u) {
|
|
if (!/^(?:https?|bns|data):/i.test(String(u))) throw new Error("unsupported icon URL");
|
|
const r = await session.defaultSession.fetch(u, { cache: "force-cache" });
|
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
|
return Buffer.from(await r.arrayBuffer());
|
|
}
|
|
function bundledIcon() {
|
|
return app.isPackaged ? path.join(process.resourcesPath, "icon.ico") : path.join(__dirname, "build", "icon.ico");
|
|
}
|
|
// Manifest icon → PNG (≤ 256 px, square) + ICO on disk. Falls back to the
|
|
// page favicon, then to the Theseus icon, so an install never fails on art.
|
|
async function writeIcon(desc, faviconUrl, appDir) {
|
|
const candidates = [desc.iconUrl, faviconUrl].filter(Boolean);
|
|
for (const u of candidates) {
|
|
try {
|
|
const raw = await fetchBytes(u);
|
|
if (raw.length > 4 && raw.readUInt16LE(0) === 0 && raw.readUInt16LE(2) === 1) { // already an .ico
|
|
fs.writeFileSync(path.join(appDir, "icon.ico"), raw);
|
|
const img = nativeImage.createFromPath(path.join(appDir, "icon.ico"));
|
|
if (!img.isEmpty()) fs.writeFileSync(path.join(appDir, "icon.png"), img.toPNG());
|
|
return true;
|
|
}
|
|
let img = nativeImage.createFromBuffer(raw);
|
|
if (img.isEmpty()) continue;
|
|
const { width, height } = img.getSize();
|
|
if (width > 256 || height > 256 || width !== height) {
|
|
const s = Math.min(256, Math.max(width, height));
|
|
img = img.resize({ width: s, height: s, quality: "best" });
|
|
}
|
|
const png = img.toPNG();
|
|
fs.writeFileSync(path.join(appDir, "icon.png"), png);
|
|
fs.writeFileSync(path.join(appDir, "icon.ico"), pngToIco(png));
|
|
return true;
|
|
} catch {}
|
|
}
|
|
try { fs.copyFileSync(bundledIcon(), path.join(appDir, "icon.ico")); } catch {}
|
|
return false;
|
|
}
|
|
const icoPath = (entry) => path.join(dir, entry.key, "icon.ico");
|
|
const pngPath = (entry) => path.join(dir, entry.key, "icon.png");
|
|
function windowIcon(entry) {
|
|
for (const p of [pngPath(entry), icoPath(entry)]) { try { const i = nativeImage.createFromPath(p); if (!i.isEmpty()) return i; } catch {} }
|
|
return bundledIcon();
|
|
}
|
|
|
|
// ---- shortcuts ---------------------------------------------------------------
|
|
function launchSpec(entry) {
|
|
const target = process.execPath;
|
|
// Dev runs are `electron.exe <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 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 };
|