fix(theseus): extension updates re-check, announce and install in place

Updates published after launch never showed: the add-on channel was
checked once, 30 s after boot, and staged copies only applied on the
next launch with nothing telling the user. On 2026-09-22 Aegis 0.8.3
and VPN 0.1.3 landed minutes after the app's only check and stayed
invisible through manual scans made earlier and a restart made before
they were published.

Now: the check repeats every 4 hours; every check (boot, timer, manual,
add-on-driven) reports what is staged to the chrome, which shows a chip
for staged extensions; clicking it, or the "Update to vX" button that
appears on the extension's row and detail in Settings, promotes the
staged folder over the installed one and rebuilds the add-on host, so
the new version runs without a restart. Plug-ins (Aegis) are excluded
from the chip and the hot swap — a wallet updates from its own panel
and applies on the next launch.

Also from the same review: the new-tab button follows the last tab and
parks after the scroll arrow only when the strip overflows; Tor sits
left of the Aegis chip; plug-ins no longer appear in Settings ›
Extensions (they have Plug-ins); the extension detail view has a
labelled Back button, a close button and a Check now button; the
promotion helper returns what it promoted and accepts a filter.
This commit is contained in:
Local Dev 2026-09-22 21:25:33 +02:00
parent 806a976647
commit b2cfcd6dbf
6 changed files with 159 additions and 36 deletions

View file

