/** * Desktop self-update store. Tracks distance from the configured branch, * surfaces it as an ambient pill, and orchestrates the apply flow. */ import { atom } from 'nanostores' import type { DesktopUpdateApplyOptions, DesktopUpdateApplyResult, DesktopUpdateProgress, DesktopUpdateStage, DesktopUpdateStatus, DesktopVersionInfo } from '@/global' import { checkHermesUpdate, getActionStatus, updateHermes } from '@/hermes' import { translateNow } from '@/i18n' import { persistString, storedString } from '@/lib/storage' import { dismissNotification, notify } from '@/store/notifications' import { $connection } from '@/store/session' import type { BackendUpdateCheckResponse } from '@/types/hermes' export interface UpdateApplyState { applying: boolean stage: DesktopUpdateStage message: string percent: number | null error: string | null /** When the stage is 'manual': the exact command the user should run * (CLI install with no staged updater). */ command: string | null log: readonly { stage: DesktopUpdateStage; message: string; at: number }[] } const IDLE: UpdateApplyState = { applying: false, stage: 'idle', message: '', percent: null, error: null, command: null, log: [] } export const $desktopVersion = atom(null) export const $updateApply = atom(IDLE) export const $updateChecking = atom(false) export const $updateOverlayOpen = atom(false) export const $updateStatus = atom(null) export const setUpdateOverlayOpen = (open: boolean) => $updateOverlayOpen.set(open) export const resetUpdateApplyState = () => $updateApply.set(IDLE) const UPDATE_TOAST_ID = 'desktop-update-available' // Time-based snooze instead of per-sha dismissal: this repo lands ~100 commits // a day, so a "don't show this exact sha again" guard re-popped the toast on // every new commit. We instead suppress the toast for a cooldown window that // (re)starts whenever the user closes it. const UPDATE_TOAST_SNOOZE_KEY = 'hermes:update-toast-snooze-until' const UPDATE_TOAST_COOLDOWN_MS = 24 * 60 * 60 * 1000 function snoozeUpdateToast(): void { persistString(UPDATE_TOAST_SNOOZE_KEY, String(Date.now() + UPDATE_TOAST_COOLDOWN_MS)) } function isUpdateToastSnoozed(): boolean { const until = Number(storedString(UPDATE_TOAST_SNOOZE_KEY) || 0) return Number.isFinite(until) && Date.now() < until } // Must match tui_gateway's DESKTOP_BACKEND_CONTRACT that this build was written // against. The backend reports its own value in session runtime info; a lower // value (or none — a pre-GUI checkout) means GUI<->backend skew. const REQUIRED_BACKEND_CONTRACT = 1 const SKEW_TOAST_ID = 'backend-contract-skew' /** * Guard against a desktop GUI talking to a backend that predates its contract * (e.g. a bb/gui-built app pointed at a `main` checkout). Rather than failing * cryptically downstream, surface a persistent warning with a one-click align * that runs the normal update flow (which self-heals to the right branch). */ export function reportBackendContract(contract: number | undefined): void { if ((contract ?? 0) >= REQUIRED_BACKEND_CONTRACT) { dismissNotification(SKEW_TOAST_ID) return } notify({ action: { label: translateNow('notifications.updateHermes'), onClick: () => void applyUpdates() }, durationMs: 0, id: SKEW_TOAST_ID, kind: 'warning', message: translateNow('notifications.backendOutOfDateMessage'), title: translateNow('notifications.backendOutOfDateTitle') }) } /** * Fire a toast when an update is available, at most once per cooldown window. * Closing the toast — dismissing it or opening the updates window from it — * (re)starts the cooldown, so a busy upstream branch doesn't re-spam the user * on every new commit. The snooze is persisted, so it survives relaunches too. */ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) { if (!status || status.supported === false || status.error || !status.targetSha) { return } if ((status.behind ?? 0) <= 0) { return } if (isUpdateToastSnoozed()) { return } if ($updateApply.get().applying) { return } const behind = status.behind ?? 0 notify({ action: { label: translateNow('notifications.seeWhatsNew'), onClick: () => { snoozeUpdateToast() openUpdatesWindow() } }, durationMs: 0, id: UPDATE_TOAST_ID, kind: 'info', message: translateNow('notifications.updateReadyMessage', behind), onDismiss: () => snoozeUpdateToast(), title: translateNow('notifications.updateReadyTitle') }) } /** * Opens the updates dialog and kicks off a fresh check so the user always * sees current state, even if a stale status is cached from earlier. */ export function openUpdatesWindow(): void { $updateOverlayOpen.set(true) void checkUpdates() } /** Re-read the running app's version from the Electron main process and * publish it on `$desktopVersion`. Called when the About panel mounts, the * update flow finishes, and the window regains focus, so the About text * stays in sync with the just-installed binary instead of frozen at the * value captured at first-load. */ export async function refreshDesktopVersion(): Promise { if (typeof window === 'undefined') { return null } // Best-effort UI sync: callers (checkUpdates, startUpdatePoller, window // focus handler) all kick this off with `void refreshDesktopVersion()`, // so any rejection from the IPC bridge (e.g. main process shutting down // mid-reload, or the bridge not yet ready on first paint) would surface // as an unhandled promise rejection in the renderer. Swallow it. try { const next = await window.hermesDesktop?.getVersion?.() if (next) { $desktopVersion.set(next) } return next ?? null } catch { return null } } function isRemoteMode(): boolean { return $connection.get()?.mode === 'remote' } /** Map the backend's /api/hermes/update/check shape onto the overlay's * DesktopUpdateStatus. `can_apply` / `message` are preserved so the overlay * can show guidance (e.g. docker/nix) instead of an Install button. */ function mapBackendCheck(res: BackendUpdateCheckResponse): DesktopUpdateStatus { const behind = res.behind ?? 0 return { supported: res.can_apply, message: res.message ?? undefined, behind: behind > 0 ? behind : 0, // targetSha gates the "update available" toast in maybeNotifyUpdateAvailable; // synthesize a stable marker when the backend reports it's behind. targetSha: res.update_available ? `backend:${res.current_version}` : undefined, commits: res.commits, fetchedAt: Date.now() } } async function checkBackendUpdates(): Promise { if ($updateChecking.get()) { return $updateStatus.get() } $updateChecking.set(true) try { const res = await checkHermesUpdate(true) const status = mapBackendCheck(res) $updateStatus.set(status) maybeNotifyUpdateAvailable(status) return status } catch (error) { const previous = $updateStatus.get() const fallback: DesktopUpdateStatus = { supported: previous?.supported ?? true, branch: previous?.branch, error: 'check-failed', message: error instanceof Error ? error.message : String(error), fetchedAt: Date.now() } $updateStatus.set(fallback) return fallback } finally { $updateChecking.set(false) } } export async function checkUpdates(): Promise { // Remote thin-client mode: the version pill points at the BACKEND, not the // local Electron clone. Source the overlay from the backend's own // /api/hermes/update/check so behind-count + "what's changed" describe the // machine the user is actually connected to. if (isRemoteMode()) { return checkBackendUpdates() } const bridge = window.hermesDesktop?.updates if (!bridge || $updateChecking.get()) { return $updateStatus.get() } $updateChecking.set(true) try { const status = await bridge.check() $updateStatus.set(status) maybeNotifyUpdateAvailable(status) // The update check pulls the latest hermes_cli + bundled package metadata // into place. Re-read the running version so About reflects the now-fresh // checkout rather than the one captured at process start. void refreshDesktopVersion() return status } catch (error) { const previous = $updateStatus.get() const fallback: DesktopUpdateStatus = { supported: previous?.supported ?? true, branch: previous?.branch, error: 'check-failed', message: error instanceof Error ? error.message : String(error), fetchedAt: Date.now() } $updateStatus.set(fallback) return fallback } finally { $updateChecking.set(false) } } export async function applyUpdates(opts: DesktopUpdateApplyOptions = {}): Promise { // Remote mode: apply the update on the BACKEND via its HTTP API (the same // path the command-center "Update Hermes" button uses), then poll the action // to completion. The Electron git bridge would update the local client clone, // which is the wrong target when the version pill points at a remote backend. if (isRemoteMode()) { return applyBackendUpdate() } const bridge = window.hermesDesktop?.updates if (!bridge) { return { ok: false, error: 'unavailable', message: 'Desktop bridge unavailable.' } } dismissNotification(UPDATE_TOAST_ID) $updateApply.set({ ...IDLE, applying: true, stage: 'prepare', message: 'Starting update…' }) try { const result = await bridge.apply(opts) // CLI install with no staged updater: not an error — the user just runs // `hermes update` themselves. Land on a dedicated manual state so the // overlay shows the command + copy button instead of a dead retry loop. if (result?.manual) { $updateApply.set({ ...IDLE, applying: false, stage: 'manual', message: result.command ?? 'hermes update', command: result.command ?? 'hermes update' }) } return result } catch (error) { const message = error instanceof Error ? error.message : String(error) $updateApply.set({ ...$updateApply.get(), applying: false, stage: 'error', error: 'apply-failed', message }) return { ok: false, error: 'apply-failed', message } } } /** Apply the update on the connected backend: POST /api/hermes/update, then * poll the spawned action to completion. Drives $updateApply so the overlay * shows progress + a terminal state, mirroring the local apply flow. */ async function applyBackendUpdate(): Promise { dismissNotification(UPDATE_TOAST_ID) $updateApply.set({ ...IDLE, applying: true, stage: 'prepare', message: 'Updating backend…' }) try { const started = await updateHermes() // updateHermes returns ok:false for non-applyable installs (e.g. docker) // with guidance in the response; surface it as a manual state. if (!started.ok) { const message = (started as { message?: string }).message || 'Update not available for this backend.' const command = (started as { update_command?: string }).update_command || 'hermes update' $updateApply.set({ ...IDLE, applying: false, stage: 'manual', message, command }) return { ok: false, error: 'manual', manual: true, message, command } } $updateApply.set({ ...IDLE, applying: true, stage: 'pull', message: 'Backend updating…' }) // Poll the action until it stops running (cap the wait — the dashboard // restarts mid-update, which drops this connection; that's expected). let last: Awaited> | null = null for (let attempt = 0; attempt < 30; attempt += 1) { await new Promise(resolve => window.setTimeout(resolve, 1500)) try { last = await getActionStatus(started.name, 200) } catch { // Connection dropped — most likely the backend restarted to load the // new code. Treat as the (expected) restart phase, not a failure. $updateApply.set({ ...$updateApply.get(), applying: false, stage: 'restart', message: 'Backend restarting to load the update…' }) return { ok: true, message: 'Backend update applied; backend is restarting.' } } if (last && !last.running) { break } } const ok = !!last && (last.exit_code ?? 1) === 0 $updateApply.set({ ...$updateApply.get(), applying: false, stage: ok ? 'restart' : 'error', error: ok ? null : 'apply-failed', message: ok ? 'Backend updated. Restart it to load the new code.' : 'Backend update failed.' }) return ok ? { ok: true, message: 'Backend update applied.' } : { ok: false, error: 'apply-failed', message: 'Backend update failed.' } } catch (error) { const message = error instanceof Error ? error.message : String(error) $updateApply.set({ ...$updateApply.get(), applying: false, stage: 'error', error: 'apply-failed', message }) return { ok: false, error: 'apply-failed', message } } } function ingestProgress(payload: DesktopUpdateProgress): void { const current = $updateApply.get() const log = [...current.log, { stage: payload.stage, message: payload.message, at: payload.at }].slice(-50) const terminal = payload.stage === 'error' || payload.stage === 'restart' || payload.stage === 'manual' $updateApply.set({ applying: !terminal, stage: payload.stage, message: payload.message, percent: payload.percent, error: payload.error, // 'manual' carries the command to run in its message field. command: payload.stage === 'manual' ? payload.message : current.command, log }) } let pollerStarted = false let backgroundTimer: ReturnType | null = null let lastFocusAt = 0 /** Wire up background polling + progress streaming. Idempotent. */ export function startUpdatePoller(): void { if (pollerStarted || typeof window === 'undefined') { return } const bridge = window.hermesDesktop?.updates if (!bridge) { return } pollerStarted = true void checkUpdates() void refreshDesktopVersion() bridge.onProgress(ingestProgress) window.addEventListener('focus', onFocus) backgroundTimer = setInterval(() => void checkUpdates(), 30 * 60 * 1000) } export function stopUpdatePoller(): void { if (backgroundTimer !== null) { clearInterval(backgroundTimer) backgroundTimer = null } window.removeEventListener('focus', onFocus) pollerStarted = false } function onFocus() { const now = Date.now() if (now - lastFocusAt < 5 * 60 * 1000) { return } lastFocusAt = now void checkUpdates() // Cheap and safe to re-read on every (throttled) focus: the user may have // updated Hermes from another window/CLI between focuses, and About should // catch up without forcing a restart. void refreshDesktopVersion() }