diff --git a/addon-updater.js b/addon-updater.js
index 873341c..8b0c085 100644
--- a/addon-updater.js
+++ b/addon-updater.js
@@ -53,12 +53,16 @@ function readAddonJson(dir) {
// Called BEFORE seedBundledAddons at boot. Any staged folder whose version
// beats the currently installed copy is promoted; older or matching stages
// 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 || (() => {});
- if (!fs.existsSync(stagedDir)) return;
+ const promoted = [];
+ if (!fs.existsSync(stagedDir)) return promoted;
let entries;
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) {
if (!de.isDirectory()) continue;
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 {}
continue;
}
+ if (typeof only === "function" && !only(manifest)) continue;
const target = path.join(addonsDir, manifest.id);
const currentVer = readAddonJson(target)?.version;
if (currentVer && cmpVer(currentVer, manifest.version) >= 0) {
@@ -82,13 +87,14 @@ function promoteStagedUpdates({ addonsDir, backupsDir, stagedDir, logger }) {
try { fs.renameSync(target, backup); }
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) {
// 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); }
}
}
+ return promoted;
}
// ---------- HTTP helpers --------------------------------------------------
diff --git a/chrome.html b/chrome.html
index 8c8ce46..80179fd 100644
--- a/chrome.html
+++ b/chrome.html
@@ -55,6 +55,7 @@
matter how many tabs are open; the row scrolls (wheel) once tabs hit
their minimum width, like Chrome. */
.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
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). */
@@ -492,10 +493,11 @@
✕
+ ✕
⛓ Theseus ⚙
–
@@ -1237,6 +1239,33 @@
});
$("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 ----
// Shield colour communicates connection state at a glance:
// neutral — home / resolving (no site OR pending)
@@ -1445,15 +1474,20 @@
updateTab(el, t);
order.push(el);
}
- // "+" lives next to the row, not in it (see CSS) — created once.
- if (!document.getElementById("newtab")) {
- const plus = document.createElement("span"); plus.className = "newtab"; plus.id = "newtab"; plus.textContent = "+"; plus.title = "New tab (Ctrl+T)";
+ // "+" follows the last tab (Firefox/Chrome style) while the row fits;
+ // once tabs overflow it parks after the right scroll arrow so it stays
+ // 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();
- 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();
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
// appears). Not on every tabs event — those fire on every title/favicon/
// 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,
// each disabled at its end of travel. A click moves by ~60% of the
// 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() {
const box = $("tabs"), left = $("tabsLeft"), right = $("tabsRight");
+ placeNewTab();
const over = box.scrollWidth > box.clientWidth + 1;
left.hidden = right.hidden = !over;
if (!over) return;
diff --git a/main.js b/main.js
index 36f4631..b72ebd4 100644
--- a/main.js
+++ b/main.js
@@ -2055,13 +2055,15 @@ function initAddons() {
pubkeysHex: ADDON_UPDATE_PUBKEYS,
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) {
console.warn("[addons] panel-driven check-updates failed:", e?.message || e);
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
// 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
@@ -4015,10 +4017,11 @@ ipcMain.handle("bcnr:installExtension", (e, id) => {
try { requester = new URL(e.sender.getURL()).host || null; } catch {}
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();
- let report = [];
- let skipped = null;
+ let report = [], skipped = null;
try {
const result = await addonUpdater.checkAndStageUpdates({
addonsDir: addonsUserDir(),
@@ -4029,8 +4032,40 @@ ipcMain.handle("addons-check-updates", async () => {
});
report = result?.report || [];
skipped = result?.skipped || null;
- } catch (e) { console.warn("[addons] check-updates failed:", e?.message || e); }
- return { report, skipped, staged: listStagedAddons(stagedDir) };
+ } catch (e) { console.warn(`[addons] check-updates (${reason}) failed:`, e?.message || e); }
+ 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()));
function listStagedAddons(stagedDir) {
@@ -4316,7 +4351,7 @@ ipcMain.handle("find-stop", () => {
ipcMain.handle("app-version", () => app.getVersion());
// 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).
-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 () => {
// 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
@@ -5897,15 +5932,13 @@ if (!process.env.THESEUS_NO_AUTOSTART) {
// staged/, and promoteStagedUpdates() picks it up on the NEXT initAddons.
// Empty PUBKEYS_HEX (the shipping default until an operator ceremonies a
// key in) short-circuits inside checkAndStageUpdates — no HTTP is made.
- setTimeout(() => {
- addonUpdater.checkAndStageUpdates({
- addonsDir: addonsUserDir(),
- stagedDir: addonsStagedDir(),
- pubkeysHex: ADDON_UPDATE_PUBKEYS,
- verifyPublisher: verifyPublisherEntry,
- logger: (...a) => console.log("[addons]", ...a),
- }).catch(() => {});
- }, 30_000);
+ // A single boot check missed everything published after launch (2026-09-22:
+ // Aegis 0.8.3 and VPN 0.1.3 landed on the channel minutes after the app's
+ // check and never showed up), so re-check every 4 h while running, and
+ // tell the chrome whenever something is staged so the user sees a
+ // "restart to apply" chip instead of finding out in Settings.
+ setTimeout(() => pollAddonUpdates("boot"), 30_000);
+ setInterval(() => pollAddonUpdates("interval"), 4 * 60 * 60 * 1000);
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
diff --git a/preload.js b/preload.js
index 715a43a..6e02bcd 100644
--- a/preload.js
+++ b/preload.js
@@ -15,6 +15,13 @@ contextBridge.exposeInMainWorld("theseus", {
// download button opens the URL in the system browser; ✕ dismisses for
// the current session.
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),
installUpdateNow: () => ipcRenderer.invoke("install-update-now"),
dismissUpdate: () => ipcRenderer.invoke("dismiss-update"),
diff --git a/settings-preload.js b/settings-preload.js
index d8f77f8..82d989c 100644
--- a/settings-preload.js
+++ b/settings-preload.js
@@ -47,6 +47,8 @@ contextBridge.exposeInMainWorld("cfg", {
revealAddon: (folder) => ipcRenderer.invoke("addons-reveal", folder),
// Delete a non-bundled extension's folder (Settings › Extensions › ⋯ › Remove).
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"),
reloadAddons: () => ipcRenderer.invoke("addons-reload"),
// Community extensions from theseus.x/extensions: the catalog (with what
diff --git a/settings.html b/settings.html
index fbda06e..e32a419 100644
--- a/settings.html
+++ b/settings.html
@@ -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 .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}
- .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}
.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}
@@ -1363,7 +1364,8 @@
};
function renderAddons(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) {
addonsList.innerHTML = '
No extensions installed. Install one from theseus.x/extensions, or drop a folder into the extensions directory.
';
return;
@@ -1373,11 +1375,13 @@
return '
⚠ Load failed ' + escapeHtml(a.folder) + ' ' + escapeHtml(a.error) + ' Show folder
';
}
const s = addonShortStatus(a);
+ const st = addonUpdates.staged[a.id];
return '
'
+ '' + addonIconHtml(a.icon) + ' '
+ '' + escapeHtml(a.name) + (a.bundled ? 'BUILT-IN ' : '') + '' + escapeHtml(a.version) + ' '
- + (s ? '' + escapeHtml(s.text) + ' ' : '')
- + ' '
+ + (st ? '' : (s ? '' + escapeHtml(s.text) + ' ' : ''))
+ + '' + (st ? 'Update to ' + escapeHtml(st.version) + ' ' : '')
+ + ' '
+ '⋯ '
+ '
';
};
@@ -1404,6 +1408,25 @@
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-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) => {
e.stopPropagation(); const r = btn.getBoundingClientRect(); openAddonMenu(btn.dataset.more, r.right, r.bottom + 4, true);
}));
@@ -1463,16 +1486,21 @@
box.hidden = false;
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 upd = updateLineFor(a).replace("margin-top:4px", "margin:0") || '
' + (a.bundled ? "Updates arrive with Theseus releases or its signed channel." : "Checked with the rest at Check for updates.") + '
';
+ const st = addonUpdates.staged[a.id];
+ const upd = (st
+ ? '
v' + escapeHtml(st.version) + ' is ready.
'
+ : updateLineFor(a).replace("margin-top:4px", "margin:0") || '
' + (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.") + '
')
+ + (st ? '
Update to ' + escapeHtml(st.version) + ' ' : '
Check now ');
box.innerHTML =
- '
‹ '
+ '
‹ Back '
+ '
' + addonIconHtml(a.icon) + ' '
+ '
' + escapeHtml(a.name) + (a.bundled ? 'BUILT-IN ' : '') + (a.enabled ? '' : 'OFF ') + '' + escapeHtml(a.version) + '
'
+ '
' + escapeHtml(a.description || "No description.") + '
'
+ '
'
- + '⋯ '
+ + '
⋯ '
+ + '
✕ '
+ '
'
- + '
Updates ' + upd + '
'
+ + '
Updates ' + upd + '
'
+ '
Author ' + escapeHtml(a.author || "—") + '
'
+ '
Version ' + escapeHtml(a.version) + '
'
+ '
Type ' + escapeHtml(kind) + '
'
@@ -1486,6 +1514,7 @@
+ '
Every extension runs inside Theseus with the same access as the browser itself; these entries are the extension points it declared, not a sandbox.
'
+ (a.bundled ? '' : '
Remove extension…
');
document.getElementById("xback").onclick = closeAddonDetail;
+ document.getElementById("xclose").onclick = closeAddonDetail;
const rm = document.getElementById("xremove"); if (rm) rm.onclick = () => removeAddon(a);
wireAddonControls(box);
}