fix(theseus): an installed app can be closed and removed; Settings lists apps

An app whose page has a beforeunload handler (CoinSpectrum) could not be
closed: tabs answer the page's "stay?" request, app windows had nothing
listening, and Electron reads silence as a veto. Closing the window is now
always the user's call; a reload or navigation inside the app asks, as a
tab does.

Removing it looked dead for a related reason. The confirmation was drawn
as Theseus's sheet in the main window, where the user was not looking (or
nowhere, with the main window closed), and the removal then closed the app
window with the same call the page vetoes. The question is now asked on
the app window that asked, and removal destroys the window.

Installed apps were only reachable from the address-bar chip while on the
site. Settings gets an Apps page, at theseus://settings/apps, that lists
them with Open and Remove and follows installs, removals and open windows.
The three list calls it uses are settings-only now.
This commit is contained in:
Local Dev 2026-10-04 19:15:11 +02:00
parent 45c7ca90d7
commit 3dbbc452cd
4 changed files with 96 additions and 8 deletions

View file

@ -889,6 +889,7 @@ const SETTINGS_ONLY = new Set([
"password-setup", "password-status", "password-unlock", "password-update", "password-setup", "password-status", "password-unlock", "password-update",
"recheck-update", "remove-from-list", "set-engine-enabled", "set-engine-order", "recheck-update", "remove-from-list", "set-engine-enabled", "set-engine-order",
"settings-open-panel", "settings-section", "tor-state", "settings-open-panel", "settings-section", "tor-state",
"webapps-list", "webapps-open", "webapps-remove",
"vault-pin-clear", "vault-pin-set", "vault-pin-status", "vault-pin-unlock", "vault-pin-clear", "vault-pin-set", "vault-pin-status", "vault-pin-unlock",
// Raw seeds and WIFs. No page uses these (add-ons go through the // Raw seeds and WIFs. No page uses these (add-ons go through the
// vaultImports shim in main), but an unguarded handler is reachable by any // vaultImports shim in main), but an unguarded handler is reachable by any
@ -5993,7 +5994,7 @@ ipcMain.handle("webapp-prompt", async (e) => {
const r = await installWebAppFromTab(tab); const r = await installWebAppFromTab(tab);
return r && r.ok ? "accepted" : "dismissed"; 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-list", () => webapps.list().map((a) => ({ key: a.key, name: a.name, host: a.host, startUrl: a.startUrl, installedAt: a.installedAt, icon: webapps.iconDataUrl(a), open: webapps.isOpen(a.key) })));
ipcMain.handle("webapps-open", (_e, key) => { const a = webapps.find(String(key || "")); if (a) webapps.open(a); return !!a; }); 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("webapps-remove", (_e, key) => webapps.uninstall(String(key || ""), true).then((ok) => { emitTabs(); return ok; }));
// Only the catalog site may ask, and only from its top frame. Any page used // Only the catalog site may ask, and only from its top frame. Any page used
@ -8694,7 +8695,11 @@ if (!process.env.THESEUS_NO_AUTOSTART && !app.requestSingleInstanceLock()) {
openInTab: openInMainTab, openInTab: openInMainTab,
targetUrlFor, isBnsHost, targetUrlFor, isBnsHost,
prepareContents: (wc) => { styleScrollbars(wc); try { wc.setWebRTCIPHandlingPolicy(webrtcPolicy()); } catch {} try { wc.setBackgroundThrottling(settings.backgroundThrottle); } catch {} applyFingerprint(wc); }, prepareContents: (wc) => { styleScrollbars(wc); try { wc.setWebRTCIPHandlingPolicy(webrtcPolicy()); } catch {} try { wc.setBackgroundThrottling(settings.backgroundThrottle); } catch {} applyFingerprint(wc); },
changed: () => emitTabs(), changed: () => {
try { emitTabs(); } catch {}
// Settings › Apps re-reads its list when an app is installed, removed, opened or closed.
for (const t of tabs) if (t.settings) { try { t.view.webContents.send("webapps-changed"); } catch {} }
},
}); });
// Kick off signed add-on update polling 30 s after boot so it never // Kick off signed add-on update polling 30 s after boot so it never
// slows launch. Any staged update lands in <userData>/addons-updates- // slows launch. Any staged update lands in <userData>/addons-updates-

View file

@ -92,6 +92,11 @@ contextBridge.exposeInMainWorld("cfg", {
// Manual "Check for updates" — un-dismisses any existing chip and re- // Manual "Check for updates" — un-dismisses any existing chip and re-
// fetches the release manifest. Returns { updateAvailable, currentVersion }. // fetches the release manifest. Returns { updateAvailable, currentVersion }.
recheckUpdate: () => ipcRenderer.invoke("recheck-update"), recheckUpdate: () => ipcRenderer.invoke("recheck-update"),
// Sites installed as apps (Settings › Apps).
webapps: () => ipcRenderer.invoke("webapps-list"),
webappOpen: (key) => ipcRenderer.invoke("webapps-open", String(key || "")),
webappRemove: (key) => ipcRenderer.invoke("webapps-remove", String(key || "")),
onWebappsChanged: (cb) => ipcRenderer.on("webapps-changed", () => cb()),
appVersion: () => ipcRenderer.invoke("app-version"), appVersion: () => ipcRenderer.invoke("app-version"),
restartApp: () => ipcRenderer.invoke("app-restart"), restartApp: () => ipcRenderer.invoke("app-restart"),
}); });

