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 })