From 15dfa0a68c2ec3bd5459a816e66a9e065ac2452d Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 3 May 2026 04:03:07 +0200 Subject: [PATCH] feat(desktop): add RecoveryStateStore with subscriber lifecycle --- packages/desktop/src/recovery.test.ts | 74 +++++++++++++++++++++++++++ packages/desktop/src/recovery.ts | 64 +++++++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 packages/desktop/src/recovery.test.ts create mode 100644 packages/desktop/src/recovery.ts diff --git a/packages/desktop/src/recovery.test.ts b/packages/desktop/src/recovery.test.ts new file mode 100644 index 00000000..5a94896c --- /dev/null +++ b/packages/desktop/src/recovery.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi } from 'vitest'; +import { RecoveryStateStore, type RecoveryState } from './recovery'; + +describe('RecoveryStateStore', () => { + it('returns the initial state', () => { + const store = new RecoveryStateStore(); + const s = store.get(); + expect(s.mode).toBe('normal'); + expect(s.reason).toBeNull(); + expect(s.updateState).toBe('idle'); + expect(s.updateVersion).toBeNull(); + expect(s.lastUpdateError).toBeNull(); + expect(s.lastCheckResult).toBeNull(); + }); + + it('shallow-merges partial updates', () => { + const store = new RecoveryStateStore(); + store.update({ updateState: 'checking' }); + expect(store.get().updateState).toBe('checking'); + expect(store.get().mode).toBe('normal'); + store.update({ updateVersion: '1.2.3' }); + expect(store.get().updateState).toBe('checking'); + expect(store.get().updateVersion).toBe('1.2.3'); + }); + + it('fires listeners on every update', () => { + const store = new RecoveryStateStore(); + const cb = vi.fn(); + store.subscribe(cb); + store.update({ updateState: 'checking' }); + store.update({ updateState: 'idle' }); + expect(cb).toHaveBeenCalledTimes(2); + }); + + it('subscribers receive the latest snapshot', () => { + const store = new RecoveryStateStore(); + let received: RecoveryState | null = null; + store.subscribe((s) => { received = s; }); + store.update({ updateState: 'downloaded', updateVersion: '2.0.0' }); + expect(received).not.toBeNull(); + expect(received!.updateState).toBe('downloaded'); + expect(received!.updateVersion).toBe('2.0.0'); + }); + + it('unsubscribe stops callbacks', () => { + const store = new RecoveryStateStore(); + const cb = vi.fn(); + const unsub = store.subscribe(cb); + store.update({ updateState: 'checking' }); + unsub(); + store.update({ updateState: 'idle' }); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('multiple subscribers all receive updates', () => { + const store = new RecoveryStateStore(); + const a = vi.fn(); + const b = vi.fn(); + store.subscribe(a); + store.subscribe(b); + store.update({ updateState: 'checking' }); + expect(a).toHaveBeenCalledTimes(1); + expect(b).toHaveBeenCalledTimes(1); + }); + + it('isInRecoveryMode is false initially, true after markRecoveryEntered, false after markRecoveryExited', () => { + const store = new RecoveryStateStore(); + expect(store.isInRecoveryMode()).toBe(false); + store.markRecoveryEntered(); + expect(store.isInRecoveryMode()).toBe(true); + store.markRecoveryExited(); + expect(store.isInRecoveryMode()).toBe(false); + }); +}); diff --git a/packages/desktop/src/recovery.ts b/packages/desktop/src/recovery.ts new file mode 100644 index 00000000..002bc60c --- /dev/null +++ b/packages/desktop/src/recovery.ts @@ -0,0 +1,64 @@ +export type RecoveryReasonCode = + | 'load-failed' + | 'render-gone' + | 'unresponsive' + | 'renderer-stalled'; + +export type UpdateState = + | 'idle' + | 'checking' + | 'downloading' + | 'downloaded' + | 'error'; + +export interface RecoveryState { + mode: 'normal' | 'recovery'; + reason: { code: RecoveryReasonCode; detail: string } | null; + updateState: UpdateState; + updateVersion: string | null; + lastUpdateError: { message: string; code: string | null; at: number } | null; + lastCheckResult: 'up-to-date' | 'failed' | null; +} + +const INITIAL_STATE: RecoveryState = { + mode: 'normal', + reason: null, + updateState: 'idle', + updateVersion: null, + lastUpdateError: null, + lastCheckResult: null, +}; + +export class RecoveryStateStore { + private state: RecoveryState = { ...INITIAL_STATE }; + private listeners = new Set<(s: RecoveryState) => void>(); + private inRecoveryMode = false; + + get(): Readonly { + return this.state; + } + + update(partial: Partial): void { + this.state = { ...this.state, ...partial }; + for (const cb of this.listeners) cb(this.state); + } + + subscribe(cb: (s: RecoveryState) => void): () => void { + this.listeners.add(cb); + return () => { this.listeners.delete(cb); }; + } + + isInRecoveryMode(): boolean { + return this.inRecoveryMode; + } + + markRecoveryEntered(): void { + this.inRecoveryMode = true; + } + + markRecoveryExited(): void { + this.inRecoveryMode = false; + } +} + +export const recoveryStore = new RecoveryStateStore();