Compare commits

...

10 commits

Author SHA1 Message Date
Local Dev
b4b2c35672 build(theseus): ship webapps.js
The packaged app lists its files explicitly; the new module was not on
the list, so a packaged build failed at startup on a missing module.
2026-09-27 11:33:58 +02:00
Local Dev
6eed67624b theseus 0.3.57 (0.3.56 was already cut from the other line) 2026-09-27 11:23:27 +02:00
Local Dev
c79e0a86bb theseus 0.3.56 2026-09-27 11:22:08 +02:00
Local Dev
0774235486 fix(theseus): closing the browser window must not leave tabs talking to a destroyed window
Tabs keep emitting events while the window is torn down: a hovered
link fires update-target-url, which positioned the link-status pill
against win.getContentBounds() on a destroyed window ("Object has been
destroyed", 2026-09-27). With installed web apps the browser window can
now close while their windows keep the process alive, so this stops
being a quit-time blip and becomes a normal state.

The overlay helpers and layout() now check the window is alive, the
session is captured on close (the quit-time save no longer overwrites it
with an empty list once the tabs are gone), and "closed" drops every
reference to the window's views. A later createWindow() starts from a
clean tab list, so a page opened from an app window after the browser
window was closed brings the window back with the restored session.
2026-09-27 11:22:07 +02:00
Local Dev
729930f0b5 fix(theseus): closing a still-connecting relay socket must not crash the main process
Node's ws aborts the handshake when close() is called on a CONNECTING
socket and emits an error on the next tick; with no listener that is an
uncaught exception, and Electron answers with the modal "A JavaScript
error occurred in the main process". nostr-tools drops its onerror
handler right before closing, which is what the WizardConnect relay
teardown in Aegis runs on every wallet disconnect while a relay is
still connecting. Browser WebSockets ignore the same sequence, which is
why the library gets away with it elsewhere.

Every consumer in the main process shares the one ws module, so
close() now adds a no-op error listener to a connecting socket before
aborting it. Anything else that still escapes to the top of the process
is logged to <userData>/main-errors.log instead of raising the modal;
Electron continued after that dialog anyway, so only the interruption
goes.
2026-09-27 10:15:22 +02:00
Local Dev
d3787f8a29 feat(theseus): install a site as an app, the way Chrome and Edge offer it
Electron ships Chromium's renderer without the browser-side web-app
install machinery, so beforeinstallprompt never fires and every site's
own "Install our app" chip (coin-spectrum.com's, for one) stays hidden
in Theseus. The browser side now exists:

- webapps.js reads a page's <link rel="manifest">, accepts it when it
  names an app with a standalone-style display mode and a start_url on
  the page's origin, and records the descriptor on the tab.
- The address bar shows an install chip for such pages (filled once the
  app is installed: click then opens or removes it); the page context
  menu carries the same entry.
- Pages get a synthetic beforeinstallprompt whose prompt() routes to the
  Theseus install dialog and resolves userChoice like Chrome, and an
  appinstalled event afterwards, so sites' own chips appear and work.
- Installing stores the app under <userData>/webapps/, wraps the
  manifest icon into an .ico, writes a Start Menu (optionally desktop)
  shortcut that launches Theseus with --app=<start_url>, and opens the
  app in a chromeless window with its own taskbar identity. The window
  shares the session, BCNR resolution, fingerprint and add-on bridges
  with tabs; popups and "open in Theseus" go to the browser window,
  Alt+arrows / F5 / Ctrl+R cover navigation without a toolbar.
- Theseus takes the single-instance lock so a shortcut launch lands in
  the running browser (second-instance) instead of a second profile
  owner; launched cold, --app= opens only the app window and a later
  plain launch brings the browser window back.
2026-09-27 01:23:36 +02:00
Local Dev
718e6712c5 fix(theseus): chrome height sync must not wait for an animation frame
syncHeight queued its measurement in requestAnimationFrame, and frames
stop whenever the window is occluded — so a prompt closing, or a row
hiding, while another window covered Theseus left the chrome at its
old height until the window came back. A zero timeout coalesces the
same way and always runs. The open-prompt allowance now lives in
syncHeight itself, so any re-measure while a prompt is up keeps its
room, and the growth helper simply defers to it.
2026-09-25 08:34:44 +02:00
Local Dev
5edf8dc306 fix(theseus): prompts taller than the toolbar were cut off
The chrome view is exactly as tall as its rows, and a fixed-position
prompt mask adds nothing to the height it reports, so any prompt taller
than the toolbar — the new two-field favorites form lost its title and
buttons — was clipped at the view's edge. The view now grows to fit the
open prompt and shrinks back when it closes.
2026-09-25 08:30:19 +02:00
Local Dev
df6eb5d35b feat(theseus): add a page to favorites by hand
The favorites row's right-click menu could only save the current page.
"Add a page…" opens a two-field form: the address (scheme optional,
must be a real host — Chromium's parser happily percent-encodes junk
like "not a url" into a bogus bookmark, so hosts are checked) and a
title that fills itself from the address, brand-cased like the chips,
until you edit it. Duplicates are refused; the site's /favicon.ico is
used for the chip.
2026-09-25 08:29:00 +02:00
Local Dev
5b6b693694 fix(theseus): give pages a Chrome-shaped window.chrome
Electron hands every page an empty window.chrome. Real Chrome's carries
app, csi, loadTimes and runtime, and bot checks — Google's sign-in
botguard among them — look for exactly those to tell Chrome from an
embedded Chromium. The per-tab identity script now fills the object
with the same shapes and return types; a site learns nothing it would
not also learn from stock Chrome.
2026-09-24 22:51:49 +02:00
6 changed files with 723 additions and 14 deletions

View file

@ -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 {}

View file

@ -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,"&lt;")}</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
View file

@ -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

View file

@ -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/**/*",

View file

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