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>
</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>
<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.
@ -1242,6 +1237,34 @@
// ---- Add-ons management ----
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) {
const items = (snap && snap.installed) || [];
if (!items.length) {
@ -1269,7 +1292,7 @@
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">'
: 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) =>
'<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()); }
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 () => {
await C.reloadAddons(); loadAddons();
await C.reloadAddons(); await loadAddonUpdates(); loadAddons();
});
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
// doesn't display stale "checking…" text if the background poll finished
// while another section was open. Aegis card loads on page-init and its
@ -1311,19 +1345,9 @@
if (ar) ar.click();
});
// Manual "Check for updates" for extensions — runs the same signed-update
// polling the boot timer runs, and surfaces what's staged for the next
// launch. Staged folders live in <userData>/addons-updates-staged/ and
// 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;
}
// (Old separate "Pending updates" strip lived here. Per-card update
// line is now painted by updateLineFor() inside each addon row —
// loadAddonUpdates() populates addonUpdates.staged before renderAddons.)
document.getElementById("addonsCheckUpdates").addEventListener("click", async () => {
const btn = document.getElementById("addonsCheckUpdates");
const status = document.getElementById("addonsUpdStatus");
@ -1337,42 +1361,35 @@
const staged = Array.isArray(res) ? res : (res?.staged || []);
if (skipped === "no-pubkeys") {
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 {
// Show per-addon status so "no update" is never mistaken for a
// silent fetch failure.
const rows = report.map((r) => {
const label = escapeHtml(r.id) + " " + escapeHtml(r.currentVer || "?");
let msg = "";
switch (r.status) {
case "up-to-date": msg = "up to date"; break;
case "no-update-url": msg = "no updateURL declared"; break;
case "fetch-failed": msg = "fetch failed — " + escapeHtml(r.detail || "network"); break;
case "signature-invalid": msg = "endpoint offered " + escapeHtml(r.newVer || "?") + " with a BAD signature — rejected"; break;
case "sha256-mismatch": msg = "endpoint offered " + escapeHtml(r.newVer || "?") + " but its tarball hash didn't match"; break;
case "extract-failed": msg = "extract failed — " + escapeHtml(r.detail || ""); break;
case "manifest-mismatch": msg = "extracted manifest didn't match signed values"; break;
case "already-staged": msg = escapeHtml(r.newVer || "?") + " already staged; restart to apply"; break;
case "staged": msg = "staged " + escapeHtml(r.newVer || "?") + "; restart to apply"; break;
default: msg = escapeHtml(r.status || "unknown");
}
return '<div><b>' + label + '</b> — ' + msg + '</div>';
}).join("");
status.innerHTML = rows;
// Fold the per-addon report into addonUpdates.report so the
// per-card update line reflects the freshest check. A summary at
// the top counts staged/updated vs. up-to-date, but the per-addon
// detail lives on each card.
addonUpdates.report = {};
for (const r of report) addonUpdates.report[r.id] = r;
const stagedNow = report.filter((r) => r.status === "staged" || r.status === "already-staged").length;
const failed = report.filter((r) => ["fetch-failed","signature-invalid","sha256-mismatch","extract-failed","manifest-mismatch"].includes(r.status)).length;
if (stagedNow) {
status.textContent = stagedNow + " update" + (stagedNow > 1 ? "s" : "") + " staged; restart Theseus to apply.";
} else if (failed) {
status.textContent = failed + " failed — see the extension card" + (failed > 1 ? "s" : "") + " below.";
} else if (!report.length) {
status.textContent = "No extensions with an update endpoint.";
} else {
status.textContent = "All extensions up to date.";
}
}
await loadStagedAddonUpdates();
await loadAddonUpdates();
loadAddons();
} catch (e) {
status.textContent = "Check failed: " + (e?.message || e);
} finally {
btn.disabled = false; btn.textContent = orig;
}
});
loadStagedAddonUpdates();
// Populate on first paint so the tab is ready when the user clicks in.
loadAddons();
(async () => { await loadAddonUpdates(); loadAddons(); })();
</script>
</body>
</html>