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:
parent
45c7ca90d7
commit
3dbbc452cd
4 changed files with 96 additions and 8 deletions
9
main.js
9
main.js
|
|
@ -889,6 +889,7 @@ const SETTINGS_ONLY = new Set([
|
|||
"password-setup", "password-status", "password-unlock", "password-update",
|
||||
"recheck-update", "remove-from-list", "set-engine-enabled", "set-engine-order",
|
||||
"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",
|
||||
// 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
|
||||
|
|
@ -5993,7 +5994,7 @@ ipcMain.handle("webapp-prompt", async (e) => {
|
|||
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-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-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
|
||||
|
|
@ -8694,7 +8695,11 @@ if (!process.env.THESEUS_NO_AUTOSTART && !app.requestSingleInstanceLock()) {
|
|||
openInTab: openInMainTab,
|
||||
targetUrlFor, isBnsHost,
|
||||
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
|
||||
// slows launch. Any staged update lands in <userData>/addons-updates-
|
||||
|
|
|
|||
|
|
@ -92,6 +92,11 @@ contextBridge.exposeInMainWorld("cfg", {
|
|||
// Manual "Check for updates" — un-dismisses any existing chip and re-
|
||||
// fetches the release manifest. Returns { updateAvailable, currentVersion }.
|
||||
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"),
|
||||
restartApp: () => ipcRenderer.invoke("app-restart"),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -116,6 +116,13 @@
|
|||
.ariadne-sources .asrc-row .astate.err{color:#f6768a}
|
||||
.ariadne-sources input[type=checkbox]{accent-color:var(--acid);width:18px;height:18px;cursor:pointer}
|
||||
.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 .cs{font-size:14px;flex:none}
|
||||
/* engine checklist */
|
||||
|
|
@ -265,6 +272,7 @@
|
|||
<a data-sec="privacy">Privacy</a>
|
||||
<a data-sec="plugins">Plug-ins</a>
|
||||
<a data-sec="addons">Extensions</a>
|
||||
<a data-sec="apps">Apps</a>
|
||||
</nav>
|
||||
<div class="content">
|
||||
<!-- GENERAL -->
|
||||
|
|
@ -851,6 +859,12 @@
|
|||
<h2 class="sub">Cookie pop-ups left alone</h2>
|
||||
<div class="exlist" id="consentEx"></div>
|
||||
</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>
|
||||
<h1>Extensions</h1>
|
||||
<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();
|
||||
});
|
||||
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) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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 () => {
|
||||
await loadAddonUpdates(); loadAddons();
|
||||
});
|
||||
|
|
|
|||
45
webapps.js
45
webapps.js
|
|
@ -218,6 +218,12 @@ async function writeIcon(desc, faviconUrl, appDir) {
|
|||
}
|
||||
const icoPath = (entry) => path.join(dir, entry.key, "icon.ico");
|
||||
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) {
|
||||
for (const p of [pngPath(entry), icoPath(entry)]) { try { const i = nativeImage.createFromPath(p); if (!i.isEmpty()) return i; } catch {} }
|
||||
return bundledIcon();
|
||||
|
|
@ -305,11 +311,16 @@ async function install(desc, ctx = {}) {
|
|||
return { ok: false, error: e?.message || "install failed" };
|
||||
} 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;
|
||||
if (ask) {
|
||||
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({
|
||||
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.",
|
||||
|
|
@ -317,7 +328,9 @@ async function uninstall(key, ask = true) {
|
|||
});
|
||||
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);
|
||||
try { fs.rmSync(path.join(dir, entry.key), { recursive: true, force: true }); } catch {}
|
||||
apps = apps.filter((a) => a.key !== key); save();
|
||||
|
|
@ -346,6 +359,7 @@ function open(entry, urlOverride) {
|
|||
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
|
||||
});
|
||||
windows.set(entry.key, w);
|
||||
deps.changed && deps.changed(); // Settings › Apps shows which apps are open
|
||||
w.setMenuBarVisibility(false);
|
||||
if (process.platform === "win32") {
|
||||
// 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: "Copy page address", click: () => clipboard.writeText(norm(wc.getURL())) },
|
||||
{ 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 });
|
||||
});
|
||||
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); });
|
||||
// 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 {} });
|
||||
Promise.resolve(deps.targetUrlFor ? deps.targetUrlFor(urlOverride || entry.startUrl) : (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);
|
||||
}
|
||||
|
||||
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 };
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue