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
+18 -3
View File
@@ -105,14 +105,22 @@ jobs:
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
# BUILD ID = GitHub Run Number(严格递增,无分钟级冲突) # BUILD ID = GitHub Run Number(严格递增,无分钟级冲突)
# base 自动读 package.json(版本单一事实源,消除本地/CI 版本双轨 # base 自动读 package.json(版本单一事实源)
# 版本格式(electron-updater GitHub provider 的频道逻辑只认 alpha/beta 字符串标识):
# beta → {base}-beta.{buildId}(如 1.2.1-beta.13,频道 beta
# run → {base}(稳定版,如 1.2.1
# 数字标识(如 1.2.1-13)会被当作"自定义频道"导致更新无法识别,故弃用
- name: Generate BUILD ID & set version - name: Generate BUILD ID & set version
id: version id: version
shell: pwsh shell: pwsh
run: | run: |
$buildId = "$env:GITHUB_RUN_NUMBER" $buildId = "$env:GITHUB_RUN_NUMBER"
$base = node scripts/version.js get $base = node scripts/version.js get
$full = node scripts/version.js build ci $buildId if ("${{ inputs.mode }}" -eq "beta") {
$full = node scripts/version.js build ci "beta.$buildId"
} else {
$full = node scripts/version.js build local
}
"build_id=$buildId" | Out-File -FilePath $env:GITHUB_OUTPUT -Append "build_id=$buildId" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"base=$base" | Out-File -FilePath $env:GITHUB_OUTPUT -Append "base=$base" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"full=$full" | Out-File -FilePath $env:GITHUB_OUTPUT -Append "full=$full" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
@@ -167,13 +175,20 @@ jobs:
run: | run: |
$tag = "v${{ steps.version.outputs.full }}" $tag = "v${{ steps.version.outputs.full }}"
$prerelease = if ("${{ inputs.mode }}" -eq "beta") { "--prerelease" } else { "" } $prerelease = if ("${{ inputs.mode }}" -eq "beta") { "--prerelease" } else { "" }
# Release 标题命名(仅展示名;tag / 真实版本号仍是 {base}-{buildId},更新机制不变): $extraAssets = @()
# beta 额外上传 latest-beta.ymlGitHub provider 频道取件(latest-beta)不再 404 回退
if ("${{ inputs.mode }}" -eq "beta") {
Copy-Item "dist-electron/latest.yml" "dist-electron/latest-beta.yml" -Force
$extraAssets += "dist-electron/latest-beta.yml"
}
# Release 标题命名(仅展示名;tag / 真实版本号仍是 {base}-beta.{id} 或 {base},更新机制不变):
# 正式/预览版 → "1.2.1";测试版 → "BETA 1.2.1" # 正式/预览版 → "1.2.1";测试版 → "BETA 1.2.1"
$title = if ("${{ inputs.mode }}" -eq "beta") { "BETA ${{ steps.version.outputs.base }}" } else { "${{ steps.version.outputs.base }}" } $title = if ("${{ inputs.mode }}" -eq "beta") { "BETA ${{ steps.version.outputs.base }}" } else { "${{ steps.version.outputs.base }}" }
gh release create $tag ` gh release create $tag `
"dist-electron/koring-launcher-${{ steps.version.outputs.full }}-setup.exe" ` "dist-electron/koring-launcher-${{ steps.version.outputs.full }}-setup.exe" `
"dist-electron/latest.yml" ` "dist-electron/latest.yml" `
"release-notes.md" ` "release-notes.md" `
@extraAssets `
--title $title ` --title $title `
--notes-file release-notes.md ` --notes-file release-notes.md `
$prerelease $prerelease
+2
View File
@@ -1 +1,3 @@
oobe: false oobe: false
theme:
darkMode: light
+33 -14
View File
@@ -296,10 +296,16 @@ src/
- `sign`:是否使用 SignPath 签名 - `sign`:是否使用 SignPath 签名
- **无 `version` 输入**base 自动读 `package.json`(版本单一事实源,消除本地/CI 版本双轨) - **无 `version` 输入**base 自动读 `package.json`(版本单一事实源,消除本地/CI 版本双轨)
**版本号与 BUILD ID2026-08-30 起改为 GitHub Run Number** **版本号与 BUILD ID2026-08-30 起)**
- BUILD ID = `github.run_number`(严格递增、无分钟级冲突) - BUILD ID = `github.run_number`(严格递增、无分钟级冲突)
- 最终版本 = `{base}-{buildId}`(如 `1.2.0-12`tag = `v{base}-{buildId}` - **beta**`{base}-beta.{buildId}`(如 `1.2.1-beta.13`tag `v1.2.1-beta.13`GitHub 标记 prerelease
electron-builder 产物 = `koring-launcher-{base}-{buildId}-setup.exe``latest.yml` 同步更新 - **run**`{base}`(稳定版,如 `1.2.1`tag `v1.2.1`
- ⚠️ **为什么弃用数字标识**electron-updater 的 GitHub provider 频道逻辑只认 `alpha`/`beta`
字符串频道;`1.2.1-13` 的数字 prerelease 会被当作"自定义频道"导致更新检查
`No published versions on GitHub`(无法识别当前/最新版本)。字母标识 `beta` 解决此问题。
- **兼容性**semver 数字 < 字母):`1.2.1-beta.13 > 1.2.1-12 > 1.2.1-2608271921 > 1.2.1`
所有旧数字版本(含时间 ID 与 Run Number 时代)都能平滑升级到新格式;beta 用户可被正式版(稳定)覆盖
- beta release 额外上传 `latest-beta.yml`GitHub provider 频道取件用,避免 404 回退)
- 构建元数据(commit / buildId)由 `scripts/gen-build-info.js` 写入 `src/lib/buildInfo.ts` - 构建元数据(commit / buildId)由 `scripts/gen-build-info.js` 写入 `src/lib/buildInfo.ts`
打包进渲染层,VersionCard / 关于页显示**构建来源 commit**(`scripts/version.js build ci` 负责统一设版本) 打包进渲染层,VersionCard / 关于页显示**构建来源 commit**(`scripts/version.js build ci` 负责统一设版本)
@@ -311,23 +317,27 @@ src/
- **Release 标题命名(仅展示名,tag/真实版本号不变)**:run → `{base}`(如 `1.2.1`), - **Release 标题命名(仅展示名,tag/真实版本号不变)**:run → `{base}`(如 `1.2.1`),
beta → `BETA {base}`(如 `BETA 1.2.1` beta → `BETA {base}`(如 `BETA 1.2.1`
**⚠️ 版本语义注意(electron-updater** **⚠️ 版本语义注意(electron-updater2026-08-30 已修复并落地**
- `{base}-{buildId}` 属 semver prerelease**electron-updater 默认 `allowPrerelease=false` 会直接跳过带 - **根因**GitHub provider 用 Atom feed + 频道逻辑选版本,频道只认 `alpha`/`beta` 字符串标识。
prerelease 后缀的新版本**GitHub provider 过滤 prerelease release + isUpdateAvailable 的 数字 prerelease`1.2.1-4`)会被当作"自定义频道" → 永远选不出版本(`No published versions on GitHub`),
`semver.prerelease` 校验)——**必须**在 `electron/updater.ts` 设置 `autoUpdater.allowPrerelease = true` `allowPrerelease` 由当前版本自动决定(含 prerelease 即开启)。
否则如 v1.2.1-4 永远检测不到 v1.2.1-5(该问题已修复并落地)。 - **修复**beta 用 `{base}-beta.{buildId}`(频道 `beta`allowPrerelease 自动开启 → 正常识别);
- 开启后:同格式版本之间可正常升级(buildId 更大者胜,数值比较,如 `1.2.1-5 > 1.2.1-4` run 用稳定版 `{base}`allowPrerelease=false → 走 releases/latest,正常识别)。
- 若未来发布**不带** buildId 的稳定版本(如 `1.2.0`),稳定版用户不会自动升级到带 buildId 的构建 - 同格式升级:`1.2.1-beta.14 > 1.2.1-beta.13`(数值比较)✓;`1.2.1 > 1.2.1-beta.x`(稳定版覆盖 beta)✓
- **迁移注意**:从时间 ID`2608271921`)切换到 Run Number 后,旧格式数值更大(`2608271921 > 12`), - **无需手动设置 allowPrerelease**electron-updater 按当前版本自动判定(见 AppUpdater.js:218)。
老用户不会自动升级到新格式——切换时应同时提升 base(如 `1.3.0-12 > 1.2.0-2608271921` - 所有旧数字版本(`1.2.1-12` / `1.2.1-2608271921`都小于 `1.2.1-beta.13`,平滑升级,无需提升 base
## 14. M2 主进程更新模块(2026-08-28UI 待做) ## 14. M2 主进程更新模块(2026-08-28UI 待做)
**新增/改动** **新增/改动**
- `electron/updater.ts` — 更新服务:electron-updaterGitHub provider)优先,失败后加速源兜底; - `electron/updater.ts` — 更新服务:electron-updaterGitHub provider)优先,失败后加速源兜底;
状态机 idle/checking/available/not-available/downloading/downloaded/error,进度事件,`quitAndInstall` 状态机 idle/checking/available/not-available/downloading/downloaded/error,进度事件,`quitAndInstall`
- `electron/handlers/update.ts` — IPC`update:check` / `update:download` / `update:quitAndInstall` / `update:getState` - `electron/handlers/update.ts` — IPC`update:check` / `update:download` / `update:pause` / `update:resume` /
`update:cancel` / `update:quitAndInstall` / `update:getState` / `update:getReleaseNotes` /
`update:getChannels` / `update:setChannel` / `update:setTestVersion` / `update:compareVersions`
状态变化广播 `update:status` 到所有窗口 状态变化广播 `update:status` 到所有窗口
- 开发者工具:`window:openDevTools`(打开 Chromium DevTools+ 更新功能测试页
`debug-update`:设置测试版本号 / 检查更新 / 获取发布说明 / 版本识别列表 / 版本比对 / 下载)
- `electron/main.ts` — 注册 handler + 启动后 12s 延迟静默检查(开发模式自动跳过) - `electron/main.ts` — 注册 handler + 启动后 12s 延迟静默检查(开发模式自动跳过)
- `electron/preload.ts` + `src/types/electron.d.ts` — 暴露更新 API(UI 未接,待 M3) - `electron/preload.ts` + `src/types/electron.d.ts` — 暴露更新 API(UI 未接,待 M3)
@@ -342,7 +352,16 @@ src/
idle → checking → available → downloading → downloaded → quitAndInstall() idle → checking → available → downloading → downloaded → quitAndInstall()
└─not-available→ idle └─ error → idle(可重试) └─not-available→ idle └─ error → idle(可重试)
``` ```
`update:status` payload`{ state, manual, version?, currentVersion?, percent?, transferred?, total?, bytesPerSecond?, source?, error? }` `update:status` payload`{ state, manual, version?, currentVersion?, percent?, transferred?, total?, bytesPerSecond?, source?, channel?, error? }`
## 16. 更新通道(woker / runner2026-08-30
- **可扩展通道注册表**`electron/updater.ts``UPDATE_CHANNELS`):新增通道只需追加一项,
UI 通过 `update:getChannels` 动态渲染
- **woker(慢走模式,默认)**:`allowPrerelease=false` → 仅获取正式版(稳定)更新
- **runner(跑步模式)**`allowPrerelease=true` → 可获取预览版(测试版)更新
- 通道持久化在 `Koring.yml``update.channel``update:setChannel` 即时切换,下次检查生效
- UI:设置 → 关于 → **更新设置**区块的「更新通道」**下拉框**(选项由 `update:getChannels` 动态渲染)
## 15. 更新日志独立页面(2026-08-28 ## 15. 更新日志独立页面(2026-08-28
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -124,6 +124,8 @@ export interface UpdateConfig {
total: number; total: number;
/** 更新源:github / 加速源域名 */ /** 更新源:github / 加速源域名 */
source: string; source: string;
/** 更新通道:woker(慢走,仅正式版)/ runner(跑步,含预览版) */
channel: string;
error: string; error: string;
} }
@@ -157,7 +159,7 @@ const DEFAULTS: AppConfig = {
download: { fileSource: 'mirror', versionSource: 'mirror', threads: 16, speedLimit: 0 }, download: { fileSource: 'mirror', versionSource: 'mirror', threads: 16, speedLimit: 0 },
network: { securityId: { enabled: false, authUrl: '' } }, network: { securityId: { enabled: false, authUrl: '' } },
ui: { showInstanceTitle: true, showTaskButton: true }, 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: [], instances: [],
}; };
+39
View File
@@ -88,4 +88,43 @@ export function registerUpdateHandlers() {
return { success: false, data: null, error: String(e) }; return { success: false, data: null, error: String(e) };
} }
}); });
// 更新通道定义列表(woker/runner…,UI 动态渲染)
ipcMain.handle('update:getChannels', () => {
try {
return { success: true, data: updateService.getChannels(), error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
// 切换更新通道(持久化并立即生效)
ipcMain.handle('update:setChannel', (_event, payload?: { channel?: string }) => {
try {
const state = updateService.setChannel(payload?.channel ?? '');
return { success: true, data: state, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
// 开发者工具:设置测试版本号(覆盖当前识别版本)
ipcMain.handle('update:setTestVersion', (_event, payload?: { version?: string }) => {
try {
const state = updateService.setTestVersion(payload?.version ?? '');
return { success: true, data: state, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
// 开发者工具:版本比对(semver)
ipcMain.handle('update:compareVersions', (_event, payload?: { a?: string; b?: string }) => {
try {
const data = updateService.compareVersions(payload?.a ?? '', payload?.b ?? '');
return { success: true, data, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
} }
+6
View File
@@ -69,6 +69,12 @@ export function registerWindowHandlers(win: WinRef) {
return (win.mainWindow as any)?.themeSource ?? null; return (win.mainWindow as any)?.themeSource ?? null;
}); });
// 打开浏览器调试工具(DevTools,开发者工具测试用)
ipcMain.handle('window:openDevTools', () => {
win.mainWindow?.webContents.openDevTools({ mode: 'detach' });
return { success: true };
});
// Splash window management // Splash window management
ipcMain.handle('window:openSplash', () => { ipcMain.handle('window:openSplash', () => {
if (win.splashWindow && !win.splashWindow.isDestroyed()) { if (win.splashWindow && !win.splashWindow.isDestroyed()) {
+5
View File
@@ -42,6 +42,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
maximize: () => ipcRenderer.invoke('window:maximize'), maximize: () => ipcRenderer.invoke('window:maximize'),
close: () => ipcRenderer.invoke('window:close'), close: () => ipcRenderer.invoke('window:close'),
isMaximized: () => ipcRenderer.invoke('window:isMaximized'), isMaximized: () => ipcRenderer.invoke('window:isMaximized'),
openDevTools: () => ipcRenderer.invoke('window:openDevTools'),
onResized: (callback: () => void) => { onResized: (callback: () => void) => {
const handler = () => callback(); const handler = () => callback();
ipcRenderer.on('window:resized', handler); ipcRenderer.on('window:resized', handler);
@@ -104,6 +105,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
quitAndInstall: () => ipcRenderer.invoke('update:quitAndInstall'), quitAndInstall: () => ipcRenderer.invoke('update:quitAndInstall'),
getUpdateState: () => ipcRenderer.invoke('update:getState'), getUpdateState: () => ipcRenderer.invoke('update:getState'),
getReleaseNotes: (tag?: string) => ipcRenderer.invoke('update:getReleaseNotes', { tag }), getReleaseNotes: (tag?: string) => ipcRenderer.invoke('update:getReleaseNotes', { tag }),
getUpdateChannels: () => ipcRenderer.invoke('update:getChannels'),
setUpdateChannel: (channel: string) => ipcRenderer.invoke('update:setChannel', { channel }),
setTestVersion: (version: string) => ipcRenderer.invoke('update:setTestVersion', { version }),
compareVersions: (a: string, b: string) => ipcRenderer.invoke('update:compareVersions', { a, b }),
onUpdateStatus: (callback: (data: unknown) => void) => { onUpdateStatus: (callback: (data: unknown) => void) => {
const handler = (_event: Electron.IpcRendererEvent, data: unknown) => callback(data); const handler = (_event: Electron.IpcRendererEvent, data: unknown) => callback(data);
ipcRenderer.on('update:status', handler); ipcRenderer.on('update:status', handler);
+119 -7
View File
@@ -1,6 +1,7 @@
import { autoUpdater, CancellationToken, type ProgressInfo } from 'electron-updater'; import { autoUpdater, CancellationToken, type ProgressInfo } from 'electron-updater';
import electron from 'electron'; import electron from 'electron';
import * as os from 'os'; import * as os from 'os';
import semver from 'semver';
import { getConfig, updateConfig, flushConfig } from './config'; import { getConfig, updateConfig, flushConfig } from './config';
const { app } = electron; const { app } = electron;
@@ -30,9 +31,38 @@ export interface UpdateStatusPayload {
bytesPerSecond?: number; bytesPerSecond?: number;
/** 当前使用的更新源(github=官方 / 加速源域名) */ /** 当前使用的更新源(github=官方 / 加速源域名) */
source?: string; source?: string;
/** 当前更新通道(woker / runner */
channel?: string;
error?: string; error?: string;
} }
/** 更新通道 key(可扩展:新增通道只需在 UPDATE_CHANNELS 注册) */
export type UpdateChannelKey = 'woker' | 'runner';
export interface UpdateChannelDef {
key: UpdateChannelKey;
/** 显示名 */
label: string;
/** 说明 */
desc: string;
/** 是否接收预览版(runner 可收 betawoker 只收正式版) */
allowPrerelease: boolean;
}
/**
* 更新通道注册表(后期扩展新通道:在此追加一项即可,UI 通过 update:getChannels 动态渲染)。
* - woker(慢走模式,默认):仅检查/获取正式版(稳定)更新
* - runner(跑步模式):可获取预览版(测试版)更新
*/
const UPDATE_CHANNELS: UpdateChannelDef[] = [
{ key: 'woker', label: '慢走模式', desc: '仅获取正式版更新(稳定)', allowPrerelease: false },
{ key: 'runner', label: '跑步模式', desc: '可获取预览版(测试版)更新', allowPrerelease: true },
];
function getChannelDef(key: string): UpdateChannelDef {
return UPDATE_CHANNELS.find((c) => c.key === key) ?? UPDATE_CHANNELS[0];
}
export interface ReleaseNotesResult { export interface ReleaseNotesResult {
/** release tag,如 v1.2.0-2608271921 */ /** release tag,如 v1.2.0-2608271921 */
tag: string; tag: string;
@@ -108,6 +138,8 @@ class UpdateService {
private suppressErrors = false; private suppressErrors = false;
/** 当前下载的取消令牌(暂停/取消时 cancel) */ /** 当前下载的取消令牌(暂停/取消时 cancel) */
private downloadToken: CancellationToken | null = null; private downloadToken: CancellationToken | null = null;
/** 当前更新通道(woker 慢走 / runner 跑步;从配置读取,可运行时切换) */
private channelKey: UpdateChannelKey = 'woker';
init(listener: (payload: UpdateStatusPayload) => void): void { init(listener: (payload: UpdateStatusPayload) => void): void {
this.listener = listener; this.listener = listener;
@@ -119,6 +151,12 @@ class UpdateService {
return; return;
} }
// 恢复持久化的更新通道
const persistedChannel = getConfig().update?.channel;
if (persistedChannel && UPDATE_CHANNELS.some((c) => c.key === persistedChannel)) {
this.channelKey = persistedChannel as UpdateChannelKey;
}
// 应用能启动即说明上次安装已完成/已结束,清理持久化的进行中状态 // 应用能启动即说明上次安装已完成/已结束,清理持久化的进行中状态
const persisted = getConfig().update; const persisted = getConfig().update;
if (persisted && persisted.state && persisted.state !== 'idle') { if (persisted && persisted.state && persisted.state !== 'idle') {
@@ -128,11 +166,7 @@ class UpdateService {
autoUpdater.autoDownload = false; autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true; autoUpdater.autoInstallOnAppQuit = true;
// 项目版本方案为 {base}-{buildId}(如 1.2.1-5),属 semver prerelease(带 - 后缀)。 this.applyChannel();
// electron-updater 默认 allowPrerelease=falseGitHub provider 会过滤 prerelease release
// isUpdateAvailable 也会拒绝带 prerelease 后缀的新版本 → v1.2.1-4 永远检测不到 v1.2.1-5。
// 必须显式开启(本项目的每个发布版本都是 prerelease 形式,不存在误升级稳定版的问题)。
autoUpdater.allowPrerelease = true;
autoUpdater.logger = console; autoUpdater.logger = console;
autoUpdater.on('checking-for-update', () => { autoUpdater.on('checking-for-update', () => {
this.state = 'checking'; this.state = 'checking';
@@ -189,6 +223,7 @@ class UpdateService {
total: this.progress?.total, total: this.progress?.total,
bytesPerSecond: this.progress?.bytesPerSecond, bytesPerSecond: this.progress?.bytesPerSecond,
source: this.source, source: this.source,
channel: this.channelKey,
error: this.error, error: this.error,
}; };
} }
@@ -204,6 +239,7 @@ class UpdateService {
transferred: payload.transferred ?? 0, transferred: payload.transferred ?? 0,
total: payload.total ?? 0, total: payload.total ?? 0,
source: payload.source ?? 'github', source: payload.source ?? 'github',
channel: this.channelKey,
error: payload.error ?? '', error: payload.error ?? '',
}, },
}); });
@@ -215,17 +251,92 @@ class UpdateService {
private persistIdleConfig(): void { private persistIdleConfig(): void {
try { try {
updateConfig({ updateConfig({
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: this.channelKey, error: '' },
}); });
} catch { } catch {
/* ignore */ /* ignore */
} }
} }
/** 按当前通道应用 electron-updater 的 allowPrereleasewoker=只收正式版 / runner=可收预览版) */
private applyChannel(): void {
const def = getChannelDef(this.channelKey);
// runner(跑步) 强制开启 allowPrerelease → GitHub provider 走 Atom feed 频道逻辑可收 beta
// woker(慢走) 关闭 → 走 releases/latest 只认稳定版,不被预览版污染。
autoUpdater.allowPrerelease = def.allowPrerelease;
console.log(`[updater] 更新通道: ${def.label}${def.key}allowPrerelease=${def.allowPrerelease}`);
}
/** 通道定义列表(UI 动态渲染;可扩展) */
getChannels(): UpdateChannelDef[] {
return UPDATE_CHANNELS;
}
/** 切换更新通道(校验 + 持久化 + 立即生效,下次检查生效) */
setChannel(key: string): UpdateStatusPayload {
if (!UPDATE_CHANNELS.some((c) => c.key === key)) {
console.warn(`[updater] 未知更新通道: ${key}`);
return this.buildPayload();
}
if (this.channelKey === key) return this.buildPayload();
this.channelKey = key as UpdateChannelKey;
this.applyChannel();
try {
updateConfig({ update: { channel: key } });
} catch (e) {
console.warn('[updater] 通道写入配置失败:', e);
}
this.emit();
return this.buildPayload();
}
getState(): UpdateStatusPayload { getState(): UpdateStatusPayload {
return this.buildPayload(); return this.buildPayload();
} }
/**
* 测试用:覆盖当前识别到的版本号(影响 update:getState 与后续更新检查的比对)。
* 传入非法版本时忽略并返回当前状态。
*/
setTestVersion(version: string): UpdateStatusPayload {
const v = semver.valid(version.trim());
if (!v) {
console.warn(`[updater] 无效测试版本号: ${version}`);
return this.buildPayload();
}
this.currentVersion = v;
try {
// currentVersion 在类型声明中为 readonly,但运行时可直接赋值(测试工具用)
(autoUpdater as unknown as { currentVersion: unknown }).currentVersion = semver.parse(v);
} catch (e) {
console.warn('[updater] 设置 autoUpdater.currentVersion 失败:', e);
}
console.log(`[updater] 测试版本号 → ${v}`);
this.emit();
return this.buildPayload();
}
/** 版本比对(semver 规则,支持 v 前缀与 prerelease */
compareVersions(a: string, b: string): { a: string; b: string; result: string; detail: string } {
const va = semver.valid(a.trim());
const vb = semver.valid(b.trim());
if (!va || !vb) {
return {
a: a.trim(),
b: b.trim(),
result: 'invalid',
detail: `无效版本:${!va ? `${a.trim()}` : ''}${!va && !vb ? ' / ' : ''}${!vb ? `${b.trim()}` : ''}`,
};
}
const c = semver.compare(va, vb);
return {
a: va,
b: vb,
result: c > 0 ? 'a>b' : c < 0 ? 'a<b' : 'a==b',
detail: `${va} ${c > 0 ? '>' : c < 0 ? '<' : '=='} ${vb}`,
};
}
/** /**
* 检查更新:GitHub 官方优先,失败后依次尝试加速源。 * 检查更新:GitHub 官方优先,失败后依次尝试加速源。
*/ */
@@ -268,7 +379,8 @@ class UpdateService {
this.suppressErrors = false; this.suppressErrors = false;
// 镜像若反馈无更新(可能发现的是旧 tag / latest.yml 不匹配), // 镜像若反馈无更新(可能发现的是旧 tag / latest.yml 不匹配),
// 不要就此返回 not-available,继续尝试下一个源 // 不要就此返回 not-available,继续尝试下一个源
if (this.state !== 'not-available') return this.buildPayload(); const mirrorResult = this.buildPayload();
if (mirrorResult.state !== 'not-available') return mirrorResult;
console.warn(`[updater] ${mirror} 反馈无可用更新,尝试下一个源`); console.warn(`[updater] ${mirror} 反馈无可用更新,尝试下一个源`);
} catch (err) { } catch (err) {
console.warn(`[updater] 加速源 ${mirror} 检查失败: ${String((err as Error)?.message ?? err)}`); console.warn(`[updater] 加速源 ${mirror} 检查失败: ${String((err as Error)?.message ?? err)}`);
+2
View File
@@ -60,6 +60,7 @@
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"semver": "^7.8.5",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"three": "^0.184.0", "three": "^0.184.0",
@@ -71,6 +72,7 @@
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"@types/semver": "^7.8.0",
"@vitejs/plugin-react": "^4.6.0", "@vitejs/plugin-react": "^4.6.0",
"concurrently": "^9.1.0", "concurrently": "^9.1.0",
"electron": "^33.0.0", "electron": "^33.0.0",
+24 -6
View File
@@ -92,6 +92,9 @@ importers:
remark-gfm: remark-gfm:
specifier: ^4.0.1 specifier: ^4.0.1
version: 4.0.1 version: 4.0.1
semver:
specifier: ^7.8.5
version: 7.8.5
sonner: sonner:
specifier: ^2.0.7 specifier: ^2.0.7
version: 2.0.8(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) version: 2.0.8(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -120,6 +123,9 @@ importers:
'@types/react-dom': '@types/react-dom':
specifier: ^19.1.6 specifier: ^19.1.6
version: 19.2.5(@types/react@19.2.17) version: 19.2.5(@types/react@19.2.17)
'@types/semver':
specifier: ^7.8.0
version: 7.8.0
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^4.6.0 specifier: ^4.6.0
version: 4.7.0(vite@7.3.5(@types/node@26.0.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)) version: 4.7.0(vite@7.3.5(@types/node@26.0.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4))
@@ -1359,6 +1365,9 @@ packages:
'@types/responselike@1.0.3': '@types/responselike@1.0.3':
resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==}
'@types/semver@7.8.0':
resolution: {integrity: sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==}
'@types/stats.js@0.17.4': '@types/stats.js@0.17.4':
resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==}
@@ -3723,6 +3732,11 @@ packages:
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
semver@7.8.5:
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
engines: {node: '>=10'}
hasBin: true
send@1.2.1: send@1.2.1:
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
@@ -4613,7 +4627,7 @@ snapshots:
node-gyp: 9.4.1 node-gyp: 9.4.1
ora: 5.4.1 ora: 5.4.1
read-binary-file-arch: 1.0.6 read-binary-file-arch: 1.0.6
semver: 7.8.4 semver: 7.8.5
tar: 6.2.1 tar: 6.2.1
yargs: 17.7.3 yargs: 17.7.3
transitivePeerDependencies: transitivePeerDependencies:
@@ -4985,7 +4999,7 @@ snapshots:
'@npmcli/fs@2.1.2': '@npmcli/fs@2.1.2':
dependencies: dependencies:
'@gar/promisify': 1.1.3 '@gar/promisify': 1.1.3
semver: 7.8.4 semver: 7.8.5
'@npmcli/move-file@2.0.1': '@npmcli/move-file@2.0.1':
dependencies: dependencies:
@@ -5425,6 +5439,8 @@ snapshots:
dependencies: dependencies:
'@types/node': 26.0.1 '@types/node': 26.0.1
'@types/semver@7.8.0': {}
'@types/stats.js@0.17.4': {} '@types/stats.js@0.17.4': {}
'@types/three@0.184.1': '@types/three@0.184.1':
@@ -5601,7 +5617,7 @@ snapshots:
minimatch: 10.2.5 minimatch: 10.2.5
resedit: 1.7.2 resedit: 1.7.2
sanitize-filename: 1.6.4 sanitize-filename: 1.6.4
semver: 7.8.4 semver: 7.8.5
tar: 6.2.1 tar: 6.2.1
temp-file: 3.4.0 temp-file: 3.4.0
transitivePeerDependencies: transitivePeerDependencies:
@@ -7697,14 +7713,14 @@ snapshots:
node-abi@3.92.0: node-abi@3.92.0:
dependencies: dependencies:
semver: 7.8.4 semver: 7.8.5
node-addon-api@1.7.2: node-addon-api@1.7.2:
optional: true optional: true
node-api-version@0.2.1: node-api-version@0.2.1:
dependencies: dependencies:
semver: 7.8.4 semver: 7.8.5
node-domexception@1.0.0: {} node-domexception@1.0.0: {}
@@ -7724,7 +7740,7 @@ snapshots:
nopt: 6.0.0 nopt: 6.0.0
npmlog: 6.0.2 npmlog: 6.0.2
rimraf: 3.0.2 rimraf: 3.0.2
semver: 7.8.4 semver: 7.8.5
tar: 6.2.1 tar: 6.2.1
which: 2.0.2 which: 2.0.2
transitivePeerDependencies: transitivePeerDependencies:
@@ -8267,6 +8283,8 @@ snapshots:
semver@7.8.4: {} semver@7.8.4: {}
semver@7.8.5: {}
send@1.2.1(supports-color@8.1.1): send@1.2.1(supports-color@8.1.1):
dependencies: dependencies:
debug: 4.4.3(supports-color@8.1.1) debug: 4.4.3(supports-color@8.1.1)
+2
View File
@@ -22,6 +22,7 @@ import { Debug } from "./pages/debug";
import { SplashDebug } from "./pages/debug/splash-debug"; import { SplashDebug } from "./pages/debug/splash-debug";
import { DisplayDebug } from "./pages/debug/display-debug"; import { DisplayDebug } from "./pages/debug/display-debug";
import { VersionCardDebug } from "./pages/debug/version-card-debug"; import { VersionCardDebug } from "./pages/debug/version-card-debug";
import { UpdateDebug } from "./pages/debug/update-debug";
import { TaskDebug } from "./pages/debug/task-debug"; import { TaskDebug } from "./pages/debug/task-debug";
import { CrashDebug } from "./pages/debug/crash-debug"; import { CrashDebug } from "./pages/debug/crash-debug";
import { Oobe } from "./pages/oobe"; import { Oobe } from "./pages/oobe";
@@ -59,6 +60,7 @@ const pageMap = {
"debug-splash": SplashDebug, "debug-splash": SplashDebug,
"debug-display": DisplayDebug, "debug-display": DisplayDebug,
"debug-version-card": VersionCardDebug, "debug-version-card": VersionCardDebug,
"debug-update": UpdateDebug,
"debug-task": TaskDebug, "debug-task": TaskDebug,
"debug-crash": CrashDebug, "debug-crash": CrashDebug,
} as const; } as const;
+1
View File
@@ -89,6 +89,7 @@ export interface UpdateConfig {
transferred: number; transferred: number;
total: number; total: number;
source: string; source: string;
channel: string;
error: string; error: string;
} }
+34
View File
@@ -12,6 +12,14 @@ export type UpdateState =
| "installing" | "installing"
| "error"; | "error";
/** 更新通道定义(主进程注册表,可扩展) */
export interface UpdateChannelDef {
key: string;
label: string;
desc: string;
allowPrerelease: boolean;
}
/** 主进程广播的更新状态 payloadupdate:status */ /** 主进程广播的更新状态 payloadupdate:status */
export interface UpdateStatusPayload { export interface UpdateStatusPayload {
state: UpdateState; state: UpdateState;
@@ -27,6 +35,8 @@ export interface UpdateStatusPayload {
bytesPerSecond?: number; bytesPerSecond?: number;
/** 当前使用的更新源(github / 加速源域名) */ /** 当前使用的更新源(github / 加速源域名) */
source?: string; source?: string;
/** 当前更新通道(woker / runner */
channel?: string;
error?: string; error?: string;
} }
@@ -86,3 +96,27 @@ export const getReleaseNotes = (tag?: string): Promise<ReleaseNotesResult | null
/** 订阅更新状态变化 */ /** 订阅更新状态变化 */
export const onUpdateStatus = (cb: (status: UpdateStatusPayload) => void): (() => void) => export const onUpdateStatus = (cb: (status: UpdateStatusPayload) => void): (() => void) =>
onIpcEvent<UpdateStatusPayload>("update:status", cb); 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 { 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 = [ 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, key: "debug-crash" as const,
icon: AlertTriangle, icon: AlertTriangle,
@@ -67,6 +75,23 @@ export function Debug() {
</div> </div>
</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"> <div className="space-y-3">
{debugPages.map((p) => ( {debugPages.map((p) => (
<button <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 { VersionCard } from "@/components/VersionCard";
import { BUILD_MODE } from "@/lib/mode"; import { BUILD_MODE } from "@/lib/mode";
import { BUILD_COMMIT, BUILD_ID } from "@/lib/buildInfo"; import { BUILD_COMMIT, BUILD_ID } from "@/lib/buildInfo";
import { ExternalLink, GitFork, RotateCcw } from "lucide-react"; import { ExternalLink, GitFork, RotateCcw, ChevronDown } from "lucide-react";
import { Link } from "@heroui/react"; import { Link, Select, ListBox, ListBoxItem } from "@heroui/react";
import { SettingCard, SettingRow, PageHeader, SectionTitle } from "@/components/setting"; import { SettingCard, SettingRow, PageHeader, SectionTitle } from "@/components/setting";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
getUpdateChannels,
getUpdateState,
onUpdateStatus,
setUpdateChannel,
type UpdateChannelDef,
} from "@/api/update";
import { toast } from "sonner";
import { import {
AlertDialog, AlertDialog,
AlertDialogTrigger, AlertDialogTrigger,
@@ -27,11 +35,50 @@ const modeLabels: Record<string, string> = {
const GITHUB_URL = "https://github.com/lingke-net/koring-launcher"; const GITHUB_URL = "https://github.com/lingke-net/koring-launcher";
const OFFICIAL_URL = "https://koring.space"; 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() { export function AboutSetting() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [countdown, setCountdown] = useState(5); const [countdown, setCountdown] = useState(5);
const canConfirm = countdown <= 0; 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(() => { useEffect(() => {
if (!open) return; if (!open) return;
setCountdown(5); setCountdown(5);
@@ -87,6 +134,48 @@ export function AboutSetting() {
</div> </div>
</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> <div>
<SectionTitle></SectionTitle> <SectionTitle></SectionTitle>
<div className="space-y-3"> <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 }, download: { fileSource: "mirror", versionSource: "mirror", threads: 16, speedLimit: 0 },
network: { securityId: { enabled: false, authUrl: "" } }, network: { securityId: { enabled: false, authUrl: "" } },
ui: { showInstanceTitle: true, showTaskButton: true }, 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: [], instances: [],
}; };
+1 -1
View File
@@ -16,7 +16,7 @@ interface KoringAuthState {
logout: () => Promise<void>; logout: () => Promise<void>;
} }
export const useKoringAuthStore = create<KoringAuthState>((set) => ({ export const useKoringAuthStore = create<KoringAuthState>((set, get) => ({
user: null, user: null,
authData: null, authData: null,
loading: false, loading: false,
+2
View File
@@ -24,6 +24,7 @@ export type RouteKey =
| "debug-splash" | "debug-splash"
| "debug-display" | "debug-display"
| "debug-version-card" | "debug-version-card"
| "debug-update"
| "debug-task" | "debug-task"
| "debug-crash"; | "debug-crash";
@@ -67,6 +68,7 @@ export const allRoutes: RouteItem[] = [
{ key: "debug-splash", label: "启动动画调试", path: "/debug/splash", hidden: true }, { key: "debug-splash", label: "启动动画调试", path: "/debug/splash", hidden: true },
{ key: "debug-display", label: "显示效果调试", path: "/debug/display", hidden: true }, { key: "debug-display", label: "显示效果调试", path: "/debug/display", hidden: true },
{ key: "debug-version-card", label: "版本卡片调试", path: "/debug/version-card", 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 }, { key: "debug-task", label: "任务队列调试", path: "/debug/task", hidden: true },
]; ];
+5
View File
@@ -7,6 +7,7 @@ interface ElectronAPI {
maximize: () => Promise<void>; maximize: () => Promise<void>;
close: () => Promise<void>; close: () => Promise<void>;
isMaximized: () => Promise<boolean>; isMaximized: () => Promise<boolean>;
openDevTools: () => Promise<unknown>;
onResized: (callback: () => void) => () => void; onResized: (callback: () => void) => () => void;
getTheme: () => Promise<'light' | 'dark' | 'system' | null>; getTheme: () => Promise<'light' | 'dark' | 'system' | null>;
@@ -33,6 +34,10 @@ interface ElectronAPI {
quitAndInstall: () => Promise<unknown>; quitAndInstall: () => Promise<unknown>;
getUpdateState: () => Promise<unknown>; getUpdateState: () => Promise<unknown>;
getReleaseNotes: (tag?: string) => 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; onUpdateStatus: (callback: (data: unknown) => void) => () => void;
} }