Compare commits
10 commits
cee120cb23
...
b4b2c35672
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4b2c35672 | ||
|
|
6eed67624b | ||
|
|
c79e0a86bb | ||
|
|
0774235486 | ||
|
|
729930f0b5 | ||
|
|
d3787f8a29 | ||
|
|
718e6712c5 | ||
|
|
5edf8dc306 | ||
|
|
df6eb5d35b | ||
|
|
5b6b693694 |
6 changed files with 723 additions and 14 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 {}
|
||||
|
|
|
|||
114
chrome.html
114
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. */
|
||||
|
|
@ -401,6 +405,9 @@
|
|||
color: var(--ink); font: inherit; font-size: 13px; outline: none; }
|
||||
.promptbox input:focus { border-color: rgb(from var(--acid) r g b / .55); }
|
||||
.promptbox .pact { display: flex; gap: 6px; justify-content: flex-end; margin-top: 10px; }
|
||||
.promptbox .pfield { display: flex; flex-direction: column; gap: 4px; margin-top: 8px; }
|
||||
.promptbox .pfield label { font-size: 11.5px; color: var(--dim); }
|
||||
.promptbox .perr { color: #f6768a; font-size: 12px; margin-top: 8px; }
|
||||
.promptbox .pbtn { padding: 6px 12px; border-radius: 6px; border: 1px solid var(--line);
|
||||
background: var(--surface2, #0f1621); color: var(--ink); cursor: pointer;
|
||||
font: inherit; font-size: 12.5px; }
|
||||
|
|
@ -476,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>
|
||||
|
|
@ -519,7 +530,22 @@
|
|||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const T = window.theseus;
|
||||
function syncHeight() { requestAnimationFrame(() => T.setChromeHeight(document.body.scrollHeight)); }
|
||||
// Every caller (tab events, window resize, prompts) funnels through here,
|
||||
// so an open prompt keeps its room no matter what else re-measures the
|
||||
// chrome while it is up — a fixed mask never counts toward scrollHeight.
|
||||
// Coalesced with a zero timeout, not requestAnimationFrame: frames stop
|
||||
// whenever the window is occluded, and a height change queued then (a
|
||||
// prompt closing, a row hiding) would only land when the window came back.
|
||||
let syncHeightTimer = 0;
|
||||
function syncHeight() {
|
||||
clearTimeout(syncHeightTimer);
|
||||
syncHeightTimer = setTimeout(() => {
|
||||
let h = document.body.scrollHeight;
|
||||
const box = document.querySelector(".promptmask .promptbox");
|
||||
if (box) h = Math.max(h, Math.ceil(box.getBoundingClientRect().height) + 56);
|
||||
T.setChromeHeight(h);
|
||||
}, 0);
|
||||
}
|
||||
// Reserve room for the native window-controls overlay at the right of the
|
||||
// tab row (Windows only — the frame is hidden there, see main.js). Use the
|
||||
// overlay's real geometry when Chromium exposes it to this view; otherwise
|
||||
|
|
@ -1103,16 +1129,22 @@
|
|||
}
|
||||
// In-page prompt modal — window.prompt is disabled in Electron BrowserViews.
|
||||
// Resolves to the trimmed string on OK, or null on Cancel / Escape / mask click.
|
||||
// The chrome view is only as tall as its rows, and a fixed-position mask
|
||||
// adds nothing to body.scrollHeight — so a prompt taller than the rows was
|
||||
// cut off at the view's edge (the two-field favorites form lost its title
|
||||
// and buttons). Grow the view to fit the box while a prompt is open;
|
||||
// done() calls syncHeight() to shrink it back.
|
||||
function growForPrompt() { syncHeight(); }
|
||||
function inPrompt(label, initial) {
|
||||
return new Promise((resolve) => {
|
||||
const mask = document.createElement("div"); mask.className = "promptmask";
|
||||
mask.innerHTML = `<div class="promptbox"><div class="plbl">${label.replace(/</g,"<")}</div>`
|
||||
+ `<input type="text" spellcheck="false">`
|
||||
+ `<div class="pact"><button class="pbtn pcancel" type="button">Cancel</button><button class="pbtn primary pok" type="button">OK</button></div></div>`;
|
||||
document.body.appendChild(mask);
|
||||
document.body.appendChild(mask); growForPrompt(mask);
|
||||
const input = mask.querySelector("input");
|
||||
input.value = String(initial ?? "");
|
||||
const done = (val) => { try { mask.remove(); } catch {} document.removeEventListener("keydown", key); resolve(val); };
|
||||
const done = (val) => { try { mask.remove(); } catch {} document.removeEventListener("keydown", key); syncHeight(); resolve(val); };
|
||||
const key = (e) => { if (e.key === "Escape") done(null); else if (e.key === "Enter") done(input.value.trim() || null); };
|
||||
document.addEventListener("keydown", key);
|
||||
mask.addEventListener("mousedown", (e) => { if (e.target === mask) done(null); });
|
||||
|
|
@ -1121,10 +1153,68 @@
|
|||
setTimeout(() => { input.focus(); input.select(); }, 0);
|
||||
});
|
||||
}
|
||||
// "Add a page…": a two-field form (address + title). The title fills itself
|
||||
// from the address as you type — brand-cased host, same as the chip label —
|
||||
// until you edit it by hand; then it is yours. Resolves { url, title } or null.
|
||||
function normalizeBookmarkUrl(s) {
|
||||
let v = String(s || "").trim();
|
||||
if (!v) return "";
|
||||
if (!/^[a-z][a-z0-9+.-]*:/i.test(v)) v = "https://" + v.replace(/^\/+/, "");
|
||||
try {
|
||||
const u = new URL(v);
|
||||
if (!/^https?:$|^bns:$|^file:$/.test(u.protocol)) return "";
|
||||
// A real host: letters/digits/dots/dashes (IDNA already applied), no
|
||||
// percent-escapes from spaces, and at least one dot or "localhost".
|
||||
if (u.protocol !== "file:" && !(/^[a-z0-9.-]+$/i.test(u.hostname) && (u.hostname.includes(".") || u.hostname === "localhost"))) return "";
|
||||
return u.href;
|
||||
} catch { return ""; }
|
||||
}
|
||||
function inBookmarkForm(initial = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const mask = document.createElement("div"); mask.className = "promptmask";
|
||||
mask.innerHTML = `<div class="promptbox"><div class="plbl">Add a page to favorites</div>`
|
||||
+ `<div class="pfield"><label>Address</label><input type="text" class="purl" spellcheck="false" placeholder="example.com or https://…"></div>`
|
||||
+ `<div class="pfield"><label>Title</label><input type="text" class="ptitle" spellcheck="false" placeholder="Filled in from the address — edit as you like"></div>`
|
||||
+ `<div class="perr" hidden></div>`
|
||||
+ `<div class="pact"><button class="pbtn pcancel" type="button">Cancel</button><button class="pbtn primary pok" type="button">Save</button></div></div>`;
|
||||
document.body.appendChild(mask); growForPrompt(mask);
|
||||
const url = mask.querySelector(".purl"), title = mask.querySelector(".ptitle"), err = mask.querySelector(".perr");
|
||||
url.value = String(initial.url ?? ""); title.value = String(initial.title ?? "");
|
||||
let titleTouched = !!title.value;
|
||||
const autoTitle = () => {
|
||||
if (titleTouched) return;
|
||||
const norm = normalizeBookmarkUrl(url.value);
|
||||
let host = ""; try { host = norm ? new URL(norm).hostname.replace(/^www\./, "") : ""; } catch {}
|
||||
title.value = host ? bookmarkShortLabel("", host) : "";
|
||||
};
|
||||
url.addEventListener("input", autoTitle);
|
||||
title.addEventListener("input", () => { titleTouched = title.value.trim().length > 0; if (!titleTouched) autoTitle(); });
|
||||
const done = (val) => { try { mask.remove(); } catch {} document.removeEventListener("keydown", key); syncHeight(); resolve(val); };
|
||||
const submit = () => {
|
||||
const norm = normalizeBookmarkUrl(url.value);
|
||||
if (!norm) { err.textContent = "That doesn't look like a web address."; err.hidden = false; url.focus(); return; }
|
||||
if (bookmarks.some((b) => b.url === norm)) { err.textContent = "Already in your favorites."; err.hidden = false; url.focus(); return; }
|
||||
let host = ""; try { host = new URL(norm).hostname.replace(/^www\./, ""); } catch {}
|
||||
done({ url: norm, title: title.value.trim() || (host ? bookmarkShortLabel("", host) : norm) });
|
||||
};
|
||||
const key = (e) => { if (e.key === "Escape") done(null); else if (e.key === "Enter") submit(); };
|
||||
document.addEventListener("keydown", key);
|
||||
mask.addEventListener("mousedown", (e) => { if (e.target === mask) done(null); });
|
||||
mask.querySelector(".pcancel").addEventListener("click", () => done(null));
|
||||
mask.querySelector(".pok").addEventListener("click", submit);
|
||||
setTimeout(() => { url.focus(); url.select(); }, 0);
|
||||
});
|
||||
}
|
||||
async function addBookmarkByHand(initial) {
|
||||
const r = await inBookmarkForm(initial);
|
||||
if (!r) return;
|
||||
let favicon = null; try { const u = new URL(r.url); if (/^https?:$/.test(u.protocol)) favicon = `${u.origin}/favicon.ico`; } catch {}
|
||||
T.addBookmark({ title: r.title, url: r.url, favicon });
|
||||
}
|
||||
// Right-click context menu on the bookmarks bar. Two modes:
|
||||
// - On a specific bookmark: Open / Edit title / Remove
|
||||
// - On empty area OR the placeholder text: Add current page (or Remove
|
||||
// if the page is already bookmarked)
|
||||
// if the page is already bookmarked), and Add a page… by hand
|
||||
function closeBmMenu() { const m = document.getElementById("bmMenu"); if (m) m.remove(); }
|
||||
function openBmMenu(x, y, ctx) {
|
||||
closeBmMenu();
|
||||
|
|
@ -1147,6 +1237,7 @@
|
|||
} else {
|
||||
items.push({ label: "Add current page", disabled: true });
|
||||
}
|
||||
items.push({ label: "Add a page…", act: () => addBookmarkByHand() });
|
||||
const m = document.createElement("div");
|
||||
m.id = "bmMenu"; m.className = "ctxmenu";
|
||||
m.innerHTML = items.map((it, i) => it.sep
|
||||
|
|
@ -1185,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(); });
|
||||
|
||||
|
|
@ -1374,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 ------------------------------------
|
||||
|
|
|
|||
164
main.js
164
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;
|
||||
|
|
@ -22,6 +23,35 @@ const WebSocket = require("ws");
|
|||
// them, so a write that cannot land is not an error worth stopping for.
|
||||
for (const stream of [process.stdout, process.stderr]) stream.on("error", () => {});
|
||||
|
||||
// Node's ws turns "closed while still connecting" into a crash: close() on a
|
||||
// CONNECTING socket aborts the handshake and emits an error on the next tick,
|
||||
// and an error event with no listener is an uncaught exception. Browser
|
||||
// WebSockets shrug the same thing off, so libraries written for both do it —
|
||||
// nostr-tools drops its onerror handler and then closes the socket, which is
|
||||
// what the WizardConnect relay teardown in Aegis runs on every wallet
|
||||
// disconnect. Seen 2026-09-27 as the "JavaScript error occurred in the main
|
||||
// process" dialog. Every consumer in this process (messenger, add-ons,
|
||||
// WizardConnect via isomorphic-ws) shares this one ws module, so guard it here.
|
||||
{
|
||||
const close = WebSocket.prototype.close;
|
||||
WebSocket.prototype.close = function (...args) {
|
||||
if (this.readyState === WebSocket.CONNECTING && this.listenerCount("error") === 0) this.once("error", () => {});
|
||||
return close.apply(this, args);
|
||||
};
|
||||
}
|
||||
// Anything else that escapes to the top of the main process: Electron would
|
||||
// show a modal "JavaScript error occurred in the main process" and carry on.
|
||||
// Carry on without the modal, and keep the report where it can be found.
|
||||
process.on("uncaughtException", (err) => {
|
||||
const line = `[${new Date().toISOString()}] uncaught: ${(err && err.stack) || err}\n`;
|
||||
try { console.error(line); } catch {}
|
||||
try {
|
||||
const f = path.join(app.getPath("userData"), "main-errors.log");
|
||||
try { if (fs.statSync(f).size > 512 * 1024) fs.truncateSync(f, 0); } catch {}
|
||||
fs.appendFileSync(f, line);
|
||||
} catch {}
|
||||
});
|
||||
|
||||
// Packaged builds ship the resolver and tor/ as unpacked resources (they can't
|
||||
// run from inside app.asar); dev runs read them from the repo.
|
||||
const RES_DIR = app.isPackaged ? process.resourcesPath : __dirname;
|
||||
|
|
@ -807,6 +837,8 @@ async function applyFingerprint(wc) {
|
|||
if (settings.hideMediaDevices) {
|
||||
src += `try{const md=navigator.mediaDevices;if(md&&md.enumerateDevices){const o=md.enumerateDevices.bind(md);md.enumerateDevices=async()=>{let l=[];try{l=await o()}catch(e){}const ks=[...new Set(l.map(d=>d.kind))];return ks.map(kind=>({deviceId:'',kind:kind,label:'',groupId:'',toJSON(){return{deviceId:'',kind:kind,label:'',groupId:''}}}))};}}catch(e){}`;
|
||||
}
|
||||
// Always on: Chrome-shaped window.chrome (see CHROME_SHIM_SRC).
|
||||
src += CHROME_SHIM_SRC;
|
||||
if (src) {
|
||||
const res = await wc.debugger.sendCommand("Page.addScriptToEvaluateOnNewDocument", { source: src });
|
||||
wc._langScript = res.identifier;
|
||||
|
|
@ -875,6 +907,18 @@ function stockChromeUA() {
|
|||
// still said "Chromium" only. DataDome ("AI Threats Detection") blocked on
|
||||
// exactly that header/JS mismatch (estore.asus.com, 2026-09-20), so the same
|
||||
// metadata is now also installed in every tab via Emulation.setUserAgentOverride.
|
||||
// Electron hands every page an EMPTY window.chrome. Real Chrome's carries
|
||||
// app / csi / loadTimes / runtime, and bot checks — Google sign-in's among
|
||||
// them — look for exactly those to tell Chrome from an embedded Chromium.
|
||||
// Same shapes and return types as Chrome; nothing here is reachable by the
|
||||
// site beyond what a stock Chrome would also give it.
|
||||
const CHROME_SHIM_SRC = `try{(()=>{const c=window.chrome;if(!c||typeof c!=="object"||Object.keys(c).length)return;
|
||||
const t=()=>performance.timing;
|
||||
c.app={isInstalled:false,InstallState:{DISABLED:"disabled",INSTALLED:"installed",NOT_INSTALLED:"not_installed"},RunningState:{CANNOT_RUN:"cannot_run",READY_TO_RUN:"ready_to_run",RUNNING:"running"},getDetails(){return null},getIsInstalled(){return false},runningState(){return "cannot_run"}};
|
||||
c.csi=function(){const p=t();return{startE:p.navigationStart,onloadT:p.domContentLoadedEventEnd,pageT:performance.now(),tran:15}};
|
||||
c.loadTimes=function(){const p=t();return{requestTime:p.navigationStart/1000,startLoadTime:p.navigationStart/1000,commitLoadTime:p.responseStart/1000,finishDocumentLoadTime:p.domContentLoadedEventEnd/1000,finishLoadTime:p.loadEventEnd/1000,firstPaintTime:p.responseEnd/1000,firstPaintAfterLoadTime:0,navigationType:"Other",wasFetchedViaSpdy:true,wasNpnNegotiated:true,npnNegotiatedProtocol:"h2",wasAlternateProtocolAvailable:false,connectionInfo:"h2"}};
|
||||
c.runtime={OnInstalledReason:{CHROME_UPDATE:"chrome_update",INSTALL:"install",SHARED_MODULE_UPDATE:"shared_module_update",UPDATE:"update"},OnRestartRequiredReason:{APP_UPDATE:"app_update",OS_UPDATE:"os_update",PERIODIC:"periodic"},PlatformArch:{ARM:"arm",ARM64:"arm64",MIPS:"mips",MIPS64:"mips64",X86_32:"x86-32",X86_64:"x86-64"},PlatformNaclArch:{ARM:"arm",MIPS:"mips",MIPS64:"mips64",X86_32:"x86-32",X86_64:"x86-64"},PlatformOs:{ANDROID:"android",CROS:"cros",LINUX:"linux",MAC:"mac",OPENBSD:"openbsd",WIN:"win"},RequestUpdateCheckStatus:{NO_UPDATE:"no_update",THROTTLED:"throttled",UPDATE_AVAILABLE:"update_available"},id:undefined,connect(){throw new TypeError("chrome.runtime.connect() called from a webpage must specify an Extension ID (string) for its first argument.")},sendMessage(){throw new TypeError("chrome.runtime.sendMessage() called from a webpage must specify an Extension ID (string) for its first argument.")}};
|
||||
})()}catch(e){}`;
|
||||
function chromeBrandMetadata() {
|
||||
const full = CHROME_VERSION_CLAIMED;
|
||||
const major = parseInt(full.split(".")[0], 10) || 130;
|
||||
|
|
@ -965,7 +1009,9 @@ const sessionFile = () => path.join(app.getPath("userData"), "session.json");
|
|||
// v2 format: { v: 2, urls, active }. v1 was a bare array of URLs; reading one
|
||||
// maps to active = last tab, which is what the old restore loop ended up
|
||||
// showing (each createTab activated itself, so the rightmost tab won).
|
||||
let sessionSavedAtClose = false;
|
||||
function saveSession() {
|
||||
if (sessionSavedAtClose && !winAlive()) return; // already captured when the window closed
|
||||
try {
|
||||
const live = tabs.filter((t) => !t.settings && t.url);
|
||||
const active = Math.max(0, live.findIndex((t) => t.id === activeId));
|
||||
|
|
@ -1060,6 +1106,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
|
||||
});
|
||||
|
|
@ -1657,6 +1704,8 @@ a.btn{display:inline-block;margin-top:16px;padding:9px 16px;border-radius:999px;
|
|||
|
||||
// ---- window + tabs ----
|
||||
let win, chrome;
|
||||
// The window can be gone while tabs and app windows still fire events.
|
||||
const winAlive = () => !!win && !win.isDestroyed();
|
||||
let CHROME_H = 84; // grows when an extra bar (Tor notice / BCNR offer) is shown
|
||||
// Site-info popover: a floating overlay VIEW on top of the page content, so it
|
||||
// never pushes the page down. Positioned under the address-bar badge on demand.
|
||||
|
|
@ -2353,7 +2402,7 @@ function pushNav(prov) {
|
|||
}
|
||||
|
||||
function layout() {
|
||||
if (!win) return;
|
||||
if (!winAlive()) return;
|
||||
const { width, height } = win.getContentBounds();
|
||||
chrome.setBounds({ x: 0, y: 0, width, height: CHROME_H });
|
||||
const bodyH = Math.max(0, height - CHROME_H);
|
||||
|
|
@ -2426,19 +2475,19 @@ function showDownloads(show) {
|
|||
} else { downloadsPop.setVisible(false); dlVisible = false; }
|
||||
}
|
||||
function positionAddressPicker() {
|
||||
if (!addressPicker) return;
|
||||
if (!addressPicker || !winAlive()) return;
|
||||
const { width } = win.getContentBounds();
|
||||
const x = Math.max(6, Math.min(apPos.x, width - apW - 6));
|
||||
addressPicker.setBounds({ x, y: apPos.y, width: apW, height: apH });
|
||||
}
|
||||
function positionPwFill() {
|
||||
if (!pwFillPop) return;
|
||||
if (!pwFillPop || !winAlive()) return;
|
||||
const { width } = win.getContentBounds();
|
||||
const x = Math.max(6, Math.min(pwfPos.x, width - PWF_W - 6));
|
||||
pwFillPop.setBounds({ x, y: pwfPos.y, width: PWF_W, height: pwfH });
|
||||
}
|
||||
function positionLinkStatus() {
|
||||
if (!linkStatus || !win) return;
|
||||
if (!linkStatus || !winAlive()) return;
|
||||
const { width, height } = win.getContentBounds();
|
||||
// The pill may grow to (almost) the full width of the tab area — long
|
||||
// URLs stay readable — and ellipsises past that. It never runs under
|
||||
|
|
@ -2449,7 +2498,7 @@ function positionLinkStatus() {
|
|||
linkStatus.setBounds({ x: 0, y: Math.max(0, height - h), width: w, height: h });
|
||||
}
|
||||
function showLinkStatus(url) {
|
||||
if (!linkStatus) return;
|
||||
if (!linkStatus || !winAlive()) return;
|
||||
const s = String(url || "");
|
||||
if (!s) {
|
||||
if (linkStatusVisible) { linkStatus.setVisible(false); linkStatusVisible = false; }
|
||||
|
|
@ -2757,9 +2806,10 @@ function emitTabs() {
|
|||
collapsedGroups: [...tabGroupCollapsed],
|
||||
url: t?.url || "",
|
||||
loading: !!t?.loading,
|
||||
canBack: wc ? wc.navigationHistory.canGoBack() : false,
|
||||
canForward: wc ? wc.navigationHistory.canGoForward() : false,
|
||||
canBack: (() => { try { return !!wc && wc.navigationHistory.canGoBack(); } catch { return false; } })(),
|
||||
canForward: (() => { try { return !!wc && wc.navigationHistory.canGoForward(); } catch { return false; } })(),
|
||||
zoom: zoomPercent(t),
|
||||
webapp: webapps.chipState(t),
|
||||
});
|
||||
}
|
||||
function setLoading(tab, on) { if (tab && tab.loading !== on) { tab.loading = on; emitTabs(); } }
|
||||
|
|
@ -3008,6 +3058,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
|
||||
|
|
@ -3015,6 +3067,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,
|
||||
|
|
@ -3184,6 +3239,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();
|
||||
|
|
@ -3222,6 +3283,11 @@ function closeTab(id) {
|
|||
function createWindow() {
|
||||
chromeReadyDone = false;
|
||||
overlaysLoaded = false;
|
||||
sessionSavedAtClose = false;
|
||||
if (tabs.length) { // leftovers from a window that closed while app windows kept the process alive
|
||||
for (const t of tabs.splice(0)) { try { t.view.webContents.destroy?.(); } catch {} }
|
||||
activeId = null;
|
||||
}
|
||||
// Window ground + chrome view ground both match chrome.html's --bg for the
|
||||
// active theme. The chrome view used to sit on Chromium's default white
|
||||
// until chrome.html painted, which is the "white strip over a dark
|
||||
|
|
@ -3325,6 +3391,17 @@ function createWindow() {
|
|||
chrome.webContents.once("dom-ready", onChromeReady);
|
||||
setTimeout(onChromeReady, 8000);
|
||||
win.on("resize", layout);
|
||||
// The browser window can close while app windows (webapps.js) keep the
|
||||
// process alive. Capture the session while the tabs are still here, then
|
||||
// let go of the window's views: tab events (hover → update-target-url,
|
||||
// title updates) keep arriving during teardown and used to reach the
|
||||
// destroyed window through positionLinkStatus (2026-09-27).
|
||||
win.on("close", () => { saveSession(); sessionSavedAtClose = true; });
|
||||
win.on("closed", () => {
|
||||
win = null; chrome = null; popover = null; enginePicker = null; downloadsPop = null;
|
||||
addressPicker = null; pwFillPop = null; linkStatus = null; sidebar = null; approvalPop = null;
|
||||
linkStatusVisible = false;
|
||||
});
|
||||
layout();
|
||||
}
|
||||
|
||||
|
|
@ -4038,6 +4115,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 {}
|
||||
|
|
@ -5918,7 +6031,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();
|
||||
|
|
@ -5953,6 +6088,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.
|
||||
|
|
@ -5965,7 +6107,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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "theseus-navigator",
|
||||
"version": "0.3.55",
|
||||
"version": "0.3.57",
|
||||
"description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.",
|
||||
"author": "Silent Mode",
|
||||
"main": "main.js",
|
||||
|
|
@ -82,6 +82,7 @@
|
|||
"messages-preload.js",
|
||||
"bcnr-preload.js",
|
||||
"bcnr-origin.js",
|
||||
"webapps.js",
|
||||
"lib/**/*",
|
||||
"package.json",
|
||||
"node_modules/**/*",
|
||||
|
|
|
|||
|
|
@ -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