feat(auto-update): 实现全流程自动更新功能与配套界面

- 新增主进程更新服务,支持GitHub官方源与加速源兜底的检查、下载、安装全流程
- 新增独立更新日志页面,支持查看更新说明与更新全流程管理
- 实现更新状态持久化,重启应用可恢复上次未完成的下载进度
- 完善版本管理与构建元数据脚本,优化CI发布流水线
- 补充VersionCard的构建信息展示与更新页跳转逻辑
- 新增更新相关IPC接口、类型定义与全局状态管理
- 清理旧的无用配置项,升级相关依赖包
This commit is contained in:
2026-08-30 20:19:15 +08:00
parent b404ffaef8
commit 4d3e43394c
26 changed files with 2280 additions and 98 deletions
+37 -19
View File
@@ -1,16 +1,19 @@
# Koring Launcher 构建 & 发布流水线(手动触发):构建 → SignPath 签名 → 发布 GitHub Release
#
# 手动触发(Actions 页面 -> Run workflow):
# mode 选择构建模式:beta(测试)或 run(正式)
# version 基础版本号,如 1.2.0
# 自动生成 BUILD IDAction 运行的 UTC 时刻,格式 YYMMDDHHMM,如 2608280224),
# 最终版本号 = {version}-{BUILD ID}(如 1.2.0-2608280224),tag = v{version}-{BUILD ID}
# mode 构建模式:beta(测试)或 run(正式)
# ref 构建来源分支/tag/commit(留空 = 默认分支)
# sign 是否使用 SignPath 签名
# 无 version 输入:base 自动读 package.json(版本单一事实源,消除本地/CI 版本双轨)
# BUILD ID = GitHub Run Number(严格递增,无分钟级冲突),
# 最终版本号 = {base}-{buildId}(如 1.2.0-12),tag = v{base}-{buildId}。
#
# 发布行为:
# - beta:创建 GitHub prerelease(不占用 "Latest" 位)
# - run :创建正式 release
# - Release 正文为中文:版本信息(当前版本 / 编译状态+ 自上个 release tag 以来的提交记录(默认折叠)
# - 上传产物:koring-launcher-{version}-{BUILD ID}-setup.exe + latest.ymlelectron-updater 更新清单)
# - Release 正文为中文:版本信息(当前版本 / 编译状态 / 构建来源 commit+ 提交记录(默认折叠)
# - 上传产物:koring-launcher-{base}-{buildId}-setup.exe + latest.ymlelectron-updater 更新清单)
# - 构建元数据(commit / buildId)写入 src/lib/buildInfo.ts,打包进渲染层供 UI 显示
#
# Secrets
# SIGNPATH_API_TOKEN SignPath API Token(必填;放在 Environment "BUILDER" 的环境 Secrets 中)
@@ -27,8 +30,10 @@
# 正式对外发布需生产证书(OV/EV)+ 对应生产签名策略,届时只换 SIGNPATH_SIGNING_POLICY_SLUG。
#
# ⚠️ 版本号注意(electron-updater 语义):
# - {version}-{BUILD ID} 属于 semver prerelease;已安装同格式版本的用户可正常收到更高 BUILD ID 的更新。
# - 若未来发布不带 BUILD ID 的稳定版本(如 1.2.0),稳定版用户不会自动升级到带 BUILD ID 的构建。
# - {base}-{buildId} 属于 semver prerelease;已安装同格式版本的用户可正常收到更高 buildId 的更新。
# - 若未来发布不带 buildId 的稳定版本(如 1.2.0),稳定版用户不会自动升级到带 buildId 的构建。
# - 切换 BUILD ID 方案(时间 ID → Run Number)时,旧格式数值更大(2608271921 > 12),
# 老用户不会自动升级 —— 切换时应同时提升 base(如 1.3.0-12 > 1.2.0-2608271921)。
name: BUILD & Release (SignPath)
@@ -42,10 +47,10 @@ on:
options:
- beta
- run
version:
description: '基础版本号,如 1.2.0'
required: true
default: '1.2.0'
ref:
description: '构建来源分支 / tag / commit(留空 = 默认分支)'
required: false
default: ''
sign:
description: '是否使用 SignPath 签名(配额不足时自动跳过)'
required: true
@@ -75,6 +80,8 @@ jobs:
with:
# 需要完整历史:生成中文提交记录(自上个 release tag 以来)
fetch-depth: 0
# 构建来源:默认默认分支,可指定分支/tag/commit
ref: ${{ inputs.ref || '' }}
- uses: pnpm/action-setup@v4
with:
@@ -88,17 +95,26 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
# BUILD ID = Action 运行时刻(UTC, YYMMDDHHMM),如 2608280224
# BUILD ID = GitHub Run Number(严格递增,无分钟级冲突)
# base 自动读 package.json(版本单一事实源,消除本地/CI 版本双轨)
- name: Generate BUILD ID & set version
id: version
shell: pwsh
run: |
$buildId = [DateTime]::UtcNow.ToString('yyMMddHHmm')
$full = "${{ inputs.version }}-$buildId"
node scripts/version.js $full
$buildId = "$env:GITHUB_RUN_NUMBER"
$base = node scripts/version.js get
$full = node scripts/version.js build ci $buildId
"build_id=$buildId" | 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
Write-Host "BUILD ID: $buildId -> version: $full"
Write-Host "BUILD ID: $buildId -> version: $full (base: $base)"
# 生成构建元数据(commit / 编译号)→ 打包进渲染层,UI 显示构建来源
- name: Generate build info
shell: pwsh
run: node scripts/gen-build-info.js ${{ inputs.mode }}
env:
BUILD_ID: ${{ steps.version.outputs.build_id }}
- name: Build renderer + main (${{ inputs.mode }})
shell: pwsh
@@ -119,10 +135,11 @@ jobs:
shell: pwsh
run: |
./scripts/release-notes.ps1 `
-BaseVersion "${{ inputs.version }}" `
-BaseVersion "${{ steps.version.outputs.base }}" `
-FullVersion "${{ steps.version.outputs.full }}" `
-Mode "${{ inputs.mode }}" `
-SigningStatus "${{ inputs.sign && '已签名' || '未签名' }}" `
-Commit "${{ github.sha }}" `
-OutputPath release-notes.md
- name: Publish GitHub Release
@@ -135,6 +152,7 @@ jobs:
gh release create $tag `
"dist-electron/koring-launcher-${{ steps.version.outputs.full }}-setup.exe" `
"dist-electron/latest.yml" `
--title "Koring Launcher Releases ${{ inputs.version }}" `
"release-notes.md" `
--title "Koring Launcher Releases ${{ steps.version.outputs.base }}" `
--notes-file release-notes.md `
$prerelease
+1
View File
@@ -33,3 +33,4 @@ electron-dist/
# Build resources (generated by switch-icon.js)
build/
dsh-usage/usage-records.json
Koring.yml
-4
View File
@@ -1,5 +1 @@
oobe: false
theme:
darkMode: light
game:
gameDirs: []
+60 -12
View File
@@ -1,11 +1,11 @@
# Koring Launcher 自动更新方案(v1 规划稿)
> 状态:**M1 完成(2026-08-28**M2 待开始
> 状态:**M1 完成;M2 主进程更新模块完成(2026-08-28UI 待做**
> 目标平台:**Windows 优先**NSIS exe 安装包),macOS/Linux 后续复用同一套架构
> 决策记录:
> - 安装器模式:**保持 assisted 安装器(`oneClick: false` + 可改安装目录)→ 每次更新整包下载**
> - 更新托管:**GitHub Releases**
> - 当前交付:**M1 基础设施已完成**electron-updater 依赖 + publish 配置 + 1.2.0 打包验证)
> - 当前交付:**M1 基础设施 + M2 主进程更新模块(GitHub 优先 + 加速源兜底)已完成**
---
@@ -292,20 +292,68 @@ src/
**触发方式**Actions 页面 → Run workflow,仅手动触发(不再使用 tag 触发)。
- `mode``beta`(测试,发布为 GitHub prerelease/ `run`(正式,发布为普通 release
- `version`:基础版本号(如 `1.2.0`
- `ref`:构建来源分支/tag/commit(留空 = 默认分支
- `sign`:是否使用 SignPath 签名
- **无 `version` 输入**base 自动读 `package.json`(版本单一事实源,消除本地/CI 版本双轨)
**版本号与 BUILD ID**
- BUILD ID = Action 运行的 **UTC 时刻**,格式 `YYMMDDHHMM`(如 `2608280224`
- 最终版本 = `{version}-{BUILD ID}`(如 `1.2.0-2608280224`),tag = `v{version}-{BUILD ID}`
electron-builder 产物 = `koring-launcher-{version}-{BUILD ID}-setup.exe``latest.yml` 同步更新
**版本号与 BUILD ID2026-08-30 起改为 GitHub Run Number**
- BUILD ID = `github.run_number`(严格递增、无分钟级冲突
- 最终版本 = `{base}-{buildId}`(如 `1.2.0-12`),tag = `v{base}-{buildId}`
electron-builder 产物 = `koring-launcher-{base}-{buildId}-setup.exe``latest.yml` 同步更新
- 构建元数据(commit / buildId)由 `scripts/gen-build-info.js` 写入 `src/lib/buildInfo.ts`
打包进渲染层,VersionCard / 关于页显示**构建来源 commit**(`scripts/version.js build ci` 负责统一设版本)
**发布内容**`gh release create`,中文正文由 `scripts/release-notes.ps1` 生成):
- `# Koring Launcher Releases {version}` + 版本信息(当前版本 / 编译状态 BETA/RUN
- `# Koring Launcher Releases {base}` + 版本信息(当前版本 / 编译状态 BETA/RUN / 签名状态 / **构建来源 commit**
- `## 更新了什么内容`:自上个 `v*` tag 以来的提交记录,每条默认折叠
`<details><summary>·Commit 1cf906d</summary>…</details>`
- 上传产物:setup.exe + latest.ymlelectron-updater 更新清单)
- 上传产物:setup.exe + latest.yml + release-notes.mdelectron-updater 更新清单)
**⚠️ 版本语义注意(electron-updater**
- `{version}-{BUILD ID}` 属 semver prerelease:同格式版本之间可正常升级(BUILD ID 更大者胜)
- 若未来发布**不带** BUILD ID 的稳定版本(如 `1.2.0`),稳定版用户不会自动升级到带 BUILD ID 的构建
- 同一分钟内重复触发会产生相同 BUILD ID → tag 冲突,`gh release create` 会失败,稍候重试即可
- `{base}-{buildId}` 属 semver prerelease:同格式版本之间可正常升级(buildId 更大者胜)
- 若未来发布**不带** buildId 的稳定版本(如 `1.2.0`),稳定版用户不会自动升级到带 buildId 的构建
- **迁移注意**:从时间 ID`2608271921`)切换到 Run Number 后,旧格式数值更大(`2608271921 > 12`),
老用户不会自动升级到新格式——切换时应同时提升 base(如 `1.3.0-12 > 1.2.0-2608271921`
## 14. M2 主进程更新模块(2026-08-28UI 待做)
**新增/改动**
- `electron/updater.ts` — 更新服务:electron-updaterGitHub provider)优先,失败后加速源兜底;
状态机 idle/checking/available/not-available/downloading/downloaded/error,进度事件,`quitAndInstall`
- `electron/handlers/update.ts` — IPC`update:check` / `update:download` / `update:quitAndInstall` / `update:getState`
状态变化广播 `update:status` 到所有窗口
- `electron/main.ts` — 注册 handler + 启动后 12s 延迟静默检查(开发模式自动跳过)
- `electron/preload.ts` + `src/types/electron.d.ts` — 暴露更新 API(UI 未接,待 M3)
**加速源兜底(实测)**
- GitHub 直连在本机网络不可用;`gh.ddlc.top` 已实测可代理 `releases/download`latest.yml + 102MB 安装包)与 `/releases/latest` 页面
- 发现机制(无需 GitHub API):`{镜像}/https://github.com/{owner}/{repo}/releases/latest` 页面 HTML 提取 tag →
`autoUpdater.setFeedURL({ provider: 'generic', url: '{镜像}/.../download/{tag}/' })` → 检查/下载
- 镜像列表可用环境变量 `UPDATE_MIRRORS` 覆盖;后续建议自建 OSS/CDN(generic 直连镜像根目录)
**状态机与 IPC 契约**(前端 M3 实现时使用):
```
idle → checking → available → downloading → downloaded → quitAndInstall()
└─not-available→ idle └─ error → idle(可重试)
```
`update:status` payload`{ state, manual, version?, currentVersion?, percent?, transferred?, total?, bytesPerSecond?, source?, error? }`
## 15. 更新日志独立页面(2026-08-28
- **独立路由页面** `src/pages/update/index.tsx`route key `update`),不使用设置页 layout
- 顶栏(TitleBar)在 sub 模式下**只显示「返回」+ 页面标题「更新日志」**(routeStore 新增 `titleInBar`
其余页面仍显示品牌名)
- 页面内容:顶部 `VersionCard`,下方 Markdown 渲染当前版本发布说明
(主进程 `update:getReleaseNotes`:GitHub 直连优先 + 加速源兜底,读 release 附件 `release-notes.md`
当前版本无发布说明时回退最新版本并标注)
- **入口**:除 OOBE 与更新日志页本身外,所有 VersionCard 的「检查更新」按钮点击后**跳转到本页**;
在本页内点击则直接执行检查
- **完整下载流程(2026-08-30)**:底部遮罩驱动 —— 检查更新 → 「下载版本更新」→
进度条(百分比/已下载/总大小/速度)+ **暂停/继续/取消**(基于 electron-updater CancellationToken)→
「安装更新」(先写入 installing 状态并 flush 配置,再 quitAndInstall
- **发布说明切换**:默认显示当前版本;检测到可用更新后自动切到最新版本(`getReleaseNotes(v{version})`),
退出重进回到当前版本
- **进度持久化**:每次状态/进度变化写入 `Koring.yml``update`
state/version/percent/transferred/total/source/error);应用启动时清理上次的进行中状态
- 配套改动:发布流水线 `gh release create` 上传 `release-notes.md` 附件
(旧版本发布的 release 无此附件,页面会显示回退/空态)
File diff suppressed because one or more lines are too long
+17
View File
@@ -112,6 +112,21 @@ export interface NetworkConfig {
securityId: SecurityIdConfig;
}
/** 更新进度持久化(主进程 updater 写入;重启后可恢复/展示) */
export interface UpdateConfig {
/** 状态:idle/checking/available/not-available/downloading/paused/downloaded/installing/error */
state: string;
/** 目标版本号 */
version: string;
/** 下载进度百分比 0-100 */
percent: number;
transferred: number;
total: number;
/** 更新源:github / 加速源域名 */
source: string;
error: string;
}
export interface AppConfig {
version: number;
oobe: boolean;
@@ -125,6 +140,7 @@ export interface AppConfig {
download: DownloadConfig;
network: NetworkConfig;
ui: UiConfig;
update: UpdateConfig;
instances: InstanceMeta[];
}
@@ -141,6 +157,7 @@ const DEFAULTS: 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: [],
};
+91
View File
@@ -0,0 +1,91 @@
import electron from 'electron';
import { updateService } from '../updater';
const { ipcMain } = electron;
/**
* 更新 IPC handler。
* - 主进程更新服务初始化后,状态变化广播到所有窗口(update:status
* - 渲染进程通过 update:check / update:download / update:quitAndInstall / update:getState 交互
*/
export function registerUpdateHandlers() {
updateService.init((payload) => {
for (const win of electron.BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) {
win.webContents.send('update:status', payload);
}
}
});
ipcMain.handle('update:check', async (_event, payload?: { manual?: boolean }) => {
try {
const state = await updateService.check(payload?.manual === true);
return { success: true, data: state, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('update:download', async () => {
try {
await updateService.download();
return { success: true, data: null, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('update:pause', () => {
try {
updateService.pause();
return { success: true, data: null, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('update:resume', async () => {
try {
await updateService.download(); // paused → download() 即继续
return { success: true, data: null, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('update:cancel', () => {
try {
updateService.cancel();
return { success: true, data: null, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('update:quitAndInstall', () => {
try {
updateService.quitAndInstall();
return { success: true, data: null, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('update:getState', () => {
try {
return { success: true, data: updateService.getState(), error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
// 读取发布说明(release-notes.md 附件,原始 MarkdownGitHub 优先 + 加速源兜底)
ipcMain.handle('update:getReleaseNotes', async (_event, payload?: { tag?: string }) => {
try {
const data = await updateService.getReleaseNotes(payload?.tag);
return { success: true, data, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
}
+10
View File
@@ -14,6 +14,8 @@ import { registerWindowHandlers } from './handlers/window';
import { registerCrashHandlers, setupCrashListeners, testCrashDialog } from './handlers/crash-monitor';
import { registerKoringAuthHandlers } from './handlers/koring-auth';
import { registerJavaHandlers } from './handlers/java';
import { registerUpdateHandlers } from './handlers/update';
import { updateService } from './updater';
import { saveConfig, configExists, getConfig, flushConfig, configPath } from './config';
import { authPath } from './auth';
@@ -167,6 +169,7 @@ function registerAllHandlers() {
registerCrashHandlers();
registerKoringAuthHandlers();
registerJavaHandlers();
registerUpdateHandlers();
}
app.whenReady().then(() => {
@@ -219,6 +222,13 @@ app.whenReady().then(() => {
splashMinTimeDone = true;
tryTransition();
}, 1500);
// 延迟静默检查更新(避开启动加载,不抢带宽;开发模式在 updater.init 内自动跳过)
setTimeout(() => {
updateService.check(false).catch((e) => {
console.error('[updater] 启动静默检查失败:', e);
});
}, 12000);
});
app.on('window-all-closed', () => {
+15
View File
@@ -94,4 +94,19 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Config reset
resetConfig: () => ipcRenderer.invoke('config:reset'),
// Auto-update (main process: electron/updater.ts)
checkForUpdates: (manual = false) => ipcRenderer.invoke('update:check', { manual }),
downloadUpdate: () => ipcRenderer.invoke('update:download'),
pauseUpdate: () => ipcRenderer.invoke('update:pause'),
resumeUpdate: () => ipcRenderer.invoke('update:resume'),
cancelUpdate: () => ipcRenderer.invoke('update:cancel'),
quitAndInstall: () => ipcRenderer.invoke('update:quitAndInstall'),
getUpdateState: () => ipcRenderer.invoke('update:getState'),
getReleaseNotes: (tag?: string) => ipcRenderer.invoke('update:getReleaseNotes', { tag }),
onUpdateStatus: (callback: (data: unknown) => void) => {
const handler = (_event: Electron.IpcRendererEvent, data: unknown) => callback(data);
ipcRenderer.on('update:status', handler);
return () => ipcRenderer.removeListener('update:status', handler);
},
});
+417
View File
@@ -0,0 +1,417 @@
import { autoUpdater, CancellationToken, type ProgressInfo } from 'electron-updater';
import electron from 'electron';
import * as os from 'os';
import { getConfig, updateConfig, flushConfig } from './config';
const { app } = electron;
export type UpdateState =
| 'idle'
| 'checking'
| 'available'
| 'not-available'
| 'downloading'
| 'paused'
| 'downloaded'
| 'installing'
| 'error';
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 ReleaseNotesResult {
/** release tag,如 v1.2.0-2608271921 */
tag: string;
/** 版本号(去 v 前缀) */
version: string;
/** 发布说明原始 Markdown */
notes: string;
/** 读取来源:github / 加速源域名 */
source: string;
/** 是否为最新版本的说明(当前版本无发布说明时回退) */
isLatest: boolean;
}
const OWNER = 'dream-pep';
const REPO = 'koring-launcher';
const DISCOVER_TIMEOUT_MS = 15000;
/**
* 内置加速源(ghproxy 类,代理完整 GitHub URLlatest.yml 的相对路径可解析)。
* 实测(2026-08-30):gh.ddlc.top / gh-proxy.com / ghfast.top 行为正确(按原状转发,404 即 404);
* ghps.cc 已被移除——它对任何请求都返回 200 + HTML 跳转拦截页,会污染 latest.yml / release-notes.md。
* 第三方服务稳定性有限,可用环境变量 UPDATE_MIRRORS 覆盖(逗号分隔),
* 后续建议换成自建 OSS/CDN 镜像(generic provider 直接指向镜像根目录)。
*/
const DEFAULT_MIRRORS: string[] = ['https://gh.ddlc.top', 'https://gh-proxy.com', 'https://ghfast.top'];
function getMirrors(): string[] {
const env = process.env.UPDATE_MIRRORS;
if (env) {
return env.split(',').map((s) => s.trim()).filter(Boolean);
}
return DEFAULT_MIRRORS;
}
/**
* 更新服务:electron-updaterGitHub provider+ 加速源兜底。
* 状态:idle → checking → available → downloading ⇄ paused → downloaded → installing → quitAndInstall
* 下载控制:CancellationToken 实现暂停(中断)/继续/取消。
* 进度持久化:每次状态/进度变化写入 Koring.yml 的 update 段(下载与安装进度落盘)。
*/
class UpdateService {
private state: UpdateState = 'idle';
private manual = false;
private version: string | undefined;
private currentVersion = '';
private progress: ProgressInfo | null = null;
private source = 'github';
private error: string | undefined;
private listener: ((payload: UpdateStatusPayload) => void) | null = null;
private ready = false;
/** 检查过程中屏蔽 error 事件(避免 GitHub 失败被当成最终错误) */
private suppressErrors = false;
/** 当前下载的取消令牌(暂停/取消时 cancel) */
private downloadToken: CancellationToken | null = null;
init(listener: (payload: UpdateStatusPayload) => void): void {
this.listener = listener;
this.currentVersion = app.getVersion();
if (!app.isPackaged) {
console.log('[updater] 开发模式:跳过自动更新');
this.emit();
return;
}
// 应用能启动即说明上次安装已完成/已结束,清理持久化的进行中状态
const persisted = getConfig().update;
if (persisted && persisted.state && persisted.state !== 'idle') {
console.log(`[updater] 上次更新状态 ${persisted.state} (v${persisted.version}),已重置`);
this.persistIdleConfig();
}
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.logger = console;
autoUpdater.on('checking-for-update', () => {
this.state = 'checking';
this.emit();
});
autoUpdater.on('update-available', (info) => {
this.state = 'available';
this.version = info.version;
this.error = undefined;
this.emit();
});
autoUpdater.on('update-not-available', () => {
this.state = 'not-available';
this.version = undefined;
this.emit();
});
autoUpdater.on('download-progress', (p) => {
this.state = 'downloading';
this.progress = p;
this.emit();
});
autoUpdater.on('update-downloaded', (info) => {
this.state = 'downloaded';
this.version = info.version;
this.emit();
});
autoUpdater.on('error', (err: Error) => {
const message = String(err?.message ?? err);
console.warn(`[updater] electron-updater error: ${message}`);
if (this.suppressErrors) return; // 兜底循环内,忽略
if (this.downloadToken?.cancelled) return; // 主动暂停/取消,忽略
this.state = 'error';
this.error = message;
this.emit();
});
this.ready = true;
}
private emit(): void {
const payload = this.buildPayload();
this.listener?.(payload);
this.persist(payload);
}
private buildPayload(): UpdateStatusPayload {
return {
state: this.state,
manual: this.manual,
version: this.version,
currentVersion: this.currentVersion,
percent: this.progress?.percent,
transferred: this.progress?.transferred,
total: this.progress?.total,
bytesPerSecond: this.progress?.bytesPerSecond,
source: this.source,
error: this.error,
};
}
/** 将当前状态与下载进度写入配置(下载/安装进度落盘) */
private persist(payload: UpdateStatusPayload): void {
try {
updateConfig({
update: {
state: payload.state,
version: payload.version ?? '',
percent: payload.percent ?? 0,
transferred: payload.transferred ?? 0,
total: payload.total ?? 0,
source: payload.source ?? 'github',
error: payload.error ?? '',
},
});
} catch (e) {
console.warn('[updater] 更新进度写入配置失败:', e);
}
}
private persistIdleConfig(): void {
try {
updateConfig({
update: { state: 'idle', version: '', percent: 0, transferred: 0, total: 0, source: 'github', error: '' },
});
} catch {
/* ignore */
}
}
getState(): UpdateStatusPayload {
return this.buildPayload();
}
/**
* 检查更新:GitHub 官方优先,失败后依次尝试加速源。
*/
async check(manual = false): Promise<UpdateStatusPayload> {
if (!this.ready) return this.buildPayload();
if (['downloading', 'paused', 'downloaded', 'installing'].includes(this.state)) {
return this.buildPayload(); // 已有更新在进行中/已完成
}
this.manual = manual;
this.suppressErrors = true;
// 1) GitHub 官方(app-update.yml 内置 github provider
this.source = 'github';
this.state = 'checking';
this.emit();
try {
await autoUpdater.checkForUpdates();
this.suppressErrors = false;
return this.buildPayload();
} catch (err) {
console.warn(`[updater] GitHub 官方更新源不可用: ${String((err as Error)?.message ?? err)}`);
}
// 2) 加速源兜底:镜像页面发现最新 tag → generic feed → 检查
for (const mirror of getMirrors()) {
try {
const tag = await this.discoverLatestTag(mirror);
if (!tag) {
console.warn(`[updater] ${mirror} 无法发现最新版本,跳过`);
continue;
}
const feedUrl = `${mirror}/https://github.com/${OWNER}/${REPO}/releases/download/${tag}/`;
console.log(`[updater] 切换加速源: ${mirror} (feed: ${feedUrl})`);
autoUpdater.setFeedURL({ provider: 'generic', url: feedUrl });
this.source = mirror;
this.state = 'checking';
this.emit();
await autoUpdater.checkForUpdates();
this.suppressErrors = false;
return this.buildPayload();
} catch (err) {
console.warn(`[updater] 加速源 ${mirror} 检查失败: ${String((err as Error)?.message ?? err)}`);
}
}
this.suppressErrors = false;
this.state = 'error';
this.error = '无法连接更新服务器(GitHub 与所有加速源均不可用),请稍后重试';
this.emit();
return this.buildPayload();
}
/**
* 通过加速源发现最新 release tag(不需要 GitHub API):
* 1) /releases/latest 页面 HTML 中提取 tag(多数下载型加速源可代理该页面)
* 2) 少数加速源可代理 api.github.com,作为备选
*/
private async discoverLatestTag(mirror: string): Promise<string | null> {
// 方式 1HTML 页面
try {
const pageUrl = `${mirror}/https://github.com/${OWNER}/${REPO}/releases/latest`;
const res = await fetch(pageUrl, {
headers: { 'User-Agent': 'koring-launcher-updater' },
signal: AbortSignal.timeout(DISCOVER_TIMEOUT_MS),
});
if (res.ok) {
const html = await res.text();
const m = html.match(/\/releases\/tag\/(v[^"<]+)/);
if (m?.[1]) return m[1];
}
} catch {
/* 尝试下一种方式 */
}
// 方式 2GitHub API(经加速源代理)
try {
const apiUrl = `${mirror}/https://api.github.com/repos/${OWNER}/${REPO}/releases?per_page=20`;
const res = await fetch(apiUrl, {
headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'koring-launcher-updater' },
signal: AbortSignal.timeout(DISCOVER_TIMEOUT_MS),
});
if (res.ok) {
const releases: { draft?: boolean; tag_name?: string }[] = await res.json();
const release = (releases ?? []).find((r) => !r.draft && !!r.tag_name);
if (release?.tag_name) return release.tag_name;
}
} catch {
/* ignore */
}
return null;
}
/**
* 获取指定版本(默认当前安装版本)的发布说明(release-notes.md 附件,原始 Markdown)。
* GitHub 直连优先,失败后依次尝试加速源;当前版本没有发布说明时回退到最新版本。
*/
async getReleaseNotes(requestedTag?: string): Promise<ReleaseNotesResult | null> {
const tag = (requestedTag?.trim() || `v${app.getVersion()}`).replace(/^v(?=\d)/, 'v');
const found = await this.fetchNotesForTag(tag);
if (found) return found;
// 回退:最新版本(通过 /releases/latest 页面发现 tag
for (const mirror of getMirrors()) {
const latestTag = await this.discoverLatestTag(mirror);
if (latestTag && latestTag !== tag) {
const foundLatest = await this.fetchNotesForTag(latestTag);
if (foundLatest) {
return { ...foundLatest, isLatest: true };
}
}
}
return null;
}
private async fetchNotesForTag(tag: string): Promise<ReleaseNotesResult | null> {
const bases: { source: string; base: string }[] = [
{ source: 'github', base: `https://github.com/${OWNER}/${REPO}` },
...getMirrors().map((m) => ({ source: m, base: `${m}/https://github.com/${OWNER}/${REPO}` })),
];
for (const { source, base } of bases) {
try {
const url = `${base}/releases/download/${tag}/release-notes.md`;
const res = await fetch(url, {
headers: { 'User-Agent': 'koring-launcher-updater' },
signal: AbortSignal.timeout(DISCOVER_TIMEOUT_MS),
});
if (res.ok) {
// 部分加速源对任意 URL 返回 200 + HTML 跳转/拦截页,必须校验内容
const contentType = res.headers.get('content-type') ?? '';
if (/^text\/html/i.test(contentType)) continue;
const notes = await res.text();
if (!notes.trim()) continue;
if (/^\s*<!doctype html/i.test(notes) || /^\s*<html[\s>]/i.test(notes)) continue;
console.log(`[updater] 发布说明来源: ${source} (${tag})`);
return { tag, version: tag.replace(/^v/, ''), notes, source, isLatest: false };
}
} catch {
/* 尝试下一个源 */
}
}
return null;
}
/**
* 下载更新(触发下载,进度经 update:status 上报并写入配置)。
* available → 开始下载;paused → 继续下载。
*/
async download(): Promise<void> {
if (!this.ready) return;
if (this.state === 'downloading' || this.state === 'downloaded' || this.state === 'installing') return;
if (this.state === 'paused') {
// 继续下载(可能从断点续传,也可能重新开始,取决于 electron-updater 缓存)
console.log('[updater] 继续下载');
}
this.state = 'downloading';
this.progress = null;
this.emit();
const token = new CancellationToken();
this.downloadToken = token;
try {
await autoUpdater.downloadUpdate(token);
} catch (err) {
if (token.cancelled) return; // 主动暂停/取消,不是错误
this.state = 'error';
this.error = String((err as Error)?.message ?? err);
this.emit();
}
}
/** 暂停下载(中断当前请求,保留进度;再次下载即继续) */
pause(): void {
if (!this.ready || this.state !== 'downloading') return;
this.state = 'paused';
this.emit();
this.downloadToken?.cancel();
}
/** 取消下载:中断并清除进度,回到 available(可重新下载) */
cancel(): void {
if (!this.ready) return;
this.downloadToken?.cancel();
this.downloadToken = null;
if (this.state === 'downloading' || this.state === 'paused') {
this.state = 'available';
this.progress = null;
this.emit();
}
}
/**
* 退出并安装(NSIS 静默安装,安装完成自动重启)。
* 安装状态先写入配置并立即落盘,避免退出时 debounce 未写盘。
*/
quitAndInstall(): void {
if (!this.ready || this.state !== 'downloaded') return;
this.state = 'installing';
this.emit();
try {
flushConfig();
} catch {
/* ignore */
}
autoUpdater.quitAndInstall();
}
/** 获取系统下载临时目录(用于清理提示,暂未启用) */
getCacheDir(): string {
return os.tmpdir();
}
}
export const updateService = new UpdateService();
+4 -1
View File
@@ -1,7 +1,7 @@
{
"name": "koring-launcher",
"private": true,
"version": "1.2.0",
"version": "1.2.1",
"description": "Koring Launcher - Minecraft launcher built with Electron + React",
"author": "Shenzhen Lingke Network Technology Co., Ltd.",
"license": "LL-1.0",
@@ -57,6 +57,9 @@
"qrcode.react": "^4.2.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-markdown": "^10.1.0",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"three": "^0.184.0",
+962 -6
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
/**
* 生成构建元数据文件 src/lib/buildInfo.tsCI 构建时调用)。
*
* 用法:
* node scripts/gen-build-info.js <mode> # mode: dev | beta | run
*
* 环境变量:
* BUILD_ID 编译号(CI 传 GITHUB_RUN_NUMBER;缺省 "local"
* 本地 git HEAD 短哈希作为构建来源 commit。
*/
'use strict';
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const mode = process.argv[2] || 'run';
let commit = '';
try {
commit = execSync('git rev-parse --short HEAD').toString().trim();
} catch {
/* 非 git 环境,保持空 */
}
const buildId = process.env.BUILD_ID || process.env.GITHUB_RUN_NUMBER || 'local';
const content = `// 构建元数据:由 scripts/gen-build-info.js 自动生成(CI 覆盖;本地开发为默认值)
export const BUILD_COMMIT: string = ${JSON.stringify(commit)};
export const BUILD_ID: string = ${JSON.stringify(buildId)};
`;
fs.writeFileSync(path.join(__dirname, '..', 'src', 'lib', 'buildInfo.ts'), content);
console.log(`[build-info] mode=${mode} commit=${commit || '(none)'} buildId=${buildId}`);
+3
View File
@@ -16,10 +16,12 @@ param(
[Parameter(Mandatory = $true)][string] $FullVersion,
[Parameter(Mandatory = $true)][string] $Mode,
[string] $SigningStatus = "已签名",
[string] $Commit = "",
[string] $OutputPath = "release-notes.md"
)
$status = if ($Mode -eq 'beta') { 'BETA' } else { 'RUN' }
$commitShort = if ($Commit) { $Commit.Substring(0, [Math]::Min(7, $Commit.Length)) } else { '' }
$rec = [char]0x1e # 记录分隔符(每个 commit 一条)
$sep = [char]0x1f # 字段分隔符(hash / subject / body
@@ -68,6 +70,7 @@ $content = @"
$FullVersion
$status
$SigningStatus
$(if ($commitShort) { "构建来源:commit $commitShort" })
##
$commitsSection
+34 -5
View File
@@ -9,14 +9,11 @@ function getCurrentVersion() {
return pkg.version;
}
/** 仅写文件,不打印(stdout 纯净,供脚本/CI 捕获) */
function setVersion(version) {
// Update package.json
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
pkg.version = version;
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
console.log(`Version updated to ${version}`);
console.log(` - package.json`);
}
// Parse args
@@ -27,15 +24,45 @@ if (args.length === 0) {
process.exit(0);
}
// 输出裸版本号(供脚本/CI 使用)
if (args[0] === 'get') {
console.log(getCurrentVersion());
process.exit(0);
}
// 构建版本:统一「base + 构建号」规则,消除本地/CI 版本双轨
// version.js build ci [buildId] [base] CIbase 缺省读 package.json → 设置并输出 base-buildId
// version.js build local 本地:输出当前 base(不修改)
if (args[0] === 'build') {
const kind = args[1];
if (kind === 'ci') {
const buildId = args[2] || process.env.GITHUB_RUN_NUMBER || '0';
const base = args[3] || getCurrentVersion();
const full = `${base}-${buildId}`;
setVersion(full);
console.log(full); // stdout 仅版本号
process.exit(0);
}
if (kind === 'local') {
console.log(getCurrentVersion());
process.exit(0);
}
console.error('Usage: node scripts/version.js build ci [buildId] [base] | node scripts/version.js build local');
process.exit(1);
}
if (args[0] === '--help' || args[0] === '-h') {
console.log('Usage:');
console.log(' node scripts/version.js <version> Set version');
console.log(' node scripts/version.js Show current version');
console.log(' node scripts/version.js get Print raw version');
console.log(' node scripts/version.js build ci [buildId] [base] CI build version (base-buildId)');
console.log(' node scripts/version.js build local Print local base version');
console.log('');
console.log('Examples:');
console.log(' node scripts/version.js 1.0.0');
console.log(' node scripts/version.js 1.1.0-beta.1');
console.log(' node scripts/version.js 2.0.0-rc.1');
console.log(' node scripts/version.js build ci 12');
process.exit(0);
}
@@ -51,3 +78,5 @@ if (!/^\d+\.\d+\.\d+/.test(newVersion)) {
const current = getCurrentVersion();
console.log(`Current version: ${current}`);
setVersion(newVersion);
console.log(`Version updated to ${newVersion}`);
console.log(' - package.json');
+2
View File
@@ -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,
+12
View File
@@ -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
View File
@@ -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";
/** 主进程广播的更新状态 payloadupdate: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);
+35 -2
View File
@@ -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,6 +104,18 @@ export function VersionCard({
/>
<p className="text-sm text-white/70 font-medium">v{VERSION}</p>
{/* 构建来源(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">
@@ -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>
);
+3
View File
@@ -0,0 +1,3 @@
// 构建元数据:由 scripts/gen-build-info.js 自动生成(CI 覆盖;本地开发为默认值)
export const BUILD_COMMIT: string = "";
export const BUILD_ID: string = "local";
+9
View File
@@ -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>
+300
View File
@@ -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.ymlupdate 段)
*/
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>
);
}
+1
View File
@@ -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: [],
};
+2
View File
@@ -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 },
+123 -17
View File
@@ -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) => ({
type Setter = (partial: Partial<UpdateStoreState>) => void;
function applyStatus(set: Setter, status: UpdateStatusPayload): void {
const next: Partial<UpdateStoreState> = {
currentVersion: status.currentVersion ?? "",
source: status.source ?? "github",
};
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;
}
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 {
const update = await checkForUpdates();
set({ update, checking: false });
} catch (e: any) {
set({ error: e.message ?? String(e), checking: false });
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 {
await downloadAndInstall((progress) => {
set({ progress });
});
set({ downloading: false, installed: true });
} catch (e: any) {
set({ error: e.message ?? String(e), downloading: false });
// 智能分派:暂停→继续;已下载→安装;否则→开始下载
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 }),
}));
reset: () =>
set({
update: null,
error: null,
progress: null,
installed: false,
checking: false,
downloading: false,
}),
};
});
+11
View File
@@ -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 {