mirror of
https://github.com/dream-pep/koring-launcher.git
synced 2026-09-12 05:45:18 +08:00
feat(auto-update): 实现全流程自动更新功能与配套界面
- 新增主进程更新服务,支持GitHub官方源与加速源兜底的检查、下载、安装全流程 - 新增独立更新日志页面,支持查看更新说明与更新全流程管理 - 实现更新状态持久化,重启应用可恢复上次未完成的下载进度 - 完善版本管理与构建元数据脚本,优化CI发布流水线 - 补充VersionCard的构建信息展示与更新页跳转逻辑 - 新增更新相关IPC接口、类型定义与全局状态管理 - 清理旧的无用配置项,升级相关依赖包
This commit is contained in:
@@ -1,16 +1,19 @@
|
|||||||
# Koring Launcher 构建 & 发布流水线(手动触发):构建 → SignPath 签名 → 发布 GitHub Release
|
# Koring Launcher 构建 & 发布流水线(手动触发):构建 → SignPath 签名 → 发布 GitHub Release
|
||||||
#
|
#
|
||||||
# 手动触发(Actions 页面 -> Run workflow):
|
# 手动触发(Actions 页面 -> Run workflow):
|
||||||
# mode 选择构建模式:beta(测试)或 run(正式)
|
# mode 构建模式:beta(测试)或 run(正式)
|
||||||
# version 基础版本号,如 1.2.0
|
# ref 构建来源分支/tag/commit(留空 = 默认分支)
|
||||||
# 自动生成 BUILD ID(Action 运行的 UTC 时刻,格式 YYMMDDHHMM,如 2608280224),
|
# sign 是否使用 SignPath 签名
|
||||||
# 最终版本号 = {version}-{BUILD ID}(如 1.2.0-2608280224),tag = v{version}-{BUILD ID}。
|
# 无 version 输入:base 自动读 package.json(版本单一事实源,消除本地/CI 版本双轨)。
|
||||||
|
# BUILD ID = GitHub Run Number(严格递增,无分钟级冲突),
|
||||||
|
# 最终版本号 = {base}-{buildId}(如 1.2.0-12),tag = v{base}-{buildId}。
|
||||||
#
|
#
|
||||||
# 发布行为:
|
# 发布行为:
|
||||||
# - beta:创建 GitHub prerelease(不占用 "Latest" 位)
|
# - beta:创建 GitHub prerelease(不占用 "Latest" 位)
|
||||||
# - run :创建正式 release
|
# - run :创建正式 release
|
||||||
# - Release 正文为中文:版本信息(当前版本 / 编译状态)+ 自上个 release tag 以来的提交记录(默认折叠)
|
# - Release 正文为中文:版本信息(当前版本 / 编译状态 / 构建来源 commit)+ 提交记录(默认折叠)
|
||||||
# - 上传产物:koring-launcher-{version}-{BUILD ID}-setup.exe + latest.yml(electron-updater 更新清单)
|
# - 上传产物:koring-launcher-{base}-{buildId}-setup.exe + latest.yml(electron-updater 更新清单)
|
||||||
|
# - 构建元数据(commit / buildId)写入 src/lib/buildInfo.ts,打包进渲染层供 UI 显示
|
||||||
#
|
#
|
||||||
# Secrets:
|
# Secrets:
|
||||||
# SIGNPATH_API_TOKEN SignPath API Token(必填;放在 Environment "BUILDER" 的环境 Secrets 中)
|
# SIGNPATH_API_TOKEN SignPath API Token(必填;放在 Environment "BUILDER" 的环境 Secrets 中)
|
||||||
@@ -27,8 +30,10 @@
|
|||||||
# 正式对外发布需生产证书(OV/EV)+ 对应生产签名策略,届时只换 SIGNPATH_SIGNING_POLICY_SLUG。
|
# 正式对外发布需生产证书(OV/EV)+ 对应生产签名策略,届时只换 SIGNPATH_SIGNING_POLICY_SLUG。
|
||||||
#
|
#
|
||||||
# ⚠️ 版本号注意(electron-updater 语义):
|
# ⚠️ 版本号注意(electron-updater 语义):
|
||||||
# - {version}-{BUILD ID} 属于 semver prerelease;已安装同格式版本的用户可正常收到更高 BUILD ID 的更新。
|
# - {base}-{buildId} 属于 semver prerelease;已安装同格式版本的用户可正常收到更高 buildId 的更新。
|
||||||
# - 若未来发布不带 BUILD ID 的稳定版本(如 1.2.0),稳定版用户不会自动升级到带 BUILD ID 的构建。
|
# - 若未来发布不带 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)
|
name: BUILD & Release (SignPath)
|
||||||
|
|
||||||
@@ -42,10 +47,10 @@ on:
|
|||||||
options:
|
options:
|
||||||
- beta
|
- beta
|
||||||
- run
|
- run
|
||||||
version:
|
ref:
|
||||||
description: '基础版本号,如 1.2.0'
|
description: '构建来源分支 / tag / commit(留空 = 默认分支)'
|
||||||
required: true
|
required: false
|
||||||
default: '1.2.0'
|
default: ''
|
||||||
sign:
|
sign:
|
||||||
description: '是否使用 SignPath 签名(配额不足时自动跳过)'
|
description: '是否使用 SignPath 签名(配额不足时自动跳过)'
|
||||||
required: true
|
required: true
|
||||||
@@ -75,6 +80,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
# 需要完整历史:生成中文提交记录(自上个 release tag 以来)
|
# 需要完整历史:生成中文提交记录(自上个 release tag 以来)
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
# 构建来源:默认默认分支,可指定分支/tag/commit
|
||||||
|
ref: ${{ inputs.ref || '' }}
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
@@ -88,17 +95,26 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
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
|
- name: Generate BUILD ID & set version
|
||||||
id: version
|
id: version
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
$buildId = [DateTime]::UtcNow.ToString('yyMMddHHmm')
|
$buildId = "$env:GITHUB_RUN_NUMBER"
|
||||||
$full = "${{ inputs.version }}-$buildId"
|
$base = node scripts/version.js get
|
||||||
node scripts/version.js $full
|
$full = node scripts/version.js build ci $buildId
|
||||||
"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
|
||||||
"full=$full" | 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 }})
|
- name: Build renderer + main (${{ inputs.mode }})
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
@@ -119,10 +135,11 @@ jobs:
|
|||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
./scripts/release-notes.ps1 `
|
./scripts/release-notes.ps1 `
|
||||||
-BaseVersion "${{ inputs.version }}" `
|
-BaseVersion "${{ steps.version.outputs.base }}" `
|
||||||
-FullVersion "${{ steps.version.outputs.full }}" `
|
-FullVersion "${{ steps.version.outputs.full }}" `
|
||||||
-Mode "${{ inputs.mode }}" `
|
-Mode "${{ inputs.mode }}" `
|
||||||
-SigningStatus "${{ inputs.sign && '已签名' || '未签名' }}" `
|
-SigningStatus "${{ inputs.sign && '已签名' || '未签名' }}" `
|
||||||
|
-Commit "${{ github.sha }}" `
|
||||||
-OutputPath release-notes.md
|
-OutputPath release-notes.md
|
||||||
|
|
||||||
- name: Publish GitHub Release
|
- name: Publish GitHub Release
|
||||||
@@ -135,6 +152,7 @@ jobs:
|
|||||||
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" `
|
||||||
--title "Koring Launcher Releases ${{ inputs.version }}" `
|
"release-notes.md" `
|
||||||
|
--title "Koring Launcher Releases ${{ steps.version.outputs.base }}" `
|
||||||
--notes-file release-notes.md `
|
--notes-file release-notes.md `
|
||||||
$prerelease
|
$prerelease
|
||||||
|
|||||||
@@ -33,3 +33,4 @@ electron-dist/
|
|||||||
# Build resources (generated by switch-icon.js)
|
# Build resources (generated by switch-icon.js)
|
||||||
build/
|
build/
|
||||||
dsh-usage/usage-records.json
|
dsh-usage/usage-records.json
|
||||||
|
Koring.yml
|
||||||
|
|||||||
@@ -1,5 +1 @@
|
|||||||
oobe: false
|
oobe: false
|
||||||
theme:
|
|
||||||
darkMode: light
|
|
||||||
game:
|
|
||||||
gameDirs: []
|
|
||||||
|
|||||||
+60
-12
@@ -1,11 +1,11 @@
|
|||||||
# Koring Launcher 自动更新方案(v1 规划稿)
|
# Koring Launcher 自动更新方案(v1 规划稿)
|
||||||
|
|
||||||
> 状态:**M1 已完成(2026-08-28)**;M2 待开始
|
> 状态:**M1 完成;M2 主进程更新模块完成(2026-08-28,UI 待做)**
|
||||||
> 目标平台:**Windows 优先**(NSIS exe 安装包),macOS/Linux 后续复用同一套架构
|
> 目标平台:**Windows 优先**(NSIS exe 安装包),macOS/Linux 后续复用同一套架构
|
||||||
> 决策记录:
|
> 决策记录:
|
||||||
> - 安装器模式:**保持 assisted 安装器(`oneClick: false` + 可改安装目录)→ 每次更新整包下载**
|
> - 安装器模式:**保持 assisted 安装器(`oneClick: false` + 可改安装目录)→ 每次更新整包下载**
|
||||||
> - 更新托管:**GitHub Releases**
|
> - 更新托管:**GitHub Releases**
|
||||||
> - 当前交付:**M1 基础设施已完成**(electron-updater 依赖 + publish 配置 + 1.2.0 打包验证)
|
> - 当前交付:**M1 基础设施 + M2 主进程更新模块(GitHub 优先 + 加速源兜底)已完成**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -292,20 +292,68 @@ src/
|
|||||||
|
|
||||||
**触发方式**:Actions 页面 → Run workflow,仅手动触发(不再使用 tag 触发)。
|
**触发方式**:Actions 页面 → Run workflow,仅手动触发(不再使用 tag 触发)。
|
||||||
- `mode`:`beta`(测试,发布为 GitHub prerelease)/ `run`(正式,发布为普通 release)
|
- `mode`:`beta`(测试,发布为 GitHub prerelease)/ `run`(正式,发布为普通 release)
|
||||||
- `version`:基础版本号(如 `1.2.0`)
|
- `ref`:构建来源分支/tag/commit(留空 = 默认分支)
|
||||||
|
- `sign`:是否使用 SignPath 签名
|
||||||
|
- **无 `version` 输入**:base 自动读 `package.json`(版本单一事实源,消除本地/CI 版本双轨)
|
||||||
|
|
||||||
**版本号与 BUILD ID**:
|
**版本号与 BUILD ID(2026-08-30 起改为 GitHub Run Number)**:
|
||||||
- BUILD ID = Action 运行的 **UTC 时刻**,格式 `YYMMDDHHMM`(如 `2608280224`)
|
- BUILD ID = `github.run_number`(严格递增、无分钟级冲突)
|
||||||
- 最终版本 = `{version}-{BUILD ID}`(如 `1.2.0-2608280224`),tag = `v{version}-{BUILD ID}`,
|
- 最终版本 = `{base}-{buildId}`(如 `1.2.0-12`),tag = `v{base}-{buildId}`,
|
||||||
electron-builder 产物 = `koring-launcher-{version}-{BUILD ID}-setup.exe`,`latest.yml` 同步更新
|
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` 生成):
|
**发布内容**(`gh release create`,中文正文由 `scripts/release-notes.ps1` 生成):
|
||||||
- `# Koring Launcher Releases {version}` + 版本信息(当前版本 / 编译状态 BETA/RUN)
|
- `# Koring Launcher Releases {base}` + 版本信息(当前版本 / 编译状态 BETA/RUN / 签名状态 / **构建来源 commit**)
|
||||||
- `## 更新了什么内容`:自上个 `v*` tag 以来的提交记录,每条默认折叠
|
- `## 更新了什么内容`:自上个 `v*` tag 以来的提交记录,每条默认折叠
|
||||||
(`<details><summary>·Commit 1cf906d</summary>…</details>`)
|
(`<details><summary>·Commit 1cf906d</summary>…</details>`)
|
||||||
- 上传产物:setup.exe + latest.yml(electron-updater 更新清单)
|
- 上传产物:setup.exe + latest.yml + release-notes.md(electron-updater 更新清单)
|
||||||
|
|
||||||
**⚠️ 版本语义注意(electron-updater)**:
|
**⚠️ 版本语义注意(electron-updater)**:
|
||||||
- `{version}-{BUILD ID}` 属 semver prerelease:同格式版本之间可正常升级(BUILD ID 更大者胜)
|
- `{base}-{buildId}` 属 semver prerelease:同格式版本之间可正常升级(buildId 更大者胜)
|
||||||
- 若未来发布**不带** BUILD ID 的稳定版本(如 `1.2.0`),稳定版用户不会自动升级到带 BUILD ID 的构建
|
- 若未来发布**不带** buildId 的稳定版本(如 `1.2.0`),稳定版用户不会自动升级到带 buildId 的构建
|
||||||
- 同一分钟内重复触发会产生相同 BUILD ID → tag 冲突,`gh release create` 会失败,稍候重试即可
|
- **迁移注意**:从时间 ID(`2608271921`)切换到 Run Number 后,旧格式数值更大(`2608271921 > 12`),
|
||||||
|
老用户不会自动升级到新格式——切换时应同时提升 base(如 `1.3.0-12 > 1.2.0-2608271921`)
|
||||||
|
|
||||||
|
## 14. M2 主进程更新模块(2026-08-28,UI 待做)
|
||||||
|
|
||||||
|
**新增/改动**:
|
||||||
|
- `electron/updater.ts` — 更新服务:electron-updater(GitHub 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
@@ -112,6 +112,21 @@ export interface NetworkConfig {
|
|||||||
securityId: SecurityIdConfig;
|
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 {
|
export interface AppConfig {
|
||||||
version: number;
|
version: number;
|
||||||
oobe: boolean;
|
oobe: boolean;
|
||||||
@@ -125,6 +140,7 @@ export interface AppConfig {
|
|||||||
download: DownloadConfig;
|
download: DownloadConfig;
|
||||||
network: NetworkConfig;
|
network: NetworkConfig;
|
||||||
ui: UiConfig;
|
ui: UiConfig;
|
||||||
|
update: UpdateConfig;
|
||||||
instances: InstanceMeta[];
|
instances: InstanceMeta[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +157,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: '' },
|
||||||
instances: [],
|
instances: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 附件,原始 Markdown;GitHub 优先 + 加速源兜底)
|
||||||
|
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) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ import { registerWindowHandlers } from './handlers/window';
|
|||||||
import { registerCrashHandlers, setupCrashListeners, testCrashDialog } from './handlers/crash-monitor';
|
import { registerCrashHandlers, setupCrashListeners, testCrashDialog } from './handlers/crash-monitor';
|
||||||
import { registerKoringAuthHandlers } from './handlers/koring-auth';
|
import { registerKoringAuthHandlers } from './handlers/koring-auth';
|
||||||
import { registerJavaHandlers } from './handlers/java';
|
import { registerJavaHandlers } from './handlers/java';
|
||||||
|
import { registerUpdateHandlers } from './handlers/update';
|
||||||
|
import { updateService } from './updater';
|
||||||
import { saveConfig, configExists, getConfig, flushConfig, configPath } from './config';
|
import { saveConfig, configExists, getConfig, flushConfig, configPath } from './config';
|
||||||
import { authPath } from './auth';
|
import { authPath } from './auth';
|
||||||
|
|
||||||
@@ -167,6 +169,7 @@ function registerAllHandlers() {
|
|||||||
registerCrashHandlers();
|
registerCrashHandlers();
|
||||||
registerKoringAuthHandlers();
|
registerKoringAuthHandlers();
|
||||||
registerJavaHandlers();
|
registerJavaHandlers();
|
||||||
|
registerUpdateHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
@@ -219,6 +222,13 @@ app.whenReady().then(() => {
|
|||||||
splashMinTimeDone = true;
|
splashMinTimeDone = true;
|
||||||
tryTransition();
|
tryTransition();
|
||||||
}, 1500);
|
}, 1500);
|
||||||
|
|
||||||
|
// 延迟静默检查更新(避开启动加载,不抢带宽;开发模式在 updater.init 内自动跳过)
|
||||||
|
setTimeout(() => {
|
||||||
|
updateService.check(false).catch((e) => {
|
||||||
|
console.error('[updater] 启动静默检查失败:', e);
|
||||||
|
});
|
||||||
|
}, 12000);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
|
|||||||
@@ -94,4 +94,19 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
|
|
||||||
// Config reset
|
// Config reset
|
||||||
resetConfig: () => ipcRenderer.invoke('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);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 URL;latest.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-updater(GitHub 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> {
|
||||||
|
// 方式 1:HTML 页面
|
||||||
|
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 {
|
||||||
|
/* 尝试下一种方式 */
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方式 2:GitHub 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
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "koring-launcher",
|
"name": "koring-launcher",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.0",
|
"version": "1.2.1",
|
||||||
"description": "Koring Launcher - Minecraft launcher built with Electron + React",
|
"description": "Koring Launcher - Minecraft launcher built with Electron + React",
|
||||||
"author": "Shenzhen Lingke Network Technology Co., Ltd.",
|
"author": "Shenzhen Lingke Network Technology Co., Ltd.",
|
||||||
"license": "LL-1.0",
|
"license": "LL-1.0",
|
||||||
@@ -57,6 +57,9 @@
|
|||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^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",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"three": "^0.184.0",
|
"three": "^0.184.0",
|
||||||
|
|||||||
Generated
+962
-6
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* 生成构建元数据文件 src/lib/buildInfo.ts(CI 构建时调用)。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* 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}`);
|
||||||
@@ -16,10 +16,12 @@ param(
|
|||||||
[Parameter(Mandatory = $true)][string] $FullVersion,
|
[Parameter(Mandatory = $true)][string] $FullVersion,
|
||||||
[Parameter(Mandatory = $true)][string] $Mode,
|
[Parameter(Mandatory = $true)][string] $Mode,
|
||||||
[string] $SigningStatus = "已签名",
|
[string] $SigningStatus = "已签名",
|
||||||
|
[string] $Commit = "",
|
||||||
[string] $OutputPath = "release-notes.md"
|
[string] $OutputPath = "release-notes.md"
|
||||||
)
|
)
|
||||||
|
|
||||||
$status = if ($Mode -eq 'beta') { 'BETA' } else { 'RUN' }
|
$status = if ($Mode -eq 'beta') { 'BETA' } else { 'RUN' }
|
||||||
|
$commitShort = if ($Commit) { $Commit.Substring(0, [Math]::Min(7, $Commit.Length)) } else { '' }
|
||||||
|
|
||||||
$rec = [char]0x1e # 记录分隔符(每个 commit 一条)
|
$rec = [char]0x1e # 记录分隔符(每个 commit 一条)
|
||||||
$sep = [char]0x1f # 字段分隔符(hash / subject / body)
|
$sep = [char]0x1f # 字段分隔符(hash / subject / body)
|
||||||
@@ -68,6 +70,7 @@ $content = @"
|
|||||||
当前版本 $FullVersion
|
当前版本 $FullVersion
|
||||||
编译状态:$status
|
编译状态:$status
|
||||||
签名状态:$SigningStatus
|
签名状态:$SigningStatus
|
||||||
|
$(if ($commitShort) { "构建来源:commit $commitShort" })
|
||||||
|
|
||||||
## 更新了什么内容
|
## 更新了什么内容
|
||||||
$commitsSection
|
$commitsSection
|
||||||
|
|||||||
+34
-5
@@ -9,14 +9,11 @@ function getCurrentVersion() {
|
|||||||
return pkg.version;
|
return pkg.version;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 仅写文件,不打印(stdout 纯净,供脚本/CI 捕获) */
|
||||||
function setVersion(version) {
|
function setVersion(version) {
|
||||||
// Update package.json
|
|
||||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
||||||
pkg.version = version;
|
pkg.version = version;
|
||||||
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
||||||
|
|
||||||
console.log(`Version updated to ${version}`);
|
|
||||||
console.log(` - package.json`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse args
|
// Parse args
|
||||||
@@ -27,15 +24,45 @@ if (args.length === 0) {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 输出裸版本号(供脚本/CI 使用)
|
||||||
|
if (args[0] === 'get') {
|
||||||
|
console.log(getCurrentVersion());
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建版本:统一「base + 构建号」规则,消除本地/CI 版本双轨
|
||||||
|
// version.js build ci [buildId] [base] CI:base 缺省读 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') {
|
if (args[0] === '--help' || args[0] === '-h') {
|
||||||
console.log('Usage:');
|
console.log('Usage:');
|
||||||
console.log(' node scripts/version.js <version> Set version');
|
console.log(' node scripts/version.js <version> Set version');
|
||||||
console.log(' node scripts/version.js Show current 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('');
|
||||||
console.log('Examples:');
|
console.log('Examples:');
|
||||||
console.log(' node scripts/version.js 1.0.0');
|
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 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);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,3 +78,5 @@ if (!/^\d+\.\d+\.\d+/.test(newVersion)) {
|
|||||||
const current = getCurrentVersion();
|
const current = getCurrentVersion();
|
||||||
console.log(`Current version: ${current}`);
|
console.log(`Current version: ${current}`);
|
||||||
setVersion(newVersion);
|
setVersion(newVersion);
|
||||||
|
console.log(`Version updated to ${newVersion}`);
|
||||||
|
console.log(' - package.json');
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { Setting } from "./pages/setting";
|
|||||||
import { SettingLogin } from "./pages/setting/login";
|
import { SettingLogin } from "./pages/setting/login";
|
||||||
import { Gallery } from "./pages/gallery";
|
import { Gallery } from "./pages/gallery";
|
||||||
import { TaskQueue } from "./pages/task-queue";
|
import { TaskQueue } from "./pages/task-queue";
|
||||||
|
import { UpdatePage } from "./pages/update";
|
||||||
import { Debug } from "./pages/debug";
|
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";
|
||||||
@@ -43,6 +44,7 @@ const pageMap = {
|
|||||||
"setting/login": SettingLogin,
|
"setting/login": SettingLogin,
|
||||||
gallery: Gallery,
|
gallery: Gallery,
|
||||||
"task-queue": TaskQueue,
|
"task-queue": TaskQueue,
|
||||||
|
update: UpdatePage,
|
||||||
oobe: Oobe,
|
oobe: Oobe,
|
||||||
"oobe/language": OobeLanguage,
|
"oobe/language": OobeLanguage,
|
||||||
"oobe/agreement": OobeAgreement,
|
"oobe/agreement": OobeAgreement,
|
||||||
|
|||||||
@@ -81,6 +81,17 @@ export interface NetworkConfig {
|
|||||||
securityId: SecurityIdConfig;
|
securityId: SecurityIdConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 更新进度持久化(主进程 updater 写入) */
|
||||||
|
export interface UpdateConfig {
|
||||||
|
state: string;
|
||||||
|
version: string;
|
||||||
|
percent: number;
|
||||||
|
transferred: number;
|
||||||
|
total: number;
|
||||||
|
source: string;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface InstanceMeta {
|
export interface InstanceMeta {
|
||||||
name: string;
|
name: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -106,6 +117,7 @@ export interface AppConfig {
|
|||||||
download: DownloadConfig;
|
download: DownloadConfig;
|
||||||
network: NetworkConfig;
|
network: NetworkConfig;
|
||||||
ui: UiConfig;
|
ui: UiConfig;
|
||||||
|
update: UpdateConfig;
|
||||||
instances: InstanceMeta[];
|
instances: InstanceMeta[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+79
-14
@@ -1,23 +1,88 @@
|
|||||||
|
import { ipcInvoke, onIpcEvent } from "./ipc";
|
||||||
|
|
||||||
|
/** 更新状态(主进程 electron/updater.ts 的状态机) */
|
||||||
|
export type UpdateState =
|
||||||
|
| "idle"
|
||||||
|
| "checking"
|
||||||
|
| "available"
|
||||||
|
| "not-available"
|
||||||
|
| "downloading"
|
||||||
|
| "paused"
|
||||||
|
| "downloaded"
|
||||||
|
| "installing"
|
||||||
|
| "error";
|
||||||
|
|
||||||
|
/** 主进程广播的更新状态 payload(update:status) */
|
||||||
|
export interface UpdateStatusPayload {
|
||||||
|
state: UpdateState;
|
||||||
|
/** 是否为手动触发(手动触发时前端不弹提示) */
|
||||||
|
manual: boolean;
|
||||||
|
/** 最新版本号 */
|
||||||
|
version?: string;
|
||||||
|
/** 当前安装版本 */
|
||||||
|
currentVersion?: string;
|
||||||
|
percent?: number;
|
||||||
|
transferred?: number;
|
||||||
|
total?: number;
|
||||||
|
bytesPerSecond?: number;
|
||||||
|
/** 当前使用的更新源(github / 加速源域名) */
|
||||||
|
source?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 兼容旧调用方的下载进度结构 */
|
||||||
export interface DownloadProgress {
|
export interface DownloadProgress {
|
||||||
downloaded: number;
|
downloaded: number;
|
||||||
contentLength: number;
|
contentLength: number;
|
||||||
percent: number;
|
percent: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkForUpdates(): Promise<{ version: string; releaseNotes?: string } | null> {
|
/** 主进程 update:getReleaseNotes 返回的发布说明数据 */
|
||||||
try {
|
export interface ReleaseNotesResult {
|
||||||
return await window.electronAPI?.invoke('update:check') as { version: string; releaseNotes?: string } | null;
|
/** release tag,如 v1.2.0-2608271921 */
|
||||||
} catch {
|
tag: string;
|
||||||
return null;
|
/** 版本号(去 v 前缀) */
|
||||||
}
|
version: string;
|
||||||
|
/** 发布说明原始 Markdown */
|
||||||
|
notes: string;
|
||||||
|
/** 读取来源:github / 加速源域名 */
|
||||||
|
source: string;
|
||||||
|
/** 是否为最新版本的说明(当前版本无发布说明时回退) */
|
||||||
|
isLatest: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadAndInstall(
|
/** 检查更新(manual=true 表示用户手动点击,前端不弹提示) */
|
||||||
onProgress?: (progress: DownloadProgress) => void
|
export const checkForUpdates = (manual = false): Promise<UpdateStatusPayload> =>
|
||||||
): Promise<void> {
|
ipcInvoke<UpdateStatusPayload>("update:check", { manual });
|
||||||
await window.electronAPI?.invoke('update:install');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function relaunchApp(): Promise<void> {
|
/** 触发下载更新(进度经 update:status 事件上报);paused 状态下调用即继续 */
|
||||||
await window.electronAPI?.invoke('app:relaunch');
|
export const downloadUpdate = (): Promise<null> => ipcInvoke<null>("update:download");
|
||||||
}
|
|
||||||
|
/** 暂停下载(中断当前请求,保留进度) */
|
||||||
|
export const pauseUpdate = (): Promise<null> => ipcInvoke<null>("update:pause");
|
||||||
|
|
||||||
|
/** 继续下载 */
|
||||||
|
export const resumeUpdate = (): Promise<null> => ipcInvoke<null>("update:resume");
|
||||||
|
|
||||||
|
/** 取消下载(清除进度,回到可重新下载状态) */
|
||||||
|
export const cancelUpdate = (): Promise<null> => ipcInvoke<null>("update:cancel");
|
||||||
|
|
||||||
|
/** 退出并安装(NSIS 静默安装,安装完成自动重启) */
|
||||||
|
export const quitAndInstall = (): Promise<null> => ipcInvoke<null>("update:quitAndInstall");
|
||||||
|
|
||||||
|
/** 兼容旧调用(VersionCard「立即更新」按钮) */
|
||||||
|
export const relaunchApp = quitAndInstall;
|
||||||
|
|
||||||
|
/** 查询当前更新状态快照 */
|
||||||
|
export const getUpdateState = (): Promise<UpdateStatusPayload> => ipcInvoke<UpdateStatusPayload>("update:getState");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取指定版本(默认当前安装版本)的发布说明。
|
||||||
|
* 主进程内部:GitHub 直连优先,失败自动切换加速源;当前版本无说明时回退最新版本。
|
||||||
|
*/
|
||||||
|
export const getReleaseNotes = (tag?: string): Promise<ReleaseNotesResult | null> =>
|
||||||
|
ipcInvoke<ReleaseNotesResult | null>("update:getReleaseNotes", { tag });
|
||||||
|
|
||||||
|
/** 订阅更新状态变化 */
|
||||||
|
export const onUpdateStatus = (cb: (status: UpdateStatusPayload) => void): (() => void) =>
|
||||||
|
onIpcEvent<UpdateStatusPayload>("update:status", cb);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { BUILD_MODE, LOGO_SVG } from "@/lib/mode";
|
import { BUILD_MODE, LOGO_SVG } from "@/lib/mode";
|
||||||
import { VERSION } from "@/lib/version";
|
import { VERSION } from "@/lib/version";
|
||||||
|
import { BUILD_COMMIT, BUILD_ID } from "@/lib/buildInfo";
|
||||||
import { useUpdateStore } from "@/stores/updateStore";
|
import { useUpdateStore } from "@/stores/updateStore";
|
||||||
|
import { useRouteStore } from "@/stores/routeStore";
|
||||||
import { relaunchApp } from "@/api/update";
|
import { relaunchApp } from "@/api/update";
|
||||||
import Silk from "@/components/silk/Silk";
|
import Silk from "@/components/silk/Silk";
|
||||||
import { Button } from "@heroui/react";
|
import { Button } from "@heroui/react";
|
||||||
@@ -49,6 +51,18 @@ export function VersionCard({
|
|||||||
|
|
||||||
const { checking, downloading, installed, update, check, install } = useUpdateStore();
|
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 effectiveState: UpdateState = overrideState ?? (installed ? "installed" : update ? "hasUpdate" : "latest");
|
||||||
|
|
||||||
const Btn = (props: React.ComponentProps<typeof Button>) => (
|
const Btn = (props: React.ComponentProps<typeof Button>) => (
|
||||||
@@ -61,7 +75,12 @@ export function VersionCard({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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 }} />
|
<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>
|
<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 模式:仅显示查看亮点按钮 */}
|
||||||
{oobe ? (
|
{oobe ? (
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<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">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
{effectiveState === "latest" && (
|
{effectiveState === "latest" && (
|
||||||
<>
|
<>
|
||||||
<Btn onPress={check} isDisabled={checking}>
|
<Btn onPress={handleCheck} isDisabled={checking}>
|
||||||
{checking ? "检查中..." : "检查更新"}
|
{checking ? "检查中..." : "检查更新"}
|
||||||
</Btn>
|
</Btn>
|
||||||
<Btn>查看亮点</Btn>
|
<Btn>查看亮点</Btn>
|
||||||
@@ -118,6 +149,8 @@ export function VersionCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// 构建元数据:由 scripts/gen-build-info.js 自动生成(CI 覆盖;本地开发为默认值)
|
||||||
|
export const BUILD_COMMIT: string = "";
|
||||||
|
export const BUILD_ID: string = "local";
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { 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 { ExternalLink, GitFork, RotateCcw } from "lucide-react";
|
import { ExternalLink, GitFork, RotateCcw } from "lucide-react";
|
||||||
import { Link } from "@heroui/react";
|
import { Link } from "@heroui/react";
|
||||||
import { SettingCard, SettingRow, PageHeader, SectionTitle } from "@/components/setting";
|
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>
|
<span className="text-[13px] text-muted-foreground">{modeLabels[BUILD_MODE] ?? BUILD_MODE}</span>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</SettingCard>
|
</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>
|
<SettingCard>
|
||||||
<SettingRow label="技术栈" desc="Electron + React 19 + TypeScript + @xmcl">
|
<SettingRow label="技术栈" desc="Electron + React 19 + TypeScript + @xmcl">
|
||||||
<span className="text-[13px] text-muted-foreground">Node.js</span>
|
<span className="text-[13px] text-muted-foreground">Node.js</span>
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { VersionCard } from "@/components/VersionCard";
|
||||||
|
import { SectionTitle, SettingCard } from "@/components/setting";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { BUILD_MODE } from "@/lib/mode";
|
||||||
|
import {
|
||||||
|
cancelUpdate,
|
||||||
|
checkForUpdates,
|
||||||
|
downloadUpdate,
|
||||||
|
getReleaseNotes,
|
||||||
|
getUpdateState,
|
||||||
|
onUpdateStatus,
|
||||||
|
pauseUpdate,
|
||||||
|
quitAndInstall,
|
||||||
|
resumeUpdate,
|
||||||
|
type ReleaseNotesResult,
|
||||||
|
type UpdateStatusPayload,
|
||||||
|
} from "@/api/update";
|
||||||
|
import { ExternalLink, Loader2, RefreshCw } from "lucide-react";
|
||||||
|
import { Button, Link } from "@heroui/react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import rehypeRaw from "rehype-raw";
|
||||||
|
|
||||||
|
const GITHUB_RELEASES = "https://github.com/dream-pep/koring-launcher/releases";
|
||||||
|
|
||||||
|
/** 主按钮颜色随构建模式:dev 橙 / beta 绿 / run 蓝(与 VersionCard 一致) */
|
||||||
|
const MODE_BUTTON_COLORS: Record<string, { bg: string; hover: string }> = {
|
||||||
|
dev: { bg: "#F59E0B", hover: "#D97706" },
|
||||||
|
beta: { bg: "#10B981", hover: "#059669" },
|
||||||
|
run: { bg: "#3b82f6", hover: "#2563eb" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const modeColors = MODE_BUTTON_COLORS[BUILD_MODE] ?? MODE_BUTTON_COLORS.run;
|
||||||
|
|
||||||
|
/** 主题 accent 为灰色系,内联覆盖按钮 CSS 变量 */
|
||||||
|
const BUTTON_STYLE = {
|
||||||
|
"--button-bg": modeColors.bg,
|
||||||
|
"--button-bg-hover": modeColors.hover,
|
||||||
|
"--button-bg-pressed": modeColors.hover,
|
||||||
|
"--button-fg": "#ffffff",
|
||||||
|
} as React.CSSProperties;
|
||||||
|
|
||||||
|
function formatMB(bytes?: number): string {
|
||||||
|
if (!bytes || bytes <= 0) return "0 MB";
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新日志(独立页面,不走设置 layout):
|
||||||
|
* - 顶栏由路由切换为「返回 + 更新日志」(routeStore 的 titleInBar)
|
||||||
|
* - 顶部 VersionCard(此页面不显示按钮,更新操作由底部遮罩负责)
|
||||||
|
* - 发布说明:默认当前版本;检测到可用更新后自动切到最新版本
|
||||||
|
* - 底部遮罩:检查更新 → 下载(进度条 + 暂停/继续/取消)→ 安装更新
|
||||||
|
* - 下载/安装进度由主进程写入 Koring.yml(update 段)
|
||||||
|
*/
|
||||||
|
export function UpdatePage() {
|
||||||
|
// 发布说明:notesTag 为空 = 当前版本;有可用更新后切到对应 tag
|
||||||
|
const [notes, setNotes] = useState<ReleaseNotesResult | null>(null);
|
||||||
|
const [notesLoading, setNotesLoading] = useState(true);
|
||||||
|
const [notesError, setNotesError] = useState<string | null>(null);
|
||||||
|
const [notesTag, setNotesTag] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
// 更新状态:事件驱动,主进程为唯一真相源
|
||||||
|
const [status, setStatus] = useState<UpdateStatusPayload | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = onUpdateStatus(setStatus);
|
||||||
|
getUpdateState().then(setStatus).catch(() => {});
|
||||||
|
return unsub;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadNotes = useCallback(async (tag?: string) => {
|
||||||
|
setNotesLoading(true);
|
||||||
|
setNotesError(null);
|
||||||
|
try {
|
||||||
|
setNotes(await getReleaseNotes(tag));
|
||||||
|
} catch (e) {
|
||||||
|
setNotesError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setNotesLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadNotes(notesTag);
|
||||||
|
}, [notesTag, loadNotes]);
|
||||||
|
|
||||||
|
// 检测到可用更新 → 发布说明切到最新版本(退出重进自动回到当前版本)
|
||||||
|
useEffect(() => {
|
||||||
|
if (status?.state === "available" && status.version && notesTag !== `v${status.version}`) {
|
||||||
|
setNotesTag(`v${status.version}`);
|
||||||
|
}
|
||||||
|
}, [status, notesTag]);
|
||||||
|
|
||||||
|
const st = status?.state ?? "idle";
|
||||||
|
const pct = status?.percent ?? 0;
|
||||||
|
const isDownloading = st === "downloading";
|
||||||
|
const isPaused = st === "paused";
|
||||||
|
|
||||||
|
const handleCheck = async () => {
|
||||||
|
try {
|
||||||
|
await checkForUpdates(true);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
try {
|
||||||
|
await downloadUpdate();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePause = async () => {
|
||||||
|
try {
|
||||||
|
await pauseUpdate();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResume = async () => {
|
||||||
|
try {
|
||||||
|
await resumeUpdate();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = async () => {
|
||||||
|
try {
|
||||||
|
await cancelUpdate();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInstall = async () => {
|
||||||
|
try {
|
||||||
|
await quitAndInstall();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openGithub = () => {
|
||||||
|
window.electronAPI?.openExternal(GITHUB_RELEASES);
|
||||||
|
};
|
||||||
|
|
||||||
|
const primaryAction =
|
||||||
|
st === "available" ? handleDownload : st === "downloaded" ? handleInstall : handleCheck;
|
||||||
|
|
||||||
|
const statusText =
|
||||||
|
st === "checking"
|
||||||
|
? "正在检查更新..."
|
||||||
|
: st === "available"
|
||||||
|
? `发现新版本 v${status?.version}`
|
||||||
|
: st === "downloading"
|
||||||
|
? `正在下载 ${pct.toFixed(0)}% · ${formatMB(status?.transferred)} / ${formatMB(status?.total)}${status?.bytesPerSecond ? ` · ${formatMB(status.bytesPerSecond)}/s` : ""}`
|
||||||
|
: st === "paused"
|
||||||
|
? `下载已暂停(${pct.toFixed(0)}%)`
|
||||||
|
: st === "downloaded"
|
||||||
|
? "更新已下载完成"
|
||||||
|
: st === "installing"
|
||||||
|
? "正在安装更新,应用即将重启..."
|
||||||
|
: st === "error"
|
||||||
|
? `更新失败:${status?.error ?? "未知错误"}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto p-6 md:p-8 pb-44">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<VersionCard />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<SectionTitle>更新内容</SectionTitle>
|
||||||
|
|
||||||
|
{notesLoading ? (
|
||||||
|
<SettingCard>
|
||||||
|
<div className="flex items-center justify-center gap-2 py-10 text-[13px] text-muted-foreground">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
正在获取发布说明...
|
||||||
|
</div>
|
||||||
|
</SettingCard>
|
||||||
|
) : notesError ? (
|
||||||
|
<SettingCard>
|
||||||
|
<div className="py-6 text-center space-y-3">
|
||||||
|
<p className="text-[13px] text-destructive">获取发布说明失败:{notesError}</p>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => loadNotes(notesTag)}>
|
||||||
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SettingCard>
|
||||||
|
) : notes ? (
|
||||||
|
<SettingCard>
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-3">
|
||||||
|
<span className="text-[12px] text-muted-foreground">
|
||||||
|
版本 v{notes.version}
|
||||||
|
{notesTag ? "(最新)" : "(当前)"}
|
||||||
|
<span className="ml-2 opacity-70">
|
||||||
|
· 来源:{notes.source === "github" ? "GitHub" : `加速源 ${notes.source}`}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => loadNotes(notesTag)}>
|
||||||
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-[13.5px] leading-relaxed text-foreground/80 space-y-3 [&_h1]:text-base [&_h1]:font-semibold [&_h1]:text-foreground [&_h2]:text-[15px] [&_h2]:font-semibold [&_h2]:text-foreground [&_h3]:text-sm [&_h3]:font-semibold [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:my-1 [&_a]:text-primary [&_a]:underline underline-offset-2 [&_code]:bg-foreground/10 [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-[12px] [&_pre]:bg-foreground/5 [&_pre]:p-3 [&_pre]:rounded-lg [&_pre]:overflow-x-auto [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_details]:border [&_details]:border-border/50 [&_details]:rounded-lg [&_details]:px-3 [&_details]:py-2 [&_summary]:cursor-pointer [&_summary]:font-medium [&_summary]:text-foreground [&_hr]:border-border/40 [&_blockquote]:border-l-2 [&_blockquote]:border-primary/40 [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
||||||
|
{notes.notes}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</SettingCard>
|
||||||
|
) : (
|
||||||
|
<SettingCard>
|
||||||
|
<div className="py-6 text-center space-y-3">
|
||||||
|
<p className="text-[13px] text-muted-foreground">
|
||||||
|
未获取到发布说明(GitHub 与加速源均不可用,或该版本尚未发布)
|
||||||
|
</p>
|
||||||
|
<Link onPress={openGithub} className="text-[13px]">
|
||||||
|
打开 GitHub Releases
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</SettingCard>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部遮罩:fixed 吸附底部,样式与顶栏一致;驱动整个更新流程 */}
|
||||||
|
<div
|
||||||
|
className="fixed bottom-0 left-0 right-0 z-20"
|
||||||
|
style={{
|
||||||
|
background: "var(--titlebar-bg)",
|
||||||
|
backdropFilter: "blur(3px)",
|
||||||
|
WebkitBackdropFilter: "blur(3px)",
|
||||||
|
borderTop: "1px solid var(--titlebar-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="max-w-3xl mx-auto px-6 py-3 space-y-2">
|
||||||
|
{statusText && (
|
||||||
|
<div className="text-center text-[13px] text-muted-foreground truncate">{statusText}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isDownloading || isPaused ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Progress
|
||||||
|
value={pct}
|
||||||
|
className="w-full [&_[data-slot='progress-indicator']]:bg-blue-500"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
fullWidth
|
||||||
|
style={BUTTON_STYLE}
|
||||||
|
onPress={isPaused ? handleResume : handlePause}
|
||||||
|
>
|
||||||
|
{isPaused ? "继续下载" : "暂停下载"}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" fullWidth onPress={handleCancel}>
|
||||||
|
取消下载
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
fullWidth
|
||||||
|
style={BUTTON_STYLE}
|
||||||
|
isDisabled={st === "checking" || st === "installing" || st === "not-available"}
|
||||||
|
onPress={primaryAction}
|
||||||
|
>
|
||||||
|
{st === "checking" && (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
检查中...
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{st === "available" && "下载版本更新"}
|
||||||
|
{st === "downloaded" && "安装更新"}
|
||||||
|
{st === "installing" && "安装中..."}
|
||||||
|
{st === "error" && "重试"}
|
||||||
|
{st === "not-available" && "已经是最新版"}
|
||||||
|
{st === "idle" && "检查更新"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -59,6 +59,7 @@ const DEFAULT_CONFIG: AppConfig = {
|
|||||||
download: { fileSource: "mirror", versionSource: "mirror", threads: 16, speedLimit: 0 },
|
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: "" },
|
||||||
instances: [],
|
instances: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type RouteKey =
|
|||||||
| "setting/login"
|
| "setting/login"
|
||||||
| "gallery"
|
| "gallery"
|
||||||
| "task-queue"
|
| "task-queue"
|
||||||
|
| "update"
|
||||||
| "oobe"
|
| "oobe"
|
||||||
| "oobe/language"
|
| "oobe/language"
|
||||||
| "oobe/agreement"
|
| "oobe/agreement"
|
||||||
@@ -51,6 +52,7 @@ export const allRoutes: RouteItem[] = [
|
|||||||
...routes,
|
...routes,
|
||||||
{ key: "setting/login", label: "登录", path: "/setting/login", hidden: true, backable: true },
|
{ key: "setting/login", label: "登录", path: "/setting/login", hidden: true, backable: true },
|
||||||
{ key: "task-queue", label: "任务队列", path: "/task-queue", hidden: 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", label: "OOBE", path: "/oobe", hidden: true },
|
||||||
{ key: "oobe/language", label: "语言设置", path: "/oobe/language", hidden: true },
|
{ key: "oobe/language", label: "语言设置", path: "/oobe/language", hidden: true },
|
||||||
{ key: "oobe/agreement", label: "同意协议", path: "/oobe/agreement", hidden: true },
|
{ key: "oobe/agreement", label: "同意协议", path: "/oobe/agreement", hidden: true },
|
||||||
|
|||||||
+123
-17
@@ -1,51 +1,157 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import {
|
import {
|
||||||
checkForUpdates,
|
checkForUpdates,
|
||||||
downloadAndInstall,
|
downloadUpdate,
|
||||||
type DownloadProgress,
|
getUpdateState,
|
||||||
|
onUpdateStatus,
|
||||||
|
quitAndInstall,
|
||||||
|
resumeUpdate,
|
||||||
|
type UpdateStatusPayload,
|
||||||
} from "../api/update";
|
} 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;
|
checking: boolean;
|
||||||
downloading: boolean;
|
downloading: boolean;
|
||||||
installed: boolean;
|
installed: boolean;
|
||||||
progress: DownloadProgress | null;
|
progress: UpdateProgress | null;
|
||||||
update: { version: string; releaseNotes?: string } | null;
|
update: { version: string; releaseNotes?: string } | null;
|
||||||
|
currentVersion: string;
|
||||||
|
source: string;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
check: () => Promise<void>;
|
check: () => Promise<void>;
|
||||||
install: () => Promise<void>;
|
install: () => Promise<void>;
|
||||||
reset: () => 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,
|
checking: false,
|
||||||
downloading: false,
|
downloading: false,
|
||||||
installed: false,
|
installed: false,
|
||||||
progress: null,
|
progress: null,
|
||||||
update: null,
|
update: null,
|
||||||
|
currentVersion: "",
|
||||||
|
source: "github",
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
check: async () => {
|
check: async () => {
|
||||||
set({ checking: true, error: null });
|
set({ checking: true, error: null });
|
||||||
try {
|
try {
|
||||||
const update = await checkForUpdates();
|
await checkForUpdates(true);
|
||||||
set({ update, checking: false });
|
} catch (e) {
|
||||||
} catch (e: any) {
|
set({ error: e instanceof Error ? e.message : String(e), checking: false });
|
||||||
set({ error: e.message ?? String(e), checking: false });
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
install: async () => {
|
install: async () => {
|
||||||
set({ downloading: true, error: null, progress: null });
|
set({ downloading: true, error: null, progress: null });
|
||||||
try {
|
try {
|
||||||
await downloadAndInstall((progress) => {
|
// 智能分派:暂停→继续;已下载→安装;否则→开始下载
|
||||||
set({ progress });
|
const state = get().installed ? "downloaded" : get().update ? "available" : "idle";
|
||||||
});
|
if (state === "downloaded") {
|
||||||
set({ downloading: false, installed: true });
|
await quitAndInstall();
|
||||||
} catch (e: any) {
|
return;
|
||||||
set({ error: e.message ?? String(e), downloading: false });
|
}
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|||||||
Vendored
+11
@@ -23,6 +23,17 @@ interface ElectronAPI {
|
|||||||
|
|
||||||
// Config reset
|
// Config reset
|
||||||
resetConfig: () => Promise<void>;
|
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 {
|
declare global {
|
||||||
|
|||||||
Reference in New Issue
Block a user