/** * 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) // Client and backend are independently updatable; each keeps its own state. export const $backendUpdateStatus = atom(null) export const $backendUpdateApply = atom(IDLE) export const $backendUpdateChecking = atom(false) export type UpdateTarget = 'client' | 'backend' export const $updateOverlayTarget = atom('client') export const setUpdateOverlayOpen = (open: boolean) => $updateOverlayOpen.set(open) export const openUpdateOverlayFor = (target: UpdateTarget) => { $updateOverlayTarget.set(target) $updateOverlayOpen.set(true) void (target === 'backend' ? checkBackendUpdates() : checkUpdates()) } export const resetUpdateApplyState = () => { $updateApply.set(IDLE) $backendUpdateApply.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 applyBackendUpdate() }, 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') }) } export function openUpdatesWindow(): void { openUpdateOverlayFor(isRemoteMode() ? 'backend' : 'client') } /** 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' } 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: res.update_available ? `backend:${res.current_version}` : undefined, commits: res.commits, fetchedAt: Date.now() } } export async function checkBackendUpdates(): Promise { if (!isRemoteMode() || $backendUpdateChecking.get()) { return $backendUpdateStatus.get() } $backendUpdateChecking.set(true) try { const status = mapBackendCheck(await checkHermesUpdate(true)) $backendUpdateStatus.set(status) maybeNotifyUpdateAvailable(status) return status } catch (error) { const fallback: DesktopUpdateStatus = { supported: $backendUpdateStatus.get()?.supported ?? true, error: 'check-failed', message: error instanceof Error ? error.message : String(error), fetchedAt: Date.now() } $backendUpdateStatus.set(fallback) return fallback } finally { $backendUpdateChecking.set(false) } } export async function checkUpdates(): Promise { 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) 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 { 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 } } } const BACKEND_RETURN_POLL_MS = 1500 const BACKEND_RETURN_MAX_ATTEMPTS = 40 async function waitForBackendReturn(): Promise { for (let attempt = 0; attempt < BACKEND_RETURN_MAX_ATTEMPTS; attempt += 1) { await new Promise(resolve => globalThis.setTimeout(resolve, BACKEND_RETURN_POLL_MS)) try { await checkHermesUpdate() return true } catch { continue } } return false } function finishBackendApply(returned: boolean): DesktopUpdateApplyResult { if (returned) { $backendUpdateApply.set(IDLE) setUpdateOverlayOpen(false) void checkBackendUpdates() return { ok: true, message: 'Backend update applied.' } } $backendUpdateApply.set({ ...$backendUpdateApply.get(), applying: false, stage: 'error', error: 'apply-failed', message: translateNow('updates.applyStatus.noReturn') }) return { ok: false, error: 'apply-failed', message: 'Backend did not come back online.' } } export async function applyBackendUpdate(): Promise { dismissNotification(UPDATE_TOAST_ID) $backendUpdateApply.set({ ...IDLE, applying: true, stage: 'prepare', message: translateNow('updates.applyStatus.preparing') }) try { const started = await updateHermes() if (!started.ok) { const message = (started as { message?: string }).message || translateNow('updates.applyStatus.notAvailable') const command = (started as { update_command?: string }).update_command || 'hermes update' $backendUpdateApply.set({ ...IDLE, applying: false, stage: 'manual', message, command }) return { ok: false, error: 'manual', manual: true, message, command } } $backendUpdateApply.set({ ...IDLE, applying: true, stage: 'pull', message: translateNow('updates.applyStatus.pulling') }) let last: Awaited> | null = null for (let attempt = 0; attempt < 30; attempt += 1) { await new Promise(resolve => globalThis.setTimeout(resolve, 1500)) try { last = await getActionStatus(started.name, 200) } catch { // The dashboard restarts mid-update, dropping this connection — expected, not a failure. $backendUpdateApply.set({ ...$backendUpdateApply.get(), applying: true, stage: 'restart', message: translateNow('updates.applyStatus.restarting') }) return finishBackendApply(await waitForBackendReturn()) } if (last && !last.running) { break } } const ok = !!last && (last.exit_code ?? 1) === 0 if (ok) { $backendUpdateApply.set({ ...$backendUpdateApply.get(), applying: true, stage: 'restart', message: translateNow('updates.applyStatus.restarting') }) return finishBackendApply(await waitForBackendReturn()) } $backendUpdateApply.set({ ...$backendUpdateApply.get(), applying: false, stage: 'error', error: 'apply-failed', message: translateNow('updates.applyStatus.failed') }) return { ok: false, error: 'apply-failed', message: 'Backend update failed.' } } catch (error) { const message = error instanceof Error ? error.message : String(error) $backendUpdateApply.set({ ...$backendUpdateApply.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 let connectionUnsub: (() => void) | null = null let lastConnectionMode: string | undefined /** 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 checkBackendUpdates() void refreshDesktopVersion() bridge.onProgress(ingestProgress) // The poller starts at mount, before the gateway connects — so the first // backend check above sees mode≠remote and no-ops. Re-check once the // connection resolves to remote. connectionUnsub = $connection.subscribe(conn => { if (conn?.mode === lastConnectionMode) { return } lastConnectionMode = conn?.mode if (conn?.mode === 'remote') { void checkBackendUpdates() } }) window.addEventListener('focus', onFocus) backgroundTimer = setInterval(() => { void checkUpdates() void checkBackendUpdates() }, 30 * 60 * 1000) } export function stopUpdatePoller(): void { if (backgroundTimer !== null) { clearInterval(backgroundTimer) backgroundTimer = null } connectionUnsub?.() connectionUnsub = null lastConnectionMode = undefined window.removeEventListener('focus', onFocus) pollerStarted = false } function onFocus() { const now = Date.now() if (now - lastFocusAt < 5 * 60 * 1000) { return } lastFocusAt = now void checkUpdates() void checkBackendUpdates() void refreshDesktopVersion() }