@ -53,12 +53,16 @@ function readAddonJson(dir) {
// Called BEFORE seedBundledAddons at boot. Any staged folder whose version // Called BEFORE seedBundledAddons at boot. Any staged folder whose version
// beats the currently installed copy is promoted; older or matching stages // beats the currently installed copy is promoted; older or matching stages
// are cleaned up so they don't loop on every boot. // are cleaned up so they don't loop on every boot.
function promoteStagedUpdates({ addonsDir, backupsDir, stagedDir, logger }) { // `only(manifest)` limits the promotion to some staged entries (the live
// "Install update" path applies extensions in place but leaves plug-ins for
// the next launch). Returns the list of { id, version } actually promoted.
function promoteStagedUpdates({ addonsDir, backupsDir, stagedDir, logger, only }) {
const log = logger || (() => {}); const log = logger || (() => {});
if (!fs.existsSync(stagedDir)) return; const promoted = [];
if (!fs.existsSync(stagedDir)) return promoted;
let entries; let entries;
try { entries = fs.readdirSync(stagedDir, { withFileTypes: true }); } try { entries = fs.readdirSync(stagedDir, { withFileTypes: true }); }
catch (e) { log("promote: readdir failed:", e?.message); return; } catch (e) { log("promote: readdir failed:", e?.message); return promoted; }
for (const de of entries) { for (const de of entries) {
if (!de.isDirectory()) continue; if (!de.isDirectory()) continue;
const from = path.join(stagedDir, de.name); const from = path.join(stagedDir, de.name);
@ -68,6 +72,7 @@ function promoteStagedUpdates({ addonsDir, backupsDir, stagedDir, logger }) {
try { fs.rmSync(from, { recursive: true, force: true }); } catch {} try { fs.rmSync(from, { recursive: true, force: true }); } catch {}
continue; continue;
} }
if (typeof only === "function" && !only(manifest)) continue;
const target = path.join(addonsDir, manifest.id); const target = path.join(addonsDir, manifest.id);
const currentVer = readAddonJson(target)?.version; const currentVer = readAddonJson(target)?.version;
if (currentVer && cmpVer(currentVer, manifest.version) >= 0) { if (currentVer && cmpVer(currentVer, manifest.version) >= 0) {
@ -82,13 +87,14 @@ function promoteStagedUpdates({ addonsDir, backupsDir, stagedDir, logger }) {
try { fs.renameSync(target, backup); } try { fs.renameSync(target, backup); }
catch (e) { log(`promote: backup ${manifest.id} failed, keeping staged for next boot:`, e?.message); continue; } catch (e) { log(`promote: backup ${manifest.id} failed, keeping staged for next boot:`, e?.message); continue; }
} }
try { fs.renameSync(from, target); log(`promote: ${manifest.id} -> ${manifest.version}`); } try { fs.renameSync(from, target); log(`promote: ${manifest.id} -> ${manifest.version}`); promoted.push({ id: manifest.id, version: manifest.version }); }
catch (e) { catch (e) {
// Cross-drive rename can fail on Windows; copy then rm. // Cross-drive rename can fail on Windows; copy then rm.
try { fs.cpSync(from, target, { recursive: true }); fs.rmSync(from, { recursive: true, force: true }); log(`promote: ${manifest.id} -> ${manifest.version} (via copy)`); } try { fs.cpSync(from, target, { recursive: true }); fs.rmSync(from, { recursive: true, force: true }); promoted.push({ id: manifest.id, version: manifest.version }); log(`promote: ${manifest.id} -> ${manifest.version} (via copy)`); }
catch (ee) { log(`promote: move ${manifest.id} failed:`, ee.message); } catch (ee) { log(`promote: move ${manifest.id} failed:`, ee.message); }
} }
} }
return promoted;
} }
// ---------- HTTP helpers -------------------------------------------------- // ---------- HTTP helpers --------------------------------------------------

View file

@ -55,6 +55,7 @@
matter how many tabs are open; the row scrolls (wheel) once tabs hit matter how many tabs are open; the row scrolls (wheel) once tabs hit
their minimum width, like Chrome. */ their minimum width, like Chrome. */
.tabs > .newtab { flex: none; margin-left: 2px; } .tabs > .newtab { flex: none; margin-left: 2px; }
.tabrow > .newtab { flex: none; align-self: center; margin-left: 2px; }
/* Firefox-style overflow: once tabs hit their minimum width the row /* Firefox-style overflow: once tabs hit their minimum width the row
scrolls, the earliest tabs slide out on the left, and an arrow at each scrolls, the earliest tabs slide out on the left, and an arrow at each
end moves the row (they only appear when there is something to scroll). */ end moves the row (they only appear when there is something to scroll). */
@ -492,10 +493,11 @@
<button class="extmore" id="extmore" title="Extensions" aria-label="Extensions"></button> <button class="extmore" id="extmore" title="Extensions" aria-label="Extensions"></button>
</span> </span>
<span class="upchip" id="upchip" hidden><button class="upcore" id="upDownload" title=""></button><button class="updismiss" id="upDismiss" title="Not now"></button></span> <span class="upchip" id="upchip" hidden><button class="upcore" id="upDownload" title=""></button><button class="updismiss" id="upDismiss" title="Not now"></button></span>
<span class="upchip" id="xupchip" hidden><button class="upcore" id="xupRestart" title=""></button><button class="updismiss" id="xupDismiss" title="Not now"></button></span>
<button class="logo" id="logo" title="Settings"><span>⛓ Theseus</span><span class="gear"></span></button> <button class="logo" id="logo" title="Settings"><span>⛓ Theseus</span><span class="gear"></span></button>
</div> </div>
<div class="loadbar" id="loadbar"></div> <div class="loadbar" id="loadbar"></div>
<div class="bookmarks" id="bookmarks" hidden><div id="bmlist"></div><span class="bmright"><span class="plugdock" id="plugdock" hidden></span><button class="tor" id="tor" title="Route traffic through Tor">🧅 Tor: Off</button></span></div> <div class="bookmarks" id="bookmarks" hidden><div id="bmlist"></div><span class="bmright"><button class="tor" id="tor" title="Route traffic through Tor">🧅 Tor: Off</button><span class="plugdock" id="plugdock" hidden></span></span></div>
<div class="findbar" id="findbar" hidden> <div class="findbar" id="findbar" hidden>
<input id="findInput" type="text" placeholder="Find in page" spellcheck="false" autocomplete="off"> <input id="findInput" type="text" placeholder="Find in page" spellcheck="false" autocomplete="off">
<span class="findcount" id="findCount"></span> <span class="findcount" id="findCount"></span>
@ -1237,6 +1239,33 @@
}); });
$("upDismiss") && ($("upDismiss").onclick = () => T.dismissUpdate()); $("upDismiss") && ($("upDismiss").onclick = () => T.dismissUpdate());
// ---- staged extension updates chip ----
// Main sends "addon-updates" { staged: [{id,name,version}] } after every
// update check (boot, 4-hourly, manual) — extensions only, plug-ins update
// from inside their own panel. Clicking installs the staged versions in
// place (no restart). Dismiss hides this exact set; a new version staged
// later brings the chip back.
let xupDismissedKey = "";
function renderAddonUpdates(d) {
const chip = $("xupchip"); if (!chip) return;
const staged = (d && Array.isArray(d.staged)) ? d.staged : [];
const key = staged.map((s) => s.id + "@" + s.version).sort().join(",");
if (!staged.length || key === xupDismissedKey) { chip.hidden = true; return; }
chip.hidden = false;
const core = $("xupRestart");
const names = staged.map((s) => (s.name || s.id) + " " + s.version).join(", ");
core.textContent = staged.length === 1 ? `↻ Update ${staged[0].name || staged[0].id} to ${staged[0].version}` : `↻ ${staged.length} extension updates — install`;
core.title = `Signed updates ready: ${names}. Installs them now, no restart needed.`;
core.onclick = async () => {
core.textContent = "↻ Installing…"; core.onclick = null;
try { await (T.applyStagedAddons ? T.applyStagedAddons() : Promise.resolve()); } catch {}
// main re-sends "addon-updates" with whatever is still pending.
};
$("xupDismiss").onclick = () => { xupDismissedKey = key; chip.hidden = true; };
}
T.onAddonUpdates && T.onAddonUpdates(renderAddonUpdates);
T.stagedAddons && T.stagedAddons().then((list) => renderAddonUpdates({ staged: list || [] })).catch(() => {});
// ---- security badge + minimal registry indicator ---- // ---- security badge + minimal registry indicator ----
// Shield colour communicates connection state at a glance: // Shield colour communicates connection state at a glance:
// neutral — home / resolving (no site OR pending) // neutral — home / resolving (no site OR pending)
@ -1445,15 +1474,20 @@
updateTab(el, t); updateTab(el, t);
order.push(el); order.push(el);
} }
// "+" lives next to the row, not in it (see CSS) — created once. // "+" follows the last tab (Firefox/Chrome style) while the row fits;
if (!document.getElementById("newtab")) { // once tabs overflow it parks after the right scroll arrow so it stays
const plus = document.createElement("span"); plus.className = "newtab"; plus.id = "newtab"; plus.textContent = "+"; plus.title = "New tab (Ctrl+T)"; // reachable. placeNewTab() moves it between the two spots — created once.
let plus = document.getElementById("newtab");
if (!plus) {
plus = document.createElement("span"); plus.className = "newtab"; plus.id = "newtab"; plus.textContent = "+"; plus.title = "New tab (Ctrl+T)";
plus.onclick = () => T.newTab(); plus.onclick = () => T.newTab();
box.parentElement.appendChild(plus); box.appendChild(plus);
} }
const keep = new Set(order); const keep = new Set(order); keep.add(plus);
for (const el of [...box.children]) if (!keep.has(el)) el.remove(); for (const el of [...box.children]) if (!keep.has(el)) el.remove();
order.forEach((el, i) => { if (box.children[i] !== el) box.insertBefore(el, box.children[i] || null); }); order.forEach((el, i) => { if (box.children[i] !== el) box.insertBefore(el, box.children[i] || null); });
if (plus.parentElement === box && box.lastElementChild !== plus) box.appendChild(plus);
placeNewTab();
// Bring the selected tab into view when the SELECTION changes (or a tab // Bring the selected tab into view when the SELECTION changes (or a tab
// appears). Not on every tabs event — those fire on every title/favicon/ // appears). Not on every tabs event — those fire on every title/favicon/
// loading change, and re-snapping then would undo the user's own // loading change, and re-snapping then would undo the user's own
@ -1477,8 +1511,20 @@
// Arrows at both ends of the row: shown only while the row overflows, // Arrows at both ends of the row: shown only while the row overflows,
// each disabled at its end of travel. A click moves by ~60% of the // each disabled at its end of travel. A click moves by ~60% of the
// visible width; the wheel scrolls too. // visible width; the wheel scrolls too.
// The + button: inside the row after the last tab while everything fits,
// after the right arrow once the row overflows. Measured with the button
// out of the row so its own width can't flip the decision back and forth.
function placeNewTab() {
const box = $("tabs"), plus = $("newtab"); if (!plus) return;
const strip = box.parentElement;
const tabsWidth = [...box.children].filter((el) => el !== plus).reduce((w, el) => w + el.getBoundingClientRect().width + 4, 0);
const over = tabsWidth + (plus.getBoundingClientRect().width || 28) > box.clientWidth + 1;
if (over && plus.parentElement === box) strip.appendChild(plus);
else if (!over && plus.parentElement !== box) box.appendChild(plus);
}
function updateTabScroll() { function updateTabScroll() {
const box = $("tabs"), left = $("tabsLeft"), right = $("tabsRight"); const box = $("tabs"), left = $("tabsLeft"), right = $("tabsRight");
placeNewTab();
const over = box.scrollWidth > box.clientWidth + 1; const over = box.scrollWidth > box.clientWidth + 1;
left.hidden = right.hidden = !over; left.hidden = right.hidden = !over;
if (!over) return; if (!over) return;

67
main.js
View file

@ -2055,13 +2055,15 @@ function initAddons() {
pubkeysHex: ADDON_UPDATE_PUBKEYS, pubkeysHex: ADDON_UPDATE_PUBKEYS,
logger: (...a) => console.log("[addons]", ...a), logger: (...a) => console.log("[addons]", ...a),
}); });
return { report: result?.report || [], skipped: result?.skipped || null, staged: listStagedAddons(stagedDir) }; const staged = listStagedAddons(stagedDir);
notifyStagedAddons(staged);
return { report: result?.report || [], skipped: result?.skipped || null, staged };
} catch (e) { } catch (e) {
console.warn("[addons] panel-driven check-updates failed:", e?.message || e); console.warn("[addons] panel-driven check-updates failed:", e?.message || e);
return { report: [], skipped: "unexpected-error", staged: [] }; return { report: [], skipped: "unexpected-error", staged: [] };
} }
}, },
restartApp: () => { try { app.relaunch(); } catch {} app.quit(); }, restartApp: () => { console.log("[restart] requested by an add-on"); try { app.relaunch(); } catch {} app.quit(); },
// open-tab (addon-file variant): open one of the add-on's OWN files in a // open-tab (addon-file variant): open one of the add-on's OWN files in a
// full tab. The path is joined against the resolved add-on folder and // full tab. The path is joined against the resolved add-on folder and
// rejected if the result escapes it — belt-and-braces with the sanity // rejected if the result escapes it — belt-and-braces with the sanity
@ -4015,10 +4017,11 @@ ipcMain.handle("bcnr:installExtension", (e, id) => {
try { requester = new URL(e.sender.getURL()).host || null; } catch {} try { requester = new URL(e.sender.getURL()).host || null; } catch {}
return installExtensionWithConsent(id, requester); return installExtensionWithConsent(id, requester);
}); });
ipcMain.handle("addons-check-updates", async () => { // Background / manual add-on update check. Whatever gets staged is pushed to
// the chrome ("addon-updates") so the toolbar can offer "Restart to apply".
async function pollAddonUpdates(reason) {
const stagedDir = addonsStagedDir(); const stagedDir = addonsStagedDir();
let report = []; let report = [], skipped = null;
let skipped = null;
try { try {
const result = await addonUpdater.checkAndStageUpdates({ const result = await addonUpdater.checkAndStageUpdates({
addonsDir: addonsUserDir(), addonsDir: addonsUserDir(),
@ -4029,8 +4032,40 @@ ipcMain.handle("addons-check-updates", async () => {
}); });
report = result?.report || []; report = result?.report || [];
skipped = result?.skipped || null; skipped = result?.skipped || null;
} catch (e) { console.warn("[addons] check-updates failed:", e?.message || e); } } catch (e) { console.warn(`[addons] check-updates (${reason}) failed:`, e?.message || e); }
return { report, skipped, staged: listStagedAddons(stagedDir) }; const staged = listStagedAddons(stagedDir);
notifyStagedAddons(staged);
return { report, skipped, staged };
}
// Plug-ins (manifest category "plugin", e.g. Aegis) carry their own update
// UI inside their panel, so the toolbar chip only announces extensions.
function isPluginAddon(id) {
try { return (addonHost?.snapshot().installed || []).some((a) => a.id === id && a.category === "plugin"); } catch { return false; }
}
function notifyStagedAddons(staged) {
const list = (staged || listStagedAddons(addonsStagedDir())).filter((s) => !isPluginAddon(s.id));
try { chrome?.webContents.send("addon-updates", { staged: list.map((s) => ({ id: s.id, name: s.name, version: s.version })) }); } catch {}
}
ipcMain.handle("addons-check-updates", () => pollAddonUpdates("manual"));
// Apply staged extension updates now (no restart): promote the staged
// folder(s) over the installed copy and rebuild the add-on host, which
// re-requires each add-on's main from disk. Plug-ins are left for the next
// launch — a wallet mid-session is not something to hot-swap. `id` limits
// the apply to one extension (Settings row button); omitted = every
// staged extension (toolbar chip).
ipcMain.handle("addons-apply-staged", (_e, id) => {
if (!addonHost) return { ok: false, error: "add-ons not ready", applied: [] };
const want = typeof id === "string" && id ? id : null;
const applied = addonUpdater.promoteStagedUpdates({
addonsDir: addonsUserDir(),
backupsDir: addonsBackupDir(),
stagedDir: addonsStagedDir(),
logger: (...a) => console.log("[addons]", ...a),
only: (m) => (want ? m.id === want : true) && !isPluginAddon(m.id),
});
if (applied.length) { addonHost.discoverAndActivate(); emitSidebarState(); }
notifyStagedAddons();
return { ok: true, applied, pending: listStagedAddons(addonsStagedDir()).map((s) => ({ id: s.id, version: s.version })) };
}); });
ipcMain.handle("addons-list-staged", () => listStagedAddons(addonsStagedDir())); ipcMain.handle("addons-list-staged", () => listStagedAddons(addonsStagedDir()));
function listStagedAddons(stagedDir) { function listStagedAddons(stagedDir) {
@ -4316,7 +4351,7 @@ ipcMain.handle("find-stop", () => {
ipcMain.handle("app-version", () => app.getVersion()); ipcMain.handle("app-version", () => app.getVersion());
// Clean relaunch — used by the Aegis card to apply a staged add-on update // Clean relaunch — used by the Aegis card to apply a staged add-on update
// (promotion happens on next boot; this is just how the user gets there). // (promotion happens on next boot; this is just how the user gets there).
ipcMain.handle("app-restart", () => { try { app.relaunch(); } catch {} app.quit(); }); ipcMain.handle("app-restart", (e) => { let from = "?"; try { from = e.sender.getURL(); } catch {} console.log("[restart] requested via app-restart IPC from", from); try { app.relaunch(); } catch {} app.quit(); });
ipcMain.handle("recheck-update", async () => { ipcMain.handle("recheck-update", async () => {
// Manual "Check for updates" also un-dismisses any chip the user closed // Manual "Check for updates" also un-dismisses any chip the user closed
// in this session — they're actively asking to see the status, so honour // in this session — they're actively asking to see the status, so honour
@ -5897,15 +5932,13 @@ if (!process.env.THESEUS_NO_AUTOSTART) {
// staged/, and promoteStagedUpdates() picks it up on the NEXT initAddons. // staged/, and promoteStagedUpdates() picks it up on the NEXT initAddons.
// Empty PUBKEYS_HEX (the shipping default until an operator ceremonies a // Empty PUBKEYS_HEX (the shipping default until an operator ceremonies a
// key in) short-circuits inside checkAndStageUpdates — no HTTP is made. // key in) short-circuits inside checkAndStageUpdates — no HTTP is made.
setTimeout(() => { // A single boot check missed everything published after launch (2026-09-22:
addonUpdater.checkAndStageUpdates({ // Aegis 0.8.3 and VPN 0.1.3 landed on the channel minutes after the app's
addonsDir: addonsUserDir(), // check and never showed up), so re-check every 4 h while running, and
stagedDir: addonsStagedDir(), // tell the chrome whenever something is staged so the user sees a
pubkeysHex: ADDON_UPDATE_PUBKEYS, // "restart to apply" chip instead of finding out in Settings.
verifyPublisher: verifyPublisherEntry, setTimeout(() => pollAddonUpdates("boot"), 30_000);
logger: (...a) => console.log("[addons]", ...a), setInterval(() => pollAddonUpdates("interval"), 4 * 60 * 60 * 1000);
}).catch(() => {});
}, 30_000);
createWindow(); createWindow();
// Multi-source BNS warm-up so the first .bch page opens near-instantly and // 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 // stays fresh for as long as the browser is running. Every source runs in

View file

@ -15,6 +15,13 @@ contextBridge.exposeInMainWorld("theseus", {
// download button opens the URL in the system browser; ✕ dismisses for // download button opens the URL in the system browser; ✕ dismisses for
// the current session. // the current session.
onUpdateAvailable: (cb) => ipcRenderer.on("update-available", (_e, d) => cb(d)), onUpdateAvailable: (cb) => ipcRenderer.on("update-available", (_e, d) => cb(d)),
// Signed extension updates staged by the background check: { staged:
// [{id,name,version}] }. They apply on the next launch, so the chrome
// offers a restart. stagedAddons() asks once at boot.
onAddonUpdates: (cb) => ipcRenderer.on("addon-updates", (_e, d) => cb(d)),
stagedAddons: () => ipcRenderer.invoke("addons-list-staged"),
applyStagedAddons: () => ipcRenderer.invoke("addons-apply-staged"),
restartApp: () => ipcRenderer.invoke("app-restart"),
openUpdateDownload: (url) => ipcRenderer.invoke("open-update-download", url), openUpdateDownload: (url) => ipcRenderer.invoke("open-update-download", url),
installUpdateNow: () => ipcRenderer.invoke("install-update-now"), installUpdateNow: () => ipcRenderer.invoke("install-update-now"),
dismissUpdate: () => ipcRenderer.invoke("dismiss-update"), dismissUpdate: () => ipcRenderer.invoke("dismiss-update"),

View file

@ -47,6 +47,8 @@ contextBridge.exposeInMainWorld("cfg", {
revealAddon: (folder) => ipcRenderer.invoke("addons-reveal", folder), revealAddon: (folder) => ipcRenderer.invoke("addons-reveal", folder),
// Delete a non-bundled extension's folder (Settings Extensions Remove). // Delete a non-bundled extension's folder (Settings Extensions Remove).
removeAddon: (id) => ipcRenderer.invoke("addons-remove", id), removeAddon: (id) => ipcRenderer.invoke("addons-remove", id),
// Install a staged signed update now (Settings Extensions "Update to vX"); no id = every staged extension.
applyStagedAddons: (id) => ipcRenderer.invoke("addons-apply-staged", typeof id === "string" ? id : undefined),
openAddonsDir: () => ipcRenderer.invoke("addons-open-dir"), openAddonsDir: () => ipcRenderer.invoke("addons-open-dir"),
reloadAddons: () => ipcRenderer.invoke("addons-reload"), reloadAddons: () => ipcRenderer.invoke("addons-reload"),
// Community extensions from theseus.x/extensions: the catalog (with what // Community extensions from theseus.x/extensions: the catalog (with what

View file

@ -152,7 +152,8 @@
.xhead{display:flex;align-items:flex-start;gap:12px;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 14px 14px 10px} .xhead{display:flex;align-items:flex-start;gap:12px;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 14px 14px 10px}
.xhead .xi{width:36px;height:36px;flex:none;display:inline-grid;place-items:center;font-size:26px;line-height:1} .xhead .xi{width:36px;height:36px;flex:none;display:inline-grid;place-items:center;font-size:26px;line-height:1}
.xhead .xtitle{flex:1;min-width:0} .xhead .xtitle .t{font-weight:700;font-size:15px} .xhead .xtitle .d{color:var(--mut);font-size:13px;margin-top:3px} .xhead .xtitle{flex:1;min-width:0} .xhead .xtitle .t{font-weight:700;font-size:15px} .xhead .xtitle .d{color:var(--mut);font-size:13px;margin-top:3px}
.xback{width:32px;height:32px;border-radius:8px;border:1px solid var(--line);background:transparent;color:var(--ink);font-size:18px;cursor:pointer;flex:none;margin-top:2px} .xback{height:32px;padding:0 12px 0 8px;border-radius:8px;border:1px solid var(--line);background:transparent;color:var(--ink);font:inherit;font-size:13px;font-weight:600;cursor:pointer;flex:none;margin-top:2px;white-space:nowrap}
.xupd{padding:5px 12px;font-size:12.5px;font-weight:600;background:var(--acid);color:#0b0e14;border-color:transparent} .xupd:hover{filter:brightness(1.08);background:var(--acid)}
.xback:hover{background:#ffffff14} .xback:hover{background:#ffffff14}
.xdt{margin-top:10px;background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden} .xdt{margin-top:10px;background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden}
.xdt .xr{display:flex;align-items:center;gap:16px;padding:11px 14px;border-top:1px solid var(--line);font-size:13.5px} .xdt .xr{display:flex;align-items:center;gap:16px;padding:11px 14px;border-top:1px solid var(--line);font-size:13.5px}
@ -1363,7 +1364,8 @@
}; };
function renderAddons(snap) { function renderAddons(snap) {
lastAddonSnap = snap; lastAddonSnap = snap;
const items = (snap && snap.installed) || []; // Plug-ins (Aegis, category "plugin") have their own cards under Plug-ins.
const items = ((snap && snap.installed) || []).filter((a) => a.category !== "plugin");
if (!items.length) { if (!items.length) {
addonsList.innerHTML = '<div class="d" style="color:var(--dim)">No extensions installed. Install one from theseus.x/extensions, or drop a folder into the extensions directory.</div>'; addonsList.innerHTML = '<div class="d" style="color:var(--dim)">No extensions installed. Install one from theseus.x/extensions, or drop a folder into the extensions directory.</div>';
return; return;
@ -1373,11 +1375,13 @@
return '<div class="xrow" data-id="" data-folder="' + escapeAttr(a.folder) + '" title="' + escapeAttr(a.error) + '"><span class="xi"></span><span class="xn">Load failed <span class="xv">' + escapeHtml(a.folder) + '</span></span><span class="xs warn">' + escapeHtml(a.error) + '</span><span class="xctl"><button class="btn" data-reveal="' + escapeAttr(a.folder) + '" style="padding:5px 10px;font-size:12px">Show folder</button></span></div>'; return '<div class="xrow" data-id="" data-folder="' + escapeAttr(a.folder) + '" title="' + escapeAttr(a.error) + '"><span class="xi"></span><span class="xn">Load failed <span class="xv">' + escapeHtml(a.folder) + '</span></span><span class="xs warn">' + escapeHtml(a.error) + '</span><span class="xctl"><button class="btn" data-reveal="' + escapeAttr(a.folder) + '" style="padding:5px 10px;font-size:12px">Show folder</button></span></div>';
} }
const s = addonShortStatus(a); const s = addonShortStatus(a);
const st = addonUpdates.staged[a.id];
return '<div class="xrow" data-id="' + escapeAttr(a.id) + '" tabindex="0" title="' + escapeAttr(a.description || "") + '">' return '<div class="xrow" data-id="' + escapeAttr(a.id) + '" tabindex="0" title="' + escapeAttr(a.description || "") + '">'
+ '<span class="xi">' + addonIconHtml(a.icon) + '</span>' + '<span class="xi">' + addonIconHtml(a.icon) + '</span>'
+ '<span class="xn">' + escapeHtml(a.name) + (a.bundled ? '<span class="xbadge">BUILT-IN</span>' : '') + '<span class="xv">' + escapeHtml(a.version) + '</span></span>' + '<span class="xn">' + escapeHtml(a.name) + (a.bundled ? '<span class="xbadge">BUILT-IN</span>' : '') + '<span class="xv">' + escapeHtml(a.version) + '</span></span>'
+ (s ? '<span class="xs ' + s.cls + '">' + escapeHtml(s.text) + '</span>' : '') + (st ? '' : (s ? '<span class="xs ' + s.cls + '">' + escapeHtml(s.text) + '</span>' : ''))
+ '<span class="xctl"><label class="sw sm" title="' + (a.enabled ? "On" : "Off") + '"><input type="checkbox" data-toggle="' + escapeAttr(a.id) + '" ' + (a.enabled ? "checked" : "") + '><span class="track"><span class="knob"></span></span></label>' + '<span class="xctl">' + (st ? '<button class="btn xupd" data-apply="' + escapeAttr(a.id) + '" title="Signed update staged — installs now, no restart">Update to ' + escapeHtml(st.version) + '</button>' : '')
+ '<label class="sw sm" title="' + (a.enabled ? "On" : "Off") + '"><input type="checkbox" data-toggle="' + escapeAttr(a.id) + '" ' + (a.enabled ? "checked" : "") + '><span class="track"><span class="knob"></span></span></label>'
+ '<button class="xmore" data-more="' + escapeAttr(a.id) + '" title="More options" aria-label="More options"></button></span>' + '<button class="xmore" data-more="' + escapeAttr(a.id) + '" title="More options" aria-label="More options"></button></span>'
+ '</div>'; + '</div>';
}; };
@ -1404,6 +1408,25 @@
cb.addEventListener("change", async () => { await C.setAddonEnabled(cb.dataset.toggle, cb.checked); loadAddons(); }); cb.addEventListener("change", async () => { await C.setAddonEnabled(cb.dataset.toggle, cb.checked); loadAddons(); });
}); });
root.querySelectorAll('button[data-reveal]').forEach((btn) => btn.addEventListener("click", (e) => { e.stopPropagation(); C.revealAddon(btn.dataset.reveal); })); root.querySelectorAll('button[data-reveal]').forEach((btn) => btn.addEventListener("click", (e) => { e.stopPropagation(); C.revealAddon(btn.dataset.reveal); }));
root.querySelectorAll('button[data-apply]').forEach((btn) => btn.addEventListener("click", async (e) => {
e.stopPropagation();
btn.disabled = true; btn.textContent = "Installing…";
try { await C.applyStagedAddons(btn.dataset.apply); } catch {}
await loadAddonUpdates(); loadAddons();
}));
root.querySelectorAll('button[data-check]').forEach((btn) => btn.addEventListener("click", async (e) => {
e.stopPropagation();
btn.disabled = true; const orig = btn.textContent; btn.textContent = "Checking…";
try {
const res = await C.checkAddonUpdates();
const rep = (res && res.report || []).find((r) => r.id === btn.dataset.check);
addonUpdates.report = addonUpdates.report || {};
for (const r of (res && res.report || [])) addonUpdates.report[r.id] = r;
void rep;
} catch {}
await loadAddonUpdates(); loadAddons();
btn.disabled = false; btn.textContent = orig;
}));
root.querySelectorAll('button[data-more]').forEach((btn) => btn.addEventListener("click", (e) => { root.querySelectorAll('button[data-more]').forEach((btn) => btn.addEventListener("click", (e) => {
e.stopPropagation(); const r = btn.getBoundingClientRect(); openAddonMenu(btn.dataset.more, r.right, r.bottom + 4, true); e.stopPropagation(); const r = btn.getBoundingClientRect(); openAddonMenu(btn.dataset.more, r.right, r.bottom + 4, true);
})); }));
@ -1463,16 +1486,21 @@
box.hidden = false; box.hidden = false;
const caps = (a.capabilities || []); const caps = (a.capabilities || []);
const kind = a.bundled ? "Built into Theseus — ships with every release; turning it off hides it, a newer Theseus reseeds it" : "Installed extension — remove it from the ⋯ menu"; const kind = a.bundled ? "Built into Theseus — ships with every release; turning it off hides it, a newer Theseus reseeds it" : "Installed extension — remove it from the ⋯ menu";
const upd = updateLineFor(a).replace("margin-top:4px", "margin:0") || '<div class="d" style="margin:0;color:var(--dim)">' + (a.bundled ? "Updates arrive with Theseus releases or its signed channel." : "Checked with the rest at Check for updates.") + '</div>'; const st = addonUpdates.staged[a.id];
const upd = (st
? '<div class="d" style="margin:0;color:var(--acid-text)">v' + escapeHtml(st.version) + ' is ready.</div>'
: updateLineFor(a).replace("margin-top:4px", "margin:0") || '<div class="d" style="margin:0;color:var(--dim)">' + (a.bundled ? "Checked at start and every few hours; Check now asks the channel right away." : "Checked at start and every few hours; Check now asks the channel right away.") + '</div>')
+ (st ? '<button class="btn xupd" data-apply="' + escapeAttr(a.id) + '">Update to ' + escapeHtml(st.version) + '</button>' : '<button class="btn" data-check="' + escapeAttr(a.id) + '">Check now</button>');
box.innerHTML = box.innerHTML =
'<div class="xhead"><button class="xback" id="xback" title="Back to the list" aria-label="Back"></button>' '<div class="xhead"><button class="xback" id="xback" title="Back to the list" aria-label="Back"> Back</button>'
+ '<span class="xi">' + addonIconHtml(a.icon) + '</span>' + '<span class="xi">' + addonIconHtml(a.icon) + '</span>'
+ '<div class="xtitle"><div class="t">' + escapeHtml(a.name) + (a.bundled ? '<span class="xbadge">BUILT-IN</span>' : '') + (a.enabled ? '' : '<span class="xbadge off">OFF</span>') + '<span class="xv">' + escapeHtml(a.version) + '</span></div>' + '<div class="xtitle"><div class="t">' + escapeHtml(a.name) + (a.bundled ? '<span class="xbadge">BUILT-IN</span>' : '') + (a.enabled ? '' : '<span class="xbadge off">OFF</span>') + '<span class="xv">' + escapeHtml(a.version) + '</span></div>'
+ '<div class="d">' + escapeHtml(a.description || "No description.") + '</div></div>' + '<div class="d">' + escapeHtml(a.description || "No description.") + '</div></div>'
+ '<span class="xctl"><label class="sw sm" title="' + (a.enabled ? "On" : "Off") + '"><input type="checkbox" data-toggle="' + escapeAttr(a.id) + '" ' + (a.enabled ? "checked" : "") + '><span class="track"><span class="knob"></span></span></label>' + '<span class="xctl"><label class="sw sm" title="' + (a.enabled ? "On" : "Off") + '"><input type="checkbox" data-toggle="' + escapeAttr(a.id) + '" ' + (a.enabled ? "checked" : "") + '><span class="track"><span class="knob"></span></span></label>'
+ '<button class="xmore" data-more="' + escapeAttr(a.id) + '" title="More options" aria-label="More options"></button></span></div>' + '<button class="xmore" data-more="' + escapeAttr(a.id) + '" title="More options" aria-label="More options"></button>'
+ '<button class="xmore" id="xclose" title="Close" aria-label="Close"></button></span></div>'
+ '<div class="xdt">' + '<div class="xdt">'
+ '<div class="xr"><span class="k">Updates</span><span class="v"><span>' + upd + '</span></span></div>' + '<div class="xr"><span class="k">Updates</span><span class="v">' + upd + '</span></div>'
+ '<div class="xr"><span class="k">Author</span><span class="v">' + escapeHtml(a.author || "—") + '</span></div>' + '<div class="xr"><span class="k">Author</span><span class="v">' + escapeHtml(a.author || "—") + '</span></div>'
+ '<div class="xr"><span class="k">Version</span><span class="v">' + escapeHtml(a.version) + '</span></div>' + '<div class="xr"><span class="k">Version</span><span class="v">' + escapeHtml(a.version) + '</span></div>'
+ '<div class="xr"><span class="k">Type</span><span class="v">' + escapeHtml(kind) + '</span></div>' + '<div class="xr"><span class="k">Type</span><span class="v">' + escapeHtml(kind) + '</span></div>'
@ -1486,6 +1514,7 @@
+ '<div class="d" style="color:var(--dim);margin-top:10px">Every extension runs inside Theseus with the same access as the browser itself; these entries are the extension points it declared, not a sandbox.</div>' + '<div class="d" style="color:var(--dim);margin-top:10px">Every extension runs inside Theseus with the same access as the browser itself; these entries are the extension points it declared, not a sandbox.</div>'
+ (a.bundled ? '' : '<div style="margin-top:1.2rem;display:flex;justify-content:flex-end"><button class="btn xdanger" id="xremove">Remove extension…</button></div>'); + (a.bundled ? '' : '<div style="margin-top:1.2rem;display:flex;justify-content:flex-end"><button class="btn xdanger" id="xremove">Remove extension…</button></div>');
document.getElementById("xback").onclick = closeAddonDetail; document.getElementById("xback").onclick = closeAddonDetail;
document.getElementById("xclose").onclick = closeAddonDetail;
const rm = document.getElementById("xremove"); if (rm) rm.onclick = () => removeAddon(a); const rm = document.getElementById("xremove"); if (rm) rm.onclick = () => removeAddon(a);
wireAddonControls(box); wireAddonControls(box);
} }