feat: rebuild portable auto-update flow
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
267
src/renderer/src/components/UpdateDialog.tsx
Normal file
267
src/renderer/src/components/UpdateDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
src/renderer/src/env.d.ts
vendored
1
src/renderer/src/env.d.ts
vendored
@@ -2,3 +2,4 @@
|
||||
|
||||
declare const __APP_VERSION__: string
|
||||
declare const __GIT_HASH__: string
|
||||
declare const __APP_CHANNEL__: 'stable' | 'preview'
|
||||
|
||||
Reference in New Issue
Block a user