View file

@ -116,6 +116,13 @@
.ariadne-sources .asrc-row .astate.err{color:#f6768a} .ariadne-sources .asrc-row .astate.err{color:#f6768a}
.ariadne-sources input[type=checkbox]{accent-color:var(--acid);width:18px;height:18px;cursor:pointer} .ariadne-sources input[type=checkbox]{accent-color:var(--acid);width:18px;height:18px;cursor:pointer}
.pmuted{color:var(--mut)} .pmuted{color:var(--mut)}
/* Settings › Apps */
.app{display:flex;align-items:center;gap:12px;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:10px 14px;margin-bottom:8px}
.app img,.app .ph{width:32px;height:32px;border-radius:7px;flex:none;display:grid;place-items:center;font-size:20px}
.app .nm{flex:1;min-width:0} .app .nm b{display:block;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.app .nm span{color:var(--mut);font-size:12.5px}
.app .btn.rm{background:transparent;color:#f6768a;border-color:rgba(246,118,138,.4)}
.app .btn.rm:hover{background:rgba(246,118,138,.12)}
.ceng{display:flex;align-items:center;gap:8px;background:#10151f;border:1px solid var(--line);border-radius:8px;padding:6px 10px;margin-bottom:6px;font-size:13px} .ceng{display:flex;align-items:center;gap:8px;background:#10151f;border:1px solid var(--line);border-radius:8px;padding:6px 10px;margin-bottom:6px;font-size:13px}
.ceng .cs{font-size:14px;flex:none} .ceng .cs{font-size:14px;flex:none}
/* engine checklist */ /* engine checklist */
@ -265,6 +272,7 @@
<a data-sec="privacy">Privacy</a> <a data-sec="privacy">Privacy</a>
<a data-sec="plugins">Plug-ins</a> <a data-sec="plugins">Plug-ins</a>
<a data-sec="addons">Extensions</a> <a data-sec="addons">Extensions</a>
<a data-sec="apps">Apps</a>
</nav> </nav>
<div class="content"> <div class="content">
<!-- GENERAL --> <!-- GENERAL -->
@ -851,6 +859,12 @@
<h2 class="sub">Cookie pop-ups left alone</h2> <h2 class="sub">Cookie pop-ups left alone</h2>
<div class="exlist" id="consentEx"></div> <div class="exlist" id="consentEx"></div>
</section> </section>
<section id="apps" hidden>
<h1>Apps</h1>
<p class="lede">Sites you installed as apps. Each opens in its own window with its own taskbar button and shortcuts, and runs on the same Theseus underneath: same names, same protections, same sign-ins.</p>
<div id="appsList"></div>
<p class="subd" id="appsEmpty" hidden>No apps installed yet. When a site can be installed, an install chip appears at the right end of the address bar.</p>
</section>
<section id="addons" hidden> <section id="addons" hidden>
<h1>Extensions</h1> <h1>Extensions</h1>
<p class="lede">Small modules that add capabilities to Theseus. Extensions live as folders under <p class="lede">Small modules that add capabilities to Theseus. Extensions live as folders under
@ -2498,6 +2512,37 @@
await C.reloadAddons(); await loadAddonUpdates(); loadAddons(); await C.reloadAddons(); await loadAddonUpdates(); loadAddons();
}); });
document.getElementById("addonsOpenDir").addEventListener("click", () => C.openAddonsDir()); document.getElementById("addonsOpenDir").addEventListener("click", () => C.openAddonsDir());
// Apps: sites installed as apps (webapps.js). Until this page existed the
// only way to see or remove one was the address-bar chip while on the site,
// or the app window's own menu. The list is small, so it is simply re-read
// whenever the page is shown or main says the set changed.
(function () {
const A = window.cfg;
const listEl = document.getElementById("appsList"), emptyEl = document.getElementById("appsEmpty");
if (!listEl || !emptyEl || !A || !A.webapps) return;
const escA = (s) => String(s == null ? "" : s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
async function renderApps() {
let apps = []; try { apps = (await A.webapps()) || []; } catch {}
emptyEl.hidden = apps.length > 0;
listEl.innerHTML = apps.map((a) => {
let when = ""; try { if (a.installedAt) when = new Date(a.installedAt).toLocaleDateString(); } catch {}
return `<div class="app" data-key="${escA(a.key)}">` +
(a.icon ? `<img src="${escA(a.icon)}" alt="">` : `<span class="ph">🧩</span>`) +
`<div class="nm"><b>${escA(a.name)}</b><span>${escA(a.host)}${when ? " · installed " + escA(when) : ""}${a.open ? " · open now" : ""}</span></div>` +
`<button class="btn" data-act="open">Open</button>` +
`<button class="btn rm" data-act="remove">Remove…</button></div>`;
}).join("");
}
listEl.addEventListener("click", async (e) => {
const b = e.target.closest("button[data-act]"); if (!b) return;
const key = b.closest(".app").dataset.key;
if (b.dataset.act === "open") { await A.webappOpen(key); renderApps(); }
else { await A.webappRemove(key); renderApps(); }
});
document.addEventListener("section", (e) => { if (String(e.detail) === "apps") renderApps(); });
if (A.onWebappsChanged) A.onWebappsChanged(renderApps);
renderApps();
})();
document.querySelector('.side a[data-sec="addons"]').addEventListener("click", async () => { document.querySelector('.side a[data-sec="addons"]').addEventListener("click", async () => {
await loadAddonUpdates(); loadAddons(); await loadAddonUpdates(); loadAddons();
}); });

View file

@ -218,6 +218,12 @@ async function writeIcon(desc, faviconUrl, appDir) {
} }
const icoPath = (entry) => path.join(dir, entry.key, "icon.ico"); const icoPath = (entry) => path.join(dir, entry.key, "icon.ico");
const pngPath = (entry) => path.join(dir, entry.key, "icon.png"); const pngPath = (entry) => path.join(dir, entry.key, "icon.png");
// For Settings › Apps: the app's icon as a small data URL, and whether its window is open.
function iconDataUrl(entry) {
try { const i = nativeImage.createFromPath(pngPath(entry)); if (!i.isEmpty()) return i.resize({ width: 64, height: 64 }).toDataURL(); } catch {}
return null;
}
const isOpen = (key) => { const w = windows.get(key); return !!(w && !w.isDestroyed()); };
function windowIcon(entry) { function windowIcon(entry) {
for (const p of [pngPath(entry), icoPath(entry)]) { try { const i = nativeImage.createFromPath(p); if (!i.isEmpty()) return i; } catch {} } for (const p of [pngPath(entry), icoPath(entry)]) { try { const i = nativeImage.createFromPath(p); if (!i.isEmpty()) return i; } catch {} }
return bundledIcon(); return bundledIcon();
@ -305,11 +311,16 @@ async function install(desc, ctx = {}) {
return { ok: false, error: e?.message || "install failed" }; return { ok: false, error: e?.message || "install failed" };
} finally { promptOpen = false; } } finally { promptOpen = false; }
} }
async function uninstall(key, ask = true) { // `from` is the app window the request came from, if any; the question is then
// asked on that window. Theseus's own prompt sheet lives in the main window,
// so asked from an app window it appeared where the user was not looking (or
// nowhere at all with the main window closed) and the removal looked dead.
async function uninstall(key, ask = true, from = null) {
const entry = find(key); if (!entry) return false; const entry = find(key); if (!entry) return false;
if (ask) { if (ask) {
const parent = deps.parentWindow ? deps.parentWindow() : undefined; const parent = deps.parentWindow ? deps.parentWindow() : undefined;
const ask = (o) => (deps.ask ? deps.ask(o) : dialog.showMessageBox(parent, o)); const fromWin = from && !from.isDestroyed() ? from : null;
const ask = (o) => (fromWin ? dialog.showMessageBox(fromWin, o) : deps.ask ? deps.ask(o) : dialog.showMessageBox(parent, o));
const { response } = await ask({ const { response } = await ask({
type: "question", title: "Remove app", message: `Remove ${entry.name} from Theseus?`, 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.", detail: "Its window and shortcuts go away. The site itself and your data on it are untouched.",
@ -317,7 +328,9 @@ async function uninstall(key, ask = true) {
}); });
if (response !== 0) return false; if (response !== 0) return false;
} }
const w = windows.get(key); if (w && !w.isDestroyed()) { try { w.close(); } catch {} } // destroy(), not close(): a page's beforeunload handler can veto a close, and
// it has no say in the app being removed.
const w = windows.get(key); if (w && !w.isDestroyed()) { try { w.destroy(); } catch {} }
removeShortcuts(entry); removeShortcuts(entry);
try { fs.rmSync(path.join(dir, entry.key), { recursive: true, force: true }); } catch {} try { fs.rmSync(path.join(dir, entry.key), { recursive: true, force: true }); } catch {}
apps = apps.filter((a) => a.key !== key); save(); apps = apps.filter((a) => a.key !== key); save();
@ -346,6 +359,7 @@ function open(entry, urlOverride) {
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true }, webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
}); });
windows.set(entry.key, w); windows.set(entry.key, w);
deps.changed && deps.changed(); // Settings › Apps shows which apps are open
w.setMenuBarVisibility(false); w.setMenuBarVisibility(false);
if (process.platform === "win32") { if (process.platform === "win32") {
// Own taskbar identity: pinning the button pins the app, not Theseus, // Own taskbar identity: pinning the button pins the app, not Theseus,
@ -410,13 +424,32 @@ function open(entry, urlOverride) {
{ label: "Open this page in Theseus", click: () => deps.openInTab(wc.getURL()) }, { label: "Open this page in Theseus", click: () => deps.openInTab(wc.getURL()) },
{ label: "Copy page address", click: () => clipboard.writeText(norm(wc.getURL())) }, { label: "Copy page address", click: () => clipboard.writeText(norm(wc.getURL())) },
{ type: "separator" }, { type: "separator" },
{ label: `Remove ${entry.shortName} from Theseus…`, click: () => uninstall(entry.key, true) }, { label: `Remove ${entry.shortName} from Theseus…`, click: () => uninstall(entry.key, true, w) },
); );
Menu.buildFromTemplate(items).popup({ window: w }); Menu.buildFromTemplate(items).popup({ window: w });
}); });
const remember = () => { try { if (!w.isMinimized()) { entry.bounds = w.getNormalBounds(); save(); } } catch {} }; const remember = () => { try { if (!w.isMinimized()) { entry.bounds = w.getNormalBounds(); save(); } } catch {} };
w.on("resize", remember); w.on("move", remember); w.on("resize", remember); w.on("move", remember);
w.on("closed", () => { if (windows.get(entry.key) === w) windows.delete(entry.key); }); // A page with a beforeunload handler asks to keep the user ("unsaved
// changes"). With nobody listening Electron takes that as a veto, silently:
// an installed app whose page has such a handler could not be closed at all
// (CoinSpectrum, 2026-10-04). Closing the window is the user's decision and
// always goes through; a reload or navigation inside the app asks, as a tab does.
let closing = false;
w.on("close", () => { closing = true; });
wc.on("will-prevent-unload", (e) => {
if (closing) { e.preventDefault(); return; } // preventDefault here means "leave anyway"
let choice = 1;
try {
choice = dialog.showMessageBoxSync(w, {
type: "question", buttons: ["Stay on page", "Leave anyway"], defaultId: 0, cancelId: 0, noLink: true,
title: entry.name, message: "This page is asking you to stay.",
detail: "You may have unsaved changes that will be lost if you leave.",
});
} catch {}
if (choice === 1) e.preventDefault();
});
w.on("closed", () => { if (windows.get(entry.key) === w) windows.delete(entry.key); deps.changed && deps.changed(); });
w.once("ready-to-show", () => { try { w.show(); } catch {} }); w.once("ready-to-show", () => { try { w.show(); } catch {} });
Promise.resolve(deps.targetUrlFor ? deps.targetUrlFor(urlOverride || entry.startUrl) : (urlOverride || entry.startUrl)) Promise.resolve(deps.targetUrlFor ? deps.targetUrlFor(urlOverride || entry.startUrl) : (urlOverride || entry.startUrl))
.then((t) => wc.loadURL(t || urlOverride || entry.startUrl)) .then((t) => wc.loadURL(t || urlOverride || entry.startUrl))
@ -443,4 +476,4 @@ function launch(url) {
return open(entry, entry.startUrl === u ? undefined : u); return open(entry, entry.startUrl === u ? undefined : u);
} }
module.exports = { init, list, find, probeTab, chipState, install, uninstall, open, openWindows, launch, appUrlFromArgv, describe }; module.exports = { init, list, find, probeTab, chipState, install, uninstall, open, openWindows, launch, appUrlFromArgv, describe, iconDataUrl, isOpen };