feat: rebuild portable auto-update flow

This commit is contained in:
Misaka
2026-03-20 21:20:28 +08:00
parent 5e50a8fbcf
commit 6ad9463e73
29 changed files with 2333 additions and 13 deletions

View File

@@ -9,7 +9,17 @@
*/
import React, { useState, useEffect } from 'react'
import { LayoutDashboard, Download, Trash2, Settings, Database, User, LogOut } from 'lucide-react'
import {
LayoutDashboard,
Download,
Trash2,
Settings,
Database,
User,
LogOut,
ArrowUpCircle,
LoaderCircle
} from 'lucide-react'
import { useLogger } from './hooks/useLogger'
import LoginDialog from './components/LoginDialog'
import UserSelectionDialog, {
@@ -19,6 +29,12 @@ import { Toast } from './components/ui/Toast'
import ExtractorPage from './pages/ExtractorPage'
import CleanerPage from './pages/CleanerPage'
import SettingsPage from './pages/SettingsPage'
import UpdateDialog from './components/UpdateDialog'
import type {
DownloadReleaseRequest,
UpdateDialogCatalog,
UpdateStatus
} from '../../main/types/update.types'
type Page = 'home' | 'extractor' | 'cleaner' | 'settings'
@@ -51,6 +67,9 @@ function App(): React.JSX.Element {
// Navigation state
const [currentPage, setCurrentPage] = useState<Page>('extractor') // Default to extractor for the new layout
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null)
const [updateCatalog, setUpdateCatalog] = useState<UpdateDialogCatalog | null>(null)
const [showUpdateDialog, setShowUpdateDialog] = useState(false)
// Load error message from sessionStorage
const showError = (message: string) => {
@@ -65,6 +84,46 @@ function App(): React.JSX.Element {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
const unsubscribe = window.electron.update.onStatusChanged((status) => {
setUpdateStatus(status)
if (status.phase === 'available' || status.phase === 'downloaded' || status.phase === 'idle') {
void refreshUpdateCatalog()
}
})
void refreshUpdateState()
return unsubscribe
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
if (isAuthenticated) {
void refreshUpdateState()
void refreshUpdateCatalog()
return
}
setUpdateStatus(null)
setUpdateCatalog(null)
setShowUpdateDialog(false)
}, [isAuthenticated])
const refreshUpdateState = async () => {
const result = await window.electron.update.getStatus()
if (result.success && result.data) {
setUpdateStatus(result.data)
}
}
const refreshUpdateCatalog = async () => {
const result = await window.electron.update.getCatalog()
if (result.success && result.data) {
setUpdateCatalog(result.data)
}
}
const initializeAuth = async () => {
logger.info('=== Starting initializeAuth ===')
try {
@@ -195,6 +254,44 @@ function App(): React.JSX.Element {
setShowLoginDialog(true)
}
const openUpdateDialog = async () => {
await refreshUpdateCatalog()
setShowUpdateDialog(true)
}
const handleInstallUserRelease = async () => {
if (!updateCatalog?.recommendedRelease) {
showError('暂无可安装更新')
return
}
if (updateStatus?.phase !== 'downloaded') {
const downloadResult = await window.electron.update.downloadRelease(updateCatalog.recommendedRelease)
if (!downloadResult.success) {
showError(downloadResult.error || '下载更新失败')
return
}
}
const installResult = await window.electron.update.installDownloaded()
if (!installResult.success) {
showError(installResult.error || '启动安装失败')
}
}
const handleAdminDownloadAndInstall = async (release: DownloadReleaseRequest) => {
const downloadResult = await window.electron.update.downloadRelease(release)
if (!downloadResult.success) {
showError(downloadResult.error || '下载更新失败')
return
}
const installResult = await window.electron.update.installDownloaded()
if (!installResult.success) {
showError(installResult.error || '启动安装失败')
}
}
// Check if should show logout button
// Show logout if: user is Admin, OR user was switched by Admin
const shouldShowLogout = currentUser?.userType === 'Admin' || isSwitchedByAdmin
@@ -317,6 +414,24 @@ function App(): React.JSX.Element {
{ id: 'settings', label: '系统设置 (Settings)', icon: <Settings size={18} /> }
]
const showUpdateEntry =
!!updateStatus &&
((currentUser?.userType === 'User' && updateStatus.phase === 'downloaded') ||
(currentUser?.userType === 'Admin' &&
(updateStatus.adminHasAnyRelease ||
updateStatus.phase === 'downloading' ||
updateStatus.phase === 'downloaded' ||
updateStatus.phase === 'installing')))
const updateButtonLabel =
currentUser?.userType === 'Admin'
? updateStatus?.phase === 'downloading'
? '下载更新中...'
: '有可用版本'
: updateStatus?.phase === 'downloaded'
? `发现稳定版 V${updateStatus.latestVersion}`
: '发现新版本'
return (
<div className="flex flex-col h-screen bg-slate-50 text-slate-800 font-sans overflow-hidden">
{/* ================= 顶部导航与标题栏 ================= */}
@@ -370,6 +485,20 @@ function App(): React.JSX.Element {
className="flex items-center gap-4 text-sm"
style={{ WebkitAppRegion: 'no-drag' } as any}
>
{showUpdateEntry && (
<button
onClick={() => void openUpdateDialog()}
className="inline-flex items-center gap-2 rounded-full border border-blue-500/40 bg-blue-500/10 px-3 py-1.5 text-xs font-medium text-blue-100 transition hover:bg-blue-500/20"
title={updateStatus?.message || '查看更新'}
>
{updateStatus?.phase === 'downloading' || updateStatus?.phase === 'installing' ? (
<LoaderCircle size={15} className="animate-spin" />
) : (
<ArrowUpCircle size={15} />
)}
<span>{updateButtonLabel}</span>
</button>
)}
<div className="flex items-center gap-2 text-xs bg-slate-800 px-3 py-1.5 rounded-full border border-slate-700">
<Database size={14} className="text-green-500" />
<span className="text-slate-300"></span>
@@ -418,6 +547,22 @@ function App(): React.JSX.Element {
{/* Toast Notifications */}
<Toast />
<UpdateDialog
isOpen={showUpdateDialog}
userType={currentUser?.userType ?? null}
status={updateStatus}
catalog={updateCatalog}
onClose={() => setShowUpdateDialog(false)}
onInstallUserRelease={handleInstallUserRelease}
onDownloadAndInstallAdminRelease={async (release) =>
handleAdminDownloadAndInstall(release)
}
onRefreshCatalog={async () => {
await window.electron.update.checkNow()
await refreshUpdateCatalog()
await refreshUpdateState()
}}
/>
</div>
)
}

View File

@@ -0,0 +1,267 @@
import React, { useEffect, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { DownloadCloud, LoaderCircle, RefreshCw } from 'lucide-react'
import Modal from './ui/Modal'
import type { UserType } from '../../../main/types/user.types'
import type {
DownloadReleaseRequest,
UpdateDialogCatalog,
UpdateRelease,
UpdateStatus
} from '../../../main/types/update.types'
interface UpdateDialogProps {
isOpen: boolean
userType: UserType | null
status: UpdateStatus | null
catalog: UpdateDialogCatalog | null
onClose: () => void
onInstallUserRelease: () => Promise<void>
onDownloadAndInstallAdminRelease: (release: DownloadReleaseRequest) => Promise<void>
onRefreshCatalog: () => Promise<void>
}
function releaseKey(release: UpdateRelease): string {
return `${release.channel}:${release.version}`
}
export default function UpdateDialog({
isOpen,
userType,
status,
catalog,
onClose,
onInstallUserRelease,
onDownloadAndInstallAdminRelease,
onRefreshCatalog
}: UpdateDialogProps): React.JSX.Element {
const [selectedRelease, setSelectedRelease] = useState<UpdateRelease | undefined>(undefined)
const [changelog, setChangelog] = useState<string>('')
const [isLoadingChangelog, setIsLoadingChangelog] = useState(false)
useEffect(() => {
if (!isOpen || !catalog) return
if (catalog.mode === 'user') {
setSelectedRelease(catalog.recommendedRelease)
return
}
if (catalog.mode === 'admin') {
setSelectedRelease(
catalog.recommendedRelease ?? catalog.channels?.stable[0] ?? catalog.channels?.preview[0]
)
}
}, [catalog, isOpen])
useEffect(() => {
if (!isOpen || !selectedRelease) {
setChangelog('')
return
}
let active = true
setIsLoadingChangelog(true)
window.electron.update
.getChangelog(selectedRelease)
.then((result) => {
if (!active) return
setChangelog(result.success && result.data ? result.data : '暂无更新说明。')
})
.catch(() => {
if (!active) return
setChangelog('暂无更新说明。')
})
.finally(() => {
if (active) {
setIsLoadingChangelog(false)
}
})
return () => {
active = false
}
}, [isOpen, selectedRelease])
const isBusy =
status?.phase === 'downloading' || status?.phase === 'installing' || status?.phase === 'checking'
const renderReleaseList = (title: string, releases: UpdateRelease[]) => {
if (releases.length === 0) {
return (
<div className="rounded-lg border border-dashed border-slate-200 px-3 py-4 text-sm text-slate-500">
</div>
)
}
return (
<div className="space-y-2">
<div className="text-xs font-semibold uppercase tracking-wide text-slate-500">{title}</div>
{releases.map((release) => {
const selected = selectedRelease && releaseKey(selectedRelease) === releaseKey(release)
return (
<button
key={releaseKey(release)}
onClick={() => setSelectedRelease(release)}
className={`w-full rounded-lg border px-3 py-3 text-left transition ${
selected
? 'border-blue-500 bg-blue-50 text-blue-900'
: 'border-slate-200 bg-white text-slate-700 hover:border-slate-300'
}`}
>
<div className="flex items-center justify-between gap-3">
<span className="font-medium">V{release.version}</span>
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs uppercase text-slate-600">
{release.channel}
</span>
</div>
<div className="mt-1 text-xs text-slate-500">{release.publishedAt}</div>
{release.notesSummary && (
<div className="mt-2 text-xs text-slate-600">{release.notesSummary}</div>
)}
</button>
)
})}
</div>
)
}
return (
<Modal
isOpen={isOpen}
onClose={isBusy ? () => undefined : onClose}
title="应用更新"
size="3xl"
disableBackdropClick={isBusy}
disableEscapeKey={isBusy}
>
<div className="space-y-4">
<div className="flex items-center justify-between rounded-lg bg-slate-50 px-4 py-3 text-sm">
<div className="text-slate-600">
<span className="font-semibold text-slate-900">V{status?.currentVersion}</span>
<span className="ml-2 rounded-full bg-slate-200 px-2 py-0.5 text-xs uppercase text-slate-700">
{status?.currentChannel ?? __APP_CHANNEL__}
</span>
</div>
<button
onClick={() => void onRefreshCatalog()}
className="inline-flex items-center gap-2 rounded-md border border-slate-200 px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100"
disabled={isBusy}
>
<RefreshCw size={14} />
</button>
</div>
{catalog?.mode === 'admin' ? (
<div className="grid gap-4 lg:grid-cols-[280px_1fr]">
<div className="space-y-4">
{renderReleaseList('Stable', catalog.channels?.stable ?? [])}
{renderReleaseList('Preview', catalog.channels?.preview ?? [])}
</div>
<div className="rounded-xl border border-slate-200 bg-white">
<div className="border-b border-slate-200 px-4 py-3">
<div className="text-sm font-semibold text-slate-900">
{selectedRelease
? `${selectedRelease.channel.toUpperCase()} V${selectedRelease.version}`
: '请选择一个版本'}
</div>
<div className="mt-1 text-xs text-slate-500">
{selectedRelease?.notesSummary ?? '选择版本后可查看详细更新说明。'}
</div>
</div>
<div className="max-h-[420px] overflow-auto px-4 py-4 prose prose-slate max-w-none prose-headings:mb-2 prose-p:my-2 prose-li:my-1">
{isLoadingChangelog ? (
<div className="flex items-center gap-2 text-sm text-slate-500">
<LoaderCircle size={16} className="animate-spin" />
...
</div>
) : (
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{changelog || '暂无更新说明。'}
</ReactMarkdown>
)}
</div>
</div>
</div>
) : (
<div className="rounded-xl border border-slate-200 bg-white">
<div className="border-b border-slate-200 px-4 py-3">
<div className="text-sm font-semibold text-slate-900">
{selectedRelease ? `发现稳定版 V${selectedRelease.version}` : '暂无可用更新'}
</div>
<div className="mt-1 text-xs text-slate-500">
{status?.currentChannel === 'preview'
? '当前设备正在运行预览版,普通用户将更新回稳定版。'
: '更新包已在后台准备完成,确认后将自动关闭并重启应用。'}
</div>
</div>
<div className="max-h-[420px] overflow-auto px-4 py-4 prose prose-slate max-w-none prose-headings:mb-2 prose-p:my-2 prose-li:my-1">
{isLoadingChangelog ? (
<div className="flex items-center gap-2 text-sm text-slate-500">
<LoaderCircle size={16} className="animate-spin" />
...
</div>
) : (
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{changelog || '暂无更新说明。'}
</ReactMarkdown>
)}
</div>
</div>
)}
{status?.error && (
<div className="rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{status.error}
</div>
)}
<div className="flex items-center justify-between border-t border-slate-200 pt-4">
<div className="text-sm text-slate-500">{status?.message ?? ' '}</div>
<div className="flex items-center gap-3">
<button
onClick={onClose}
className="rounded-md border border-slate-200 px-4 py-2 text-sm text-slate-600 hover:bg-slate-50"
disabled={isBusy}
>
</button>
{userType === 'Admin' ? (
<button
onClick={() =>
selectedRelease ? void onDownloadAndInstallAdminRelease(selectedRelease) : undefined
}
className="inline-flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-blue-300"
disabled={!selectedRelease || isBusy}
>
{isBusy ? (
<LoaderCircle size={16} className="animate-spin" />
) : (
<DownloadCloud size={16} />
)}
</button>
) : (
<button
onClick={() => void onInstallUserRelease()}
className="inline-flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-blue-300"
disabled={!selectedRelease || isBusy}
>
{isBusy ? (
<LoaderCircle size={16} className="animate-spin" />
) : (
<DownloadCloud size={16} />
)}
</button>
)}
</div>
</div>
</div>
</Modal>
)
}

View File

@@ -2,3 +2,4 @@
declare const __APP_VERSION__: string
declare const __GIT_HASH__: string
declare const __APP_CHANNEL__: 'stable' | 'preview'