feat(update): 实现更新通道管理系统与更新调试页面

本次提交完成以下变更:
1. 实现完整的更新通道管理功能,支持慢走/跑步两种更新模式,可在设置页切换且配置持久化生效
2. 新增更新调试页面,集成检查更新、版本比对、设置测试版本等调试工具
3. 新增快速打开Chromium DevTools的按钮,方便开发调试
4. 完善自动更新后端逻辑,新增版本比对、测试版本覆盖、通道动态获取等IPC接口
5. 调整GitHub Release构建脚本,修复prerelease版本的命名与发布问题
6. 添加semver依赖与类型定义,完善全量类型声明
7. 更新自动更新文档,补充更新通道相关的设计说明
8. 微调默认配置,添加主题暗黑模式的初始配置
This commit is contained in:
2026-08-31 02:20:25 +08:00
parent ca8eeb09ac
commit 8fc6fcaf05
21 changed files with 626 additions and 37 deletions
+2
View File
@@ -22,6 +22,7 @@ import { Debug } from "./pages/debug";
import { SplashDebug } from "./pages/debug/splash-debug";
import { DisplayDebug } from "./pages/debug/display-debug";
import { VersionCardDebug } from "./pages/debug/version-card-debug";
import { UpdateDebug } from "./pages/debug/update-debug";
import { TaskDebug } from "./pages/debug/task-debug";
import { CrashDebug } from "./pages/debug/crash-debug";
import { Oobe } from "./pages/oobe";
@@ -59,6 +60,7 @@ const pageMap = {
"debug-splash": SplashDebug,
"debug-display": DisplayDebug,
"debug-version-card": VersionCardDebug,
"debug-update": UpdateDebug,
"debug-task": TaskDebug,
"debug-crash": CrashDebug,
} as const;
+1
View File
@@ -89,6 +89,7 @@ export interface UpdateConfig {
transferred: number;
total: number;
source: string;
channel: string;
error: string;
}
+34
View File
@@ -12,6 +12,14 @@ export type UpdateState =
| "installing"
| "error";
/** 更新通道定义(主进程注册表,可扩展) */
export interface UpdateChannelDef {
key: string;
label: string;
desc: string;
allowPrerelease: boolean;
}
/** 主进程广播的更新状态 payloadupdate:status */
export interface UpdateStatusPayload {
state: UpdateState;
@@ -27,6 +35,8 @@ export interface UpdateStatusPayload {
bytesPerSecond?: number;
/** 当前使用的更新源(github / 加速源域名) */
source?: string;
/** 当前更新通道(woker / runner */
channel?: string;
error?: string;
}
@@ -86,3 +96,27 @@ export const getReleaseNotes = (tag?: string): Promise<ReleaseNotesResult | null
/** 订阅更新状态变化 */
export const onUpdateStatus = (cb: (status: UpdateStatusPayload) => void): (() => void) =>
onIpcEvent<UpdateStatusPayload>("update:status", cb);
/** 获取更新通道定义列表(UI 动态渲染,可扩展) */
export const getUpdateChannels = (): Promise<UpdateChannelDef[]> =>
ipcInvoke<UpdateChannelDef[]>("update:getChannels");
/** 切换更新通道(woker 慢走 / runner 跑步;持久化并立即生效) */
export const setUpdateChannel = (channel: string): Promise<UpdateStatusPayload> =>
ipcInvoke<UpdateStatusPayload>("update:setChannel", { channel });
/** 开发者工具:设置测试版本号(覆盖当前识别版本) */
export const setTestVersion = (version: string): Promise<UpdateStatusPayload> =>
ipcInvoke<UpdateStatusPayload>("update:setTestVersion", { version });
/** 开发者工具:版本比对结果 */
export interface VersionCompareResult {
a: string;
b: string;
result: "a>b" | "a<b" | "a==b" | "invalid";
detail: string;
}
/** 开发者工具:版本比对(semver) */
export const compareVersions = (a: string, b: string): Promise<VersionCompareResult> =>
ipcInvoke<VersionCompareResult>("update:compareVersions", { a, b });
+26 -1
View File
@@ -1,7 +1,15 @@
import { useRouteStore } from "@/stores/routeStore";
import { Monitor, Paintbrush, CreditCard, ListTodo, ChevronRight, FlaskConical, Rocket, AlertTriangle } from "lucide-react";
import { Monitor, Paintbrush, CreditCard, ListTodo, ChevronRight, FlaskConical, Rocket, AlertTriangle, RefreshCw, SquareTerminal } from "lucide-react";
const debugPages = [
{
key: "debug-update" as const,
icon: RefreshCw,
title: "更新功能测试",
desc: "设置测试版本号、检查更新、获取发布说明、版本比对与下载",
color: "text-emerald-500",
bg: "bg-emerald-500/10",
},
{
key: "debug-crash" as const,
icon: AlertTriangle,
@@ -67,6 +75,23 @@ export function Debug() {
</div>
</div>
{/* 打开浏览器调试工具(DevTools) */}
<button
onClick={() => window.electronAPI?.openDevTools()}
className="glass-card w-full px-5 py-4 text-left hover:scale-[1.01] active:scale-[0.99] transition-transform cursor-pointer group mb-4"
>
<div className="flex items-center gap-4">
<div className="p-2.5 rounded-xl bg-foreground/[0.08]">
<SquareTerminal className="w-5 h-5 text-foreground/60" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground"></p>
<p className="text-[13px] text-muted-foreground mt-0.5">Chromium DevTools</p>
</div>
<ChevronRight className="w-4 h-4 text-foreground/20 group-hover:text-foreground/40 transition-colors shrink-0" />
</div>
</button>
<div className="space-y-3">
{debugPages.map((p) => (
<button
+211
View File
@@ -0,0 +1,211 @@
import { useEffect, useState } from "react";
import { GlassCard, PageHeader } from "./components";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
cancelUpdate,
checkForUpdates,
compareVersions,
downloadUpdate,
getReleaseNotes,
getUpdateState,
onUpdateStatus,
pauseUpdate,
quitAndInstall,
resumeUpdate,
setTestVersion,
type ReleaseNotesResult,
type UpdateStatusPayload,
type VersionCompareResult,
} from "@/api/update";
/** 更新功能测试:版本识别 / 检查 / 介绍 / 比对 / 下载 */
export function UpdateDebug() {
const [status, setStatus] = useState<UpdateStatusPayload | null>(null);
const [testVersion, setTestVersionInput] = useState("");
const [versionMsg, setVersionMsg] = useState("");
const [notesTag, setNotesTag] = useState("");
const [notes, setNotes] = useState<ReleaseNotesResult | null>(null);
const [notesMsg, setNotesMsg] = useState("");
const [cmpA, setCmpA] = useState("");
const [cmpB, setCmpB] = useState("");
const [cmpResult, setCmpResult] = useState<VersionCompareResult | null>(null);
useEffect(() => {
const unsub = onUpdateStatus(setStatus);
getUpdateState().then(setStatus).catch(() => {});
return unsub;
}, []);
const s = status;
const pct = s?.percent ?? 0;
const run = async (fn: () => Promise<unknown>, setMsg: (m: string) => void) => {
try {
setMsg("执行中...");
const r = await fn();
setMsg(JSON.stringify(r));
} catch (e) {
setMsg(`失败:${e instanceof Error ? e.message : String(e)}`);
}
};
return (
<div className="max-w-2xl mx-auto p-8 space-y-6">
<PageHeader title="更新功能测试" desc="测试更新检查、版本识别、发布说明、版本比对与下载" />
{/* 1. 版本识别列表 */}
<div>
<h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider mb-3">
</h3>
<GlassCard>
<div className="space-y-1.5 text-[13px] font-mono">
<p className="flex justify-between"><span className="text-muted-foreground"></span><span>{s?.currentVersion ?? "-"}</span></p>
<p className="flex justify-between"><span className="text-muted-foreground">/</span><span>{s?.version ?? "-"}</span></p>
<p className="flex justify-between"><span className="text-muted-foreground"></span><span>{s?.state ?? "-"}</span></p>
<p className="flex justify-between"><span className="text-muted-foreground"></span><span>{s?.channel ?? "-"}</span></p>
<p className="flex justify-between"><span className="text-muted-foreground"></span><span className="max-w-[60%] truncate">{s?.source ?? "-"}</span></p>
{s?.error && <p className="flex justify-between text-red-500"><span className="text-muted-foreground"></span><span className="max-w-[60%] truncate">{s.error}</span></p>}
</div>
</GlassCard>
</div>
{/* 2. 设置版本号 */}
<div>
<h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider mb-3">
</h3>
<GlassCard>
<div className="flex gap-2">
<Input
value={testVersion}
onChange={(e) => setTestVersionInput(e.target.value)}
placeholder="如 1.2.1-beta.13"
className="flex-1"
/>
<Button size="sm" onClick={() => run(async () => {
const r = await setTestVersion(testVersion.trim());
setStatus(r);
return r;
}, setVersionMsg)}>
</Button>
</div>
{versionMsg && <p className="mt-2 text-[12px] text-muted-foreground font-mono break-all">{versionMsg}</p>}
</GlassCard>
</div>
{/* 3. 检查更新 */}
<div>
<h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider mb-3">
</h3>
<GlassCard>
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={() => run(async () => {
const r = await checkForUpdates(true);
setStatus(r);
return r;
}, setVersionMsg)}>
</Button>
</div>
</GlassCard>
</div>
{/* 4. 获取更新介绍 */}
<div>
<h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider mb-3">
</h3>
<GlassCard>
<div className="flex gap-2">
<Input
value={notesTag}
onChange={(e) => setNotesTag(e.target.value)}
placeholder="留空=当前版本;或填 tag 如 v1.2.1-beta.13"
className="flex-1"
/>
<Button size="sm" onClick={() => run(async () => {
const r = await getReleaseNotes(notesTag.trim() || undefined);
setNotes(r);
return r ? { tag: r.tag, source: r.source, version: r.version, len: r.notes.length } : null;
}, setNotesMsg)}>
</Button>
</div>
{notes && (
<div className="mt-3 text-[12px] text-muted-foreground font-mono break-all">
<p>tag: {notes.tag} · : {notes.source} · : {notes.notes.length}</p>
<div className="mt-1 max-h-40 overflow-y-auto border border-border/40 rounded-lg p-2 bg-foreground/[0.03] whitespace-pre-wrap">
{notes.notes.slice(0, 600)}{notes.notes.length > 600 ? "…" : ""}
</div>
</div>
)}
{notesMsg && <p className="mt-2 text-[12px] text-muted-foreground font-mono break-all">{notesMsg}</p>}
</GlassCard>
</div>
{/* 5. 版本比对 */}
<div>
<h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider mb-3">
</h3>
<GlassCard>
<div className="flex gap-2">
<Input value={cmpA} onChange={(e) => setCmpA(e.target.value)} placeholder="版本 A" className="flex-1" />
<span className="self-center text-muted-foreground text-[13px]">vs</span>
<Input value={cmpB} onChange={(e) => setCmpB(e.target.value)} placeholder="版本 B" className="flex-1" />
<Button size="sm" onClick={() => run(async () => {
const r = await compareVersions(cmpA.trim(), cmpB.trim());
setCmpResult(r);
return r;
}, setNotesMsg)}>
</Button>
</div>
{cmpResult && (
<p className="mt-2 text-[13px] font-mono">
<span className={cmpResult.result === "a>b" ? "text-emerald-500" : cmpResult.result === "a<b" ? "text-red-500" : "text-foreground"}>
{cmpResult.detail}
</span>
</p>
)}
</GlassCard>
</div>
{/* 6. 下载版本 */}
<div>
<h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider mb-3">
</h3>
<GlassCard>
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={() => run(downloadUpdate, setNotesMsg)}></Button>
<Button size="sm" variant="outline" onClick={() => run(pauseUpdate, setNotesMsg)}></Button>
<Button size="sm" variant="outline" onClick={() => run(resumeUpdate, setNotesMsg)}></Button>
<Button size="sm" variant="outline" onClick={() => run(cancelUpdate, setNotesMsg)}></Button>
<Button size="sm" variant="outline" onClick={() => run(quitAndInstall, setNotesMsg)}></Button>
</div>
{(s?.state === "downloading" || s?.state === "paused") && (
<div className="mt-3">
<div className="h-1.5 w-full rounded-full bg-foreground/10 overflow-hidden">
<div className="h-full bg-blue-500 transition-all" style={{ width: `${pct}%` }} />
</div>
<p className="mt-1.5 text-[12px] text-muted-foreground font-mono">
{pct.toFixed(1)}% · {s.state}
{s.transferred != null && s.total ? ` · ${(s.transferred / 1024 / 1024).toFixed(1)} / ${(s.total / 1024 / 1024).toFixed(1)} MB` : ""}
{s.bytesPerSecond ? ` · ${(s.bytesPerSecond / 1024 / 1024).toFixed(1)} MB/s` : ""}
</p>
</div>
)}
</GlassCard>
</div>
<p className="text-[12px] text-muted-foreground/50 text-center">
/
</p>
</div>
);
}
+91 -2
View File
@@ -2,10 +2,18 @@ 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 { ExternalLink, GitFork, RotateCcw, ChevronDown } from "lucide-react";
import { Link, Select, ListBox, ListBoxItem } from "@heroui/react";
import { SettingCard, SettingRow, PageHeader, SectionTitle } from "@/components/setting";
import { Button } from "@/components/ui/button";
import {
getUpdateChannels,
getUpdateState,
onUpdateStatus,
setUpdateChannel,
type UpdateChannelDef,
} from "@/api/update";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogTrigger,
@@ -27,11 +35,50 @@ const modeLabels: Record<string, string> = {
const GITHUB_URL = "https://github.com/lingke-net/koring-launcher";
const OFFICIAL_URL = "https://koring.space";
/** 兜底通道列表:主进程 update:getChannels 不可用时使用,保证下拉框始终有选项 */
const FALLBACK_CHANNELS: UpdateChannelDef[] = [
{ key: "woker", label: "慢走模式", desc: "仅获取正式版更新(稳定)", allowPrerelease: false },
{ key: "runner", label: "跑步模式", desc: "可获取预览版(测试版)更新", allowPrerelease: true },
];
export function AboutSetting() {
const [open, setOpen] = useState(false);
const [countdown, setCountdown] = useState(5);
const canConfirm = countdown <= 0;
// 更新通道(下拉框,选项来自主进程通道注册表)
const [channels, setChannels] = useState<UpdateChannelDef[]>([]);
const [activeChannel, setActiveChannel] = useState("woker");
useEffect(() => {
getUpdateChannels()
.then((list) => setChannels(list.length ? list : FALLBACK_CHANNELS))
.catch((e) => {
console.error("[update] 获取更新通道失败,使用内置列表:", e);
setChannels(FALLBACK_CHANNELS);
});
const unsub = onUpdateStatus((s) => {
if (s.channel) setActiveChannel(s.channel);
});
getUpdateState()
.then((s) => {
if (s.channel) setActiveChannel(s.channel);
})
.catch(() => {});
return unsub;
}, []);
const handleChannelChange = async (key: unknown) => {
if (typeof key !== "string" || !key || key === activeChannel) return;
try {
await setUpdateChannel(key);
setActiveChannel(key);
toast.success("更新通道已切换,下次检查更新生效");
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
}
};
useEffect(() => {
if (!open) return;
setCountdown(5);
@@ -87,6 +134,48 @@ export function AboutSetting() {
</div>
</div>
<div>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<SettingCard>
<SettingRow label="更新通道" desc="慢走模式仅获取正式版;跑步模式可获取预览版">
<Select.Root
selectedKey={activeChannel}
onSelectionChange={handleChannelChange}
aria-label="更新通道"
className="w-48"
>
<Select.Trigger className="h-8 rounded-lg border border-border/40 dark:border-white/[0.08] bg-white/60 dark:bg-black/30 px-3 hover:border-primary/30 transition-colors">
<Select.Value className="text-[13px] text-foreground">
{channels.find((c) => c.key === activeChannel)?.label ?? "慢走模式"}
</Select.Value>
<Select.Indicator>
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
</Select.Indicator>
</Select.Trigger>
<Select.Popover className="z-50 rounded-xl border border-border/50 dark:border-white/[0.08] bg-background shadow-xl p-1.5 w-52">
<ListBox className="outline-none">
{channels.map((c) => (
<ListBoxItem
key={c.key}
id={c.key}
textValue={c.label}
className="text-[13px] py-1.5 px-2.5 rounded-lg data-[selected=true]:bg-primary/10 data-[selected=true]:text-primary outline-none cursor-pointer"
>
<div className="flex items-center justify-between gap-2">
<span>{c.label}</span>
<span className="text-[12px] text-muted-foreground">{c.desc}</span>
</div>
</ListBoxItem>
))}
</ListBox>
</Select.Popover>
</Select.Root>
</SettingRow>
</SettingCard>
</div>
</div>
<div>
<SectionTitle></SectionTitle>
<div className="space-y-3">
+1 -1
View File
@@ -59,7 +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: "" },
update: { state: "idle", version: "", percent: 0, transferred: 0, total: 0, source: "github", channel: "woker", error: "" },
instances: [],
};
+1 -1
View File
@@ -16,7 +16,7 @@ interface KoringAuthState {
logout: () => Promise<void>;
}
export const useKoringAuthStore = create<KoringAuthState>((set) => ({
export const useKoringAuthStore = create<KoringAuthState>((set, get) => ({
user: null,
authData: null,
loading: false,
+2
View File
@@ -24,6 +24,7 @@ export type RouteKey =
| "debug-splash"
| "debug-display"
| "debug-version-card"
| "debug-update"
| "debug-task"
| "debug-crash";
@@ -67,6 +68,7 @@ export const allRoutes: RouteItem[] = [
{ key: "debug-splash", label: "启动动画调试", path: "/debug/splash", hidden: true },
{ key: "debug-display", label: "显示效果调试", path: "/debug/display", hidden: true },
{ key: "debug-version-card", label: "版本卡片调试", path: "/debug/version-card", hidden: true },
{ key: "debug-update", label: "更新功能测试", path: "/debug/update", hidden: true },
{ key: "debug-task", label: "任务队列调试", path: "/debug/task", hidden: true },
];
+5
View File
@@ -7,6 +7,7 @@ interface ElectronAPI {
maximize: () => Promise<void>;
close: () => Promise<void>;
isMaximized: () => Promise<boolean>;
openDevTools: () => Promise<unknown>;
onResized: (callback: () => void) => () => void;
getTheme: () => Promise<'light' | 'dark' | 'system' | null>;
@@ -33,6 +34,10 @@ interface ElectronAPI {
quitAndInstall: () => Promise<unknown>;
getUpdateState: () => Promise<unknown>;
getReleaseNotes: (tag?: string) => Promise<unknown>;
getUpdateChannels: () => Promise<unknown>;
setUpdateChannel: (channel: string) => Promise<unknown>;
setTestVersion: (version: string) => Promise<unknown>;
compareVersions: (a: string, b: string) => Promise<unknown>;
onUpdateStatus: (callback: (data: unknown) => void) => () => void;
}