From 057b41a11aa87f993ab245a2277359e488a8fdda Mon Sep 17 00:00:00 2001 From: Jonathan Bursztyn Date: Sun, 9 Aug 2026 18:57:54 +0200 Subject: [PATCH 1/3] feat(preview): check and install newer preview builds at startup Preview installs are self-hosted (update_url feeds -> GitHub release artifacts), but peerd's offscreen keepalive holds the MV3 SW alive, which is exactly the state where Chrome parks a downloaded extension update forever. background/update-check.js closes that gap: Chrome forces the update_url poll at boot and reloads when a downloaded update can apply with nothing live (no turn or goal run, no UI port, no engine tab, no other extension page), re-attempting when a surface closes; Firefox (no requestUpdateCheck API) reads the gecko feed and offers the XPI in a notice, persisted in storage.session so it survives event-page respawns. The onUpdateAvailable listener registers ONLY on self-hosted manifests: on Firefox a listener's mere presence defers every add-on update until reload()/browser restart, so a listener on the store package would break AMO's automatic updates; a disabled toggle there restores the no-listener default (apply immediately). Feed update_links are pinned to github.com/peerd.ai and version strings shape-checked before they reach the notice. Setting: autoUpdateEnabled, a preview-only channel key (absent from store CHANNEL_DEFAULTS, patch-gated on key presence), default ON, with an Auto-update toggle in Settings -> Behavior. Dev (load-unpacked) has no update_url so every path is a structural no-op there. The side panel's NoticeBar gains a generic https-only open-url action. Signed-off-by: Jonathan Bursztyn --- extension/background/routes/settings.js | 3 + extension/background/service-worker.js | 73 +++++ extension/background/settings-patch.js | 8 + extension/background/update-check.js | 351 ++++++++++++++++++++++++ extension/options/sections/behavior.js | 25 +- extension/shared/channel-config.js | 1 + extension/sidepanel/components/app.js | 13 +- packaging/check-tscheck.ts | 2 +- packaging/default-settings.mjs | 12 + tests/background/settings-patch.test.ts | 11 + tests/background/update-check.test.ts | 342 +++++++++++++++++++++++ 11 files changed, 838 insertions(+), 3 deletions(-) create mode 100644 extension/background/update-check.js create mode 100644 tests/background/update-check.test.ts diff --git a/extension/background/routes/settings.js b/extension/background/routes/settings.js index c410e698..f396a87e 100644 --- a/extension/background/routes/settings.js +++ b/extension/background/routes/settings.js @@ -34,6 +34,9 @@ export const makeSettingsRoutes = (deps) => { knownProviderNames: listProviders().map((/** @type {{ name: string }} */ p) => p.name), reasoningEffortLevels: REASONING_EFFORT_LEVELS, dwebEnabled: DWEB_ENABLED, + // Preview-only key: its presence in this package's defaults IS the + // channel gate (no separate build flag, unlike dweb). + autoUpdateAvailable: Object.hasOwn(DEFAULT_SETTINGS, 'autoUpdateEnabled'), normalizeVariant, normalizeEngine, }); diff --git a/extension/background/service-worker.js b/extension/background/service-worker.js index f3191881..40736929 100644 --- a/extension/background/service-worker.js +++ b/extension/background/service-worker.js @@ -439,6 +439,7 @@ import { makeModelCatalog } from './model-catalog.js'; import { makeTabAffordances } from './tab-affordances.js'; import { makeMintOnce } from './mint-once.js'; import { makeDwebInboundRateCap } from './dweb-inbound-rate-cap.js'; +import { makeUpdateCheck } from './update-check.js'; import { makeDwebTransfer, IdentityTransferError } from './dweb-transfer.js'; import { makeDwebShare } from './dweb-share.js'; import { makeReseedSharedApps } from './dweb-reseed.js'; @@ -4364,9 +4365,16 @@ browser.runtime.onConnect.addListener((port) => { for (const ev of (goalRunner?.activeStates?.() ?? [])) { try { port.postMessage(ev); } catch { /* port closing */ } } + // Replay an undelivered "update available" notice to this fresh surface + // and re-check for a newer preview build (throttled inside; declared at + // the boot tail - connect events only ever fire after module eval). + updateCheck.onUiConnect(); port.onDisconnect.addListener(() => { uiPorts.remove(port); broadcastSurfaces(); + // A parked downloaded update may now be able to apply (the module + // re-checks that everything is quiet before reloading). + updateCheck.onQuiet(); // Sidebar just closed → if the user is sitting on a peerd-opened web tab, // surface the reminder (and start its 15s timer) right then. if (port.name === 'sidepanel' && !uiPorts.hasNamed('sidepanel')) { @@ -7415,6 +7423,71 @@ void dwebSettingsGate.stopWhenDisabled(stopBaseNetwork).catch((error) => { console.warn('[sw] dweb OFF reconciliation failed; next boot will retry', error); }); +// Self-update (preview channel; background/update-check.js). Chrome: force the +// update_url poll at boot and reload when a downloaded update can apply +// without destroying live work. Firefox: read the gecko feed and offer the +// XPI in a notice. Dev/store manifests carry no self-hosted update_url, so +// start() registers nothing there (load-bearing on Firefox - a mere +// onUpdateAvailable listener defers AMO's automatic updates). start() runs +// synchronously at boot - a downloaded update can be the event that wakes +// this worker. +const updateCheck = makeUpdateCheck({ + runtime: browser.runtime, + // why a bare fetch: a chassis-internal DATA fetch of the manifest's own + // update feed - same class as the voice model download; no secret, no + // agent influence over the URL (see update-check.js's header). + fetchFn: (url, init) => fetch(url, init), + ready: settingsReady, + isEnabled: () => settingsStore.get().autoUpdateEnabled === true, + // "peerd is doing work": live turn slots AND goal runs - a goal run holds + // its slot only while an individual turn is in flight, so between + // iterations busySessionIds() alone reads idle mid-run. + busy: () => turnSlots.busySessionIds().length > 0 + || (goalRunner?.activeStates?.().length ?? 0) > 0, + // "a user-facing extension page exists": UI ports, the deliberately + // PORTLESS engine tabs (a running WebVM / notebook / app holds real + // in-memory state a reload would destroy), and - on Chrome, where the SW + // can enumerate its window clients - any other extension page (options, + // permission pages). The offscreen doc is excluded: the keepalive keeps + // it open always, and counting it would block the reload forever. + surfacesOpen: async () => { + if (uiConnected()) return true; + if (vmTabTracker.listLive().length > 0 + || jsTabTracker.listLive().length > 0 + || appTabTracker.listLive().length > 0) return true; + try { + // why the cast: tsconfig lib is DOM (one program checks SW, pages and + // tests alike), so the ServiceWorkerGlobalScope clients API isn't on + // `self`'s type; it exists at runtime only in the Chrome SW. + const swScope = /** @type {{ clients?: { matchAll?: (q: { type: string }) => Promise> } }} */ ( + /** @type {unknown} */ (globalThis)); + const windowClients = await swScope.clients?.matchAll?.({ type: 'window' }); + if (windowClients?.some((c) => !c.url.includes('/offscreen/'))) return true; + } catch { /* not a SW context (Firefox event page) - covered above */ } + return false; + }, + notify: (text, action) => { + if (!uiConnected()) return false; + postChatNote(text, action ?? null); + return true; + }, + // storage.session, not storage.local: the throttle + pending-notice state + // must survive SW/event-page respawns but reset with the browser session. + sessionKv: { + get: async (key) => { + try { return (await browser.storage?.session?.get(key))?.[key]; } + catch { return undefined; } + }, + set: async (key, value) => { + try { await browser.storage?.session?.set({ [key]: value }); } + catch { /* best-effort - a lost throttle just means an extra check */ } + }, + }, + log: (...args) => console.log('[sw]', ...args), +}); +updateCheck.start(); +void updateCheck.checkNow('boot').catch(() => {}); + // SW boot logging — we want a clear timeline of when the SW comes up // (cold start, extension reload, idle respawn). The console clears // when the SW dies, so each fresh boot starts a new transcript. diff --git a/extension/background/settings-patch.js b/extension/background/settings-patch.js index 4448d968..de75f13e 100644 --- a/extension/background/settings-patch.js +++ b/extension/background/settings-patch.js @@ -27,6 +27,7 @@ * knownProviderNames: string[], * reasoningEffortLevels: readonly string[], * dwebEnabled: boolean, + * autoUpdateAvailable: boolean, * normalizeVariant: (v: string) => string, * normalizeEngine: (v: string) => string, * }} deps @@ -36,6 +37,7 @@ export const normalizeSettingsPatch = (patch, { knownProviderNames, reasoningEffortLevels, dwebEnabled, + autoUpdateAvailable, normalizeVariant, normalizeEngine, }) => { @@ -232,6 +234,12 @@ export const normalizeSettingsPatch = (patch, { if (dwebEnabled && typeof patch.dwebAgentEnabled === 'boolean') { next.dwebAgentEnabled = patch.dwebAgentEnabled; } + // Self-update check (preview packages only). Same two-layer posture as the + // dweb keys: the key is absent from other channels' CHANNEL_DEFAULTS AND + // the patch is refused where the package doesn't carry it. + if (autoUpdateAvailable && typeof patch.autoUpdateEnabled === 'boolean') { + next.autoUpdateEnabled = patch.autoUpdateEnabled; + } // Ollama host (issue #104). Accept ONLY a well-formed http(s) ORIGIN, stored // normalized (origin-only — scheme + host + port, no path/query). why strict: // this value is added to the egress allowlist and fetched with no key, so a diff --git a/extension/background/update-check.js b/extension/background/update-check.js new file mode 100644 index 00000000..323b4632 --- /dev/null +++ b/extension/background/update-check.js @@ -0,0 +1,351 @@ +// @ts-check +// background/update-check.js - self-update for the self-hosted preview channel. +// +// why this exists: preview installs update via `update_url` feeds +// (peerd.ai/updates → GitHub release artifacts), but the browsers' own polls +// are slow AND peerd's offscreen keepalive holds the MV3 service worker +// alive - which is exactly the state where Chrome parks a downloaded +// extension update waiting for an "idle" that never comes. So on startup we +// ask for the update ourselves and apply it while nothing is live. +// +// Per browser: +// Chrome - runtime.requestUpdateCheck() forces the update_url poll; a found +// update downloads in the background and fires runtime.onUpdateAvailable, +// where we runtime.reload() IF nothing is live (no turn or goal run in +// flight, no UI port, no engine tab, no other extension page); otherwise a +// note is posted and the parked update is re-attempted when a UI surface +// disconnects (onQuiet), or the browser applies it at its next restart. +// Firefox - has no requestUpdateCheck API at all, so we read the gecko +// update feed directly (it is served with open CORS) and surface an +// "update available" notice whose action opens the XPI; Firefox's own +// daily add-on poll still auto-applies it regardless. +// Dev (load-unpacked) and store packages carry no self-hosted update_url: +// start() registers NOTHING there. That gate is load-bearing on Firefox, +// where the mere PRESENCE of an onUpdateAvailable listener defers every +// downloaded add-on update until reload()/browser restart (presence-based, +// unlike Chrome's unload-based deferral) - a listener on the store package +// would break AMO's automatic updates. The store artifact also omits the +// autoUpdateEnabled key entirely (store updates belong to the store). +// +// why fetchFn is injected, not a bare fetch: the feed read is the same class +// of chassis-internal DATA fetch as the voice model download (a hardcoded, +// manifest-derived URL, no secret attached - see voice/model-store.js's +// rationale for why the egress allowlist doesn't apply), but this module +// stays IO-free so the decision logic is Bun-testable. + +/** Session-storage key for per-browser-session state (throttle + notice). */ +export const UPDATE_CHECK_SESSION_KEY = 'updateCheck.v1'; + +// Re-checks within this window are skipped - startup and panel-open events +// can cluster (SW respawns, panel toggling), and the browsers poll on their +// own cadence anyway; this is a floor, not a schedule. Only a COMPLETED +// check starts the window (an offline boot must not burn it). +export const MIN_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; + +// The only hosts an update_link may point at. The browsers' own update +// pipelines are protected by package signing (CRX key, AMO signature); this +// link instead becomes a trusted-UI "Install update" button, so a compromised +// feed must not be able to aim it at an arbitrary https URL. +export const ALLOWED_UPDATE_LINK_HOSTS = Object.freeze(['github.com', 'peerd.ai']); + +// Release versions are plain dotted numerics; anything else in a feed is +// junk and must not reach compareVersions or the notice copy. +const VERSION_SHAPE = /^\d+(\.\d+)*$/; +const VERSION_MAX_LENGTH = 32; + +/** + * Numeric dotted-version compare ("0.6.0" style). Missing parts count as 0, + * non-numeric parts as 0 - release versions here are plain x.y.z triples. + * @param {string} a + * @param {string} b + * @returns {number} negative if a < b, 0 if equal, positive if a > b + */ +export const compareVersions = (a, b) => { + const pa = String(a).split('.'); + const pb = String(b).split('.'); + const len = Math.max(pa.length, pb.length); + for (let i = 0; i < len; i++) { + const na = Number.parseInt(pa[i] ?? '0', 10) || 0; + const nb = Number.parseInt(pb[i] ?? '0', 10) || 0; + if (na !== nb) return na - nb; + } + return 0; +}; + +/** + * Pick the newest update entry for our gecko id out of an AMO-style update + * feed. Defensive against a malformed or hostile feed: entries must carry a + * plain dotted-numeric version and an https update_link on an allowed host, + * or they are skipped. + * @param {unknown} feedJson + * @param {string} geckoId + * @returns {{ version: string, updateLink: string } | null} + */ +export const latestGeckoUpdate = (feedJson, geckoId) => { + if (!feedJson || typeof feedJson !== 'object') return null; + const addons = /** @type {{ addons?: Record }} */ (feedJson).addons; + const updates = addons?.[geckoId]?.updates; + if (!Array.isArray(updates)) return null; + /** @type {{ version: string, updateLink: string } | null} */ + let best = null; + for (const entry of updates) { + if (!entry || typeof entry !== 'object') continue; + const { version, update_link: updateLink } = /** @type {Record} */ (entry); + if (typeof version !== 'string' || typeof updateLink !== 'string') continue; + if (version.length > VERSION_MAX_LENGTH || !VERSION_SHAPE.test(version)) continue; + let url; + try { url = new URL(updateLink); } catch { continue; } + if (url.protocol !== 'https:') continue; + if (!ALLOWED_UPDATE_LINK_HOSTS.includes(url.hostname)) continue; + if (!best || compareVersions(version, best.version) > 0) { + best = { version, updateLink }; + } + } + return best; +}; + +/** + * @typedef {{ + * version: string, + * update_url?: string, + * browser_specific_settings?: { gecko?: { id?: string, update_url?: string } }, + * }} UpdateManifest + */ + +/** + * @typedef {{ + * lastCheckAt?: number, + * notifiedVersion?: string, + * pendingNotice?: { version: string, text: string, url: string }, + * }} UpdateSessionState + */ + +/** + * @param {{ + * runtime: { + * getManifest: () => UpdateManifest, + * requestUpdateCheck?: () => Promise, + * onUpdateAvailable?: { addListener: (fn: (details: { version: string }) => void) => void }, + * reload: () => void, + * }, + * fetchFn: (url: string, init?: RequestInit) => Promise, + * ready: Promise, + * isEnabled: () => boolean, + * busy: () => boolean, + * surfacesOpen: () => boolean | Promise, + * notify: (text: string, action?: { kind: string, label: string, url: string }) => boolean, + * sessionKv: { + * get: (key: string) => Promise, + * set: (key: string, value: unknown) => Promise, + * }, + * now?: () => number, + * log?: (...args: unknown[]) => void, + * }} deps + * busy is "peerd is doing work" (live turns AND goal runs between turns); + * surfacesOpen is "a user-facing extension page exists" (UI ports, portless + * engine tabs holding VM/notebook state, any other extension page). Both + * must be false before runtime.reload() may fire. + * notify returns whether the note was actually DELIVERED to a live UI + * surface (false when none is connected) - delivery gates every + * notify-once marker so a note nobody saw can post again later. + * sessionKv is per-BROWSER-session storage (storage.session): the throttle + * and the pending notice must survive event-page/SW respawns but reset + * with the browser, matching "check when you start the browser". + */ +export const makeUpdateCheck = ({ + runtime, fetchFn, ready, isEnabled, busy, surfacesOpen, notify, sessionKv, + now = () => Date.now(), + log = () => {}, +}) => { + /** @type {string | null} a downloaded update waiting for a quiet moment */ + let pendingDownloadedVersion = null; + /** @type {string | null} downloaded-update note DELIVERED (per SW lifetime) */ + let downloadNotedVersion = null; + /** @type {Promise | null} coalesces concurrent checkNow calls */ + let inFlightCheck = null; + + /** @returns {Promise} */ + const sessionState = async () => { + const state = await sessionKv.get(UPDATE_CHECK_SESSION_KEY); + return (state && typeof state === 'object') ? state : {}; + }; + + // Serialize every read-modify-write of the session record - checkNow and + // the notice replay can run concurrently (boot vs port connect), and an + // unserialized read-then-write would double-post the notice. + /** @type {Promise} */ + let sessionChain = Promise.resolve(); + /** @param {(state: UpdateSessionState) => Promise | UpdateSessionState | null} mutate */ + const withSession = (mutate) => { + const run = sessionChain.then(async () => { + const state = await sessionState(); + const next = await mutate(state); + if (next) await sessionKv.set(UPDATE_CHECK_SESSION_KEY, next); + }); + sessionChain = run.catch(() => {}); + return run; + }; + + // Post the persisted "update available" notice (the Firefox feed path), at + // most once per version per browser session - counting only deliveries a + // UI surface received. Persisted, not module state: the Firefox event page + // suspends when idle, and a notice held in memory would die with it while + // the throttle survived in storage.session. + const postPendingNotice = () => withSession((state) => { + const notice = state.pendingNotice; + if (!notice || state.notifiedVersion === notice.version) return null; + if (!notify(notice.text, { kind: 'open-url', label: 'Install update', url: notice.url })) return null; + return { ...state, notifiedVersion: notice.version }; + }); + + // A downloaded update is parked; apply it only when nothing is live. + // Reloading restarts the whole extension - open panels close, engine tabs + // (a running WebVM, a notebook mid-compute) are destroyed, the vault + // re-locks - so every surface and every unit of work blocks it. + const maybeApplyPendingDownload = async () => { + const version = pendingDownloadedVersion; + if (!version || !isEnabled()) return; + if (busy() || await surfacesOpen()) { + if (downloadNotedVersion !== version + && notify(`peerd v${version} is downloaded - it installs when peerd goes quiet or the browser restarts.`)) { + downloadNotedVersion = version; + } + return; + } + log('[update] applying downloaded update', version); + runtime.reload(); + }; + + // The browser downloaded an update (our request or its own poll). + const onUpdateDownloaded = async (/** @type {string} */ version) => { + await ready; // stored settings may say OFF; never act on the channel default + if (!isEnabled()) { + // Firefox defers a downloaded update whenever ANY onUpdateAvailable + // listener exists, so "disabled" must restore the no-listener default + // there: apply immediately, live work and all - exactly what Firefox + // does on its own. Chrome's no-listener default (install at the next + // SW unload / browser restart) is preserved by doing nothing. + if (typeof runtime.requestUpdateCheck !== 'function') runtime.reload(); + return; + } + pendingDownloadedVersion = version; + await maybeApplyPendingDownload(); + }; + + /** @returns {Promise} completed (throttle may start) or failed */ + const checkChrome = async () => { + if (typeof runtime.requestUpdateCheck !== 'function') return false; + let result; + try { result = await runtime.requestUpdateCheck(); } + catch (error) { log('[update] requestUpdateCheck failed', error); return false; } + // The polyfill resolves Chrome's two-arg callback as [status, details]; + // a native promise resolves { status, version }. Normalize for the log - + // behavior needs nothing more: a found update downloads in the background + // and fires onUpdateAvailable, which onUpdateDownloaded handles. + const status = typeof result === 'string' ? result + : Array.isArray(result) ? result[0] + : (result && typeof result === 'object' && 'status' in result + ? /** @type {{ status?: unknown }} */ (result).status : undefined); + log('[update] requestUpdateCheck:', status); + return true; + }; + + /** @returns {Promise} completed (throttle may start) or failed */ + const checkGeckoFeed = async ( + /** @type {UpdateManifest} */ manifest, + /** @type {{ id?: string, update_url?: string }} */ gecko, + ) => { + if (!gecko.update_url || !gecko.id) return false; + let feed; + try { + const response = await fetchFn(gecko.update_url, { cache: 'no-store', credentials: 'omit' }); + if (!response.ok) { log('[update] feed fetch failed', response.status); return false; } + feed = await response.json(); + } catch (error) { log('[update] feed fetch failed', error); return false; } + const latest = latestGeckoUpdate(feed, gecko.id); + if (!latest || compareVersions(latest.version, manifest.version) <= 0) return true; + await withSession((state) => ({ + ...state, + pendingNotice: { + version: latest.version, + text: `peerd v${latest.version} is available (you have v${manifest.version}). ` + + 'Firefox installs preview updates on its own daily check, or install it now.', + url: latest.updateLink, + }, + })); + await postPendingNotice(); + return true; + }; + + const runCheck = async (/** @type {string} */ reason) => { + await ready; + if (!isEnabled()) return; + const manifest = runtime.getManifest(); + const gecko = manifest.browser_specific_settings?.gecko; + const chromePath = Boolean(manifest.update_url) && typeof runtime.requestUpdateCheck === 'function'; + if (!chromePath && !gecko?.update_url) return; + const state = await sessionState(); + if (typeof state.lastCheckAt === 'number' && now() - state.lastCheckAt < MIN_CHECK_INTERVAL_MS) return; + log('[update] checking for a newer build -', reason); + const completed = chromePath + ? await checkChrome() + : await checkGeckoFeed(manifest, /** @type {{ id?: string, update_url?: string }} */ (gecko)); + if (completed) await withSession((latest) => ({ ...latest, lastCheckAt: now() })); + }; + + /** + * Ask for a newer build now. Coalesced (boot and a panel connect race on a + * cold start) and throttled. Chrome preview manifests carry a top-level + * update_url + the requestUpdateCheck API; Firefox preview carries only + * the gecko feed URL; dev/store manifests carry neither. + * @param {string} reason + */ + const checkNow = (reason) => { + if (!inFlightCheck) { + inFlightCheck = runCheck(reason) + .catch((error) => { log('[update] check failed', error); }) + .finally(() => { inFlightCheck = null; }); + } + return inFlightCheck; + }; + + return { + /** + * Register the update-downloaded listener - ONLY on a self-hosted + * (preview) manifest, and synchronously at SW boot (a downloaded update + * can be the very event that wakes the worker). Store/dev packages must + * not register: on Firefox a listener's mere presence defers every + * add-on update until reload()/browser restart. + */ + start() { + const manifest = runtime.getManifest(); + const selfHosted = (Boolean(manifest.update_url) && typeof runtime.requestUpdateCheck === 'function') + || Boolean(manifest.browser_specific_settings?.gecko?.update_url); + if (!selfHosted) return; + runtime.onUpdateAvailable?.addListener((details) => { + void onUpdateDownloaded(details?.version ?? '').catch(() => {}); + }); + }, + + checkNow, + + /** + * A UI surface connected: replay an undelivered "update available" + * notice to it, then re-check (the throttle keeps this cheap). + */ + onUiConnect() { + void postPendingNotice() + .then(() => checkNow('ui-connect')) + .catch(() => {}); + }, + + /** + * A UI surface disconnected: a parked downloaded update may now be able + * to apply (the offscreen keepalive means Chrome's own "install when + * idle" moment never comes on its own). + */ + onQuiet() { + void ready.then(() => maybeApplyPendingDownload()).catch(() => {}); + }, + }; +}; diff --git a/extension/options/sections/behavior.js b/extension/options/sections/behavior.js index 77e2762f..9eebb7f5 100644 --- a/extension/options/sections/behavior.js +++ b/extension/options/sections/behavior.js @@ -188,6 +188,13 @@ export const BehaviorSection = { const fallbacks = Array.isArray(s.providerFallbacks) ? s.providerFallbacks : []; const otherProviders = listProviders().map((p) => p.name).filter((n) => n !== activeProvider); + // Preview-only key: absent from store packages' CHANNEL_DEFAULTS, so the + // row simply doesn't render there. Presence, not typeof: a crafted + // transfer import can store a non-boolean verbatim, and that must not + // hide the row on a preview build (the toggle itself heals the value). + const autoUpdateAvailable = Object.hasOwn(s, 'autoUpdateEnabled'); + const auOn = s.autoUpdateEnabled === true; + const behaviorRows = [ settingsRow({ label: 'Toolbar button', @@ -262,6 +269,21 @@ export const BehaviorSection = { apply: () => send({ type: 'settings/update', patch: { autoResumeInterruptedTurns: !arOn } }), }), + ...(autoUpdateAvailable ? [toggleRow({ + id: 'auto-update', + label: 'Auto-update', + on: auOn, + busyKey: 'autoUpdateBusy', + badge: 'PREVIEW', + summary: auOn + ? 'Checks for a new preview build at startup and installs it when nothing is running.' + : 'Preview updates wait for the browser’s own periodic check.', + why: auOn + ? 'On. At startup peerd asks the browser for a newer preview build from the release feed. In Chrome the update installs immediately when no chat is mid-turn and no peerd surface is open; otherwise it applies on the next browser restart. In Firefox, which gives extensions no way to trigger their own update, peerd shows an “update available” notice with an install link, and Firefox’s own daily add-on check still installs it on its own.' + : 'Off. peerd stays on the installed build until the browser’s own periodic update check picks up a new preview release. Turn this on to check at startup and install right away.', + apply: () => send({ type: 'settings/update', patch: { autoUpdateEnabled: !auOn } }), + })] : []), + toggleRow({ id: 'failover', label: 'Provider failover', @@ -419,7 +441,8 @@ export const BehaviorSection = { 'frontDoorView', 'reasoningEnabled', 'reasoningEffort', 'confirmWebWrites', 'schemaValidatedReplies', 'advancedAutomationEnabled', 'devMode', - 'autoResumeInterruptedTurns', 'providerFailoverEnabled', 'providerFallbacks', + 'autoResumeInterruptedTurns', 'autoUpdateEnabled', + 'providerFailoverEnabled', 'providerFallbacks', 'prewalkEnabled', 'enginePrewalkEnabled', 'prewalkExecutorModel', ]), ]); diff --git a/extension/shared/channel-config.js b/extension/shared/channel-config.js index 5769efdf..a7071fac 100644 --- a/extension/shared/channel-config.js +++ b/extension/shared/channel-config.js @@ -48,6 +48,7 @@ export const CHANNEL_DEFAULTS = Object.freeze({ providerFallbacks: [], vaultAutoLockMs: 2700000, auditLogMaxEntries: 20000, + autoUpdateEnabled: true, dwebEnabled: true, dwebAgentEnabled: false, }); diff --git a/extension/sidepanel/components/app.js b/extension/sidepanel/components/app.js index 54f0fc93..10593102 100644 --- a/extension/sidepanel/components/app.js +++ b/extension/sidepanel/components/app.js @@ -230,7 +230,7 @@ const PlaceholderView = { // equality) — /init progress and the grant-debugger nudge must be visible // wherever the user is, not just the side panel. /** - * @typedef {{ id: number, text?: string, action?: { kind?: string, label?: string } | null }} Notice + * @typedef {{ id: number, text?: string, action?: { kind?: string, label?: string, url?: string } | null }} Notice */ export const NoticeBar = { @@ -254,6 +254,17 @@ export const NoticeBar = { onclick: () => uiActions?.requestDebugger?.(n.id), }, n.action.label ?? 'Enable') : null, + // open-url: the SW attaches an https link (e.g. the preview + // update's XPI - background/update-check.js). The click IS the user + // gesture the target flow needs, so no SW round-trip: open the tab + // from here. https only, checked again at render (defense in depth - + // the SW already validates the feed's link). + n.action?.kind === 'open-url' && n.action.url?.startsWith('https://') + ? m('button.notice-action', { + type: 'button', + onclick: () => { window.open(n.action?.url, '_blank', 'noopener'); }, + }, n.action.label ?? 'Open') + : null, m('button.notice-dismiss', { type: 'button', 'aria-label': 'Dismiss notice', diff --git a/packaging/check-tscheck.ts b/packaging/check-tscheck.ts index 81431fc3..225477b5 100644 --- a/packaging/check-tscheck.ts +++ b/packaging/check-tscheck.ts @@ -158,7 +158,7 @@ import { computeCoverage } from './tscheck-coverage.ts'; // route, Options surface, and rendered side-panel coverage. // 673 → 676: the Actor Fabric adds its pure topology model, SW live projection, // and rendered browser contract while replacing the checked async-task bar. -const COVERED_FLOOR = 698; +const COVERED_FLOOR = 699; // The scan (walk + // @ts-check detection + the ES5-injected exemption set) // lives in tscheck-coverage.ts so the badge generator reports the same number. diff --git a/packaging/default-settings.mjs b/packaging/default-settings.mjs index 1750a199..7b0e0754 100644 --- a/packaging/default-settings.mjs +++ b/packaging/default-settings.mjs @@ -253,6 +253,18 @@ export const defaults = { auditLogMaxEntries: { store: 20000, preview: 20000 }, // ── preview-only keys ────────────────────────────────────────────── + // Self-update check at startup (background/update-check.js). Preview + // installs are self-hosted (update_url → the peerd.ai feeds → GitHub + // release artifacts), and peerd's offscreen keepalive holds the MV3 SW + // alive - the exact state where Chrome parks a downloaded extension + // update waiting for an "idle" that never comes. ON by default on + // preview: testers should just be current. Chrome checks + reloads when + // nothing is in flight; Firefox (no requestUpdateCheck API) reads the + // feed and offers the XPI in a notice. Store packages omit the key + // entirely - store updates belong to the store; dev (load-unpacked) has + // no update_url, so the check is a structural no-op there. + autoUpdateEnabled: { preview: true }, + // Dweb participation is ON BY DEFAULT on the dev/preview package (owner // call, 2026-06-13 — supersedes the earlier "opt-in even on preview", // spec §12). Preview ships to contributors and early testers; making diff --git a/tests/background/settings-patch.test.ts b/tests/background/settings-patch.test.ts index c0afdba4..ef0fe7eb 100644 --- a/tests/background/settings-patch.test.ts +++ b/tests/background/settings-patch.test.ts @@ -9,6 +9,7 @@ const deps = { knownProviderNames: ['anthropic', 'openrouter', 'ollama'], reasoningEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'] as const, dwebEnabled: true, + autoUpdateAvailable: true, normalizeVariant: (_v: string) => 'base', normalizeEngine: (v: string) => (['auto', 'web-speech', 'moonshine'].includes(v) ? v : 'auto'), }; @@ -130,6 +131,16 @@ describe('normalizeSettingsPatch — dweb gate', () => { }); }); +describe('normalizeSettingsPatch - auto-update gate (preview-only key)', () => { + test('autoUpdateEnabled honored only where the package carries the key', () => { + expect(norm({ autoUpdateEnabled: false })).toEqual({ autoUpdateEnabled: false }); + expect(norm({ autoUpdateEnabled: true }, { autoUpdateAvailable: false })).toEqual({}); + }); + test('non-boolean autoUpdateEnabled dropped even when available', () => { + expect(norm({ autoUpdateEnabled: 'yes' })).toEqual({}); + }); +}); + describe('normalizeSettingsPatch — ollamaHost (issue #104)', () => { test('keeps a valid http(s) origin', () => { expect(norm({ ollamaHost: 'http://localhost:11434' })).toEqual({ ollamaHost: 'http://localhost:11434' }); diff --git a/tests/background/update-check.test.ts b/tests/background/update-check.test.ts new file mode 100644 index 00000000..9f506d10 --- /dev/null +++ b/tests/background/update-check.test.ts @@ -0,0 +1,342 @@ +import { describe, test, expect } from 'bun:test'; +import { + compareVersions, latestGeckoUpdate, makeUpdateCheck, + UPDATE_CHECK_SESSION_KEY, MIN_CHECK_INTERVAL_MS, +} from '../../extension/background/update-check.js'; + +// Pins the preview self-update contract: the pure version/feed logic, the +// Chrome requestUpdateCheck → onUpdateAvailable → reload-when-quiet flow +// (with the onQuiet re-arm), the Firefox feed → persisted-notice flow, the +// Firefox listener-presence semantics (register only on self-hosted +// manifests; disabled restores the no-listener default), and the structural +// no-op on manifests without a self-hosted update_url (dev, store). + +describe('compareVersions', () => { + test('orders numeric triples', () => { + expect(compareVersions('0.6.0', '0.7.0')).toBeLessThan(0); + expect(compareVersions('0.7.0', '0.6.9')).toBeGreaterThan(0); + expect(compareVersions('1.0.0', '0.99.99')).toBeGreaterThan(0); + expect(compareVersions('0.6.0', '0.6.0')).toBe(0); + }); + test('missing parts count as zero', () => { + expect(compareVersions('0.6', '0.6.0')).toBe(0); + expect(compareVersions('0.6.1', '0.6')).toBeGreaterThan(0); + }); + test('garbage parts count as zero, never NaN-poison the compare', () => { + expect(compareVersions('x.y', '0.0')).toBe(0); + expect(compareVersions('0.abc.1', '0.0.1')).toBe(0); + }); +}); + +describe('latestGeckoUpdate', () => { + const GECKO_ID = 'peerd-preview@peerd.ai'; + const feed = (updates: unknown[]) => ({ addons: { [GECKO_ID]: { updates } } }); + + test('picks the highest version', () => { + const result = latestGeckoUpdate(feed([ + { version: '0.6.0', update_link: 'https://github.com/a.xpi' }, + { version: '0.7.1', update_link: 'https://github.com/c.xpi' }, + { version: '0.7.0', update_link: 'https://github.com/b.xpi' }, + ]), GECKO_ID); + expect(result).toEqual({ version: '0.7.1', updateLink: 'https://github.com/c.xpi' }); + }); + test('skips non-https links and malformed entries', () => { + const result = latestGeckoUpdate(feed([ + { version: '0.9.0', update_link: 'http://github.com/evil.xpi' }, + { version: '0.9.1', update_link: 'not a url' }, + { version: 42, update_link: 'https://github.com/x.xpi' }, + null, + { version: '0.8.0', update_link: 'https://github.com/ok.xpi' }, + ]), GECKO_ID); + expect(result).toEqual({ version: '0.8.0', updateLink: 'https://github.com/ok.xpi' }); + }); + test('rejects hosts outside the allow-list - a compromised feed cannot aim the Install button elsewhere', () => { + const result = latestGeckoUpdate(feed([ + { version: '0.9.0', update_link: 'https://evil.example/fake-peerd.xpi' }, + { version: '0.8.0', update_link: 'https://peerd.ai/dl/ok.xpi' }, + ]), GECKO_ID); + expect(result).toEqual({ version: '0.8.0', updateLink: 'https://peerd.ai/dl/ok.xpi' }); + }); + test('rejects junk version strings before they reach the compare or the notice copy', () => { + expect(latestGeckoUpdate(feed([ + { version: '0.7.0-beta