feat(theseus/addons): per-add-on diagnostic report from checkAndStageUpdates

The Settings > Extensions "Check for updates" button used to report
one of two lines: "N updates staged; restart to apply" or "All
extensions are up to date". The second collapsed several distinct
outcomes into one indistinguishable line, so a user seeing "up to
date" couldn't tell whether the check actually reached the endpoint
or the fetch had silently failed.

checkAndStageUpdates now returns { report, skipped? } with one entry
per installed add-on and a status of:
  no-update-url  — addon.json doesn't declare updateURL
  fetch-failed   — DNS / connection / HTTP error on updates.json or the tarball (detail carries the message)
  up-to-date     — endpoint reached, no version strictly newer than installed
  signature-invalid — offered version's sig didn't verify against any baked-in pubkey
  sha256-mismatch — downloaded tarball's hash didn't match the signed one
  extract-failed  — tar could not extract (detail carries the message)
  manifest-mismatch — extracted addon.json didn't match signed id/version
  staged / already-staged — success

The Settings UI now renders one row per add-on with that status, so
a "no update" outcome is never mistaken for a silent fetch failure.
Return shape is back-compat: if a caller expects a bare array, the
UI normalizes.
This commit is contained in:
Local Dev 2026-09-08 18:14:07 +02:00
parent b5f1cf468b
commit 8055d39a9d
3 changed files with 68 additions and 22 deletions

View file

