From 8055d39a9dfaa4ace387f63c03f5a8252a7a0c6d Mon Sep 17 00:00:00 2001 From: Local Dev Date: Tue, 8 Sep 2026 18:14:07 +0200 Subject: [PATCH] feat(theseus/addons): per-add-on diagnostic report from checkAndStageUpdates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- addon-updater.js | 45 ++++++++++++++++++++++++++++++--------------- main.js | 8 ++++++-- settings.html | 37 ++++++++++++++++++++++++++++++++----- 3 files changed, 68 insertions(+), 22 deletions(-) diff --git a/addon-updater.js b/addon-updater.js index 12c4bad..fd85966 100644 --- a/addon-updater.js +++ b/addon-updater.js @@ -146,11 +146,11 @@ function verifySignature(id, version, tarballSha256, sigB64, pubkeysHex) { async function stageOne({ id, currentVer, updateURL, stagedDir, pubkeysHex, log, timeoutMs }) { let manifestBuf; 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; 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 : []; 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 (!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)) { 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 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 {} } let tarball; 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"); if (gotHash.toLowerCase() !== String(best.sha256).toLowerCase()) { 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")}`; @@ -203,7 +203,7 @@ async function stageOne({ id, currentVer, updateURL, stagedDir, pubkeysHex, log, log(`updates: extract ${id}@${best.version} failed:`, e.message); try { fs.rmSync(tmpFile, { 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); @@ -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`); try { fs.rmSync(tmpFile, { 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.renameSync(tmpDir, stageOut); } catch { 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 {} 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 }) { const log = logger || (() => {}); + const report = []; if (!Array.isArray(pubkeysHex) || pubkeysHex.length === 0) { log("updates: no operator pubkeys configured — skipping update check"); - return; + return { report, skipped: "no-pubkeys" }; } let entries; try { entries = fs.readdirSync(addonsDir, { withFileTypes: true }); } - catch { return; } + catch { return { report, skipped: "no-addons-dir" }; } const pending = []; for (const de of entries) { if (!de.isDirectory()) continue; 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( stageOne({ id: manifest.id, currentVer: manifest.version, updateURL: manifest.updateURL, 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); + return { report }; } module.exports = { diff --git a/main.js b/main.js index 543d35a..df22655 100644 --- a/main.js +++ b/main.js @@ -2693,15 +2693,19 @@ ipcMain.handle("addons-open-dir", () => { // staged dir so the UI can render "Update to — restart to apply". ipcMain.handle("addons-check-updates", async () => { const stagedDir = addonsStagedDir(); + let report = []; + let skipped = null; try { - await addonUpdater.checkAndStageUpdates({ + const result = await addonUpdater.checkAndStageUpdates({ addonsDir: addonsUserDir(), stagedDir, pubkeysHex: ADDON_UPDATE_PUBKEYS, logger: (...a) => console.log("[addons]", ...a), }); + report = result?.report || []; + skipped = result?.skipped || null; } 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())); function listStagedAddons(stagedDir) { diff --git a/settings.html b/settings.html index 2bb795a..4edf5cb 100644 --- a/settings.html +++ b/settings.html @@ -1260,13 +1260,40 @@ const btn = document.getElementById("addonsCheckUpdates"); const status = document.getElementById("addonsUpdStatus"); btn.disabled = true; const orig = btn.textContent; btn.textContent = "Checking…"; - status.textContent = ""; + status.innerHTML = ""; try { - const staged = await C.checkAddonUpdates(); - if (!staged || !staged.length) { - status.textContent = "All extensions are up to date."; - } else { + const res = await C.checkAddonUpdates(); + // Back-compat: some callers still pass a bare array. Normalize. + const report = Array.isArray(res) ? [] : (res?.report || []); + 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."; + } 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 '
' + label + ' — ' + msg + '
'; + }).join(""); + status.innerHTML = rows; } await loadStagedAddonUpdates(); } catch (e) {