feat(theseus/settings): per-extension update info inline on the card

The Extensions page had a "Pending updates" strip at the top listing the
staged versions AND a "Check for updates" button that dumped a summary of
every extension's status into a global status blob just below the button.
Two places to look for what a single card was doing.

Fold both surfaces into the extension card itself:
- Each card grows a small update line under its description: green ↻
  "Update vX.Y.Z staged — restart to apply" when a staged tarball is
  waiting, red "Update failed" (with the addon-updater's detail) when
  the last check-updates run couldn't advance the version, plain "Up to
  date" when it could and there was nothing newer.
- The top strip is gone. The "Check for updates" button now just prints a
  one-line summary (N staged / N failed / all up to date) — the detail
  lives on each card.
- listStagedAddonUpdates fires on tab visit and after Reload, so the
  card badge reflects the background poll without needing the user to
  click Check.
This commit is contained in:
Local Dev 2026-09-09 02:43:28 +02:00
parent 0d583fb749
commit 124325673f

View file

@ -602,11 +602,6 @@
<button id="addonsOpenDir" class="btn" type="button">Open extensions folder</button> <button id="addonsOpenDir" class="btn" type="button">Open extensions folder</button>
</div> </div>
<div id="addonsUpdStatus" class="pmuted" style="font-size:12.5px;margin-top:6px;text-align:right"></div> <div id="addonsUpdStatus" class="pmuted" style="font-size:12.5px;margin-top:6px;text-align:right"></div>
<div id="addonsStagedBox" hidden style="margin-top:1rem;padding:12px 14px;border:1px solid var(--acid);border-radius:10px;background:rgba(214,255,61,.06)">
<div style="font-weight:600;margin-bottom:6px">Pending updates</div>
<div id="addonsStaged" style="font-size:13px"></div>
<div style="color:var(--dim);font-size:12px;margin-top:8px">These apply on the next Theseus restart.</div>
</div>
<h2 class="sub" style="border-top:0;padding-top:0;margin-top:1.5rem">Installed</h2> <h2 class="sub" style="border-top:0;padding-top:0;margin-top:1.5rem">Installed</h2>
<div id="addonsList"><div class="d" style="color:var(--dim)">Loading…</div></div> <div id="addonsList"><div class="d" style="color:var(--dim)">Loading…</div></div>
<div class="note">Extensions run with full app access — treat installing one like installing an unsigned executable. <div class="note">Extensions run with full app access — treat installing one like installing an unsigned executable.
@ -1242,6 +1237,34 @@
// ---- Add-ons management ---- // ---- Add-ons management ----
const addonsList = document.getElementById("addonsList"); const addonsList = document.getElementById("addonsList");
// Per-addon update state, keyed by addon id. Populated by loadAddonUpdates()
// (staged: from listStagedAddonUpdates, background-polled) and by the
// manual "Check for updates" button (report: fresh per-addon status).
// renderAddons reads both when drawing each card so the update line lives
// inside the card — no separate "Pending updates" strip at the top.
const addonUpdates = { staged: {}, report: {} };
function updateLineFor(a) {
const st = addonUpdates.staged[a.id];
const rep = addonUpdates.report[a.id];
if (st) {
return '<div class="d" style="color:var(--acid);margin-top:4px">↻ Update <b>v' + escapeHtml(st.version) + '</b> staged — restart Theseus to apply.</div>';
}
if (!rep) return "";
let msg = "", cls = "color:var(--dim)";
switch (rep.status) {
case "up-to-date": msg = "Up to date."; break;
case "no-update-url": return ""; // don't clutter cards that never opted in
case "fetch-failed": msg = "Update check failed — " + escapeHtml(rep.detail || "network"); cls = "color:#f6768a"; break;
case "signature-invalid": msg = "Endpoint offered v" + escapeHtml(rep.newVer || "?") + " with a BAD signature — rejected."; cls = "color:#f6768a"; break;
case "sha256-mismatch": msg = "Endpoint offered v" + escapeHtml(rep.newVer || "?") + " but the tarball hash didn't match."; cls = "color:#f6768a"; break;
case "extract-failed": msg = "v" + escapeHtml(rep.newVer || "?") + " downloaded but wouldn't extract — " + escapeHtml(rep.detail || ""); cls = "color:#f6768a"; break;
case "manifest-mismatch": msg = "Extracted manifest didn't match signed values."; cls = "color:#f6768a"; break;
case "already-staged": msg = "↻ v" + escapeHtml(rep.newVer || "?") + " already staged — restart to apply."; cls = "color:var(--acid)"; break;
case "staged": msg = "↻ Staged v" + escapeHtml(rep.newVer || "?") + " — restart to apply."; cls = "color:var(--acid)"; break;
default: msg = escapeHtml(rep.status || "unknown");
}
return '<div class="d" style="' + cls + ';margin-top:4px">' + msg + '</div>';
}
function renderAddons(snap) { function renderAddons(snap) {
const items = (snap && snap.installed) || []; const items = (snap && snap.installed) || [];
if (!items.length) { if (!items.length) {
@ -1269,7 +1292,7 @@
const iconHtml = /^data:image\//i.test(a.icon || "") const iconHtml = /^data:image\//i.test(a.icon || "")
? '<img src="' + escapeAttr(a.icon) + '" alt="" style="width:16px;height:16px;vertical-align:middle;margin-right:4px">' ? '<img src="' + escapeAttr(a.icon) + '" alt="" style="width:16px;height:16px;vertical-align:middle;margin-right:4px">'
: escapeHtml(a.icon || "•"); : escapeHtml(a.icon || "•");
return '<div class="row"><div class="txt"><div class="t">' + iconHtml + ' ' + escapeHtml(a.name) + builtInBadge + ' <span style="color:var(--dim);font-weight:400">v' + escapeHtml(a.version) + '</span>' + caps + '</div><div class="d">' + escapeHtml(a.description || "") + (a.author ? ' <span style="color:var(--dim)">— ' + escapeHtml(a.author) + '</span>' : '') + '</div></div><div style="display:flex;gap:8px;align-items:center"><button class="btn" data-reveal="' + escapeAttr(a.folder) + '">Show folder</button><label class="sw"><input type="checkbox" data-toggle="' + escapeAttr(a.id) + '" ' + (a.enabled ? "checked" : "") + '><span class="track"><span class="knob"></span></span></label></div></div>'; return '<div class="row"><div class="txt"><div class="t">' + iconHtml + ' ' + escapeHtml(a.name) + builtInBadge + ' <span style="color:var(--dim);font-weight:400">v' + escapeHtml(a.version) + '</span>' + caps + '</div><div class="d">' + escapeHtml(a.description || "") + (a.author ? ' <span style="color:var(--dim)">— ' + escapeHtml(a.author) + '</span>' : '') + '</div>' + updateLineFor(a) + '</div><div style="display:flex;gap:8px;align-items:center"><button class="btn" data-reveal="' + escapeAttr(a.folder) + '">Show folder</button><label class="sw"><input type="checkbox" data-toggle="' + escapeAttr(a.id) + '" ' + (a.enabled ? "checked" : "") + '><span class="track"><span class="knob"></span></span></label></div></div>';
}; };
const sectionHeader = (label, hint) => const sectionHeader = (label, hint) =>
'<div class="d" style="color:var(--dim);margin:12px 0 6px;font-size:12px;text-transform:uppercase;letter-spacing:.04em">' + escapeHtml(label) + '</div>' '<div class="d" style="color:var(--dim);margin:12px 0 6px;font-size:12px;text-transform:uppercase;letter-spacing:.04em">' + escapeHtml(label) + '</div>'
@ -1296,11 +1319,22 @@
try { renderAddons(await C.listAddons()); } try { renderAddons(await C.listAddons()); }
catch (e) { addonsList.textContent = "Failed to load extensions: " + (e?.message || e); } catch (e) { addonsList.textContent = "Failed to load extensions: " + (e?.message || e); }
} }
// Refresh the staged-updates map from the background poll. Whatever came
// back through listStagedAddonUpdates is what promoteStagedUpdates will
// pick up on the next launch — the per-card badge reflects exactly that.
async function loadAddonUpdates() {
let staged = [];
try { staged = await C.listStagedAddonUpdates(); } catch { staged = []; }
addonUpdates.staged = {};
for (const s of (staged || [])) addonUpdates.staged[s.id] = { version: s.version, name: s.name };
}
document.getElementById("addonsReload").addEventListener("click", async () => { document.getElementById("addonsReload").addEventListener("click", async () => {
await C.reloadAddons(); loadAddons(); await C.reloadAddons(); await loadAddonUpdates(); loadAddons();
}); });
document.getElementById("addonsOpenDir").addEventListener("click", () => C.openAddonsDir()); document.getElementById("addonsOpenDir").addEventListener("click", () => C.openAddonsDir());
document.querySelector('.side a[data-sec="addons"]').addEventListener("click", () => { loadAddons(); loadStagedAddonUpdates(); }); document.querySelector('.side a[data-sec="addons"]').addEventListener("click", async () => {
await loadAddonUpdates(); loadAddons();
});
// Plug-ins tab: refresh Ariadne's live daemon state on every visit so it // Plug-ins tab: refresh Ariadne's live daemon state on every visit so it
// doesn't display stale "checking…" text if the background poll finished // doesn't display stale "checking…" text if the background poll finished
// while another section was open. Aegis card loads on page-init and its // while another section was open. Aegis card loads on page-init and its
@ -1311,19 +1345,9 @@
if (ar) ar.click(); if (ar) ar.click();
}); });
// Manual "Check for updates" for extensions — runs the same signed-update // (Old separate "Pending updates" strip lived here. Per-card update
// polling the boot timer runs, and surfaces what's staged for the next // line is now painted by updateLineFor() inside each addon row —
// launch. Staged folders live in <userData>/addons-updates-staged/ and // loadAddonUpdates() populates addonUpdates.staged before renderAddons.)
// promoteStagedUpdates() applies them on next init.
async function loadStagedAddonUpdates() {
let staged = [];
try { staged = await C.listStagedAddonUpdates(); } catch { staged = []; }
const box = document.getElementById("addonsStagedBox");
const list = document.getElementById("addonsStaged");
if (!staged || !staged.length) { box.hidden = true; return; }
list.innerHTML = staged.map((s) => '<div>' + escapeHtml(s.name) + ' → <b>' + escapeHtml(s.version) + '</b></div>').join("");
box.hidden = false;
}
document.getElementById("addonsCheckUpdates").addEventListener("click", async () => { document.getElementById("addonsCheckUpdates").addEventListener("click", async () => {
const btn = document.getElementById("addonsCheckUpdates"); const btn = document.getElementById("addonsCheckUpdates");
const status = document.getElementById("addonsUpdStatus"); const status = document.getElementById("addonsUpdStatus");
@ -1337,42 +1361,35 @@
const staged = Array.isArray(res) ? res : (res?.staged || []); const staged = Array.isArray(res) ? res : (res?.staged || []);
if (skipped === "no-pubkeys") { if (skipped === "no-pubkeys") {
status.textContent = "Update endpoint disabled — no operator pubkey baked into this build."; status.textContent = "Update endpoint disabled — no operator pubkey baked into this build.";
} else if (staged.length) {
status.textContent = staged.length + " update" + (staged.length > 1 ? "s" : "") + " staged; restart Theseus to apply.";
} else if (!report.length) {
status.textContent = "No extensions with an update endpoint.";
} else { } else {
// Show per-addon status so "no update" is never mistaken for a // Fold the per-addon report into addonUpdates.report so the
// silent fetch failure. // per-card update line reflects the freshest check. A summary at
const rows = report.map((r) => { // the top counts staged/updated vs. up-to-date, but the per-addon
const label = escapeHtml(r.id) + " " + escapeHtml(r.currentVer || "?"); // detail lives on each card.
let msg = ""; addonUpdates.report = {};
switch (r.status) { for (const r of report) addonUpdates.report[r.id] = r;
case "up-to-date": msg = "up to date"; break; const stagedNow = report.filter((r) => r.status === "staged" || r.status === "already-staged").length;
case "no-update-url": msg = "no updateURL declared"; break; const failed = report.filter((r) => ["fetch-failed","signature-invalid","sha256-mismatch","extract-failed","manifest-mismatch"].includes(r.status)).length;
case "fetch-failed": msg = "fetch failed — " + escapeHtml(r.detail || "network"); break; if (stagedNow) {
case "signature-invalid": msg = "endpoint offered " + escapeHtml(r.newVer || "?") + " with a BAD signature — rejected"; break; status.textContent = stagedNow + " update" + (stagedNow > 1 ? "s" : "") + " staged; restart Theseus to apply.";
case "sha256-mismatch": msg = "endpoint offered " + escapeHtml(r.newVer || "?") + " but its tarball hash didn't match"; break; } else if (failed) {
case "extract-failed": msg = "extract failed — " + escapeHtml(r.detail || ""); break; status.textContent = failed + " failed — see the extension card" + (failed > 1 ? "s" : "") + " below.";
case "manifest-mismatch": msg = "extracted manifest didn't match signed values"; break; } else if (!report.length) {
case "already-staged": msg = escapeHtml(r.newVer || "?") + " already staged; restart to apply"; break; status.textContent = "No extensions with an update endpoint.";
case "staged": msg = "staged " + escapeHtml(r.newVer || "?") + "; restart to apply"; break; } else {
default: msg = escapeHtml(r.status || "unknown"); status.textContent = "All extensions up to date.";
} }
return '<div><b>' + label + '</b> — ' + msg + '</div>';
}).join("");
status.innerHTML = rows;
} }
await loadStagedAddonUpdates(); await loadAddonUpdates();
loadAddons();
} catch (e) { } catch (e) {
status.textContent = "Check failed: " + (e?.message || e); status.textContent = "Check failed: " + (e?.message || e);
} finally { } finally {
btn.disabled = false; btn.textContent = orig; btn.disabled = false; btn.textContent = orig;
} }
}); });
loadStagedAddonUpdates();
// Populate on first paint so the tab is ready when the user clicks in. // Populate on first paint so the tab is ready when the user clicks in.
loadAddons(); (async () => { await loadAddonUpdates(); loadAddons(); })();
</script> </script>
</body> </body>
</html> </html>