feat(desktop): add auto-update via electron-updater + manual CI trigger

This commit is contained in:
zhayujie
2026-06-23 20:37:27 +08:00
parent ec4c36f450
commit e9352e6984
12 changed files with 620 additions and 7 deletions

View File

@@ -5,6 +5,7 @@ import http from 'http'
import { PythonBackend } from './python-manager'
import { buildAppMenu } from './menu'
import { createTray, destroyTray } from './tray'
import { initUpdater, checkForUpdates, startDownload, quitAndInstall } from './updater'
let mainWindow: BrowserWindow | null = null
let pythonBackend: PythonBackend | null = null
@@ -208,6 +209,11 @@ function setupIPC() {
})
ipcMain.handle('window-close', () => mainWindow?.close())
ipcMain.handle('window-is-maximized', () => mainWindow?.isMaximized() ?? false)
// Auto-update controls (renderer-driven: check, then opt-in download/install)
ipcMain.handle('update-check', () => checkForUpdates())
ipcMain.handle('update-download', () => startDownload())
ipcMain.handle('update-install', () => quitAndInstall())
}
function emitMaximizeState() {
@@ -259,6 +265,11 @@ app.whenReady().then(async () => {
})
await startBackend()
// Wire auto-update and do a first silent check a few seconds after launch so
// it doesn't compete with backend startup for resources.
initUpdater(() => mainWindow)
setTimeout(() => checkForUpdates(), 5000)
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()

View File

@@ -39,5 +39,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
return () => ipcRenderer.removeListener('menu-action', handler)
},
// Auto-update: trigger checks/download/install and subscribe to status.
checkForUpdate: () => ipcRenderer.invoke('update-check'),
downloadUpdate: () => ipcRenderer.invoke('update-download'),
installUpdate: () => ipcRenderer.invoke('update-install'),
onUpdateStatus: (callback: (status: unknown) => void) => {
const handler = (_event: unknown, status: unknown) => callback(status)
ipcRenderer.on('update-status', handler)
return () => ipcRenderer.removeListener('update-status', handler)
},
platform: process.platform,
})

View File

@@ -0,0 +1,72 @@
import { app, BrowserWindow } from 'electron'
import pkg from 'electron-updater'
// electron-updater ships as CommonJS; grab autoUpdater from the default export.
const { autoUpdater } = pkg
// Status payloads pushed to the renderer over the 'update-status' channel.
// The renderer drives the NavRail badge + update panel from these.
export type UpdateStatus =
| { state: 'checking' }
| { state: 'available'; version: string; notes?: string }
| { state: 'not-available' }
| { state: 'downloading'; percent: number }
| { state: 'downloaded'; version: string }
| { state: 'error'; message: string }
let getWindow: () => BrowserWindow | null = () => null
function send(status: UpdateStatus) {
getWindow()?.webContents.send('update-status', status)
}
export function initUpdater(windowGetter: () => BrowserWindow | null): void {
getWindow = windowGetter
// In dev (not packaged) there's no update feed; skip wiring entirely so
// electron-updater doesn't throw on the missing app-update.yml.
if (!app.isPackaged) {
return
}
// User-driven flow: we surface "available" and let the user opt in to the
// download, rather than pulling bytes silently in the background.
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = true
autoUpdater.on('checking-for-update', () => send({ state: 'checking' }))
autoUpdater.on('update-available', (info) =>
send({ state: 'available', version: info.version, notes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined })
)
autoUpdater.on('update-not-available', () => send({ state: 'not-available' }))
autoUpdater.on('download-progress', (p) =>
send({ state: 'downloading', percent: Math.round(p.percent) })
)
autoUpdater.on('update-downloaded', (info) =>
send({ state: 'downloaded', version: info.version })
)
autoUpdater.on('error', (err) =>
send({ state: 'error', message: err == null ? 'unknown' : (err.message || String(err)) })
)
}
// Silent check shortly after launch; safe to call when not packaged (no-op).
export function checkForUpdates(): void {
if (!app.isPackaged) return
autoUpdater.checkForUpdates().catch((err) => {
send({ state: 'error', message: err?.message || String(err) })
})
}
export function startDownload(): void {
if (!app.isPackaged) return
autoUpdater.downloadUpdate().catch((err) => {
send({ state: 'error', message: err?.message || String(err) })
})
}
export function quitAndInstall(): void {
if (!app.isPackaged) return
// isSilent=false (show installer), isForceRunAfter=true (relaunch after).
autoUpdater.quitAndInstall(false, true)
}