Files
DeskClaw/src/stores/update.ts
2026-03-08 11:54:49 +08:00

219 lines
5.9 KiB
TypeScript

/**
* 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<void>;
checkForUpdates: () => Promise<void>;
downloadUpdate: () => Promise<void>;
installUpdate: () => void;
cancelAutoInstall: () => Promise<void>;
setChannel: (channel: 'stable' | 'beta' | 'dev') => Promise<void>;
setAutoDownload: (enable: boolean) => Promise<void>;
clearError: () => void;
}
export const useUpdateStore = create<UpdateState>((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<string>('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' }),
}));