hermes-agent/apps/desktop/src/store/updates.ts
David Metcalfe 9738870489 fix(desktop): call checkUpdates() in startUpdatePoller so version pill auto-populates
startUpdatePoller() only called checkBackendUpdates() — never checkUpdates().
The statusbar version pill reads $updateStatus (set by checkUpdates()), so the
commit-behind counter stayed null after restart. It only appeared when the user
manually clicked the pill, which triggered checkUpdates() via openUpdateOverlayFor.

Added void checkUpdates() in three places alongside the existing
checkBackendUpdates() calls:
- On startup in startUpdatePoller()
- In the 30-minute setInterval callback
- In the onFocus handler

checkUpdates() uses the Electron IPC bridge (local git check), not the gateway,
so no mode gating is needed. The existing $updateChecking atom guard prevents
double-fire on overlap.

Fixes #53079
2026-07-02 11:52:57 -05:00

668 lines
20 KiB
TypeScript

/**
* 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<DesktopVersionInfo | null>(null)
export const $updateApply = atom<UpdateApplyState>(IDLE)
export const $updateChecking = atom<boolean>(false)
export const $updateOverlayOpen = atom<boolean>(false)
export const $updateStatus = atom<DesktopUpdateStatus | null>(null)
// Client and backend are independently updatable; each keeps its own state.
export const $backendUpdateStatus = atom<DesktopUpdateStatus | null>(null)
export const $backendUpdateApply = atom<UpdateApplyState>(IDLE)
export const $backendUpdateChecking = atom<boolean>(false)
export type UpdateTarget = 'client' | 'backend'
export const $updateOverlayTarget = atom<UpdateTarget>('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.
// v2: requires the file.attach RPC (remote-gateway non-image file upload).
const REQUIRED_BACKEND_CONTRACT = 2
const SKEW_TOAST_ID = 'backend-contract-skew'
// The contract check runs on every session.resume (applyRuntimeInfo), so
// without a snooze the warning re-popped on every thread the user opened, even
// right after they closed it. Mirror the update toast: persist a cooldown when
// the user dismisses it. It still reminds again after the window if the backend
// is still behind, and clears immediately once the backend catches up.
const SKEW_TOAST_SNOOZE_KEY = 'hermes:backend-skew-toast-snooze-until'
const SKEW_TOAST_COOLDOWN_MS = 24 * 60 * 60 * 1000
function snoozeSkewToast(): void {
persistString(SKEW_TOAST_SNOOZE_KEY, String(Date.now() + SKEW_TOAST_COOLDOWN_MS))
}
function isSkewToastSnoozed(): boolean {
const until = Number(storedString(SKEW_TOAST_SNOOZE_KEY) || 0)
return Number.isFinite(until) && Date.now() < until
}
/**
* 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 warning with a one-click align that runs
* the normal update flow (which self-heals to the right branch).
*
* Runs on every session open; closing the toast snoozes it for a cooldown so it
* doesn't nag on every thread switch.
*/
export function reportBackendContract(contract: number | undefined): void {
if ((contract ?? 0) >= REQUIRED_BACKEND_CONTRACT) {
dismissNotification(SKEW_TOAST_ID)
// Backend caught up — forget any prior snooze so a future regression warns
// immediately rather than staying silent for the rest of the window.
persistString(SKEW_TOAST_SNOOZE_KEY, null)
return
}
if (isSkewToastSnoozed()) {
return
}
notify({
action: {
label: translateNow('notifications.updateHermes'),
onClick: () => {
snoozeSkewToast()
void applyBackendUpdate()
}
},
durationMs: 0,
id: SKEW_TOAST_ID,
kind: 'warning',
message: translateNow('notifications.backendOutOfDateMessage'),
onDismiss: () => snoozeSkewToast(),
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')
}
/**
* Start applying the available update for the active target right away. Opens
* the updates overlay first so the user sees apply progress (the overlay
* renders ApplyingView once `applying` flips true), then kicks off the install.
* Used by the "Update now" affordance on the About panel, which would otherwise
* only be able to open the changelog overlay.
*/
export function startActiveUpdate(): void {
const target: UpdateTarget = isRemoteMode() ? 'backend' : 'client'
$updateOverlayTarget.set(target)
$updateOverlayOpen.set(true)
void (target === 'backend' ? applyBackendUpdate() : applyUpdates())
}
/** 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<DesktopVersionInfo | null> {
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,
updateAvailable: res.update_available,
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<DesktopUpdateStatus | null> {
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<DesktopUpdateStatus | null> {
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<DesktopUpdateApplyResult> {
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
}
// A detached relauncher took over (macOS bundle swap / Linux re-exec): the
// app is about to quit and reopen, so hold the "Restarting…" view until it
// does. Every other resolved outcome MUST land on a terminal, closeable
// state: the apply IPC resolves here, but the progress stream may have left
// us on a non-terminal stage (e.g. 'done'/'rebuild'), which renders as a
// spinner with no close button — the exact hang this guards against.
// Linux GUI/backend skew (#45205): the backend was updated but the running
// desktop app PACKAGE was not changed (AppImage/.deb/.rpm). We must NOT tell
// the user "the new version loads next launch" — that's false; this packaged
// shell keeps running old GUI code against the new backend. Land on the
// dedicated, closeable guiSkew terminal state telling them to update/reinstall
// the desktop app.
if (result?.guiSkew) {
$updateApply.set({
...IDLE,
applying: false,
stage: 'guiSkew',
message: result.message ?? translateNow('updates.guiSkewBody')
})
return result
}
// Backend updated but the app couldn't auto-relaunch (e.g. the rebuilt
// sandbox helper isn't launchable): keep a closeable manual-restart state so
// the user keeps a working window instead of a dead app or a stuck spinner.
if (result?.ok && result?.manualRestart) {
$updateApply.set({
...IDLE,
applying: false,
stage: 'manual',
message: result.message ?? translateNow('updates.manualPickedUp')
})
return result
}
if (!result?.handedOff) {
if (result?.ok) {
// Updated, but couldn't relaunch in place (AppImage / dev run). Dismiss
// the overlay and let the user know the new version loads next launch
// rather than stranding them on an un-closeable spinner.
setUpdateOverlayOpen(false)
resetUpdateApplyState()
notify({
durationMs: 8000,
id: UPDATE_TOAST_ID,
kind: 'success',
message: translateNow('updates.manualPickedUp'),
title: translateNow('updates.allSetTitle')
})
} else {
$updateApply.set({
...$updateApply.get(),
applying: false,
stage: 'error',
error: result?.error ?? 'apply-failed',
message: result?.message ?? translateNow('updates.errorBody')
})
}
}
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<boolean> {
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.' }
}
function ingestBackendActionStatus(status: Awaited<ReturnType<typeof getActionStatus>>): void {
const current = $backendUpdateApply.get()
const log = status.lines
.filter(line => line.trim().length > 0)
.map(line => ({ at: Date.now(), message: line, stage: current.stage }))
.slice(-50)
const latest = log.at(-1)?.message
if (log.length === 0 && !latest) {
return
}
$backendUpdateApply.set({
...current,
log,
message: latest ?? current.message
})
}
export async function applyBackendUpdate(): Promise<DesktopUpdateApplyResult> {
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<ReturnType<typeof getActionStatus>> | 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)
ingestBackendActionStatus(last)
} 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' ||
payload.stage === 'guiSkew'
$updateApply.set({
applying: !terminal,
stage: payload.stage,
message: payload.message,
// Streamed log lines carry percent: null; keep the last milestone percent
// (10/60/…) instead of resetting the bar to indeterminate on every line.
percent: payload.percent ?? current.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<typeof setInterval> | 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()
}