mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-18 12:07:15 +08:00
feat(desktop): add auto-update via electron-updater + manual CI trigger
This commit is contained in:
@@ -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) => {
|
||||
|
||||
97
desktop/src/renderer/src/components/UpdateBanner.tsx
Normal file
97
desktop/src/renderer/src/components/UpdateBanner.tsx
Normal 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
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
55
desktop/src/renderer/src/store/updateStore.ts
Normal file
55
desktop/src/renderer/src/store/updateStore.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user