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

View File

@@ -9,6 +9,7 @@ import { useBackend } from './hooks/useBackend'
import { usePlatform } from './hooks/usePlatform'
import { useUIStore } from './store/uiStore'
import { useSessionStore } from './store/sessionStore'
import { initUpdateListener } from './store/updateStore'
import apiClient from './api/client'
import { t } from './i18n'
import ChatPage from './pages/ChatPage'
@@ -32,6 +33,9 @@ const App: React.FC = () => {
if (backend.status === 'ready') apiClient.setBaseUrl(backend.baseUrl)
}, [backend.status, backend.baseUrl])
// Subscribe to auto-update status from the main process (no-op in dev).
useEffect(() => initUpdateListener(), [])
// Handle app-menu / shortcut actions forwarded from the main process.
useEffect(() => {
const off = window.electronAPI?.onMenuAction?.((action) => {

View File

@@ -0,0 +1,97 @@
import React, { useEffect, useState } from 'react'
import { Download, RefreshCw, X, Loader2 } from 'lucide-react'
import { t } from '../i18n'
import { useUpdateStore, hasPendingUpdate } from '../store/updateStore'
// Compact update panel anchored to the NavRail footer. Only mounts content
// when there's a pending update; otherwise renders nothing so it stays out of
// the way until electron-updater reports a new version.
const UpdateBanner: React.FC = () => {
const state = useUpdateStore()
const [open, setOpen] = useState(false)
const pending = hasPendingUpdate(state)
const status = state.status
// Auto-open the panel the moment a new version is first detected.
useEffect(() => {
if (status?.state === 'available') setOpen(true)
}, [status?.state])
if (!pending) return null
const version = state.version
const downloading = status?.state === 'downloading'
const downloaded = status?.state === 'downloaded'
return (
<div className="absolute bottom-14 left-2 right-2 z-40">
{/* Collapsed pill: a red-dotted button that re-opens the panel. */}
{!open && (
<button
onClick={() => setOpen(true)}
className="relative w-full flex items-center gap-2 rounded-btn bg-accent-soft text-accent px-3 py-2 text-[13px] font-medium cursor-pointer hover:bg-accent-soft/80 transition-colors"
>
<span className="absolute -top-1 -left-1 h-2 w-2 rounded-full bg-danger" />
<Download size={15} />
<span className="truncate">{t('update_available')}</span>
</button>
)}
{open && (
<div className="rounded-lg border border-default bg-elevated shadow-lg p-3 space-y-2.5">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-[13px] font-semibold text-content">{t('update_available')}</p>
{version && <p className="text-xs text-content-tertiary mt-0.5">v{version}</p>}
</div>
<button
onClick={() => {
setOpen(false)
state.dismiss()
}}
className="text-content-tertiary hover:text-content cursor-pointer flex-shrink-0"
title={t('update_later')}
>
<X size={15} />
</button>
</div>
{downloading && (
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs text-content-secondary">
<Loader2 size={13} className="animate-spin" />
<span>{t('update_downloading')} {state.percent}%</span>
</div>
<div className="h-1.5 w-full rounded-full bg-surface-2 overflow-hidden">
<div className="h-full bg-accent transition-[width] duration-200" style={{ width: `${state.percent}%` }} />
</div>
</div>
)}
{!downloading && !downloaded && (
<button
onClick={() => state.download()}
className="w-full inline-flex items-center justify-center gap-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover px-3 py-2 text-[13px] font-medium cursor-pointer transition-colors"
>
<Download size={15} />
{t('update_download')}
</button>
)}
{downloaded && (
<button
onClick={() => state.install()}
className="w-full inline-flex items-center justify-center gap-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover px-3 py-2 text-[13px] font-medium cursor-pointer transition-colors"
>
<RefreshCw size={15} />
{t('update_restart')}
</button>
)}
</div>
)}
</div>
)
}
export default UpdateBanner

View File

@@ -31,6 +31,12 @@ const translations: Record<string, Record<string, string>> = {
knowledge_doc_load_error: '文档加载失败',
nav_expand: '展开侧栏',
nav_collapse: '收起侧栏',
update_available: '发现新版本',
update_download: '下载更新',
update_downloading: '正在下载',
update_restart: '重启以更新',
update_later: '稍后',
update_latest: '已是最新版本',
sessions_title: '会话',
session_new: '新对话',
session_rename: '重命名',
@@ -298,6 +304,12 @@ const translations: Record<string, Record<string, string>> = {
menu_settings: 'Settings',
nav_expand: 'Expand sidebar',
nav_collapse: 'Collapse sidebar',
update_available: 'New version available',
update_download: 'Download update',
update_downloading: 'Downloading',
update_restart: 'Restart to update',
update_later: 'Later',
update_latest: 'You are up to date',
sessions_title: 'Chats',
session_new: 'New chat',
session_rename: 'Rename',

View File

@@ -18,6 +18,7 @@ import type { LucideIcon } from 'lucide-react'
import { t, getLang, setLang, Lang } from '../i18n'
import { useUIStore } from '../store/uiStore'
import { useTheme } from '../hooks/useTheme'
import UpdateBanner from '../components/UpdateBanner'
interface NavItem {
path: string
@@ -87,6 +88,11 @@ const NavRail: React.FC<NavRailProps> = ({ onLangChange }) => {
})}
</nav>
{/* Update banner floats above the footer when a new version is pending */}
<div className="relative">
{!collapsed && <UpdateBanner />}
</div>
{/* Footer actions */}
<div className={`flex-shrink-0 px-2 py-2 border-t border-subtle ${collapsed ? 'space-y-0.5' : 'flex items-center gap-1'}`}>
<FooterBtn

View File

@@ -0,0 +1,55 @@
import { create } from 'zustand'
import type { UpdateStatus } from '../types'
interface UpdateState {
status: UpdateStatus | null
/** Latest available version, kept across download progress updates. */
version: string | null
/** Download progress 0-100 while state === 'downloading'. */
percent: number
/** User dismissed the badge for this version (don't nag again until next). */
dismissedVersion: string | null
setStatus: (s: UpdateStatus) => void
dismiss: () => void
// Actions proxied to the main process via the preload bridge.
download: () => void
install: () => void
}
export const useUpdateStore = create<UpdateState>((set, get) => ({
status: null,
version: null,
percent: 0,
dismissedVersion: null,
setStatus: (s) =>
set(() => {
if (s.state === 'available') return { status: s, version: s.version, percent: 0 }
if (s.state === 'downloading') return { status: s, percent: s.percent }
if (s.state === 'downloaded') return { status: s, version: s.version, percent: 100 }
return { status: s }
}),
dismiss: () => set((st) => ({ dismissedVersion: st.version })),
download: () => window.electronAPI?.downloadUpdate?.(),
install: () => window.electronAPI?.installUpdate?.(),
}))
// Subscribe to main-process update events. Returns an unsubscribe fn.
export function initUpdateListener(): (() => void) | undefined {
return window.electronAPI?.onUpdateStatus?.((status) => {
useUpdateStore.getState().setStatus(status as UpdateStatus)
})
}
// Whether a new version should be surfaced (available/downloading/downloaded
// and not dismissed for that version).
export function hasPendingUpdate(state: UpdateState): boolean {
const s = state.status
if (!s) return false
const active = s.state === 'available' || s.state === 'downloading' || s.state === 'downloaded'
return active && state.dismissedVersion !== state.version
}

View File

@@ -17,9 +17,23 @@ export interface ElectronAPI {
windowIsMaximized: () => Promise<boolean>
onMaximizeChange: (callback: (maximized: boolean) => void) => () => void
onMenuAction?: (callback: (action: string) => void) => () => void
// Auto-update
checkForUpdate?: () => Promise<void>
downloadUpdate?: () => Promise<void>
installUpdate?: () => Promise<void>
onUpdateStatus?: (callback: (status: UpdateStatus) => void) => () => void
platform: string
}
// Mirrors UpdateStatus in src/main/updater.ts.
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 }
export interface BackendStatusEvent {
status: 'ready' | 'error' | 'starting'
port?: number