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:
parent
7562009a83
commit
0459769746
9 changed files with 428 additions and 95 deletions
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
121
apps/desktop/src/main/updater/index.ts
Normal file
121
apps/desktop/src/main/updater/index.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
144
apps/desktop/src/renderer/src/components/update-notification.tsx
Normal file
144
apps/desktop/src/renderer/src/components/update-notification.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue