feat(desktop): add auto-update functionality

Implement one-click desktop auto-update with version checking, download progress, and automatic installation. Includes toast notification UI in bottom-right corner showing update status (checking, available, downloading, ready, or error).

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Jiang Bohan 2026-02-11 13:59:58 +08:00
parent 7562009a83
commit 0459769746
9 changed files with 428 additions and 95 deletions

View file

@ -48,6 +48,7 @@ import { app, BrowserWindow, shell, ipcMain } from 'electron'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import { registerAllIpcHandlers, initializeApp, cleanupAll, setupDeviceConfirmation } from './ipc/index.js'
import { createUpdater, AutoUpdater } from './updater/index.js'
// CJS output will have __dirname natively, but TypeScript source needs this for type checking
const __dirname = path.dirname(fileURLToPath(import.meta.url))
@ -67,6 +68,7 @@ process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT,
const forceOnboarding = process.argv.includes('--force-onboarding')
let win: BrowserWindow | null
let updater: AutoUpdater
function createWindow() {
win = new BrowserWindow({
@ -128,4 +130,30 @@ app.whenReady().then(async () => {
if (win) {
setupDeviceConfirmation(win)
}
// Initialize auto-updater
const forceDevUpdate = process.env.FORCE_DEV_UPDATE === 'true'
updater = createUpdater(forceDevUpdate)
updater.setMainWindow(() => win)
// Auto-check for updates in production (or when forced in dev)
const isDev = !!VITE_DEV_SERVER_URL
if (!isDev || forceDevUpdate) {
win?.once('ready-to-show', () => {
updater.checkForUpdates()
})
}
// Update IPC handlers
ipcMain.handle('update:check', async () => {
await updater.checkForUpdates()
})
ipcMain.handle('update:download', async () => {
await updater.downloadUpdate()
})
ipcMain.handle('update:install', () => {
updater.quitAndInstall()
})
})

View file

@ -0,0 +1,121 @@
/**
* Auto-updater module using electron-updater
* Checks for updates from GitHub releases and handles download/install
*/
import { autoUpdater, UpdateInfo, ProgressInfo } from 'electron-updater'
import { BrowserWindow } from 'electron'
export interface UpdateStatus {
status: 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error'
info?: UpdateInfo
progress?: ProgressInfo
error?: string
}
export class AutoUpdater {
private mainWindow: (() => BrowserWindow | null) | null = null
constructor(forceDevUpdateConfig = false) {
// Configure auto-updater
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = true
// Enable update checking in dev mode for testing
if (forceDevUpdateConfig) {
autoUpdater.forceDevUpdateConfig = true
console.log('[AutoUpdater] Force dev update config enabled')
}
// Enable logging
autoUpdater.logger = {
info: (msg) => console.log('[AutoUpdater]', msg),
warn: (msg) => console.warn('[AutoUpdater]', msg),
error: (msg) => console.error('[AutoUpdater]', msg),
debug: (msg) => console.log('[AutoUpdater:debug]', msg)
}
// Set up event handlers
autoUpdater.on('checking-for-update', () => {
this.sendStatus({ status: 'checking' })
})
autoUpdater.on('update-available', (info: UpdateInfo) => {
this.sendStatus({ status: 'available', info })
})
autoUpdater.on('update-not-available', (info: UpdateInfo) => {
this.sendStatus({ status: 'not-available', info })
})
autoUpdater.on('download-progress', (progress: ProgressInfo) => {
this.sendStatus({ status: 'downloading', progress })
})
autoUpdater.on('update-downloaded', (info: UpdateInfo) => {
this.sendStatus({ status: 'downloaded', info })
})
autoUpdater.on('error', (err: Error) => {
this.sendStatus({ status: 'error', error: err.message })
})
}
/**
* Set the main window reference for sending IPC messages
*/
setMainWindow(getWindow: () => BrowserWindow | null): void {
this.mainWindow = getWindow
}
/**
* Check for updates
*/
async checkForUpdates(): Promise<void> {
try {
await autoUpdater.checkForUpdates()
} catch (err) {
console.error('[AutoUpdater] Check for updates failed:', err)
this.sendStatus({
status: 'error',
error: err instanceof Error ? err.message : 'Unknown error'
})
}
}
/**
* Download the available update
*/
async downloadUpdate(): Promise<void> {
try {
await autoUpdater.downloadUpdate()
} catch (err) {
console.error('[AutoUpdater] Download update failed:', err)
this.sendStatus({
status: 'error',
error: err instanceof Error ? err.message : 'Download failed'
})
}
}
/**
* Quit and install the downloaded update
*/
quitAndInstall(): void {
autoUpdater.quitAndInstall()
}
/**
* Send update status to renderer
*/
private sendStatus(status: UpdateStatus): void {
const window = this.mainWindow?.()
if (window && !window.isDestroyed()) {
window.webContents.send('update:status', status)
}
}
}
// Factory function to create updater with options
export function createUpdater(forceDevUpdateConfig = false): AutoUpdater {
return new AutoUpdater(forceDevUpdateConfig)
}

View file

@ -237,6 +237,26 @@ const electronAPI = {
wake: (reason?: string) => ipcRenderer.invoke('heartbeat:wake', reason),
},
// Auto-update
update: {
/** Check for updates */
check: () => ipcRenderer.invoke('update:check'),
/** Download the available update */
download: () => ipcRenderer.invoke('update:download'),
/** Quit and install the downloaded update */
install: () => ipcRenderer.invoke('update:install'),
/** Listen for update status changes (returns unsubscribe function) */
onStatus: (callback: (status: { status: string; info?: unknown; progress?: unknown; error?: string }) => void) => {
const listener = (_event: Electron.IpcRendererEvent, status: Parameters<typeof callback>[0]): void => {
callback(status)
}
ipcRenderer.on('update:status', listener)
return (): void => {
ipcRenderer.removeListener('update:status', listener)
}
},
},
// Local chat (direct IPC, no Gateway required)
localChat: {
/** Subscribe to agent events for local direct chat */

View file

@ -0,0 +1,144 @@
/**
* Update notification component
* Shows when a new version is available and allows user to download/install
*/
import { useState, useEffect } from 'react'
import { HugeiconsIcon } from '@hugeicons/react'
import {
Download04Icon,
Loading03Icon,
CheckmarkCircle02Icon,
AlertCircleIcon,
Cancel01Icon,
} from '@hugeicons/core-free-icons'
import { Button } from '@multica/ui/components/ui/button'
interface UpdateInfo {
version: string
releaseDate?: string
releaseNotes?: string | null
}
interface UpdateProgress {
percent: number
bytesPerSecond: number
total: number
transferred: number
}
interface UpdateStatus {
status: 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error'
info?: UpdateInfo
progress?: UpdateProgress
error?: string
}
export function UpdateNotification(): React.JSX.Element | null {
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null)
const [dismissed, setDismissed] = useState(false)
useEffect(() => {
const unsubscribe = window.electronAPI.update.onStatus((status: UpdateStatus) => {
setUpdateStatus(status)
// Reset dismissed state when a new update becomes available
if (status.status === 'available') {
setDismissed(false)
}
})
return () => unsubscribe()
}, [])
const handleDownload = async (): Promise<void> => {
await window.electronAPI.update.download()
}
const handleInstall = (): void => {
window.electronAPI.update.install()
}
const handleDismiss = (): void => {
setDismissed(true)
}
// Don't show if dismissed or no relevant status
if (dismissed) return null
if (!updateStatus) return null
if (updateStatus.status === 'checking' || updateStatus.status === 'not-available') return null
const version = updateStatus.info?.version
const isError = updateStatus.status === 'error'
return (
<div className="fixed bottom-4 right-4 z-50 animate-in slide-in-from-bottom-2 fade-in duration-300">
<div className="flex items-center gap-3 rounded-lg border bg-card p-3 shadow-lg">
{/* Icon */}
<div
className={`flex h-8 w-8 items-center justify-center rounded-full ${isError ? 'bg-destructive/10' : 'bg-primary/10'}`}
>
{isError ? (
<HugeiconsIcon icon={AlertCircleIcon} className="h-4 w-4 text-destructive" />
) : updateStatus.status === 'downloaded' ? (
<HugeiconsIcon icon={CheckmarkCircle02Icon} className="h-4 w-4 text-primary" />
) : updateStatus.status === 'downloading' ? (
<HugeiconsIcon icon={Loading03Icon} className="h-4 w-4 text-primary animate-spin" />
) : (
<HugeiconsIcon icon={Download04Icon} className="h-4 w-4 text-primary" />
)}
</div>
{/* Content */}
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">
{isError
? 'Update failed'
: updateStatus.status === 'downloaded'
? 'Update ready'
: updateStatus.status === 'downloading'
? 'Downloading update...'
: 'Update available'}
</span>
<span className="text-xs text-muted-foreground">
{isError
? 'Please download manually from GitHub'
: updateStatus.status === 'downloading' && updateStatus.progress
? `${Math.round(updateStatus.progress.percent)}%`
: version
? `Version ${version}`
: 'New version available'}
</span>
</div>
{/* Actions */}
<div className="flex items-center gap-1 ml-2">
{updateStatus.status === 'available' && (
<Button size="sm" variant="default" onClick={handleDownload}>
Download
</Button>
)}
{updateStatus.status === 'downloaded' && (
<Button size="sm" variant="default" onClick={handleInstall}>
Restart
</Button>
)}
{isError && (
<Button
size="sm"
variant="outline"
onClick={() =>
window.open('https://github.com/multica-ai/multica/releases', '_blank')
}
>
View Releases
</Button>
)}
{updateStatus.status !== 'downloading' && (
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={handleDismiss}>
<HugeiconsIcon icon={Cancel01Icon} className="h-4 w-4" />
</Button>
)}
</div>
</div>
</div>
)
}

View file

@ -13,6 +13,7 @@ import {
} from '@hugeicons/core-free-icons'
import { cn } from '@multica/ui/lib/utils'
import { DeviceConfirmDialog } from '../components/device-confirm-dialog'
import { UpdateNotification } from '../components/update-notification'
import ChatPage from './chat'
const tabs = [
@ -85,6 +86,7 @@ export default function Layout() {
</main>
<Toaster />
<DeviceConfirmDialog />
<UpdateNotification />
</div>
)
}