Install-this-app, remove-app, the extension install flow (install, already installed, installed, not found, failed) and the add-on restart question were still bare OS message boxes titled "theseus-navigator" after page dialogs moved to the sheet. askSheet() is a drop-in for dialog.showMessageBox with the same options and result: title as the headline, detail under it, the caller's buttons with the default first, an optional checkbox, and the app's or extension's icon when there is one. Tone follows the box type (error, warning) or the wording. The native box stays as the fallback when the browser window is not there, and for the two synchronous cases (beforeunload, app windows).
440 lines
23 KiB
JavaScript
440 lines
23 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; }
|
|
// 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 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 };
|