/** * Update State Store * Manages application update state */ import { create } from 'zustand'; import { useSettingsStore } from './settings'; import { invokeIpc } from '@/lib/api-client'; export interface UpdateInfo { version: string; releaseDate?: string; releaseNotes?: string | null; } export interface ProgressInfo { total: number; delta: number; transferred: number; percent: number; bytesPerSecond: number; } export type UpdateStatus = | 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error'; interface UpdateState { status: UpdateStatus; currentVersion: string; updateInfo: UpdateInfo | null; progress: ProgressInfo | null; error: string | null; isInitialized: boolean; /** Seconds remaining before auto-install, or null if inactive. */ autoInstallCountdown: number | null; // Actions init: () => Promise; checkForUpdates: () => Promise; downloadUpdate: () => Promise; installUpdate: () => void; cancelAutoInstall: () => Promise; setChannel: (channel: 'stable' | 'beta' | 'dev') => Promise; setAutoDownload: (enable: boolean) => Promise; clearError: () => void; } export const useUpdateStore = create((set, get) => ({ status: 'idle', currentVersion: '0.0.0', updateInfo: null, progress: null, error: null, isInitialized: false, autoInstallCountdown: null, init: async () => { if (get().isInitialized) return; // Get current version try { const version = await invokeIpc('update:version'); set({ currentVersion: version as string }); } catch (error) { console.error('Failed to get version:', error); } // Get current status try { const status = await invokeIpc<{ status: UpdateStatus; info?: UpdateInfo; progress?: ProgressInfo; error?: string; }>('update:status'); set({ status: status.status, updateInfo: status.info || null, progress: status.progress || null, error: status.error || null, }); } catch (error) { console.error('Failed to get update status:', error); } // Listen for update events // Single source of truth: listen only to update:status-changed // (sent by AppUpdater.updateStatus() in the main process) window.electron.ipcRenderer.on('update:status-changed', (data) => { const status = data as { status: UpdateStatus; info?: UpdateInfo; progress?: ProgressInfo; error?: string; }; set({ status: status.status, updateInfo: status.info || null, progress: status.progress || null, error: status.error || null, }); }); window.electron.ipcRenderer.on('update:auto-install-countdown', (data) => { const { seconds, cancelled } = data as { seconds: number; cancelled?: boolean }; set({ autoInstallCountdown: cancelled ? null : seconds }); }); set({ isInitialized: true }); // Apply persisted settings from the settings store const { autoCheckUpdate, autoDownloadUpdate } = useSettingsStore.getState(); // Sync auto-download preference to the main process if (autoDownloadUpdate) { invokeIpc('update:setAutoDownload', true).catch(() => {}); } // Auto-check for updates on startup (respects user toggle) if (autoCheckUpdate) { setTimeout(() => { get().checkForUpdates().catch(() => {}); }, 10000); } }, checkForUpdates: async () => { set({ status: 'checking', error: null }); try { const result = await Promise.race([ invokeIpc('update:check'), new Promise((_, reject) => setTimeout(() => reject(new Error('Update check timed out')), 30000)) ]) as { success: boolean; error?: string; status?: { status: UpdateStatus; info?: UpdateInfo; progress?: ProgressInfo; error?: string; }; }; if (result.status) { set({ status: result.status.status, updateInfo: result.status.info || null, progress: result.status.progress || null, error: result.status.error || null, }); } else if (!result.success) { set({ status: 'error', error: result.error || 'Failed to check for updates' }); } } catch (error) { set({ status: 'error', error: String(error) }); } finally { // In dev mode autoUpdater skips without emitting events, so the // status may still be 'checking' or even 'idle'. Catch both. const currentStatus = get().status; if (currentStatus === 'checking' || currentStatus === 'idle') { set({ status: 'error', error: 'Update check completed without a result. This usually means the app is running in dev mode.' }); } } }, downloadUpdate: async () => { set({ status: 'downloading', error: null }); try { const result = await invokeIpc<{ success: boolean; error?: string; }>('update:download'); if (!result.success) { set({ status: 'error', error: result.error || 'Failed to download update' }); } } catch (error) { set({ status: 'error', error: String(error) }); } }, installUpdate: () => { void invokeIpc('update:install'); }, cancelAutoInstall: async () => { try { await invokeIpc('update:cancelAutoInstall'); } catch (error) { console.error('Failed to cancel auto-install:', error); } }, setChannel: async (channel) => { try { await invokeIpc('update:setChannel', channel); } catch (error) { console.error('Failed to set update channel:', error); } }, setAutoDownload: async (enable) => { try { await invokeIpc('update:setAutoDownload', enable); } catch (error) { console.error('Failed to set auto-download:', error); } }, clearError: () => set({ error: null, status: 'idle' }), }));