@ -146,11 +146,11 @@ function verifySignature(id, version, tarballSha256, sigB64, pubkeysHex) {
async function stageOne({ id, currentVer, updateURL, stagedDir, pubkeysHex, log, timeoutMs }) { async function stageOne({ id, currentVer, updateURL, stagedDir, pubkeysHex, log, timeoutMs }) {
let manifestBuf; let manifestBuf;
try { manifestBuf = await httpGet(updateURL, { timeoutMs, maxBytes: MAX_MANIFEST_BYTES }); } try { manifestBuf = await httpGet(updateURL, { timeoutMs, maxBytes: MAX_MANIFEST_BYTES }); }
catch (e) { log(`updates: fetch ${id} failed:`, e.message); return null; } catch (e) { log(`updates: fetch ${id} failed:`, e.message); return { status: "fetch-failed", detail: e.message }; }
let manifest; let manifest;
try { manifest = JSON.parse(manifestBuf.toString("utf8")); } try { manifest = JSON.parse(manifestBuf.toString("utf8")); }
catch (e) { log(`updates: manifest ${id} unparseable:`, e.message); return null; } catch (e) { log(`updates: manifest ${id} unparseable:`, e.message); return { status: "fetch-failed", detail: "manifest not JSON: " + e.message }; }
const addons = Array.isArray(manifest?.addons) ? manifest.addons : []; const addons = Array.isArray(manifest?.addons) ? manifest.addons : [];
let best = null; let best = null;
@ -159,26 +159,26 @@ async function stageOne({ id, currentVer, updateURL, stagedDir, pubkeysHex, log,
if (currentVer && cmpVer(e.version, currentVer) <= 0) continue; if (currentVer && cmpVer(e.version, currentVer) <= 0) continue;
if (!best || cmpVer(e.version, best.version) > 0) best = e; if (!best || cmpVer(e.version, best.version) > 0) best = e;
} }
if (!best) return null; if (!best) return { status: "up-to-date" };
if (!verifySignature(id, best.version, best.sha256, best.sig, pubkeysHex)) { if (!verifySignature(id, best.version, best.sha256, best.sig, pubkeysHex)) {
log(`updates: ${id}@${best.version} signature INVALID, skipping`); log(`updates: ${id}@${best.version} signature INVALID, skipping`);
return null; return { status: "signature-invalid", newVer: best.version };
} }
const stageOut = path.join(stagedDir, `${id}-${best.version}`); const stageOut = path.join(stagedDir, `${id}-${best.version}`);
const stagedVer = readAddonJson(stageOut)?.version; const stagedVer = readAddonJson(stageOut)?.version;
if (stagedVer === best.version) { log(`updates: ${id}@${best.version} already staged`); return stageOut; } if (stagedVer === best.version) { log(`updates: ${id}@${best.version} already staged`); return { status: "already-staged", newVer: best.version, stagePath: stageOut }; }
if (fs.existsSync(stageOut)) { try { fs.rmSync(stageOut, { recursive: true, force: true }); } catch {} } if (fs.existsSync(stageOut)) { try { fs.rmSync(stageOut, { recursive: true, force: true }); } catch {} }
let tarball; let tarball;
try { tarball = await httpGet(best.url, { timeoutMs: 60000, maxBytes: MAX_TARBALL_BYTES }); } try { tarball = await httpGet(best.url, { timeoutMs: 60000, maxBytes: MAX_TARBALL_BYTES }); }
catch (e) { log(`updates: tarball ${id}@${best.version} fetch failed:`, e.message); return null; } catch (e) { log(`updates: tarball ${id}@${best.version} fetch failed:`, e.message); return { status: "fetch-failed", newVer: best.version, detail: "tarball: " + e.message }; }
const gotHash = crypto.createHash("sha256").update(tarball).digest("hex"); const gotHash = crypto.createHash("sha256").update(tarball).digest("hex");
if (gotHash.toLowerCase() !== String(best.sha256).toLowerCase()) { if (gotHash.toLowerCase() !== String(best.sha256).toLowerCase()) {
log(`updates: ${id}@${best.version} sha256 mismatch (${gotHash} vs ${best.sha256}), skipping`); log(`updates: ${id}@${best.version} sha256 mismatch (${gotHash} vs ${best.sha256}), skipping`);
return null; return { status: "sha256-mismatch", newVer: best.version };
} }
const tag = `${Date.now()}-${crypto.randomBytes(4).toString("hex")}`; const tag = `${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
@ -203,7 +203,7 @@ async function stageOne({ id, currentVer, updateURL, stagedDir, pubkeysHex, log,
log(`updates: extract ${id}@${best.version} failed:`, e.message); log(`updates: extract ${id}@${best.version} failed:`, e.message);
try { fs.rmSync(tmpFile, { force: true }); } catch {} try { fs.rmSync(tmpFile, { force: true }); } catch {}
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {} try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
return null; return { status: "extract-failed", newVer: best.version, detail: e.message };
} }
const extracted = readAddonJson(tmpDir); const extracted = readAddonJson(tmpDir);
@ -211,43 +211,58 @@ async function stageOne({ id, currentVer, updateURL, stagedDir, pubkeysHex, log,
log(`updates: extracted ${id}@${best.version} manifest mismatch (got ${extracted?.id}@${extracted?.version}), dropping`); log(`updates: extracted ${id}@${best.version} manifest mismatch (got ${extracted?.id}@${extracted?.version}), dropping`);
try { fs.rmSync(tmpFile, { force: true }); } catch {} try { fs.rmSync(tmpFile, { force: true }); } catch {}
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {} try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
return null; return { status: "manifest-mismatch", newVer: best.version, detail: `got ${extracted?.id}@${extracted?.version}` };
} }
try { fs.mkdirSync(stagedDir, { recursive: true }); } catch {} try { fs.mkdirSync(stagedDir, { recursive: true }); } catch {}
try { fs.renameSync(tmpDir, stageOut); } try { fs.renameSync(tmpDir, stageOut); }
catch { catch {
try { fs.cpSync(tmpDir, stageOut, { recursive: true }); fs.rmSync(tmpDir, { recursive: true, force: true }); } try { fs.cpSync(tmpDir, stageOut, { recursive: true }); fs.rmSync(tmpDir, { recursive: true, force: true }); }
catch (ee) { log(`updates: stage move ${id}@${best.version} failed:`, ee.message); return null; } catch (ee) { log(`updates: stage move ${id}@${best.version} failed:`, ee.message); return { status: "extract-failed", newVer: best.version, detail: "stage move: " + ee.message }; }
} }
try { fs.rmSync(tmpFile, { force: true }); } catch {} try { fs.rmSync(tmpFile, { force: true }); } catch {}
log(`updates: staged ${id}@${best.version} — will apply on next launch`); log(`updates: staged ${id}@${best.version} — will apply on next launch`);
return stageOut; return { status: "staged", newVer: best.version, stagePath: stageOut };
} }
// Returns a `report` array: one entry per installed add-on the client
// considered — { id, currentVer, updateURL, status, detail? }. Status is
// one of: "no-update-url" | "fetch-failed" | "up-to-date" | "signature-invalid"
// | "sha256-mismatch" | "extract-failed" | "manifest-mismatch"
// | "staged" | "already-staged". The manual UI in Settings > Extensions
// uses this to tell the user WHY nothing landed instead of a single
// "up to date" that hides fetch/verify failures.
async function checkAndStageUpdates({ addonsDir, stagedDir, pubkeysHex, timeoutMs = 15000, logger }) { async function checkAndStageUpdates({ addonsDir, stagedDir, pubkeysHex, timeoutMs = 15000, logger }) {
const log = logger || (() => {}); const log = logger || (() => {});
const report = [];
if (!Array.isArray(pubkeysHex) || pubkeysHex.length === 0) { if (!Array.isArray(pubkeysHex) || pubkeysHex.length === 0) {
log("updates: no operator pubkeys configured — skipping update check"); log("updates: no operator pubkeys configured — skipping update check");
return; return { report, skipped: "no-pubkeys" };
} }
let entries; let entries;
try { entries = fs.readdirSync(addonsDir, { withFileTypes: true }); } try { entries = fs.readdirSync(addonsDir, { withFileTypes: true }); }
catch { return; } catch { return { report, skipped: "no-addons-dir" }; }
const pending = []; const pending = [];
for (const de of entries) { for (const de of entries) {
if (!de.isDirectory()) continue; if (!de.isDirectory()) continue;
const manifest = readAddonJson(path.join(addonsDir, de.name)); const manifest = readAddonJson(path.join(addonsDir, de.name));
if (!manifest?.id || !manifest?.updateURL) continue; if (!manifest?.id) continue;
if (!manifest?.updateURL) {
report.push({ id: manifest.id, currentVer: manifest.version, updateURL: null, status: "no-update-url" });
continue;
}
pending.push( pending.push(
stageOne({ stageOne({
id: manifest.id, id: manifest.id,
currentVer: manifest.version, currentVer: manifest.version,
updateURL: manifest.updateURL, updateURL: manifest.updateURL,
stagedDir, pubkeysHex, log, timeoutMs, stagedDir, pubkeysHex, log, timeoutMs,
}).catch((e) => { log(`updates: ${manifest.id} unexpected error:`, e.message); return null; }) })
.then((r) => report.push({ id: manifest.id, currentVer: manifest.version, updateURL: manifest.updateURL, ...r }))
.catch((e) => { log(`updates: ${manifest.id} unexpected error:`, e.message); report.push({ id: manifest.id, currentVer: manifest.version, updateURL: manifest.updateURL, status: "unexpected-error", detail: e.message }); })
); );
} }
await Promise.all(pending); await Promise.all(pending);
return { report };
} }
module.exports = { module.exports = {

View file

@ -2693,15 +2693,19 @@ ipcMain.handle("addons-open-dir", () => {
// staged dir so the UI can render "Update to <ver> — restart to apply". // staged dir so the UI can render "Update to <ver> — restart to apply".
ipcMain.handle("addons-check-updates", async () => { ipcMain.handle("addons-check-updates", async () => {
const stagedDir = addonsStagedDir(); const stagedDir = addonsStagedDir();
let report = [];
let skipped = null;
try { try {
await addonUpdater.checkAndStageUpdates({ const result = await addonUpdater.checkAndStageUpdates({
addonsDir: addonsUserDir(), addonsDir: addonsUserDir(),
stagedDir, stagedDir,
pubkeysHex: ADDON_UPDATE_PUBKEYS, pubkeysHex: ADDON_UPDATE_PUBKEYS,
logger: (...a) => console.log("[addons]", ...a), logger: (...a) => console.log("[addons]", ...a),
}); });
report = result?.report || [];
skipped = result?.skipped || null;
} catch (e) { console.warn("[addons] check-updates failed:", e?.message || e); } } catch (e) { console.warn("[addons] check-updates failed:", e?.message || e); }
return listStagedAddons(stagedDir); return { report, skipped, staged: listStagedAddons(stagedDir) };
}); });
ipcMain.handle("addons-list-staged", () => listStagedAddons(addonsStagedDir())); ipcMain.handle("addons-list-staged", () => listStagedAddons(addonsStagedDir()));
function listStagedAddons(stagedDir) { function listStagedAddons(stagedDir) {

View file

@ -1260,13 +1260,40 @@
const btn = document.getElementById("addonsCheckUpdates"); const btn = document.getElementById("addonsCheckUpdates");
const status = document.getElementById("addonsUpdStatus"); const status = document.getElementById("addonsUpdStatus");
btn.disabled = true; const orig = btn.textContent; btn.textContent = "Checking…"; btn.disabled = true; const orig = btn.textContent; btn.textContent = "Checking…";
status.textContent = ""; status.innerHTML = "";
try { try {
const staged = await C.checkAddonUpdates(); const res = await C.checkAddonUpdates();
if (!staged || !staged.length) { // Back-compat: some callers still pass a bare array. Normalize.
status.textContent = "All extensions are up to date."; const report = Array.isArray(res) ? [] : (res?.report || []);
} else { const skipped = Array.isArray(res) ? null : (res?.skipped || null);
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."; 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;
} }
await loadStagedAddonUpdates(); await loadStagedAddonUpdates();
} catch (e) { } catch (e) {