/** * Auto-Updater Module * Handles automatic application updates using electron-updater * * Update providers are configured in electron-builder.yml (OSS primary, GitHub fallback). * For prerelease channels (alpha, beta), the feed URL is overridden at runtime * to point at the channel-specific OSS directory (e.g. /alpha/, /beta/). */ import { autoUpdater, UpdateInfo, ProgressInfo, UpdateDownloadedEvent } from 'electron-updater'; import { BrowserWindow, app, ipcMain } from 'electron'; import { logger } from '../utils/logger'; import { EventEmitter } from 'events'; import { setQuitting } from './app-state'; /** Base CDN URL (without trailing channel path) */ const OSS_BASE_URL = 'https://oss.intelli-spectrum.com'; export interface UpdateStatus { status: 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error'; info?: UpdateInfo; progress?: ProgressInfo; error?: string; } export interface UpdaterEvents { 'status-changed': (status: UpdateStatus) => void; 'checking-for-update': () => void; 'update-available': (info: UpdateInfo) => void; 'update-not-available': (info: UpdateInfo) => void; 'download-progress': (progress: ProgressInfo) => void; 'update-downloaded': (event: UpdateDownloadedEvent) => void; 'error': (error: Error) => void; } /** * Detect the update channel from a semver version string. * e.g. "0.1.8-alpha.0" → "alpha", "1.0.0-beta.1" → "beta", "1.0.0" → "latest" */ function detectChannel(version: string): string { const match = version.match(/-([a-zA-Z]+)/); return match ? match[1] : 'latest'; } export class AppUpdater extends EventEmitter { private mainWindow: BrowserWindow | null = null; private status: UpdateStatus = { status: 'idle' }; private autoInstallTimer: NodeJS.Timeout | null = null; private autoInstallCountdown = 0; /** Delay (in seconds) before auto-installing a downloaded update. */ private static readonly AUTO_INSTALL_DELAY_SECONDS = 5; constructor() { super(); autoUpdater.autoDownload = false; autoUpdater.autoInstallOnAppQuit = true; autoUpdater.logger = { info: (msg: string) => logger.info('[Updater]', msg), warn: (msg: string) => logger.warn('[Updater]', msg), error: (msg: string) => logger.error('[Updater]', msg), debug: (msg: string) => logger.debug('[Updater]', msg), }; // Override feed URL for prerelease channels so that // alpha -> /alpha/alpha-mac.yml, beta -> /beta/beta-mac.yml, etc. const version = app.getVersion(); const channel = detectChannel(version); const feedUrl = `${OSS_BASE_URL}/${channel}`; logger.info(`[Updater] Version: ${version}, channel: ${channel}, feedUrl: ${feedUrl}`); // Set channel so electron-updater requests the correct yml filename. // e.g. channel "alpha" → requests alpha-mac.yml, channel "latest" → requests latest-mac.yml autoUpdater.channel = channel; autoUpdater.setFeedURL({ provider: 'generic', url: feedUrl, useMultipleRangeRequest: false, }); this.setupListeners(); } /** * Set the main window for sending update events */ setMainWindow(window: BrowserWindow): void { this.mainWindow = window; } /** * Get current update status */ getStatus(): UpdateStatus { return this.status; } /** * Setup auto-updater event listeners */ private setupListeners(): void { autoUpdater.on('checking-for-update', () => { this.updateStatus({ status: 'checking' }); this.emit('checking-for-update'); }); autoUpdater.on('update-available', (info: UpdateInfo) => { this.updateStatus({ status: 'available', info }); this.emit('update-available', info); }); autoUpdater.on('update-not-available', (info: UpdateInfo) => { this.updateStatus({ status: 'not-available', info }); this.emit('update-not-available', info); }); autoUpdater.on('download-progress', (progress: ProgressInfo) => { this.updateStatus({ status: 'downloading', progress }); this.emit('download-progress', progress); }); autoUpdater.on('update-downloaded', (event: UpdateDownloadedEvent) => { this.updateStatus({ status: 'downloaded', info: event }); this.emit('update-downloaded', event); if (autoUpdater.autoDownload) { this.startAutoInstallCountdown(); } }); autoUpdater.on('error', (error: Error) => { this.updateStatus({ status: 'error', error: error.message }); this.emit('error', error); }); } /** * Update status and notify renderer */ private updateStatus(newStatus: Partial): void { this.status = { status: newStatus.status ?? this.status.status, info: newStatus.info, progress: newStatus.progress, error: newStatus.error, }; this.sendToRenderer('update:status-changed', this.status); } /** * Send event to renderer process */ private sendToRenderer(channel: string, data: unknown): void { if (this.mainWindow && !this.mainWindow.isDestroyed()) { this.mainWindow.webContents.send(channel, data); } } /** * Check for updates. * electron-updater automatically tries providers defined in electron-builder.yml in order. * * In dev mode (not packed), autoUpdater.checkForUpdates() silently returns * null without emitting any events, so we must detect this and force a * final status so the UI never gets stuck in 'checking'. */ async checkForUpdates(): Promise { try { const result = await autoUpdater.checkForUpdates(); // In dev mode (app not packaged), autoUpdater silently returns null // without emitting ANY events (not even checking-for-update). // Detect this and force an error so the UI never stays silent. if (result == null) { this.updateStatus({ status: 'error', error: 'Update check skipped (dev mode – app is not packaged)', }); return null; } // Safety net: if events somehow didn't fire, force a final state. if (this.status.status === 'checking' || this.status.status === 'idle') { this.updateStatus({ status: 'not-available' }); } return result.updateInfo || null; } catch (error) { logger.error('[Updater] Check for updates failed:', error); this.updateStatus({ status: 'error', error: (error as Error).message || String(error) }); throw error; } } /** * Download available update */ async downloadUpdate(): Promise { try { await autoUpdater.downloadUpdate(); } catch (error) { logger.error('[Updater] Download update failed:', error); throw error; } } /** * Install update and restart. * * On macOS, electron-updater delegates to Squirrel.Mac (ShipIt). The * native quitAndInstall() spawns ShipIt then internally calls app.quit(). * However, the tray close handler in index.ts intercepts window close * and hides to tray unless isQuitting is true. Squirrel's internal quit * sometimes fails to trigger before-quit in time, so we set isQuitting * BEFORE calling quitAndInstall(). This lets the native quit flow close * the window cleanly while ShipIt runs independently to replace the app. */ quitAndInstall(): void { logger.info('[Updater] quitAndInstall called'); setQuitting(); autoUpdater.quitAndInstall(); } /** * Start a countdown that auto-installs the downloaded update. * Sends `update:auto-install-countdown` events to the renderer each second. */ private startAutoInstallCountdown(): void { this.clearAutoInstallTimer(); this.autoInstallCountdown = AppUpdater.AUTO_INSTALL_DELAY_SECONDS; this.sendToRenderer('update:auto-install-countdown', { seconds: this.autoInstallCountdown }); this.autoInstallTimer = setInterval(() => { this.autoInstallCountdown--; this.sendToRenderer('update:auto-install-countdown', { seconds: this.autoInstallCountdown }); if (this.autoInstallCountdown <= 0) { this.clearAutoInstallTimer(); this.quitAndInstall(); } }, 1000); } cancelAutoInstall(): void { this.clearAutoInstallTimer(); this.sendToRenderer('update:auto-install-countdown', { seconds: -1, cancelled: true }); } private clearAutoInstallTimer(): void { if (this.autoInstallTimer) { clearInterval(this.autoInstallTimer); this.autoInstallTimer = null; } } /** * Set update channel (stable, beta, dev) */ setChannel(channel: 'stable' | 'beta' | 'dev'): void { autoUpdater.channel = channel; } /** * Set auto-download preference */ setAutoDownload(enable: boolean): void { autoUpdater.autoDownload = enable; } /** * Get current version */ getCurrentVersion(): string { return app.getVersion(); } } /** * Register IPC handlers for update operations */ export function registerUpdateHandlers( updater: AppUpdater, mainWindow: BrowserWindow ): void { updater.setMainWindow(mainWindow); // Get current update status ipcMain.handle('update:status', () => { return updater.getStatus(); }); // Get current version ipcMain.handle('update:version', () => { return updater.getCurrentVersion(); }); // Check for updates – always return final status so the renderer // never gets stuck in 'checking' waiting for a push event. ipcMain.handle('update:check', async () => { try { await updater.checkForUpdates(); return { success: true, status: updater.getStatus() }; } catch (error) { return { success: false, error: String(error), status: updater.getStatus() }; } }); // Download update ipcMain.handle('update:download', async () => { try { await updater.downloadUpdate(); return { success: true }; } catch (error) { return { success: false, error: String(error) }; } }); // Install update and restart ipcMain.handle('update:install', () => { updater.quitAndInstall(); return { success: true }; }); // Set update channel ipcMain.handle('update:setChannel', (_, channel: 'stable' | 'beta' | 'dev') => { updater.setChannel(channel); return { success: true }; }); // Set auto-download preference ipcMain.handle('update:setAutoDownload', (_, enable: boolean) => { updater.setAutoDownload(enable); return { success: true }; }); // Cancel pending auto-install countdown ipcMain.handle('update:cancelAutoInstall', () => { updater.cancelAutoInstall(); return { success: true }; }); } // Export singleton instance export const appUpdater = new AppUpdater();