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

@ -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>
)
}