Makelore 2.0 initial clean snapshot

This commit is contained in:
inman
2026-07-29 17:22:35 +08:00
commit b8ca3f8eea
694 changed files with 139782 additions and 0 deletions

388
electron/main/updater.ts Normal file
View File

@@ -0,0 +1,388 @@
/**
* 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();