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:
parent
b5f1cf468b
commit
8055d39a9d
3 changed files with 68 additions and 22 deletions
|
|
@ -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 = {
|
||||||
|
|
|
||||||
8
main.js
8
main.js
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue