Files
makelore/electron/main/updater.ts
2026-07-29 17:22:35 +08:00

389 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Auto-Updater Module
* Handles automatic application updates using electron-updater
*
* Stable updates use Works Square target-specific feeds. Prerelease channels
* (alpha, beta) keep their channel-specific OSS directories.
*/
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';
const WORKS_SQUARE_UPDATE_BASE_URL = 'https://square.nianxx.cn/api/app-updates';
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 type UpdateFeedResolution =
| { channel: string; url: string; error?: never }
| { channel: 'latest'; url?: never; error: string };
export function resolveUpdateFeed(
version: string,
platform = process.platform,
architecture = process.arch,
): UpdateFeedResolution {
const channel = detectChannel(version);
if (channel !== 'latest') {
return { channel, url: `${OSS_BASE_URL}/${channel}` };
}
if (platform === 'win32' && architecture === 'x64') {
return { channel, url: `${WORKS_SQUARE_UPDATE_BASE_URL}/windows/x64` };
}
if (platform === 'darwin' && (architecture === 'x64' || architecture === 'arm64')) {
return { channel, url: `${WORKS_SQUARE_UPDATE_BASE_URL}/mac/${architecture}` };
}
return { channel, error: `Unsupported update platform: ${platform}/${architecture}` };
}
export class AppUpdater extends EventEmitter {
private mainWindow: BrowserWindow | null = null;
private status: UpdateStatus = { status: 'idle' };
private autoInstallTimer: NodeJS.Timeout | null = null;
private autoInstallCountdown = 0;
private readonly updateFeedError: string | null;
/** Delay (in seconds) before auto-installing a downloaded update. */
private static readonly AUTO_INSTALL_DELAY_SECONDS = 5;
constructor() {
super();
// EventEmitter treats an unhandled 'error' event as fatal. Keep a default
// listener so updater failures surface in logs/UI without terminating main.
this.on('error', (error: Error) => {
logger.error('[Updater] AppUpdater emitted error:', error);
});
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),
};
// Select the stable target feed or keep the existing prerelease channel feed.
const version = app.getVersion();
const feed = resolveUpdateFeed(version);
this.updateFeedError = feed.error ?? null;
logger.info(`[Updater] Version: ${version}, channel: ${feed.channel}, feedUrl: ${feed.url ?? 'unsupported'}`);
// 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 = feed.channel;
if (feed.url) {
autoUpdater.setFeedURL({
provider: 'generic',
url: feed.url,
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.
* The runtime feed is selected during construction for the current target.
*
* 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 {
this.updateStatus({ status: 'checking' });
if (this.updateFeedError) {
const error = new Error(this.updateFeedError);
this.emit('error', error);
throw error;
}
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(result.isUpdateAvailable
? { status: 'available', info: result.updateInfo }
: { 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();