diff --git a/addon-updater.js b/addon-updater.js index f89fce5..b88de1f 100644 --- a/addon-updater.js +++ b/addon-updater.js @@ -143,47 +143,54 @@ function verifySignature(id, version, tarballSha256, sigB64, pubkeysHex) { } // ---------- single-add-on staging ---------------------------------------- -async function stageOne({ id, currentVer, updateURL, stagedDir, pubkeysHex, log, timeoutMs }) { +// Read a channel manifest and pick its best entry. An entry is trusted when +// EITHER the operator signed it (Ed25519 `sig`, bundled add-ons) OR the +// publisher signed it (`publisherSig`, community extensions — verified by the +// caller-supplied verifyPublisher against the publisher name's NFT owner). +async function pickChannelEntry({ id, currentVer, updateURL, pubkeysHex, verifyPublisher, log, timeoutMs }) { let manifestBuf; try { manifestBuf = await httpGet(updateURL, { timeoutMs, maxBytes: MAX_MANIFEST_BYTES }); } 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 { status: "fetch-failed", detail: "manifest not JSON: " + e.message }; } - const addons = Array.isArray(manifest?.addons) ? manifest.addons : []; let best = null; for (const e of addons) { - if (!e?.version || !e?.url || !e?.sha256 || !e?.sig) continue; + if (!e?.version || !e?.url || !e?.sha256 || !(e?.sig || e?.publisherSig)) continue; if (currentVer && cmpVer(e.version, currentVer) <= 0) continue; if (!best || cmpVer(e.version, best.version) > 0) best = e; } if (!best) return { status: "up-to-date" }; - - if (!verifySignature(id, best.version, best.sha256, best.sig, pubkeysHex)) { + let trusted = false; + if (best.sig && Array.isArray(pubkeysHex) && pubkeysHex.length) trusted = verifySignature(id, best.version, best.sha256, best.sig, pubkeysHex); + if (!trusted && best.publisherSig && typeof verifyPublisher === "function") { + try { trusted = !!(await verifyPublisher({ ...best, id })); } catch (e) { log(`updates: publisher verify ${id}@${best.version} threw:`, e.message); } + } + if (!trusted) { log(`updates: ${id}@${best.version} signature INVALID, skipping`); return { status: "signature-invalid", newVer: best.version }; } + return { status: "ok", best }; +} - 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 { status: "already-staged", newVer: best.version, stagePath: stageOut }; } - if (fs.existsSync(stageOut)) { try { fs.rmSync(stageOut, { recursive: true, force: true }); } catch {} } - +// Download a package, check its sha256 against the signed value, and extract +// it to a temp dir whose addon.json must match id + version. Returns +// { ok, tmpDir, tmpFile } or { status, detail }. The caller moves tmpDir. +async function fetchVerifiedPackage({ id, version, url, sha256, log }) { 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 { status: "fetch-failed", newVer: best.version, detail: "tarball: " + e.message }; } + try { tarball = await httpGet(url, { timeoutMs: 60000, maxBytes: MAX_TARBALL_BYTES }); } + catch (e) { log(`updates: tarball ${id}@${version} fetch failed:`, e.message); return { status: "fetch-failed", newVer: 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 { status: "sha256-mismatch", newVer: best.version }; + if (gotHash.toLowerCase() !== String(sha256).toLowerCase()) { + log(`updates: ${id}@${version} sha256 mismatch (${gotHash} vs ${sha256}), skipping`); + return { status: "sha256-mismatch", newVer: version }; } const tag = `${Date.now()}-${crypto.randomBytes(4).toString("hex")}`; - const tmpFile = path.join(os.tmpdir(), `sm-addon-${id}-${best.version}-${tag}.tgz`); - const tmpDir = path.join(os.tmpdir(), `sm-addon-${id}-${best.version}-${tag}.dir`); + const tmpFile = path.join(os.tmpdir(), `sm-addon-${id}-${version}-${tag}.tgz`); + const tmpDir = path.join(os.tmpdir(), `sm-addon-${id}-${version}-${tag}.dir`); try { await fsp.writeFile(tmpFile, tarball); await fsp.mkdir(tmpDir, { recursive: true }); @@ -216,30 +223,97 @@ async function stageOne({ id, currentVer, updateURL, stagedDir, pubkeysHex, log, } } } catch (e) { - log(`updates: extract ${id}@${best.version} failed:`, e.message); + log(`updates: extract ${id}@${version} failed:`, e.message); try { fs.rmSync(tmpFile, { force: true }); } catch {} try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {} - return { status: "extract-failed", newVer: best.version, detail: e.message }; + return { status: "extract-failed", newVer: version, detail: e.message }; } - const extracted = readAddonJson(tmpDir); - if (!extracted || extracted.id !== id || extracted.version !== best.version) { - log(`updates: extracted ${id}@${best.version} manifest mismatch (got ${extracted?.id}@${extracted?.version}), dropping`); + // A package may wrap everything in one top-level folder (tar -czf x.tgz my-ext); + // unwrap it so addon.json sits at the root like the bundled add-ons. + let root = tmpDir; + if (!readAddonJson(root)) { + const kids = fs.readdirSync(root, { withFileTypes: true }).filter((d) => !d.name.startsWith(".")); + if (kids.length === 1 && kids[0].isDirectory() && readAddonJson(path.join(root, kids[0].name))) root = path.join(root, kids[0].name); + } + const extracted = readAddonJson(root); + if (!extracted || extracted.id !== id || extracted.version !== version) { + log(`updates: extracted ${id}@${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 { status: "manifest-mismatch", newVer: best.version, detail: `got ${extracted?.id}@${extracted?.version}` }; + return { status: "manifest-mismatch", newVer: version, detail: `got ${extracted?.id}@${extracted?.version}` }; } + return { ok: true, tmpDir: root, tmpTop: tmpDir, tmpFile, manifest: extracted }; +} + +// Move an extracted package into place (rename, with copy fallback across volumes). +function placeDir(from, to) { + try { fs.renameSync(from, to); } + catch { fs.cpSync(from, to, { recursive: true }); fs.rmSync(from, { recursive: true, force: true }); } +} + +async function stageOne({ id, currentVer, updateURL, stagedDir, pubkeysHex, verifyPublisher, log, timeoutMs }) { + const picked = await pickChannelEntry({ id, currentVer, updateURL, pubkeysHex, verifyPublisher, log, timeoutMs }); + if (picked.status !== "ok") return picked; + const best = picked.best; + + 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 { status: "already-staged", newVer: best.version, stagePath: stageOut }; } + if (fs.existsSync(stageOut)) { try { fs.rmSync(stageOut, { recursive: true, force: true }); } catch {} } + + const pkg = await fetchVerifiedPackage({ id, version: best.version, url: best.url, sha256: best.sha256, log }); + if (!pkg.ok) return pkg; 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 { status: "extract-failed", newVer: best.version, detail: "stage move: " + ee.message }; } - } - try { fs.rmSync(tmpFile, { force: true }); } catch {} + try { placeDir(pkg.tmpDir, stageOut); } + 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(pkg.tmpTop, { recursive: true, force: true }); } catch {} + try { fs.rmSync(pkg.tmpFile, { force: true }); } catch {} log(`updates: staged ${id}@${best.version} — will apply on next launch`); return { status: "staged", newVer: best.version, stagePath: stageOut }; } +// ---------- community install (theseus.x/extensions) --------------------- +// Install or update one community extension straight into addonsDir from +// its channel manifest. Trust is the publisher's signature (verifyPublisher +// checks it against the name's NFT owner); a prior copy is kept in backupsDir +// like every other add-on swap. The installed addon.json gets `updateURL` +// and `publisher` so the regular update check covers it from then on. +async function installCommunity({ id, updatesUrl, addonsDir, backupsDir, verifyPublisher, log = () => {}, timeoutMs = 15000 }) { + if (typeof verifyPublisher !== "function") return { ok: false, error: "no publisher verifier" }; + const dest = path.join(addonsDir, id); + const currentVer = readAddonJson(dest)?.version || null; + const picked = await pickChannelEntry({ id, currentVer, updateURL: updatesUrl, pubkeysHex: [], verifyPublisher, log, timeoutMs }); + if (picked.status === "up-to-date") return { ok: false, error: currentVer ? `already installed (v${currentVer})` : "channel has no installable version" }; + if (picked.status !== "ok") return { ok: false, error: picked.status + (picked.detail ? ": " + picked.detail : ""), status: picked.status }; + const best = picked.best; + const pkg = await fetchVerifiedPackage({ id, version: best.version, url: best.url, sha256: best.sha256, log }); + if (!pkg.ok) return { ok: false, error: pkg.status + (pkg.detail ? ": " + pkg.detail : ""), status: pkg.status }; + try { + // Record where updates come from and who signed this copy. + const mf = { ...pkg.manifest }; + if (!mf.updateURL) mf.updateURL = updatesUrl; + mf.publisher = best.publisher; + fs.writeFileSync(path.join(pkg.tmpDir, "addon.json"), JSON.stringify(mf, null, 2)); + fs.mkdirSync(addonsDir, { recursive: true }); + if (fs.existsSync(dest)) { + fs.mkdirSync(backupsDir, { recursive: true }); + const backup = path.join(backupsDir, `${id}-${currentVer || "unknown"}-${Date.now()}`); + placeDir(dest, backup); + log(`community: previous ${id} moved to ${backup}`); + } + placeDir(pkg.tmpDir, dest); + } catch (e) { + try { fs.rmSync(pkg.tmpTop, { recursive: true, force: true }); } catch {} + return { ok: false, error: "install: " + e.message }; + } finally { + try { fs.rmSync(pkg.tmpTop, { recursive: true, force: true }); } catch {} + try { fs.rmSync(pkg.tmpFile, { force: true }); } catch {} + } + log(`community: installed ${id}@${best.version} signed by ${best.publisher}`); + return { ok: true, id, version: best.version, publisher: best.publisher, previous: currentVer }; +} + // 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" @@ -247,10 +321,10 @@ async function stageOne({ id, currentVer, updateURL, stagedDir, pubkeysHex, log, // | "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, verifyPublisher, timeoutMs = 15000, logger }) { const log = logger || (() => {}); const report = []; - if (!Array.isArray(pubkeysHex) || pubkeysHex.length === 0) { + if ((!Array.isArray(pubkeysHex) || pubkeysHex.length === 0) && typeof verifyPublisher !== "function") { log("updates: no operator pubkeys configured — skipping update check"); return { report, skipped: "no-pubkeys" }; } @@ -271,7 +345,7 @@ async function checkAndStageUpdates({ addonsDir, stagedDir, pubkeysHex, timeoutM id: manifest.id, currentVer: manifest.version, updateURL: manifest.updateURL, - stagedDir, pubkeysHex, log, timeoutMs, + stagedDir, pubkeysHex, verifyPublisher, log, timeoutMs, }) .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 }); }) @@ -285,6 +359,7 @@ module.exports = { cmpVer, promoteStagedUpdates, checkAndStageUpdates, + installCommunity, verifySignature, SIG_DOMAIN, }; diff --git a/lib/publisher-sig.mjs b/lib/publisher-sig.mjs new file mode 100644 index 0000000..921df65 --- /dev/null +++ b/lib/publisher-sig.mjs @@ -0,0 +1,44 @@ +// Publisher signatures on community extensions. +// +// A community extension's updates.json entry carries `publisherSig`: a +// 65-byte BCH message signature (base64) over +// sha256("silentmode.extension-v1||||") +// made with the key that holds the publisher name's NFT. The gateway checks +// it before storing the entry; Theseus checks it AGAIN against the owner it +// reads from its own chain index, so a tampered catalog or a compromised +// relay cannot hand the browser someone else's code under a trusted name. +// +// ESM because @bitauth/libauth is ESM-only; main.js loads it with import(). +import { secp256k1, sha256, ripemd160, encodeCashAddress, base64ToBin, utf8ToBin } from "@bitauth/libauth"; + +export const EXT_SIG_DOMAIN = "silentmode.extension-v1"; + +export function entryMessage({ id, version, sha256: tarballSha256, publisher }) { + return `${EXT_SIG_DOMAIN}|${id}|${version}|${String(tarballSha256).toLowerCase()}|${publisher}`; +} + +// Recover the signer of `message` and return it as a CashAddress with the +// same prefix as `likeAddress` (bitcoincash: / bchtest:). null on any error. +export function recoverSigner(message, signatureBase64, likeAddress) { + try { + const sig = base64ToBin(String(signatureBase64 || "").trim()); + if (!(sig instanceof Uint8Array) || sig.length !== 65) return null; + const header = sig[0]; + if (header < 27 || header > 34) return null; + const digest = sha256.hash(utf8ToBin(message)); + const recover = header >= 31 ? secp256k1.recoverPublicKeyCompressed : secp256k1.recoverPublicKeyUncompressed; + const pub = recover(sig.slice(1), (header - 27) & 3, digest); + if (typeof pub === "string") return null; + const prefix = String(likeAddress || "bitcoincash:").split(":")[0] || "bitcoincash"; + const enc = encodeCashAddress({ prefix, type: "p2pkh", payload: ripemd160.hash(sha256.hash(pub)) }); + return typeof enc === "string" ? enc : (enc?.address ?? null); + } catch { return null; } +} + +// True when the entry was signed by `ownerAddress` (the publisher name's +// current NFT holder as the caller's own index reports it). +export function verifyPublisherEntry(entry, ownerAddress) { + if (!entry || !ownerAddress || !entry.publisherSig) return false; + const who = recoverSigner(entryMessage(entry), entry.publisherSig, ownerAddress); + return !!who && who === ownerAddress; +} diff --git a/main.js b/main.js index 658f213..3c8b677 100644 --- a/main.js +++ b/main.js @@ -3647,6 +3647,63 @@ ipcMain.handle("addons-open-dir", () => { // Manual "Check for updates" from Settings > Extensions. Runs the same // checkAndStageUpdates the boot timer runs; returns a snapshot of the // staged dir so the UI can render "Update to — restart to apply". +// ---- community extensions (theseus.x/extensions) ---------------------------- +// Anyone who owns a BNS name can publish an extension through the gateway +// (PUT /api/ext///); the catalog and every package live +// on Sia and are served through the public relay. Trust: each channel entry +// carries the publisher's BCH signature over id|version|sha256|publisher. +// Before installing or updating, Theseus recovers the signer and compares +// it with the name's current NFT owner from ITS OWN chain index — so neither +// the relay nor a tampered catalog can slip in code under a trusted name. +const COMMUNITY_CATALOG_URL = "https://navigate.st/api/ext/catalog"; +let publisherSigLib = null; +async function getPublisherSig() { + if (!publisherSigLib) publisherSigLib = await import(`file://${path.join(__dirname, "lib", "publisher-sig.mjs").replace(/\\/g, "/")}`); + return publisherSigLib; +} +async function verifyPublisherEntry(entry) { + try { + const name = String(entry?.publisher || "").toLowerCase(); + if (!name) return false; + const owner = (await resolveHost(name))?.owner; + if (!owner) { console.warn(`[addons] publisher ${name}: owner unknown to the local index`); return false; } + const lib = await getPublisherSig(); + const ok = lib.verifyPublisherEntry(entry, owner); + if (!ok) console.warn(`[addons] publisher signature for ${entry.id}@${entry.version} does not match ${name}'s owner`); + return ok; + } catch (e) { console.warn("[addons] publisher verify failed:", e?.message); return false; } +} +async function fetchCommunityCatalog() { + const r = await fetch(COMMUNITY_CATALOG_URL, { signal: AbortSignal.timeout(15000), cache: "no-store" }); + if (!r.ok) throw new Error(`catalog HTTP ${r.status}`); + const j = await r.json(); + return Array.isArray(j?.extensions) ? j.extensions : []; +} +ipcMain.handle("addons-community-catalog", async () => { + try { + const list = await fetchCommunityCatalog(); + const installed = addonHost ? addonHost.snapshot().installed : []; + return { ok: true, extensions: list.map((e) => { + const cur = installed.find((a) => a.id === e.id); + return { ...e, installedVersion: cur ? cur.version : null, canUpdate: !!(cur && addonUpdater.cmpVer(e.latest, cur.version) > 0) }; + }) }; + } catch (e) { return { ok: false, error: e?.message || String(e), extensions: [] }; } +}); +ipcMain.handle("addons-install-community", async (_e, id) => { + if (typeof id !== "string" || !/^[a-z0-9][a-z0-9._-]{1,63}$/.test(id)) return { ok: false, error: "bad id" }; + try { + const card = (await fetchCommunityCatalog()).find((e) => e.id === id); + if (!card) return { ok: false, error: "not in the catalog" }; + const r = await addonUpdater.installCommunity({ + id, updatesUrl: card.updatesUrl, + addonsDir: addonsUserDir(), backupsDir: addonsBackupDir(), + verifyPublisher: verifyPublisherEntry, + log: (...a) => console.log("[addons]", ...a), + }); + if (r.ok && addonHost) addonHost.discoverAndActivate(); + return r; + } catch (e) { return { ok: false, error: e?.message || String(e) }; } +}); ipcMain.handle("addons-check-updates", async () => { const stagedDir = addonsStagedDir(); let report = []; @@ -3656,6 +3713,7 @@ ipcMain.handle("addons-check-updates", async () => { addonsDir: addonsUserDir(), stagedDir, pubkeysHex: ADDON_UPDATE_PUBKEYS, + verifyPublisher: verifyPublisherEntry, logger: (...a) => console.log("[addons]", ...a), }); report = result?.report || []; @@ -5478,6 +5536,7 @@ if (!process.env.THESEUS_NO_AUTOSTART) { addonsDir: addonsUserDir(), stagedDir: addonsStagedDir(), pubkeysHex: ADDON_UPDATE_PUBKEYS, + verifyPublisher: verifyPublisherEntry, logger: (...a) => console.log("[addons]", ...a), }).catch(() => {}); }, 30_000); diff --git a/settings-preload.js b/settings-preload.js index 20babb6..0466da9 100644 --- a/settings-preload.js +++ b/settings-preload.js @@ -47,6 +47,10 @@ contextBridge.exposeInMainWorld("cfg", { revealAddon: (folder) => ipcRenderer.invoke("addons-reveal", folder), openAddonsDir: () => ipcRenderer.invoke("addons-open-dir"), reloadAddons: () => ipcRenderer.invoke("addons-reload"), + // Community extensions from theseus.x/extensions: the catalog (with what + // is installed already) and a verified install/update of one entry. + communityCatalog: () => ipcRenderer.invoke("addons-community-catalog"), + installCommunity: (id) => ipcRenderer.invoke("addons-install-community", id), checkAddonUpdates: () => ipcRenderer.invoke("addons-check-updates"), listStagedAddonUpdates: () => ipcRenderer.invoke("addons-list-staged"), }); diff --git a/settings.html b/settings.html index e6cd70c..7e1446c 100644 --- a/settings.html +++ b/settings.html @@ -243,6 +243,7 @@ @@ -1326,6 +1333,39 @@ btn.addEventListener("click", () => C.revealAddon(btn.dataset.reveal)); }); } + // ---- community catalog (theseus.x/extensions) ---- + const communityList = document.getElementById("communityList"); + async function loadCommunity() { + communityList.innerHTML = '
Loading…
'; + let r; + try { r = await C.communityCatalog(); } catch (e) { r = { ok: false, error: e?.message || String(e), extensions: [] }; } + if (!r.ok) { communityList.innerHTML = '
Catalog unavailable: ' + escapeHtml(r.error) + '
'; return; } + if (!r.extensions.length) { communityList.innerHTML = '
Nothing published yet. Be the first — see theseus.x/extensions.
'; return; } + communityList.innerHTML = r.extensions.map((e) => { + const state = e.installedVersion + ? (e.canUpdate ? '' + : 'Installed v' + escapeHtml(e.installedVersion) + '') + : ''; + return '
' + escapeHtml(e.icon || "🧩") + ' ' + escapeHtml(e.name) + + ' v' + escapeHtml(e.latest) + '
' + + '
' + escapeHtml(e.description || "") + '
' + + '
Published by ' + escapeHtml(e.publisher) + ' · ' + escapeHtml(String(e.versions || 1)) + ' version' + (e.versions === 1 ? "" : "s") + + (e.capabilities && e.capabilities.length ? ' · ' + escapeHtml(e.capabilities.join(", ")) : "") + '
' + + '
' + state + '
'; + }).join(""); + communityList.querySelectorAll("button[data-install]").forEach((btn) => { + btn.addEventListener("click", async () => { + const id = btn.dataset.install; + const status = communityList.querySelector('[data-status="' + CSS.escape(id) + '"]'); + btn.disabled = true; const orig = btn.textContent; btn.textContent = "Verifying…"; + let res; + try { res = await C.installCommunity(id); } catch (e) { res = { ok: false, error: e?.message || String(e) }; } + if (res && res.ok) { status.style.color = "var(--acid)"; status.textContent = "✓ Installed v" + res.version + " (signed by " + res.publisher + ")"; loadAddons(); loadCommunity(); } + else { status.style.color = "#f6768a"; status.textContent = "✗ " + (res?.error || "install failed"); btn.disabled = false; btn.textContent = orig; } + }); + }); + } + document.getElementById("communityRefresh").addEventListener("click", loadCommunity); function escapeHtml(s) { return String(s || "").replace(/[&<>"']/g, (c) => ({ "&":"&","<":"<",">":">",'"':""","'":"'" })[c]); } function escapeAttr(s) { return escapeHtml(s); } async function loadAddons() { @@ -1346,7 +1386,7 @@ }); document.getElementById("addonsOpenDir").addEventListener("click", () => C.openAddonsDir()); document.querySelector('.side a[data-sec="addons"]').addEventListener("click", async () => { - await loadAddonUpdates(); loadAddons(); + await loadAddonUpdates(); loadAddons(); loadCommunity(); }); // 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 @@ -1402,7 +1442,7 @@ } }); // Populate on first paint so the tab is ready when the user clicks in. - (async () => { await loadAddonUpdates(); loadAddons(); })(); + (async () => { await loadAddonUpdates(); loadAddons(); loadCommunity(); })();