From 81647458c7a7a9c3ae941af8b49fcf005d4ce5ff Mon Sep 17 00:00:00 2001 From: yoniebans Date: Mon, 8 Jun 2026 13:19:04 +0200 Subject: [PATCH] fix(desktop): recover the backend update overlay after the remote restarts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend Install path set stage:'restart' and stopped — in remote mode no boot-progress events arrive to carry the overlay to done, so it sat on the restarting spinner until a manual reload while the backend had already come back. Poll the backend until it answers again, then clear the overlay and refresh the backend status. Target-aware applying copy explains the remote restart + auto-reconnect instead of the local-updater-window wording. Also switch the apply poll sleeps from window.setTimeout to globalThis.setTimeout so the flow is exercisable off the renderer. --- apps/desktop/src/app/updates-overlay.tsx | 7 ++-- apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/ja.ts | 1 + apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh-hant.ts | 1 + apps/desktop/src/i18n/zh.ts | 1 + apps/desktop/src/store/updates.test.ts | 33 ++++++++++++++++-- apps/desktop/src/store/updates.ts | 44 +++++++++++++++++++----- 8 files changed, 75 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/app/updates-overlay.tsx b/apps/desktop/src/app/updates-overlay.tsx index 7c354eb171..d55f2d79c1 100644 --- a/apps/desktop/src/app/updates-overlay.tsx +++ b/apps/desktop/src/app/updates-overlay.tsx @@ -92,7 +92,7 @@ export function UpdatesOverlay() { className="max-w-sm overflow-hidden border-border/70 p-0 gap-0" showCloseButton={phase !== 'applying'} > - {phase === 'applying' && } + {phase === 'applying' && } {phase === 'manual' && ( handleClose(false)} /> @@ -309,10 +309,11 @@ function ManualView({ command, onDone }: { command: string; onDone: () => void } ) } -function ApplyingView({ apply }: { apply: UpdateApplyState }) { +function ApplyingView({ apply, isBackend }: { apply: UpdateApplyState; isBackend: boolean }) { const { t } = useI18n() const u = t.updates const label = u.stages[apply.stage as DesktopUpdateStage] ?? u.stages.idle + const body = isBackend ? u.applyingBodyBackend : u.applyingBody const percent = typeof apply.percent === 'number' && Number.isFinite(apply.percent) @@ -326,7 +327,7 @@ function ApplyingView({ apply }: { apply: UpdateApplyState }) { {label} - {u.applyingBody} + {body} diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 88dce1e4b1..b02fde4690 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1253,6 +1253,7 @@ export const en: Translations = { copied: 'Copied', done: 'Done', applyingBody: 'The Hermes updater will take over in its own window and reopen Hermes when it’s done.', + applyingBodyBackend: 'The remote backend is applying the update and will restart. Hermes reconnects automatically when it’s back.', applyingClose: 'Hermes will close to apply the update.', errorTitle: 'Update didn’t finish', errorBody: 'No worries — nothing was lost. You can try again now.', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index f0cdcaa2a7..afaf629a88 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1395,6 +1395,7 @@ export const ja = defineLocale({ copied: 'コピーしました', done: '完了', applyingBody: 'Hermes アップデーターが独自のウィンドウで引き継ぎ、完了後に Hermes を再度開きます。', + applyingBodyBackend: 'リモートバックエンドが更新を適用して再起動します。復帰すると Hermes が自動的に再接続します。', applyingClose: 'Hermes は更新を適用するために閉じます。', errorTitle: '更新が完了しませんでした', errorBody: 'ご安心ください。何も失われていません。今すぐ再試行できます。', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index b7a4572a46..c32fbae451 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -953,6 +953,7 @@ export interface Translations { copied: string done: string applyingBody: string + applyingBodyBackend: string applyingClose: string errorTitle: string errorBody: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index cfa82d4013..8804f36cfd 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1360,6 +1360,7 @@ export const zhHant = defineLocale({ copied: '已複製', done: '完成', applyingBody: 'Hermes 更新程式會在自己的視窗中接管,並在完成後重新開啟 Hermes。', + applyingBodyBackend: '遠端後端正在套用更新並將重新啟動。恢復後 Hermes 會自動重新連線。', applyingClose: 'Hermes 將關閉以套用更新。', errorTitle: '更新未完成', errorBody: '沒有資料遺失。您可以現在重試。', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 40518aed94..e3309af7c2 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1440,6 +1440,7 @@ export const zh: Translations = { copied: '已复制', done: '完成', applyingBody: 'Hermes 更新器会在自己的窗口中接管,并在完成后重新打开 Hermes。', + applyingBodyBackend: '远程后端正在应用更新并将重启。恢复后 Hermes 会自动重新连接。', applyingClose: 'Hermes 将关闭以应用更新。', errorTitle: '更新未完成', errorBody: '没有数据丢失。你可以现在重试。', diff --git a/apps/desktop/src/store/updates.test.ts b/apps/desktop/src/store/updates.test.ts index 30d1965593..3aac4004c3 100644 --- a/apps/desktop/src/store/updates.test.ts +++ b/apps/desktop/src/store/updates.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { DesktopUpdateStatus } from '@/global' @@ -33,7 +33,7 @@ vi.mock('@/hermes', () => ({ getActionStatus: (...args: unknown[]) => getActionStatusSpy(...args) })) -const { maybeNotifyUpdateAvailable, checkBackendUpdates, $backendUpdateStatus } = await import('./updates') +const { maybeNotifyUpdateAvailable, checkBackendUpdates, $backendUpdateStatus, applyBackendUpdate, $backendUpdateApply } = await import('./updates') const { setConnection } = await import('./session') const status = (over: Partial = {}): DesktopUpdateStatus => ({ @@ -155,3 +155,32 @@ describe('checkBackendUpdates', () => { }) }) +describe('applyBackendUpdate recovery', () => { + beforeEach(() => { + storage.clear() + checkHermesUpdateSpy.mockReset() + updateHermesSpy.mockReset() + getActionStatusSpy.mockReset() + $backendUpdateApply.set({ applying: false, stage: 'idle', message: '', percent: null, error: null, command: null, log: [] }) + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('waits for the backend to return after the restart drops the connection, then clears the overlay', async () => { + updateHermesSpy.mockResolvedValue({ ok: true, name: 'update', pid: 1 }) + getActionStatusSpy.mockRejectedValue(new Error('ECONNREFUSED')) + checkHermesUpdateSpy.mockResolvedValue({ install_method: 'git', current_version: '0.16.0', behind: 0, update_available: false, can_apply: true, update_command: 'hermes update', message: null }) + + const promise = applyBackendUpdate() + await vi.advanceTimersByTimeAsync(5000) + const result = await promise + + expect(result.ok).toBe(true) + expect($backendUpdateApply.get().stage).toBe('idle') + expect($backendUpdateApply.get().applying).toBe(false) + }) +}) + diff --git a/apps/desktop/src/store/updates.ts b/apps/desktop/src/store/updates.ts index bc78e1ecfe..c31e3e9d9f 100644 --- a/apps/desktop/src/store/updates.ts +++ b/apps/desktop/src/store/updates.ts @@ -304,6 +304,22 @@ export async function applyUpdates(opts: DesktopUpdateApplyOptions = {}): Promis } } +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 + } catch { + continue + } + } +} + export async function applyBackendUpdate(): Promise { dismissNotification(UPDATE_TOAST_ID) $backendUpdateApply.set({ ...IDLE, applying: true, stage: 'prepare', message: 'Updating backend…' }) @@ -323,19 +339,22 @@ export async function applyBackendUpdate(): Promise { let last: Awaited> | null = null for (let attempt = 0; attempt < 30; attempt += 1) { - await new Promise(resolve => window.setTimeout(resolve, 1500)) + 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: false, + applying: true, stage: 'restart', message: 'Backend restarting to load the update…' }) + await waitForBackendReturn() + $backendUpdateApply.set(IDLE) + void checkBackendUpdates() - return { ok: true, message: 'Backend update applied; backend is restarting.' } + return { ok: true, message: 'Backend update applied; backend is back online.' } } if (last && !last.running) { @@ -344,17 +363,24 @@ export async function applyBackendUpdate(): Promise { } const ok = !!last && (last.exit_code ?? 1) === 0 + if (ok) { + $backendUpdateApply.set({ ...$backendUpdateApply.get(), applying: true, stage: 'restart', message: 'Backend restarting to load the update…' }) + await waitForBackendReturn() + $backendUpdateApply.set(IDLE) + void checkBackendUpdates() + + return { ok: true, message: 'Backend update applied.' } + } + $backendUpdateApply.set({ ...$backendUpdateApply.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.' + stage: 'error', + error: 'apply-failed', + message: 'Backend update failed.' }) - return ok - ? { ok: true, message: 'Backend update applied.' } - : { ok: false, error: 'apply-failed', message: 'Backend update 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 })