Merge feat/auto-update: add auto-update functionality

This commit is contained in:
Jiang Bohan
2026-01-16 16:23:59 +08:00
12 changed files with 4667 additions and 5361 deletions

3
dev-app-update.yml Normal file
View File

@@ -0,0 +1,3 @@
provider: github
owner: multica-ai
repo: multica

View File

@@ -39,5 +39,6 @@ appImage:
artifactName: ${name}-${version}.${ext}
npmRebuild: false
publish:
provider: generic
url: https://example.com/auto-updates
provider: github
owner: multica-ai
repo: multica

View File

@@ -45,6 +45,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"electron-updater": "^6.7.3",
"lucide-react": "^0.562.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",

9700
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,11 +7,13 @@ import { Conductor } from './conductor/Conductor'
import { IPC_CHANNELS } from '../shared/ipc-channels'
import type { PermissionResponse } from '../shared/electron-api'
import { PermissionManager } from './permission'
import { createUpdater, AutoUpdater } from './updater'
// Global instances
let conductor: Conductor
let mainWindow: BrowserWindow | null = null
let permissionManager: PermissionManager
let updater: AutoUpdater
function createWindow(): BrowserWindow {
const window = new BrowserWindow({
@@ -121,6 +123,32 @@ app.whenReady().then(async () => {
mainWindow = createWindow()
// Initialize auto-updater
// Set FORCE_DEV_UPDATE=true to test updates in dev mode
const forceDevUpdate = process.env.FORCE_DEV_UPDATE === 'true'
updater = createUpdater(forceDevUpdate)
updater.setMainWindow(() => mainWindow)
// Auto-check for updates (in production or when forced)
if (!is.dev || forceDevUpdate) {
mainWindow.once('ready-to-show', () => {
updater.checkForUpdates()
})
}
// Register update IPC handlers
ipcMain.handle(IPC_CHANNELS.UPDATE_CHECK, async () => {
await updater.checkForUpdates()
})
ipcMain.handle(IPC_CHANNELS.UPDATE_DOWNLOAD, async () => {
await updater.downloadUpdate()
})
ipcMain.handle(IPC_CHANNELS.UPDATE_INSTALL, () => {
updater.quitAndInstall()
})
app.on('activate', function () {
// On macOS it's common to re-create a window when dock icon is clicked
if (BrowserWindow.getAllWindows().length === 0) createWindow()

122
src/main/updater/index.ts Normal file
View File

@@ -0,0 +1,122 @@
/**
* 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'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
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(IPC_CHANNELS.UPDATE_STATUS, status)
}
}
}
// Factory function to create updater with options
export function createUpdater(forceDevUpdateConfig = false): AutoUpdater {
return new AutoUpdater(forceDevUpdateConfig)
}

View File

@@ -103,7 +103,19 @@ const electronAPI: ElectronAPI = {
},
// Terminal
runInTerminal: (command: string) => ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_RUN, command)
runInTerminal: (command: string) => ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_RUN, command),
// Auto-update
checkForUpdates: () => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_CHECK),
downloadUpdate: () => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_DOWNLOAD),
installUpdate: () => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_INSTALL),
onUpdateStatus: (callback) => {
const listener = (_event: Electron.IpcRendererEvent, status: unknown) =>
callback(status as Parameters<typeof callback>[0])
ipcRenderer.on(IPC_CHANNELS.UPDATE_STATUS, listener)
return () => ipcRenderer.removeListener(IPC_CHANNELS.UPDATE_STATUS, listener)
}
}
// Expose API to renderer via contextBridge

View File

@@ -3,7 +3,7 @@
*/
import { useState } from 'react'
import { useApp } from './hooks/useApp'
import { ChatView, MessageInput, StatusBar } from './components'
import { ChatView, MessageInput, StatusBar, UpdateNotification } from './components'
import { AppSidebar } from './components/AppSidebar'
import { Modals } from './components/Modals'
import { ThemeProvider } from './contexts/ThemeContext'
@@ -170,6 +170,9 @@ function AppContent(): React.JSX.Element {
{/* Toast notifications */}
<Toaster position="bottom-right" />
{/* Update notification */}
<UpdateNotification />
</div>
)
}

View File

@@ -0,0 +1,114 @@
/**
* Update notification component
* Shows when a new version is available and allows user to download/install
*/
import { useState, useEffect } from 'react'
import { Download, RefreshCw, X, CheckCircle, AlertCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import type { UpdateStatus } from '../../../shared/electron-api'
export function UpdateNotification(): React.JSX.Element | null {
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null)
const [dismissed, setDismissed] = useState(false)
useEffect(() => {
const unsubscribe = window.electronAPI.onUpdateStatus((status) => {
setUpdateStatus(status)
// Reset dismissed state when a new update becomes available
if (status.status === 'available') {
setDismissed(false)
}
})
return () => unsubscribe()
}, [])
const handleDownload = async () => {
await window.electronAPI.downloadUpdate()
}
const handleInstall = () => {
window.electronAPI.installUpdate()
}
const handleDismiss = () => {
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 ? (
<AlertCircle className="h-4 w-4 text-destructive" />
) : updateStatus.status === 'downloaded' ? (
<CheckCircle className="h-4 w-4 text-primary" />
) : updateStatus.status === 'downloading' ? (
<RefreshCw className="h-4 w-4 text-primary animate-spin" />
) : (
<Download 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}>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
</div>
)
}

View File

@@ -4,3 +4,4 @@ export { ChatView } from './ChatView'
export { MessageInput } from './MessageInput'
export { StatusBar } from './StatusBar'
export { Settings } from './Settings'
export { UpdateNotification } from './UpdateNotification'

View File

@@ -127,6 +127,27 @@ export interface InstallResult {
error?: string
}
// Auto-update types
export interface UpdateInfo {
version: string
releaseDate?: string
releaseNotes?: string | null
}
export interface UpdateProgress {
percent: number
bytesPerSecond: number
total: number
transferred: number
}
export interface UpdateStatus {
status: 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error'
info?: UpdateInfo
progress?: UpdateProgress
error?: string
}
export interface ElectronAPI {
// Agent status (per-session agents)
getAgentStatus(): Promise<RunningSessionsStatus>
@@ -177,6 +198,12 @@ export interface ElectronAPI {
// Terminal
runInTerminal(command: string): Promise<void>
// Auto-update
checkForUpdates(): Promise<void>
downloadUpdate(): Promise<void>
installUpdate(): Promise<void>
onUpdateStatus(callback: (status: UpdateStatus) => void): () => void
}
declare global {

View File

@@ -49,7 +49,13 @@ export const IPC_CHANNELS = {
FS_OPEN_WITH: 'fs:open-with',
// Terminal
TERMINAL_RUN: 'terminal:run'
TERMINAL_RUN: 'terminal:run',
// Auto-update
UPDATE_CHECK: 'update:check',
UPDATE_DOWNLOAD: 'update:download',
UPDATE_INSTALL: 'update:install',
UPDATE_STATUS: 'update:status'
} as const
export type IPCChannel = (typeof IPC_CHANNELS)[keyof typeof IPC_CHANNELS]