349 lines
10 KiB
TypeScript
349 lines
10 KiB
TypeScript
/**
|
||
* 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<UpdateStatus>): 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<UpdateInfo | null> {
|
||
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<void> {
|
||
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();
|