mirror of
https://github.com/dream-pep/koring-launcher.git
synced 2026-09-12 05:45:18 +08:00
feat(auto-update): 实现全流程自动更新功能与配套界面
- 新增主进程更新服务,支持GitHub官方源与加速源兜底的检查、下载、安装全流程 - 新增独立更新日志页面,支持查看更新说明与更新全流程管理 - 实现更新状态持久化,重启应用可恢复上次未完成的下载进度 - 完善版本管理与构建元数据脚本,优化CI发布流水线 - 补充VersionCard的构建信息展示与更新页跳转逻辑 - 新增更新相关IPC接口、类型定义与全局状态管理 - 清理旧的无用配置项,升级相关依赖包
This commit is contained in:
@@ -17,6 +17,7 @@ import { Setting } from "./pages/setting";
|
||||
import { SettingLogin } from "./pages/setting/login";
|
||||
import { Gallery } from "./pages/gallery";
|
||||
import { TaskQueue } from "./pages/task-queue";
|
||||
import { UpdatePage } from "./pages/update";
|
||||
import { Debug } from "./pages/debug";
|
||||
import { SplashDebug } from "./pages/debug/splash-debug";
|
||||
import { DisplayDebug } from "./pages/debug/display-debug";
|
||||
@@ -43,6 +44,7 @@ const pageMap = {
|
||||
"setting/login": SettingLogin,
|
||||
gallery: Gallery,
|
||||
"task-queue": TaskQueue,
|
||||
update: UpdatePage,
|
||||
oobe: Oobe,
|
||||
"oobe/language": OobeLanguage,
|
||||
"oobe/agreement": OobeAgreement,
|
||||
|
||||
@@ -81,6 +81,17 @@ export interface NetworkConfig {
|
||||
securityId: SecurityIdConfig;
|
||||
}
|
||||
|
||||
/** 更新进度持久化(主进程 updater 写入) */
|
||||
export interface UpdateConfig {
|
||||
state: string;
|
||||
version: string;
|
||||
percent: number;
|
||||
transferred: number;
|
||||
total: number;
|
||||
source: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface InstanceMeta {
|
||||
name: string;
|
||||
displayName: string;
|
||||
@@ -106,6 +117,7 @@ export interface AppConfig {
|
||||
download: DownloadConfig;
|
||||
network: NetworkConfig;
|
||||
ui: UiConfig;
|
||||
update: UpdateConfig;
|
||||
instances: InstanceMeta[];
|
||||
}
|
||||
|
||||
|
||||
+79
-14
@@ -1,23 +1,88 @@
|
||||
import { ipcInvoke, onIpcEvent } from "./ipc";
|
||||
|
||||
/** 更新状态(主进程 electron/updater.ts 的状态机) */
|
||||
export type UpdateState =
|
||||
| "idle"
|
||||
| "checking"
|
||||
| "available"
|
||||
| "not-available"
|
||||
| "downloading"
|
||||
| "paused"
|
||||
| "downloaded"
|
||||
| "installing"
|
||||
| "error";
|
||||
|
||||
/** 主进程广播的更新状态 payload(update:status) */
|
||||
export interface UpdateStatusPayload {
|
||||
state: UpdateState;
|
||||
/** 是否为手动触发(手动触发时前端不弹提示) */
|
||||
manual: boolean;
|
||||
/** 最新版本号 */
|
||||
version?: string;
|
||||
/** 当前安装版本 */
|
||||
currentVersion?: string;
|
||||
percent?: number;
|
||||
transferred?: number;
|
||||
total?: number;
|
||||
bytesPerSecond?: number;
|
||||
/** 当前使用的更新源(github / 加速源域名) */
|
||||
source?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** 兼容旧调用方的下载进度结构 */
|
||||
export interface DownloadProgress {
|
||||
downloaded: number;
|
||||
contentLength: number;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
export async function checkForUpdates(): Promise<{ version: string; releaseNotes?: string } | null> {
|
||||
try {
|
||||
return await window.electronAPI?.invoke('update:check') as { version: string; releaseNotes?: string } | null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
/** 主进程 update:getReleaseNotes 返回的发布说明数据 */
|
||||
export interface ReleaseNotesResult {
|
||||
/** release tag,如 v1.2.0-2608271921 */
|
||||
tag: string;
|
||||
/** 版本号(去 v 前缀) */
|
||||
version: string;
|
||||
/** 发布说明原始 Markdown */
|
||||
notes: string;
|
||||
/** 读取来源:github / 加速源域名 */
|
||||
source: string;
|
||||
/** 是否为最新版本的说明(当前版本无发布说明时回退) */
|
||||
isLatest: boolean;
|
||||
}
|
||||
|
||||
export async function downloadAndInstall(
|
||||
onProgress?: (progress: DownloadProgress) => void
|
||||
): Promise<void> {
|
||||
await window.electronAPI?.invoke('update:install');
|
||||
}
|
||||
/** 检查更新(manual=true 表示用户手动点击,前端不弹提示) */
|
||||
export const checkForUpdates = (manual = false): Promise<UpdateStatusPayload> =>
|
||||
ipcInvoke<UpdateStatusPayload>("update:check", { manual });
|
||||
|
||||
export async function relaunchApp(): Promise<void> {
|
||||
await window.electronAPI?.invoke('app:relaunch');
|
||||
}
|
||||
/** 触发下载更新(进度经 update:status 事件上报);paused 状态下调用即继续 */
|
||||
export const downloadUpdate = (): Promise<null> => ipcInvoke<null>("update:download");
|
||||
|
||||
/** 暂停下载(中断当前请求,保留进度) */
|
||||
export const pauseUpdate = (): Promise<null> => ipcInvoke<null>("update:pause");
|
||||
|
||||
/** 继续下载 */
|
||||
export const resumeUpdate = (): Promise<null> => ipcInvoke<null>("update:resume");
|
||||
|
||||
/** 取消下载(清除进度,回到可重新下载状态) */
|
||||
export const cancelUpdate = (): Promise<null> => ipcInvoke<null>("update:cancel");
|
||||
|
||||
/** 退出并安装(NSIS 静默安装,安装完成自动重启) */
|
||||
export const quitAndInstall = (): Promise<null> => ipcInvoke<null>("update:quitAndInstall");
|
||||
|
||||
/** 兼容旧调用(VersionCard「立即更新」按钮) */
|
||||
export const relaunchApp = quitAndInstall;
|
||||
|
||||
/** 查询当前更新状态快照 */
|
||||
export const getUpdateState = (): Promise<UpdateStatusPayload> => ipcInvoke<UpdateStatusPayload>("update:getState");
|
||||
|
||||
/**
|
||||
* 读取指定版本(默认当前安装版本)的发布说明。
|
||||
* 主进程内部:GitHub 直连优先,失败自动切换加速源;当前版本无说明时回退最新版本。
|
||||
*/
|
||||
export const getReleaseNotes = (tag?: string): Promise<ReleaseNotesResult | null> =>
|
||||
ipcInvoke<ReleaseNotesResult | null>("update:getReleaseNotes", { tag });
|
||||
|
||||
/** 订阅更新状态变化 */
|
||||
export const onUpdateStatus = (cb: (status: UpdateStatusPayload) => void): (() => void) =>
|
||||
onIpcEvent<UpdateStatusPayload>("update:status", cb);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { BUILD_MODE, LOGO_SVG } from "@/lib/mode";
|
||||
import { VERSION } from "@/lib/version";
|
||||
import { BUILD_COMMIT, BUILD_ID } from "@/lib/buildInfo";
|
||||
import { useUpdateStore } from "@/stores/updateStore";
|
||||
import { useRouteStore } from "@/stores/routeStore";
|
||||
import { relaunchApp } from "@/api/update";
|
||||
import Silk from "@/components/silk/Silk";
|
||||
import { Button } from "@heroui/react";
|
||||
@@ -49,6 +51,18 @@ export function VersionCard({
|
||||
|
||||
const { checking, downloading, installed, update, check, install } = useUpdateStore();
|
||||
|
||||
// 检查更新按钮:除 OOBE 与更新日志页本身外,点击跳转到更新日志页
|
||||
const current = useRouteStore((s) => s.current);
|
||||
const navigate = useRouteStore((s) => s.navigate);
|
||||
const isOnUpdatePage = current === "update";
|
||||
const handleCheck = () => {
|
||||
if (isOnUpdatePage) {
|
||||
check();
|
||||
} else {
|
||||
navigate("update");
|
||||
}
|
||||
};
|
||||
|
||||
const effectiveState: UpdateState = overrideState ?? (installed ? "installed" : update ? "hasUpdate" : "latest");
|
||||
|
||||
const Btn = (props: React.ComponentProps<typeof Button>) => (
|
||||
@@ -61,7 +75,12 @@ export function VersionCard({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={clsx("relative overflow-hidden rounded-xl border border-white/10 min-h-[200px]", className)}>
|
||||
<div
|
||||
className={clsx("relative overflow-hidden rounded-xl border border-white/10 min-h-[200px]", className)}
|
||||
// 共享元素过渡:路由切换时(startViewTransition),新旧页面中同名 view-transition-name
|
||||
// 的元素会从上一个位置平滑移动/形变到当前页面的位置
|
||||
style={{ viewTransitionName: "version-card" } as React.CSSProperties}
|
||||
>
|
||||
{/* 背景层 */}
|
||||
<div className="absolute inset-0" style={{ background: gradient }} />
|
||||
|
||||
@@ -85,7 +104,19 @@ export function VersionCard({
|
||||
/>
|
||||
<p className="text-sm text-white/70 font-medium">v{VERSION}</p>
|
||||
|
||||
{/* OOBE 模式:仅显示查看亮点按钮 */}
|
||||
{/* 构建来源(CI 构建时写入;本地开发不显示) */}
|
||||
{(BUILD_COMMIT || BUILD_ID !== "local") && (
|
||||
<p className="text-[11px] text-white/50 font-mono leading-none">
|
||||
{BUILD_COMMIT && `commit ${BUILD_COMMIT}`}
|
||||
{BUILD_COMMIT && BUILD_ID !== "local" && " · "}
|
||||
{BUILD_ID !== "local" && `#${BUILD_ID}`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 更新日志页内不显示任何按钮(更新操作由页面底部遮罩负责) */}
|
||||
{!isOnUpdatePage && (
|
||||
<>
|
||||
{/* OOBE 模式:仅显示查看亮点按钮 */}
|
||||
{oobe ? (
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Btn>查看亮点</Btn>
|
||||
@@ -94,7 +125,7 @@ export function VersionCard({
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{effectiveState === "latest" && (
|
||||
<>
|
||||
<Btn onPress={check} isDisabled={checking}>
|
||||
<Btn onPress={handleCheck} isDisabled={checking}>
|
||||
{checking ? "检查中..." : "检查更新"}
|
||||
</Btn>
|
||||
<Btn>查看亮点</Btn>
|
||||
@@ -118,6 +149,8 @@ export function VersionCard({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// 构建元数据:由 scripts/gen-build-info.js 自动生成(CI 覆盖;本地开发为默认值)
|
||||
export const BUILD_COMMIT: string = "";
|
||||
export const BUILD_ID: string = "local";
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { VersionCard } from "@/components/VersionCard";
|
||||
import { BUILD_MODE } from "@/lib/mode";
|
||||
import { BUILD_COMMIT, BUILD_ID } from "@/lib/buildInfo";
|
||||
import { ExternalLink, GitFork, RotateCcw } from "lucide-react";
|
||||
import { Link } from "@heroui/react";
|
||||
import { SettingCard, SettingRow, PageHeader, SectionTitle } from "@/components/setting";
|
||||
@@ -70,6 +71,14 @@ export function AboutSetting() {
|
||||
<span className="text-[13px] text-muted-foreground">{modeLabels[BUILD_MODE] ?? BUILD_MODE}</span>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
<SettingCard>
|
||||
<SettingRow label="构建来源" desc="构建所用的 commit 与编译号(CI 构建)">
|
||||
<span className="text-[13px] text-muted-foreground font-mono">
|
||||
{BUILD_COMMIT ? `commit ${BUILD_COMMIT}` : "本地构建"}
|
||||
{BUILD_ID !== "local" ? ` · #${BUILD_ID}` : ""}
|
||||
</span>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
<SettingCard>
|
||||
<SettingRow label="技术栈" desc="Electron + React 19 + TypeScript + @xmcl">
|
||||
<span className="text-[13px] text-muted-foreground">Node.js</span>
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { VersionCard } from "@/components/VersionCard";
|
||||
import { SectionTitle, SettingCard } from "@/components/setting";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { BUILD_MODE } from "@/lib/mode";
|
||||
import {
|
||||
cancelUpdate,
|
||||
checkForUpdates,
|
||||
downloadUpdate,
|
||||
getReleaseNotes,
|
||||
getUpdateState,
|
||||
onUpdateStatus,
|
||||
pauseUpdate,
|
||||
quitAndInstall,
|
||||
resumeUpdate,
|
||||
type ReleaseNotesResult,
|
||||
type UpdateStatusPayload,
|
||||
} from "@/api/update";
|
||||
import { ExternalLink, Loader2, RefreshCw } from "lucide-react";
|
||||
import { Button, Link } from "@heroui/react";
|
||||
import { toast } from "sonner";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
|
||||
const GITHUB_RELEASES = "https://github.com/dream-pep/koring-launcher/releases";
|
||||
|
||||
/** 主按钮颜色随构建模式:dev 橙 / beta 绿 / run 蓝(与 VersionCard 一致) */
|
||||
const MODE_BUTTON_COLORS: Record<string, { bg: string; hover: string }> = {
|
||||
dev: { bg: "#F59E0B", hover: "#D97706" },
|
||||
beta: { bg: "#10B981", hover: "#059669" },
|
||||
run: { bg: "#3b82f6", hover: "#2563eb" },
|
||||
};
|
||||
|
||||
const modeColors = MODE_BUTTON_COLORS[BUILD_MODE] ?? MODE_BUTTON_COLORS.run;
|
||||
|
||||
/** 主题 accent 为灰色系,内联覆盖按钮 CSS 变量 */
|
||||
const BUTTON_STYLE = {
|
||||
"--button-bg": modeColors.bg,
|
||||
"--button-bg-hover": modeColors.hover,
|
||||
"--button-bg-pressed": modeColors.hover,
|
||||
"--button-fg": "#ffffff",
|
||||
} as React.CSSProperties;
|
||||
|
||||
function formatMB(bytes?: number): string {
|
||||
if (!bytes || bytes <= 0) return "0 MB";
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新日志(独立页面,不走设置 layout):
|
||||
* - 顶栏由路由切换为「返回 + 更新日志」(routeStore 的 titleInBar)
|
||||
* - 顶部 VersionCard(此页面不显示按钮,更新操作由底部遮罩负责)
|
||||
* - 发布说明:默认当前版本;检测到可用更新后自动切到最新版本
|
||||
* - 底部遮罩:检查更新 → 下载(进度条 + 暂停/继续/取消)→ 安装更新
|
||||
* - 下载/安装进度由主进程写入 Koring.yml(update 段)
|
||||
*/
|
||||
export function UpdatePage() {
|
||||
// 发布说明:notesTag 为空 = 当前版本;有可用更新后切到对应 tag
|
||||
const [notes, setNotes] = useState<ReleaseNotesResult | null>(null);
|
||||
const [notesLoading, setNotesLoading] = useState(true);
|
||||
const [notesError, setNotesError] = useState<string | null>(null);
|
||||
const [notesTag, setNotesTag] = useState<string | undefined>(undefined);
|
||||
|
||||
// 更新状态:事件驱动,主进程为唯一真相源
|
||||
const [status, setStatus] = useState<UpdateStatusPayload | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = onUpdateStatus(setStatus);
|
||||
getUpdateState().then(setStatus).catch(() => {});
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
const loadNotes = useCallback(async (tag?: string) => {
|
||||
setNotesLoading(true);
|
||||
setNotesError(null);
|
||||
try {
|
||||
setNotes(await getReleaseNotes(tag));
|
||||
} catch (e) {
|
||||
setNotesError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setNotesLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadNotes(notesTag);
|
||||
}, [notesTag, loadNotes]);
|
||||
|
||||
// 检测到可用更新 → 发布说明切到最新版本(退出重进自动回到当前版本)
|
||||
useEffect(() => {
|
||||
if (status?.state === "available" && status.version && notesTag !== `v${status.version}`) {
|
||||
setNotesTag(`v${status.version}`);
|
||||
}
|
||||
}, [status, notesTag]);
|
||||
|
||||
const st = status?.state ?? "idle";
|
||||
const pct = status?.percent ?? 0;
|
||||
const isDownloading = st === "downloading";
|
||||
const isPaused = st === "paused";
|
||||
|
||||
const handleCheck = async () => {
|
||||
try {
|
||||
await checkForUpdates(true);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
await downloadUpdate();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePause = async () => {
|
||||
try {
|
||||
await pauseUpdate();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleResume = async () => {
|
||||
try {
|
||||
await resumeUpdate();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
try {
|
||||
await cancelUpdate();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstall = async () => {
|
||||
try {
|
||||
await quitAndInstall();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const openGithub = () => {
|
||||
window.electronAPI?.openExternal(GITHUB_RELEASES);
|
||||
};
|
||||
|
||||
const primaryAction =
|
||||
st === "available" ? handleDownload : st === "downloaded" ? handleInstall : handleCheck;
|
||||
|
||||
const statusText =
|
||||
st === "checking"
|
||||
? "正在检查更新..."
|
||||
: st === "available"
|
||||
? `发现新版本 v${status?.version}`
|
||||
: st === "downloading"
|
||||
? `正在下载 ${pct.toFixed(0)}% · ${formatMB(status?.transferred)} / ${formatMB(status?.total)}${status?.bytesPerSecond ? ` · ${formatMB(status.bytesPerSecond)}/s` : ""}`
|
||||
: st === "paused"
|
||||
? `下载已暂停(${pct.toFixed(0)}%)`
|
||||
: st === "downloaded"
|
||||
? "更新已下载完成"
|
||||
: st === "installing"
|
||||
? "正在安装更新,应用即将重启..."
|
||||
: st === "error"
|
||||
? `更新失败:${status?.error ?? "未知错误"}`
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto p-6 md:p-8 pb-44">
|
||||
<div className="space-y-6">
|
||||
<VersionCard />
|
||||
|
||||
<div>
|
||||
<SectionTitle>更新内容</SectionTitle>
|
||||
|
||||
{notesLoading ? (
|
||||
<SettingCard>
|
||||
<div className="flex items-center justify-center gap-2 py-10 text-[13px] text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
正在获取发布说明...
|
||||
</div>
|
||||
</SettingCard>
|
||||
) : notesError ? (
|
||||
<SettingCard>
|
||||
<div className="py-6 text-center space-y-3">
|
||||
<p className="text-[13px] text-destructive">获取发布说明失败:{notesError}</p>
|
||||
<Button size="sm" variant="outline" onClick={() => loadNotes(notesTag)}>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
</SettingCard>
|
||||
) : notes ? (
|
||||
<SettingCard>
|
||||
<div className="flex items-center justify-between gap-2 mb-3">
|
||||
<span className="text-[12px] text-muted-foreground">
|
||||
版本 v{notes.version}
|
||||
{notesTag ? "(最新)" : "(当前)"}
|
||||
<span className="ml-2 opacity-70">
|
||||
· 来源:{notes.source === "github" ? "GitHub" : `加速源 ${notes.source}`}
|
||||
</span>
|
||||
</span>
|
||||
<Button size="sm" variant="ghost" onClick={() => loadNotes(notesTag)}>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-[13.5px] leading-relaxed text-foreground/80 space-y-3 [&_h1]:text-base [&_h1]:font-semibold [&_h1]:text-foreground [&_h2]:text-[15px] [&_h2]:font-semibold [&_h2]:text-foreground [&_h3]:text-sm [&_h3]:font-semibold [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:my-1 [&_a]:text-primary [&_a]:underline underline-offset-2 [&_code]:bg-foreground/10 [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-[12px] [&_pre]:bg-foreground/5 [&_pre]:p-3 [&_pre]:rounded-lg [&_pre]:overflow-x-auto [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_details]:border [&_details]:border-border/50 [&_details]:rounded-lg [&_details]:px-3 [&_details]:py-2 [&_summary]:cursor-pointer [&_summary]:font-medium [&_summary]:text-foreground [&_hr]:border-border/40 [&_blockquote]:border-l-2 [&_blockquote]:border-primary/40 [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
||||
{notes.notes}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</SettingCard>
|
||||
) : (
|
||||
<SettingCard>
|
||||
<div className="py-6 text-center space-y-3">
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
未获取到发布说明(GitHub 与加速源均不可用,或该版本尚未发布)
|
||||
</p>
|
||||
<Link onPress={openGithub} className="text-[13px]">
|
||||
打开 GitHub Releases
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</SettingCard>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部遮罩:fixed 吸附底部,样式与顶栏一致;驱动整个更新流程 */}
|
||||
<div
|
||||
className="fixed bottom-0 left-0 right-0 z-20"
|
||||
style={{
|
||||
background: "var(--titlebar-bg)",
|
||||
backdropFilter: "blur(3px)",
|
||||
WebkitBackdropFilter: "blur(3px)",
|
||||
borderTop: "1px solid var(--titlebar-border)",
|
||||
}}
|
||||
>
|
||||
<div className="max-w-3xl mx-auto px-6 py-3 space-y-2">
|
||||
{statusText && (
|
||||
<div className="text-center text-[13px] text-muted-foreground truncate">{statusText}</div>
|
||||
)}
|
||||
|
||||
{isDownloading || isPaused ? (
|
||||
<div className="space-y-2">
|
||||
<Progress
|
||||
value={pct}
|
||||
className="w-full [&_[data-slot='progress-indicator']]:bg-blue-500"
|
||||
/>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
fullWidth
|
||||
style={BUTTON_STYLE}
|
||||
onPress={isPaused ? handleResume : handlePause}
|
||||
>
|
||||
{isPaused ? "继续下载" : "暂停下载"}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" fullWidth onPress={handleCancel}>
|
||||
取消下载
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
fullWidth
|
||||
style={BUTTON_STYLE}
|
||||
isDisabled={st === "checking" || st === "installing" || st === "not-available"}
|
||||
onPress={primaryAction}
|
||||
>
|
||||
{st === "checking" && (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
检查中...
|
||||
</>
|
||||
)}
|
||||
{st === "available" && "下载版本更新"}
|
||||
{st === "downloaded" && "安装更新"}
|
||||
{st === "installing" && "安装中..."}
|
||||
{st === "error" && "重试"}
|
||||
{st === "not-available" && "已经是最新版"}
|
||||
{st === "idle" && "检查更新"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -59,6 +59,7 @@ const DEFAULT_CONFIG: AppConfig = {
|
||||
download: { fileSource: "mirror", versionSource: "mirror", threads: 16, speedLimit: 0 },
|
||||
network: { securityId: { enabled: false, authUrl: "" } },
|
||||
ui: { showInstanceTitle: true, showTaskButton: true },
|
||||
update: { state: "idle", version: "", percent: 0, transferred: 0, total: 0, source: "github", error: "" },
|
||||
instances: [],
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export type RouteKey =
|
||||
| "setting/login"
|
||||
| "gallery"
|
||||
| "task-queue"
|
||||
| "update"
|
||||
| "oobe"
|
||||
| "oobe/language"
|
||||
| "oobe/agreement"
|
||||
@@ -51,6 +52,7 @@ export const allRoutes: RouteItem[] = [
|
||||
...routes,
|
||||
{ key: "setting/login", label: "登录", path: "/setting/login", hidden: true, backable: true },
|
||||
{ key: "task-queue", label: "任务队列", path: "/task-queue", hidden: true },
|
||||
{ key: "update", label: "更新日志", path: "/update", hidden: true, backable: true },
|
||||
{ key: "oobe", label: "OOBE", path: "/oobe", hidden: true },
|
||||
{ key: "oobe/language", label: "语言设置", path: "/oobe/language", hidden: true },
|
||||
{ key: "oobe/agreement", label: "同意协议", path: "/oobe/agreement", hidden: true },
|
||||
|
||||
+139
-33
@@ -1,51 +1,157 @@
|
||||
import { create } from "zustand";
|
||||
import {
|
||||
checkForUpdates,
|
||||
downloadAndInstall,
|
||||
type DownloadProgress,
|
||||
downloadUpdate,
|
||||
getUpdateState,
|
||||
onUpdateStatus,
|
||||
quitAndInstall,
|
||||
resumeUpdate,
|
||||
type UpdateStatusPayload,
|
||||
} from "../api/update";
|
||||
|
||||
interface UpdateState {
|
||||
/**
|
||||
* 更新 store(与主进程 electron/updater.ts 的状态机联动):
|
||||
* - 模块加载即订阅 update:status 事件 + 拉取一次状态快照
|
||||
* - check() / install() 触发主进程操作,状态由事件驱动更新
|
||||
*/
|
||||
|
||||
interface UpdateProgress {
|
||||
percent: number;
|
||||
transferred: number;
|
||||
total: number;
|
||||
bytesPerSecond: number;
|
||||
}
|
||||
|
||||
interface UpdateStoreState {
|
||||
checking: boolean;
|
||||
downloading: boolean;
|
||||
installed: boolean;
|
||||
progress: DownloadProgress | null;
|
||||
progress: UpdateProgress | null;
|
||||
update: { version: string; releaseNotes?: string } | null;
|
||||
currentVersion: string;
|
||||
source: string;
|
||||
error: string | null;
|
||||
check: () => Promise<void>;
|
||||
install: () => Promise<void>;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useUpdateStore = create<UpdateState>((set, get) => ({
|
||||
checking: false,
|
||||
downloading: false,
|
||||
installed: false,
|
||||
progress: null,
|
||||
update: null,
|
||||
error: null,
|
||||
type Setter = (partial: Partial<UpdateStoreState>) => void;
|
||||
|
||||
check: async () => {
|
||||
set({ checking: true, error: null });
|
||||
try {
|
||||
const update = await checkForUpdates();
|
||||
set({ update, checking: false });
|
||||
} catch (e: any) {
|
||||
set({ error: e.message ?? String(e), checking: false });
|
||||
}
|
||||
},
|
||||
function applyStatus(set: Setter, status: UpdateStatusPayload): void {
|
||||
const next: Partial<UpdateStoreState> = {
|
||||
currentVersion: status.currentVersion ?? "",
|
||||
source: status.source ?? "github",
|
||||
};
|
||||
|
||||
install: async () => {
|
||||
set({ downloading: true, error: null, progress: null });
|
||||
try {
|
||||
await downloadAndInstall((progress) => {
|
||||
set({ progress });
|
||||
});
|
||||
set({ downloading: false, installed: true });
|
||||
} catch (e: any) {
|
||||
set({ error: e.message ?? String(e), downloading: false });
|
||||
}
|
||||
},
|
||||
switch (status.state) {
|
||||
case "checking":
|
||||
next.checking = true;
|
||||
next.error = null;
|
||||
break;
|
||||
case "available":
|
||||
next.checking = false;
|
||||
next.update = { version: status.version ?? "", releaseNotes: undefined };
|
||||
next.error = null;
|
||||
break;
|
||||
case "not-available":
|
||||
next.checking = false;
|
||||
next.update = null;
|
||||
next.error = null;
|
||||
break;
|
||||
case "downloading":
|
||||
next.downloading = true;
|
||||
next.progress = {
|
||||
percent: status.percent ?? 0,
|
||||
transferred: status.transferred ?? 0,
|
||||
total: status.total ?? 0,
|
||||
bytesPerSecond: status.bytesPerSecond ?? 0,
|
||||
};
|
||||
next.error = null;
|
||||
break;
|
||||
case "paused":
|
||||
// 下载已暂停:保持 downloading 标记(VersionCard 按钮点击即继续)
|
||||
next.downloading = true;
|
||||
next.progress = {
|
||||
percent: status.percent ?? 0,
|
||||
transferred: status.transferred ?? 0,
|
||||
total: status.total ?? 0,
|
||||
bytesPerSecond: 0,
|
||||
};
|
||||
next.error = null;
|
||||
break;
|
||||
case "downloaded":
|
||||
case "installing":
|
||||
next.downloading = false;
|
||||
next.installed = true;
|
||||
next.update = { version: status.version ?? "", releaseNotes: undefined };
|
||||
next.error = null;
|
||||
break;
|
||||
case "error":
|
||||
next.checking = false;
|
||||
next.downloading = false;
|
||||
next.error = status.error ?? "更新失败";
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
reset: () => set({ update: null, error: null, progress: null, installed: false }),
|
||||
}));
|
||||
set(next);
|
||||
}
|
||||
|
||||
export const useUpdateStore = create<UpdateStoreState>((set, get) => {
|
||||
// 模块加载即订阅(VersionCard 等组件引入本 store 后生效)
|
||||
onUpdateStatus((status) => applyStatus(set, status));
|
||||
getUpdateState()
|
||||
.then((status) => applyStatus(set, status))
|
||||
.catch((e) => console.error("[update] 获取状态失败:", e));
|
||||
|
||||
return {
|
||||
checking: false,
|
||||
downloading: false,
|
||||
installed: false,
|
||||
progress: null,
|
||||
update: null,
|
||||
currentVersion: "",
|
||||
source: "github",
|
||||
error: null,
|
||||
|
||||
check: async () => {
|
||||
set({ checking: true, error: null });
|
||||
try {
|
||||
await checkForUpdates(true);
|
||||
} catch (e) {
|
||||
set({ error: e instanceof Error ? e.message : String(e), checking: false });
|
||||
}
|
||||
},
|
||||
|
||||
install: async () => {
|
||||
set({ downloading: true, error: null, progress: null });
|
||||
try {
|
||||
// 智能分派:暂停→继续;已下载→安装;否则→开始下载
|
||||
const state = get().installed ? "downloaded" : get().update ? "available" : "idle";
|
||||
if (state === "downloaded") {
|
||||
await quitAndInstall();
|
||||
return;
|
||||
}
|
||||
if (get().downloading && get().progress) {
|
||||
await resumeUpdate();
|
||||
return;
|
||||
}
|
||||
await downloadUpdate();
|
||||
} catch (e) {
|
||||
set({ error: e instanceof Error ? e.message : String(e), downloading: false });
|
||||
}
|
||||
},
|
||||
|
||||
reset: () =>
|
||||
set({
|
||||
update: null,
|
||||
error: null,
|
||||
progress: null,
|
||||
installed: false,
|
||||
checking: false,
|
||||
downloading: false,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
Vendored
+11
@@ -23,6 +23,17 @@ interface ElectronAPI {
|
||||
|
||||
// Config reset
|
||||
resetConfig: () => Promise<void>;
|
||||
|
||||
// Auto-update
|
||||
checkForUpdates: (manual?: boolean) => Promise<unknown>;
|
||||
downloadUpdate: () => Promise<unknown>;
|
||||
pauseUpdate: () => Promise<unknown>;
|
||||
resumeUpdate: () => Promise<unknown>;
|
||||
cancelUpdate: () => Promise<unknown>;
|
||||
quitAndInstall: () => Promise<unknown>;
|
||||
getUpdateState: () => Promise<unknown>;
|
||||
getReleaseNotes: (tag?: string) => Promise<unknown>;
|
||||
onUpdateStatus: (callback: (data: unknown) => void) => () => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
Reference in New Issue
Block a user