Compare commits

...
33 Commits
Author SHA1 Message Date
dream_pep 74512bf935 feat(runtime-notice): 添加 Linux 非 AppImage 运行时警告提示
- 新增 onRuntimeNotice IPC 接口与预加载桥接代码
- 在主进程添加 Linux 环境检查,未使用 AppImage 运行的打包版会提示更新组件可能受影响
- 新建 RuntimeNotices 组件处理并展示去重的持久化警告弹窗
- 将该组件注册到根布局中自动挂载
- 添加项目的 Git 提交信息规范文件
2026-09-05 21:14:43 +08:00
dream_pep aef49b173f feat(settings, version-card): 新增页面保活缓存并暂停离屏动画
为VersionCard添加IntersectionObserver,仅在卡片可见时运行Silk动画以节省GPU资源
在设置页实现页面保活缓存,保留已访问的子页组件优化切换流畅度
2026-09-05 20:57:29 +08:00
dream_pep 4acdad6535 refactor(paths): 重构路径解析逻辑并修复Linux打包版写入问题
适配Linux AppImage打包场景,将数据基准目录改为userData以解决只读目录无法写入的问题;重构路径解析相关函数,添加详细注释并优化代码结构。
2026-09-05 20:36:34 +08:00
dream_pep 3b627389c9 fix: 修复打包版背景图黑屏问题,重构数据路径逻辑
新增背景图路径归一化函数,将旧版使用的绝对路径 `/background.png` 转换为适配全环境的正确相对路径;重构Electron主进程的数据基准路径计算逻辑,修复Linux/AppImage打包版的只读目录限制
2026-09-05 20:35:59 +08:00
dream_pep caf52b9a3d fix(switch-icon脚本): 适配脚本以支持跨平台打包
添加平台检测逻辑,仅在Windows平台处理NSIS安装器所需的专属资源,Linux平台仅复制必要的图标文件,同时更新日志输出以正确显示已生成的文件。
2026-09-05 19:41:35 +08:00
dream_pep 37a5bb7538 refactor(updater, ci): 重构版本处理逻辑,适配新的版本格式
调整GitHub Release工作流的beta版本号格式,将`beta.$buildId`改为`$buildId.beta`,对齐新的版本命名规范
新增统一的版本标签解析与过滤逻辑,支持识别多种版本格式并屏蔽旧版beta候选
重写版本比较函数,修复旧版本比较的逻辑问题
重构更新检查与通道配置流程,不再依赖electron-updater的内置频道规则
优化发布说明获取的回退逻辑,统一使用generic feed结合多镜像源提升更新稳定性
2026-09-05 19:08:23 +08:00
dream_pep ffac581018 ci(release workflow): 新增Linux AppImage构建及上传发布流程
更新了工作流内的产物上传注释说明,新增build-linux job构建Linux AppImage包,生成对应平台的更新清单文件,beta版本还会额外生成beta-linux.yml,最终将所有资产上传至对应发布版本,适配electron-updater的Linux自动更新逻辑。
2026-09-05 18:57:22 +08:00
dream_pep 1785d36797 fix(updater): 为跑步者beta通道添加稳定版更新探测
修复electron-updater的semver版本判断逻辑错误,当跑步者beta通道无可用更新时,自动探测最新稳定版,检查完成后恢复原通道配置。
2026-09-05 01:28:18 +08:00
dream_pep bef1bbd85b chore: 更新 usage 记录 2026-09-05 00:45:06 +08:00
dream_pep 74b7456886 fix(update, setting): 修复更新弹窗漏弹问题并简化调试模式描述
为更新弹窗组件添加挂载时拉取更新状态的兜底逻辑,解决组件订阅晚于静默检查广播导致的更新弹窗漏弹问题
简化关于设置页面中调试模式开关的描述文字,移除冗余的启动参数和开发环境终端输出说明
2026-09-05 00:45:06 +08:00
dream_pep dfda648147 refactor: 迁移组件库至shadcn/ui并新增版本更新功能
迁移项目内所有HeroUI表单组件至shadcn/ui,统一组件风格并修复HeroUI Switch点击无响应的问题
新增全局版本更新弹窗功能,包含状态管理store、全局弹窗组件与调试页面调试工具
优化配置广播逻辑,添加250ms防抖避免频繁触发页面重渲染
新增开发环境下日志直接输出到终端的能力,完善日志输出规则
调整调试模式设置项位置,从高级设置页移动至关于设置页
为背景层添加pointer-events: none,避免遮挡页面点击事件
新增点击拦截诊断工具至资源调试页面,用于排查控件无法点击的问题
更新Koring.yml配置模板,新增advanced.preLaunchCmd字段
2026-09-05 00:45:06 +08:00
dream_pep a11e51d62c feat(更新页面): 添加版本更新速览分类卡片
在原有VersionCard组件下方添加AboutVersion组件,展示当前版本的更新内容概览
2026-09-05 00:45:05 +08:00
dream_pep e861661a55 feat: 新增跨进程统一日志系统并替换旧式控制台日志
- 新增渲染进程日志工具与主进程日志核心,支持按作用域分类日志
- 调试模式下日志会写入轮转文件,否则仅输出到控制台
- 暴露渲染器到主进程的日志 IPC 桥接接口
- 替换全项目所有旧式 console 日志调用为统一日志接口
- 新增调试页面日志状态面板
- 更新相关 TypeScript 类型定义与项目文档
2026-09-04 22:06:59 +08:00
dream_pep d5004b61d8 feat(更新模块): 提升更新安装安全性并添加统一主进程日志
重构更新验证流程:下载包验证失败时保持为已下载状态并标记校验未通过,而非直接置为错误状态
添加安装校验未通过包时的确认弹窗,支持删除异常安装包
禁用应用退出时自动安装,防止静默安装未校验的包
新增统一主进程日志模块,支持渲染进程通过IPC桥接接入日志
更新已下载但校验失败的UI提示文案
2026-09-04 21:59:10 +08:00
dream_pep 744494576e feat(background): 新增壁纸切换时的平滑淡入过渡效果
- 新增0.6秒缓出的背景淡入动画及对应CSS类
- 为所有壁纸缓存文件添加唯一后缀,确保每次切换都触发资源重载
- 重写BackgroundLayer组件,使用双层背景实现平滑交叉过渡
- 修复缓存壁纸查找逻辑,改为按修改时间选取最新文件
- 优化相关注释与代码健壮性
2026-09-04 21:36:05 +08:00
dream_pep bcac166a47 feat: 新增更新包本地完整性校验功能
- 新增更新包SHA512哈希和文件大小校验逻辑,仅在校验通过后才允许安装更新,防止损坏或被篡改的安装包被安装
- 更新更新页面UI,根据更新包是否已通过校验显示差异化提示文本
- 临时隐藏设置菜单中的Koring账户设置选项
- 修复发布说明生成脚本,改为按标签创建时间排序,并针对不同构建模式调整基准标签选取逻辑
2026-09-04 20:36:00 +08:00
dream_pep 3a760167d6 Merge branch 'master' of https://github.com/dream-pep/koring-launcher 2026-09-04 20:20:49 +08:00
dream_pep 8b7c90b730 feat(device,feedback): 新增设备识别码与意见反馈功能
重构意见反馈设置页面,替换原有占位内容,新增FeedbackButton组件
更新DEV.md与README.md文档,补充对应功能的相关说明
2026-09-04 20:16:51 +08:00
dream_pep 9eaa482a4d feat: 新增版本更新日志页面并调整初始化引导流程
- 新增 AboutVersion 组件,支持拉取 GitHub Release 日志并按提交类型分类展示
- 为 OOBE 和 UPvP 流程新增 about-version 页面,调整原版本页的跳转逻辑,先展示更新日志再进入后续步骤
- 注册新的路由配置,更新 README 与 DEV.md 文档说明新功能
2026-09-04 19:48:00 +08:00
dream_pep fad8f612bf feat: 重构背景图处理体系,优化用户UX体验
- 重构背景图全链路处理:改用文件路径存储而非base64,新增`koring-res://`自定义协议流式加载本地壁纸,大幅降低内存占用
- 调整配置文件存储路径至userData目录,解决NSIS重装/升级时配置被清空的问题
- 新增路由调试页面,支持快速跳转所有已注册的应用页面
- 优化OOBE引导和更新完成页的「前往首页」按钮,添加4秒延迟渐入动画
- 自动迁移旧版配置:将安装目录的配置文件迁移至userData,同时将base64格式的背景配置自动转换为文件路径存储
- 重构预加载脚本、主进程IPC处理器与前端页面的背景图相关逻辑
- 更新AGENTS.md开发文档,修正路径与逻辑说明
- 更新应用背景图资源文件
2026-09-04 19:34:58 +08:00
dream_pep cd4578de5a feat: 全新 Korom 资源管理服务组件,优化内存占用
新增完整的程序本体资源管理与内存优化功能:
1. 新增背景图预处理服务,主进程将大图降采样至屏幕适配尺寸,避免全分辨率大图占用渲染进程大量内存
2. 实现资源注册表系统,支持引用计数、LRU自动逐出与释放回调
3. 新增资源与内存调试面板,可查看渲染进程JS堆、Electron进程内存与资源缓存状态
4. 修复进程内存信息API的单位与字段映射错误
5. 优化背景层缓存键避免过长dataURL,为Silk动画添加窗口隐藏时停帧逻辑减少GPU占用
6. 修复资源注册表类型适配问题并更新项目文档
2026-09-04 18:56:43 +08:00
dream_pep 7d8b7bd433 fix: 修复自动更新版本判定误判问题
重构自动更新模块的版本比较逻辑,替换原有的semver比较规则为项目自定义的版本序:仅对比主版本号与构建号,忽略beta通道标记,确保同主版本下按构建号排序。新增correctAvailability方法在检查更新与下载前复核版本有效性,避免陈旧的更新信息导致误更新。调整GitHub release拉取逻辑,过滤掉草稿与不符合通道权限的预发布版本。新增src/resources目录下的运行时资源缓存管理系统,包含类型定义、图片解码管线与全局资源注册表,支持LRU内存逐出、引用计数与预算控制。新增系统内存监控的IPC接口与前端API,支持获取进程内存快照用于调试面板。更新auto-update-plan.md文档,补充本次版本判定修复的相关说明。
2026-09-04 18:42:50 +08:00
dream_pepandGitHub 3f04218059 更新 release.yml
修复了发行 Action CI 的命名问题
2026-09-03 22:54:25 +08:00
dream_pep 2c33015926 fix(auto-updater): 修复 runner 通道数字尾号版本无法检测 beta 更新的问题
更新自动更新计划文档,补充说明该修复细节和 runner 通道的正确配置要求
2026-08-31 04:07:41 +08:00
dream_pep 0415799803 feat(update页面): 添加滚动自动隐藏底部操作栏的功能
为滚动容器添加 id="app-content-scroll" 以支持滚动监听
新增滚动逻辑,非繁忙状态下向下滚动时隐藏底部栏,向上滚动或回顶时恢复显示
繁忙状态(下载、暂停、安装、检查更新)下始终显示底部栏
使用 clsx 优化底部栏的条件类名并添加平滑过渡动画
2026-08-31 03:56:41 +08:00
dream_pep ec9346b087 fix(CI/文档): 同步beta版本发布标题格式
修改GitHub发布工作流与自动更新计划文档中的beta版本标题,使其均采用包含构建序号的完整版本字符串,统一展示规则。
2026-08-31 03:52:10 +08:00
dream_pep c13acd0c23 fix(updater): 修正版本标签比较逻辑以符合semver规范
重新实现版本比较函数,正确处理各类版本标签的优先级,匹配项目定义的semver比较规则。
2026-08-31 03:44:56 +08:00
dream_pep 53cde460e5 fix(updater, ci, docs): 修复版本标签解析逻辑,调整发布流程与文档
重构electron/updater.ts中的版本比较函数,支持beta.N格式的预发布版本,调整buildId比较规则符合semver规范。
更新CI发布脚本,正式版生成带构建尾号的完整版本号并调整发布标题命名。
更新自动更新文档,说明新的版本规则与跨版本升级兼容性,纠正此前的错误认知。
2026-08-31 03:39:38 +08:00
dream_pep 512e4ae3d7 fix(oobe): 修复OOBE版本步骤的路由逻辑避免死循环
更新正式版的下一步路由为oobe/finish而非agreement,防止形成agreement → legal → welcome → version → agreement的死循环;同时补充详细的前置流程注释说明步骤顺序。
2026-08-31 03:31:41 +08:00
dream_pep 403a861b89 ci(release workflow): 优化 GitHub 发布流程,新增资产存在性检查
重构资产列表和发布命令的参数处理逻辑,新增资产存在校验以避免模糊报错,修复空参数导致的 glob 匹配错误,优化 beta 版本的附加资源上传流程。
2026-08-31 03:24:08 +08:00
dream_pep 53c042caa0 feat(pages): 添加资源、实例页临时占位页面,更新版本至1.2.5
移除原有商城和图库页面的导入,替换对应路由为临时占位组件
2026-08-31 03:17:26 +08:00
dream_pep b3da357d57 feat: 新增自动更新版本引导流程及相关配置调整
- 新增完整的upvp更新引导页面套件,涵盖版本展示、版本检查、测试协议确认与完成页流程
- 新增appVersion配置字段用于记录当前应用版本,调整配置文件存储路径与旧配置迁移逻辑
- 更新路由配置与全局状态管理以支持更新引导流程
- 更新自动更新计划文档与示例配置文件
2026-08-31 03:04:23 +08:00
dream_pep fa44d41d44 feat: 优化版本卡片交互与自动更新安装逻辑
- 为自动更新安装函数添加静默安装与安装后自动重启参数
- 重构VersionCard组件,新增设置页模式,支持整卡点击跳转更新页并显示查看更新提示
- 修复更新页面主按钮的禁用逻辑,仅在检查或安装中时禁用按钮
- 更新通用设置主页与关于页的VersionCard调用参数
2026-08-31 02:46:12 +08:00
85 changed files with 5199 additions and 588 deletions
+133 -22
View File
@@ -13,7 +13,8 @@
# - run :创建正式 release
# - Release 标题命名(仅展示名,tag/真实版本号不变):run → "{base}"(如 1.2.1),beta → "BETA {base}"(如 BETA 1.2.1
# - Release 正文为中文:版本信息(当前版本 / 编译状态 / 构建来源 commit)+ 提交记录(默认折叠)
# - 上传产物:koring-launcher-{base}-{buildId}-setup.exe + latest.ymlelectron-updater 更新清单)
# - 上传产物Windows jobkoring-launcher-{full}-setup.exe + latest.yml
# - LinuxAppImage)由 build-linux job 追加:*.AppImage + latest-linux.ymlbeta 另加 beta-linux.yml
# - 构建元数据(commit / buildId)写入 src/lib/buildInfo.ts,打包进渲染层供 UI 显示
#
# Secrets
@@ -39,7 +40,7 @@
# - 切换 BUILD ID 方案(时间 ID → Run Number)时,旧格式数值更大(2608271921 > 12),
# 老用户不会自动升级 —— 切换时应同时提升 base(如 1.3.0-12 > 1.2.0-2608271921)。
name: BUILD & Release (SignPath)
name: Koring Launcher Builder
on:
workflow_dispatch:
@@ -106,10 +107,11 @@ jobs:
# BUILD ID = GitHub Run Number(严格递增,无分钟级冲突)
# base 自动读 package.json(版本单一事实源)
# 版本格式(electron-updater GitHub provider道逻辑只认 alpha/beta 字符串标识):
# 版本格式(数字标识在 allowPrerelease道逻辑中会被当"自定义频道",但正式版用户
# allowPrerelease=false,走 /releases/latest(按 GitHub 的 prerelease 标记而非 semver):
# 只要发布时不加 --prerelease{base}-{buildId} 就是最新正式版,可正常识别):
# beta → {base}-beta.{buildId}(如 1.2.1-beta.13,频道 beta
# run → {base}(稳定版,如 1.2.1
# 数字标识(如 1.2.1-13)会被当作"自定义频道"导致更新无法识别,故弃用
# run → {base}-{buildId}(如 1.2.1-13,带构建尾号
- name: Generate BUILD ID & set version
id: version
shell: pwsh
@@ -117,9 +119,9 @@ jobs:
$buildId = "$env:GITHUB_RUN_NUMBER"
$base = node scripts/version.js get
if ("${{ inputs.mode }}" -eq "beta") {
$full = node scripts/version.js build ci "beta.$buildId"
$full = node scripts/version.js build ci "$buildId.beta"
} else {
$full = node scripts/version.js build local
$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
@@ -174,21 +176,130 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$tag = "v${{ steps.version.outputs.full }}"
$prerelease = if ("${{ inputs.mode }}" -eq "beta") { "--prerelease" } else { "" }
$extraAssets = @()
# beta 额外上传 latest-beta.ymlGitHub provider 频道取件(latest-beta)不再 404 回退
# Release 标题命名(仅展示名;tag / 真实版本号是 {base}-beta.{id} 或 {base}-{id}):
# 正式/预览版 → "1.2.5-13"(带构建尾号);测试版 → "BETA 1.2.5-beta.13"(带 Run Num
$title = if ("${{ inputs.mode }}" -eq "beta") { "BETA ${{ steps.version.outputs.full }}" } else { "${{ steps.version.outputs.full }}" }
# 资产列表(beta 额外上传 latest-beta.ymlGitHub provider 频道取件(latest-beta)不再 404 回退)
$assets = @(
"dist-electron/koring-launcher-${{ steps.version.outputs.full }}-setup.exe",
"dist-electron/latest.yml",
"release-notes.md"
)
if ("${{ inputs.mode }}" -eq "beta") {
Copy-Item "dist-electron/latest.yml" "dist-electron/latest-beta.yml" -Force
$extraAssets += "dist-electron/latest-beta.yml"
$assets += "dist-electron/latest-beta.yml"
}
# Release 标题命名(仅展示名;tag / 真实版本号仍是 {base}-beta.{id} 或 {base},更新机制不变):
# 正式/预览版 → "1.2.1";测试版 → "BETA 1.2.1"
$title = if ("${{ inputs.mode }}" -eq "beta") { "BETA ${{ steps.version.outputs.base }}" } else { "${{ steps.version.outputs.base }}" }
gh release create $tag `
"dist-electron/koring-launcher-${{ steps.version.outputs.full }}-setup.exe" `
"dist-electron/latest.yml" `
"release-notes.md" `
@extraAssets `
--title $title `
--notes-file release-notes.md `
$prerelease
# 上传前校验资产存在(避免 gh 对缺失文件的模糊报错)
foreach ($a in $assets) {
if (-not (Test-Path -LiteralPath $a)) {
Write-Error "资产不存在: $a"
exit 1
}
}
# 参数数组统一 splat--prerelease 仅在 beta 追加;
# 不能传空串占位(gh 会把位置参数当资产 glob,空串 → "no matches found for" 报错)
$ghArgs = @($tag) + $assets
$ghArgs += @("--title", $title, "--notes-file", "release-notes.md")
if ("${{ inputs.mode }}" -eq "beta") {
$ghArgs += "--prerelease"
}
gh release create @ghArgs
# ==================== LinuxAppImage====================
# electron-updater 对 Linux 只原生支持 AppImage 自动更新(deb/rpm 走系统包管理器)。
# Windows job 创建 Release 后,本 job 把 AppImage + latest-linux.yml 上传到同一 Release。
# ⚠️ Linux 清单文件带 -linux 前缀:provider 取 latest-linux.ymlbeta 先试 beta-linux.yml 再回退),
# 必须上传,否则 Linux 端更新取不到版本。
build-linux:
needs: build-sign-publish
runs-on: ubuntu-latest
env:
ELECTRON_MIRROR: https://npmmirror.com/mirrors/electron/
ELECTRON_BUILDER_BINARIES_MIRROR: https://npmmirror.com/mirrors/electron-builder-binaries/
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ inputs.ref || '' }}
- uses: pnpm/action-setup@v4
with:
version: 11.7.0
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
# 与 Windows job 相同逻辑:BUILD ID = 同一 workflow run 的 RUN_NUMBER,产物版本一致
- name: Set version (same RUN_NUMBER)
id: linuxver
shell: pwsh
run: |
$buildId = "$env:GITHUB_RUN_NUMBER"
if ("${{ inputs.mode }}" -eq "beta") {
$full = node scripts/version.js build ci "$buildId.beta"
} else {
$full = node scripts/version.js build ci "$buildId"
}
"full=$full" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
Write-Host "Linux version: $full"
- name: Generate build info
shell: pwsh
run: node scripts/gen-build-info.js ${{ inputs.mode }}
env:
BUILD_ID: ${{ github.run_number }}
- name: Build renderer + main (${{ inputs.mode }})
shell: pwsh
run: |
if ("${{ inputs.mode }}" -eq "beta") {
pnpm build:beta
pnpm icon:beta
} else {
pnpm build:run
pnpm icon:run
}
- name: Package AppImage
shell: pwsh
run: |
for ($attempt = 1; $attempt -le 2; $attempt++) {
pnpm exec electron-builder --linux AppImage --publish never
if ($LASTEXITCODE -eq 0) { break }
Write-Host "electron-builder(AppImage) 失败(第 $attempt 次,exit=$LASTEXITCODE),5 秒后重试..."
if ($attempt -eq 2) { exit $LASTEXITCODE }
Start-Sleep -Seconds 5
}
- name: Upload AppImage assets to Release
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$tag = "v${{ steps.linuxver.outputs.full }}"
$appImage = (Get-ChildItem "dist-electron/*.AppImage" -ErrorAction Stop | Select-Object -First 1).Name
$assets = @(
"dist-electron/$appImage",
"dist-electron/latest-linux.yml"
)
# beta:补 beta-linux.ymlprovider 先取该文件,避免 404 再回退)
if ("${{ inputs.mode }}" -eq "beta") {
Copy-Item "dist-electron/latest-linux.yml" "dist-electron/beta-linux.yml" -Force
$assets += "dist-electron/beta-linux.yml"
}
foreach ($a in $assets) {
if (-not (Test-Path -LiteralPath $a)) {
Write-Error "资产不存在: $a"
exit 1
}
}
gh release upload $tag @assets --clobber
Write-Host "已上传到 $tag : $($assets -join ', ')"
+1
View File
@@ -34,3 +34,4 @@ electron-dist/
build/
dsh-usage/usage-records.json
Koring.yml
dsh-usage/usage-records.json
+6
View File
@@ -0,0 +1,6 @@
---
alwaysApply: true
scene: git_message
---
在此处编写规则,自定义 AI 生成提交信息的风格。
+11 -2
View File
@@ -27,8 +27,8 @@ pnpm dist:run # production icon + Windows installer
- **Preload script**: `electron/preload.ts` exposes `window.electronAPI` via context bridge.
- **IPC handlers**: All handlers are in `electron/handlers/` directory.
- **Mutable win ref**: `electron/main.ts` uses a mutable `win` object — handlers read `win.mainWindow` at runtime, not at registration time.
- **Config**: YAML format (`Koring.yml`) stored next to executable. Sparse save (only non-default values).
- **Auth**: JSON file (`koring-auth.json`) stored next to executable.
- **Config**: YAML format (`Koring.yml`) stored in userData (packaged) / project root (dev). Sparse save (only non-default values). ⚠️ 不可放安装目录:NSIS 重装/升级会经旧卸载器删除整个安装目录。
- **Auth**: JSON file (`koring-auth.json`) stored in userData (packaged) / project root (dev).
- **Path alias**: `@/` maps to `src/` (configured in `vite.config.ts` and `tsconfig.json`).
- **Dev mode**: Vite runs on port 1420, Electron loads from localhost.
- **Asset paths**: Use `import.meta.env.BASE_URL` prefix for public assets. Absolute paths break in packaged app.
@@ -66,6 +66,9 @@ build/ ← generated by switch-icon.js (gitignored)
- `electron/config.ts`: YAML config management (sparse save)
- `electron/auth.ts`: Auth data persistence (JSON file)
- `electron/core/`: @xmcl/* integrations (auth, installer, launcher, modrinth, instance)
- `electron/core/background-image.ts`: 背景图处理服务 —— 自选壁纸复制到 userData 并按屏幕尺寸降采样/重编码**落盘**,配置文件只存**文件路径**(不使用 BASE64
- `electron/core/logger.ts`: 统一日志 —— 全局包装 `ipcMain.handle`channel/耗时/成败);开启 debug 模式(`config.advanced.debugMode`)后写 `userData/koring.log`(5MB 轮转),否则仅控制台;**dev(未打包)运行下日志直接写进程 stdout/stderr 输出到启动终端**;渲染端经 `log:write` 桥汇入
- `electron/resource-protocol.ts`: `koring-res://` 特权自定义协议 —— 渲染进程以「资源引用」流式读取本地壁纸;仅服务 userData 内 `background-custom*` 白名单文件(realpath 二次校验,防目录穿越)
- `electron/handlers/`: IPC handlers (config, auth, install, launch, mods, instance, background, task, system, window)
## Frontend notes
@@ -73,6 +76,12 @@ build/ ← generated by switch-icon.js (gitignored)
- `src/api/ipc.ts`: Core IPC utilities (invoke, onIpcEvent)
- `src/api/*.ts`: API modules wrapping IPC calls
- `src/stores/`: Zustand state management
- `src/resources/`: 启动器程序本体「资源管理」子系统(与游戏无关):
- `registry.ts` 资源注册表服务(acquire/release、引用计数、预算 + LRU 逐出、onRelease 释放回调)
- `store.ts` 注册表 → zustand 镜像(调试面板消费)
- `image.ts` 图片解码管线(按显示尺寸降采样)、`hooks.ts`/`ManagedImage.tsx` 复用组件(供列表缩略图)
- 当前接线点:`BackgroundLayer` 把当前背景(dataURL 或 koring-res 引用)登记为 `background` 类资源;自选壁纸经 `background:import` 落盘、配置存文件路径、渲染端经 `background:resolve``koring-res://` 引用(全程无 base64
- 监控入口:debug 页「资源与内存」(`debug-resource`)
- `src/hooks/useTheme.ts`: Dark mode sync with Electron theme
- `src/components/system/WindowControls.tsx`: Custom window controls (min/max/close), uses `<button>` with `WebkitAppRegion: "no-drag"`
- `src/components/system/TitleBar.tsx`: Custom title bar with navigation, uses `WebkitAppRegion: "drag"`
+12
View File
@@ -107,6 +107,11 @@ koring-launcher/
│ │ │ ├── SectionTitle.tsx # PageHeader/SectionTitleHeroUI Typography
│ │ │ ├── controls.tsx # 设置控件(Select/NumberField/Switch/Radio/TextArea/FilePicker + fieldCls
│ │ │ └── Surface.tsx # Surface 兼容别名(旧 API,样式与卡片统一)
│ │ ├── about-version/ # 版本更新内容展示
│ │ │ ├── parse.ts # release-notes 解析(details 切块 + conventional commit 分类)
│ │ │ └── index.tsx # AboutVersion 组件(getReleaseNotes → 分类卡片)
│ │ ├── feedback/ # 反馈表单按钮
│ │ │ └── FeedbackButton.tsx # HeroUI 按钮 → 系统浏览器打开 YouTrack 表单直链
│ │ ├── VersionCard.tsx # 版本/更新卡片
│ │ ├── UnderConstruction.tsx # "装修中" 占位组件
│ │ └── StartupPopup.tsx # 启动弹窗
@@ -154,6 +159,7 @@ koring-launcher/
│ │ ├── launcher.ts # @xmcl/core 游戏启动
│ │ ├── launch-options.ts # 配置→LaunchOption 映射 (parseArgs/buildLaunchOptions/resolveJavaPath)
│ │ ├── paths.ts # 相对 gameDir 归一化 (resolveGamePath)
│ │ ├── device-id.ts # 设备唯一标识(主板/硬盘/BIOS 指纹 → MachineGuid 回退 → SHA-256 UUID 样式)
│ │ ├── modrinth.ts # Modrinth/CurseForge API
│ │ └── instance.ts # 实例管理(含 importExistingInstance sourceGamePath
│ ├── handlers/ # IPC 处理器
@@ -562,3 +568,9 @@ z-200 StartupPopup 启动弹窗 (环境变量控制)
| 网络 | 安全识别服务 | 实装 | `network.securityId` |
| 网络 | 以太联机 / 陶瓦联机 | 占位 | 无后端接口 |
| 其他 | 服务与反馈 / 赞助我们 / 开发者选项 | 保留 | — |
### 版本更新内容(AboutVersion
- `src/components/about-version/`AboutVersion 组件通过 `getReleaseNotes()`GitHub release-notes.md,主进程自动切加速源、回退最新版)拉取**当前版本**说明,经 `parseReleaseNotes()` 解析后按提交类型分组,每组卡片 + 图标展示(feat 新增 / fix 修复 / perf 优化 / refactor 重构 / docs 文档 / other 其他)。
- **OOBE / UPvP 流程**`版本卡片 → 关于此版本 → …``oobe/about-version``upvp/about-version` 引用 AboutVersion;原 step-version 的下一步分别指向这两个新页)。
- **Release notes 受控格式约定(与 CI release 模板对齐)**`## 更新了什么内容` 下每条变更一个 `<details>``<summary>·Commit {sha}</summary>`,正文首行为 conventional commit `type(scope): subject`,换行后为说明);无提交时写 `· 无提交记录`。解析器亦兼容无 details 的列表兜底。
+3 -2
View File
@@ -1,3 +1,4 @@
appVersion: 1.2.6
oobe: false
theme:
darkMode: light
advanced:
preLaunchCmd: '123'
+582 -160
View File
@@ -1,192 +1,614 @@
# Koring Launcher
<p align="center">
<img src="public/icons/run/icon.png" width="96" alt="Koring Launcher" />
</p>
Minecraft launcher built with Electron + React 19 + TypeScript + Node.js (@xmcl).
<h1 align="center">Koring Launcher</h1>
<p align="center">
A modern Minecraft launcher for Windows · 基于 Electron + React 的现代化 Minecraft 启动器
</p>
<p align="center">
<a href="https://github.com/dream-pep/koring-launcher/releases"><img src="https://img.shields.io/github/v/release/dream-pep/koring-launcher?display_name=release&label=Release&logo=github&color=4c6ef5" alt="Release"></a>
<img src="https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-8a94a6" alt="Platform">
<img src="https://img.shields.io/badge/Electron%2033%20%C2%B7%20React%2019%20%C2%B7%20TypeScript-20242e" alt="Electron · React · TypeScript">
<img src="https://img.shields.io/badge/license-LL--1.0-7d5fb8" alt="License">
<a href="https://github.com/dream-pep/koring-launcher/actions/workflows/release.yml"><img src="https://github.com/dream-pep/koring-launcher/actions/workflows/release.yml/badge.svg" alt="Build & Release"></a>
</p>
<p align="center">
<a href="https://github.com/dream-pep/koring-launcher"><img src="https://img.shields.io/github/stars/dream-pep/koring-launcher?style=social" alt="Stars"></a>
</p>
---
# 中文文档
- [项目简介](#项目简介)
- [功能特性](#功能特性)
- [界面预览](#界面预览)
- [快速开始](#快速开始)
- [开发指南](#开发指南)
- [构建与发布](#构建与发布)
- [技术架构](#技术架构)
- [目录结构](#目录结构)
- [版本与自动更新](#版本与自动更新)
- [路线图](#路线图)
- [贡献指南](#贡献指南)
- [许可证](#许可证)
---
## 项目简介
**Koring Launcher** 是一款面向现代 Windows 桌面的 Minecraft 启动器,采用
**Electron + React 19 + TypeScript** 构建,游戏侧核心能力基于成熟的
[@xmcl](https://github.com/Voxelum/xmcl) 开源生态(安装、启动、任务)。
项目强调**流畅的桌面体验**与**工程化的进程架构**:自定义无边框窗口与启动画面、
三层层级 UI、主进程权威的配置体系、内置资源注册表与内存管理,以及 beta/run 双通道
自动更新。代码库以「渲染进程薄、主进程权威」为原则,所有游戏相关能力
@xmcl、文件系统、进程管理)均运行于 Electron 主进程,渲染进程只通过类型安全的
IPC 与主进程通信。
> **状态**:项目处于积极开发与内测阶段(`beta` / `run` 双通道发布)。主框架与核心
> 流程稳定可用,部分 UI 模块(见 [路线图](#路线图))仍在打磨。
## 功能特性
**桌面体验**
- 自定义**无边框窗口**:自绘标题栏(毛玻璃)与窗口控制,深浅色模式自动适配
- 独立的 **Splash 启动画面**480×320 透明窗,无框架依赖,`ready-to-show` + 最短停留时间过渡)
- **三层层级 UI**:背景层 / 内容层 / 系统层,页面间使用 View Transitions 过渡
- 内置**隐藏调试页**`debug-*` 路由):显示、更新、任务、资源与内存等专项调试
**游戏能力(基于 @xmcl**
- **统一配置驱动启动**:主进程读取权威配置,自动映射为 `LaunchOption`
Java 路径 / 内存 / GC / JVM 参数 / 游戏参数 / 窗口 / 启动前命令),
支持启动事件推送与 `afterLaunch` 窗口处理
- **Java 自动检测与路径校验**(`java:scan` / `java:resolve`
- **实例系统**:主库实例 + 游戏目录批量导入(幂等、目录变更自动重扫)
- **Mod 生态**Modrinth / CurseForge 检索与安装模块
- **账号体系**:Koring 账号、离线账号(规范离线 UUID),微软 OAuth 核心已接入
- 任务系统:安装 / 下载进度统一通过主进程任务队列(`task:*`)推进,UI 可查看队列
**配置与个性化**
- **主进程权威配置**YAML`Koring.yml`),深度合并 + 稀疏写盘 + 变更广播
- **设置中心**(参考 PCL2 的分组结构):游戏(账号 / Java / 目录 / 高级)、
个性化(主题背景 / 主界面 / 语言 / 无障碍)、网络(下载 / 安全识别)等均已接入
- **背景系统**:颜色 / 渐变 / 模糊 / 自选壁纸;自选壁纸由主进程按屏幕尺寸降采样并
落盘存储,经 `koring-res://` 特权协议流式读取(**全程无 base64**),并纳入
**资源注册表**统一管理(引用计数、预算 + LRU 逐出)
- **引导流程**:首次启动 OOBE 向导(语言 / 协议 / 登录 / 版本)与更新后引导(UPvP),
「关于此版本」将 GitHub Release Notes 按新增 / 修复 / 优化等**分类卡片化**展示
- **意见反馈**:设置 → 服务与反馈内置 HeroUI 反馈按钮,点击在系统浏览器打开 YouTrack 反馈表单
`components/feedback/FeedbackButton`,直链 `lingke.youtrack.cloud/form/<uuid>`
- **设备识别码**:关于页展示设备唯一标识 —— 由硬件指纹(主板 UUID / 硬盘序列号 / BIOS 序列号,过滤 OEM 占位值)
优先取源,缺失时回退注册表 `MachineGuid`,经 SHA-256 生成 UUID 样式标识(`electron/core/device-id.ts`,进程内缓存)
## 界面预览
> 截图整理中 —— 欢迎将截图放入 `docs/screenshots/` 后在此展示。
<!--
示例:
<img src="docs/screenshots/home.png" width="720" alt="主界面" />
-->
## 快速开始
**面向用户**:从 [GitHub Releases](https://github.com/dream-pep/koring-launcher/releases)
下载最新安装包(NSIS 安装器,可自选安装目录)。目前发布管线产出 Windows 安装程序,
macOSDMG)与 LinuxAppImage)构建目标已配置,将在 CI 中逐步开放。
**开发环境要求**
| 依赖 | 版本 | 说明 |
| --- | --- | --- |
| OS | Windows 10/11x64 | 主要开发与目标平台 |
| Node.js | ≥ 20.19CI 使用 22 | Vite 7 要求 |
| pnpm | ≥ 9CI 使用 11.7 | 包管理器,仓库基于 pnpm workspace |
> 国内网络环境:仓库 `.npmrc` 已配置 Electron 二进制走 npmmirror 镜像,
> 可避免 Electron 下载超时。
```bash
# 安装依赖
pnpm install
# 开发(完整应用:Vite dev server :1420 + Electron
pnpm dev
# 仅渲染进程(HMR,端口 1420)/ 仅主进程
pnpm dev:renderer
pnpm dev:main
```
## 开发指南
常用脚本一览(完整说明见 `DEV.md``AGENTS.md`):
| 命令 | 说明 |
| --- | --- |
| `pnpm dev` | 完整开发环境(先编译主进程,再并行启动 Vite + Electron |
| `pnpm dev:renderer` | 仅渲染进程(Vite HMR,端口 1420 |
| `pnpm dev:main` | 仅主进程(`tsc` 编译后启动 Electron |
| `pnpm build:renderer:{dev,beta,run}` | 按模式构建渲染进程 |
| `pnpm build:main` | 编译主进程 TypeScript |
| `pnpm build:{dev,beta,run}` | 对应模式的完整构建(渲染进程 + 主进程) |
| `pnpm preview` | 预览 Vite 产物 |
| `pnpm version:set` | 设置版本号(单一事实源为 `package.json` |
| `pnpm pack` | `electron-builder --dir`(未打包目录产物) |
| `pnpm dist` / `dist:win` / `dist:mac` / `dist:linux` | electron-builder 按目标平台打包 |
**开发要点速览**
- `@/` 路径别名映射到 `src/`(同时配置于 `vite.config.ts``tsconfig.json`
- 渲染进程公共资源路径须使用 `import.meta.env.BASE_URL` 前缀(打包后绝对路径失效)
- 所有 `@xmcl/*`、文件系统与子进程能力运行于主进程;渲染进程经
`ipcRenderer.invoke()``ipcMain.handle()` 调用,方向性事件经 `webContents.send()` 推送
- 主进程配置为唯一权威:渲染端只提交补丁,不直接写盘(详见「配置存储」)
- HeroUI 3(基于 react-aria)控件使用 `onChange``onValueChange` 已移除
- 窗口拖拽需使用内联样式 `WebkitAppRegion: "drag"`Electron 仅识别 CSS 属性)
## 构建与发布
### 构建模式
项目支持三种构建模式,通过环境文件(`.env.development` / `.env.beta` /
`.env.production`)注入,并配套**切换图标**(exe / 安装器图标随模式不同):
| 模式 | Vite mode | 图标目录 | 用途 |
| --- | --- | --- | --- |
| `dev` | `development` | `public/icons/dev/` | 日常开发 |
| `beta` | `beta` | `public/icons/beta/` | 内测发布 |
| `run` | `production` | `public/icons/run/` | 正式发布 |
```bash
pnpm dist:dev # build:dev → icon:dev → electron-builder --win
pnpm dist:beta # build:beta → icon:beta → electron-builder --win
pnpm dist:run # build:run → icon:run → electron-builder --win
```
打包流程:模式构建 → `scripts/switch-icon.js` 将对应图标复制到 `build/`
electron-builder 的 `buildResources`)→ electron-builder 产出 NSIS 安装程序,
产物位于 `dist-electron/koring-launcher-{version}-setup.exe`
### 发布流水线(GitHub Actions
`.github/workflows/release.yml` 手动触发(选择 `beta` / `run` 模式):
1.`package.json` 读取基础版本号,附加 **BUILD ID**GitHub Run Number
严格递增)生成最终版本:`{base}-beta.{buildId}`beta)或 `{base}-{buildId}`run
2. `pnpm build:{mode}` + `pnpm icon:{mode}`renderer + main + 图标)
3. `electron-builder --win`,经 **SignPath 远程签名**(无 `SIGNPATH_API_TOKEN`
时自动跳过,本地构建不受影响;仅对 sha256 签名以节省配额)
4. 生成中文发布说明并发布 GitHub Releasebeta 为 prerelease 并额外上传
`latest-beta.yml`run 为正式版并上传 `latest.yml`electron-updater 更新清单)
### 配置存储(主进程权威)
- **`Koring.yml`**:打包后存 `app.getPath('userData')``%APPDATA%/Koring Launcher/`),
开发模式存项目根目录;旧版 exe 旁文件首次启动自动迁移
- 主进程内存缓存为唯一权威:渲染端经 `config:update` 提交补丁 → 主进程深度合并 →
300ms debounce **稀疏写盘**(仅写非默认值)→ 广播 `config:changed` 同步渲染端镜像
- **`koring-auth.json`**(账号数据)与崩溃日志采用相同存储策略
- ⚠️ 数据文件不可放入安装目录:NSIS 重装 / 升级会经旧卸载器删除整个安装目录
## 技术架构
```
┌────────────────────────── Renderer (React 19) ─────────────────────────┐
│ pages / components / stores (Zustand) resources (注册表+LRU) │
│ │ ipcRenderer.invoke() ▲ ipcMain.handle() │
│ ▼ │ webContents.send() (方向性事件) │
├────────────────────────── Main Process (Electron / Node) ──────────────┤
│ handlers/* ── config.ts (YAML 权威) · auth.ts · updater.ts │
│ core/* ── @xmcl/core · @xmcl/installer · @xmcl/task │
│ launch-options (配置→LaunchOption) │
│ resource-protocol.ts koring-res://(白名单流式读取,realpath 校验) │
└────────────────────────────────────────────────────────────────────────┘
```
- **IPC 流**:渲染进程 `invoke` → 主进程 `handle` → 处理完成返回;主进程主动事件
`webContents.send` 推送给渲染端(如 `config:changed``launch:event`
- **可变的 `win` 引用**`main.ts` 使用可变 `win` 对象,各 handler 在运行时读取
`win.mainWindow`,而非注册时捕获
- **`koring-res://`**:特权自定义协议,仅服务 userData 内
`background-custom*` 白名单文件(realpath 二次校验,防目录穿越),
用于渲染进程「按引用」流式读取本地壁纸,避免 base64 膨胀内存
- **背景图处理**:自选壁纸由主进程复制到 userData,并按屏幕尺寸降采样 / 重编码
**落盘**;配置文件只存文件路径
- **资源注册表**`src/resources/`):acquire / release 引用计数、预算 + LRU 逐出、
onRelease 释放回调;图片按显示尺寸降采样,供列表缩略图复用
## 目录结构
```
koring-launcher/
├─ electron/ # 主进程(Electron@xmcl 全部运行于此)
│ ├─ main.ts # 入口:窗口管理、splash→主界面过渡
│ ├─ preload.ts # contextBridge 暴露 window.electronAPI
│ ├─ config.ts # YAML 配置(权威、稀疏写盘、迁移)
│ ├─ auth.ts · updater.ts · resource-protocol.ts
│ ├─ core/ # @xmcl 集成与业务核心
│ │ ├─ auth.ts · koring-auth.ts · installer.ts · launcher.ts
│ │ ├─ launch-options.ts · modrinth.ts · instance.ts
│ │ ├─ task.ts · paths.ts · background-image.ts · crash-logger.ts
│ └─ handlers/ # IPC 处理器(config/auth/install/launch/java/
│ # mods/instance/background/task/system/update/
│ # koring-auth/crash-monitor/window
├─ src/ # 渲染进程(React 19
│ ├─ api/ # IPC 类型封装
│ ├─ components/ # background/ system/ setting/ task/
│ │ # about-version/ ui/ (shadcn) …
│ ├─ pages/ # home · gallery · store · today · play-link ·
│ │ # setting(分组子页) · oobe · upvp · update ·
│ │ # task-queue · crash · debug
│ ├─ resources/ # 资源注册表(引用计数/预算/LRU)与图片解码管线
│ ├─ stores/ # Zustand 状态(config/auth/theme/route/launch…)
│ ├─ layouts/ · hooks/ · lib/ (mode/buildInfo/version) · assets/ · types/
│ ├─ App.tsx · main.tsx · index.css
├─ public/ # 静态资源:icons/{dev,beta,run}/、背景、字体
├─ scripts/ # switch-icon · version · gen-build-info ·
│ # signpath-sign · release-notes …
├─ docs/ · DEV.md · AGENTS.md # 开发文档与仓库约定
├─ electron-builder.yml # appId / 打包目标 / NSIS / 签名配置
├─ vite.config.ts · tsconfig*.json · .env.{development,beta,production}
└─ package.json # 版本单一事实源
```
## 版本与自动更新
- **版本单一事实源**`package.json``version` 字段(`scripts/version.js`
`get` / `set`),本地与 CI 共用,消除双轨
- **版本号格式**`{base}`package.json)→ 发布时追加构建号
`{base}-beta.{buildId}`beta 通道)/ `{base}-{buildId}`run 通道)
- **自动更新**`electron-updater` + GitHub providerrun 通道读 `latest.yml`
beta 通道读 `latest-beta.yml`;客户端开启 `allowPrerelease` 以接收带构建号的
beta 更新
- ⚠️ 若切换构建号方案(时间戳 → Run Number),旧版数值更大,老用户不会自动
升级,需同步提升 base 版本(详见 `docs/auto-update-plan.md`
## 路线图
- [ ] **实例中心 / 资源中心 UI**:完整实现已就绪,暂以占位页收尾打磨后放开
Minecraft 版本 / Mod / 整合包浏览与安装)
- [ ] **微软账号登录 UI**OAuth / Xbox / Minecraft 认证核心已接入,入口 UI 完善中
- [ ] **以太 / 陶瓦联机页**:目前为占位
- [ ] macOSDMG)与 LinuxAppImageCI 发布产物
- [ ] 多语言(`app.language` 偏好已定义,UI 文案逐步外置)
## 贡献指南
欢迎提交 Issue 与 Pull Request
1. Fork 本仓库并以 `master` 为基线创建功能分支
2. 遵循仓库现有风格(TypeScript 严格模式、组件/处理器分层),
提交信息建议使用约定式(`feat:` / `fix:` / `docs:` / `refactor:` …)
3. 修改渲染进程前先阅读 [AGENTS.md](AGENTS.md)(架构约束与注意点)
与 [DEV.md](DEV.md)(开发说明)
4. 涉及图标 / 安装器的改动请分别验证三种构建模式(`dist:dev/beta/run`
## 许可证
本项目使用自定义 **LingkeLice 1.0LL-1.0** 许可证,版权归
**Shenzhen Lingke Network Technology Co., Ltd.(深圳灵科网络科技有限公司)** 所有。
要点:**非商业用途**可自由使用、复制、修改与分发(含并入其他项目);任何
**商业用途**(含集成进商业产品、商业化提供服务、直接 / 间接获利)须事先获得
版权所有者的**书面授权**。详见 [LICENSE](LICENSE)。
商业授权请联系 Shenzhen Lingke Network Technology Co., Ltd.
---
# English Version
- [Introduction](#introduction)
- [Features](#features)
- [Screenshots](#screenshots)
- [Quick Start](#quick-start)
- [Development](#development)
- [Build & Release](#build--release)
- [Architecture](#architecture)
- [Project Structure](#project-structure)
- [Versioning & Auto-Update](#versioning--auto-update)
- [Roadmap](#roadmap)
- [Contributing](#contributing)
- [License](#license)
## Introduction
**Koring Launcher** is a modern Minecraft launcher for Windows built with
**Electron + React 19 + TypeScript**, with game-side capabilities powered by the
mature [@xmcl](https://github.com/Voxelum/xmcl) ecosystem (install, launch, task).
The project favors a *thin renderer, authoritative main process* architecture:
game-related features (@xmcl, filesystem, subprocesses) all run in the Electron
main process, while the renderer talks to it only through type-safe IPC. Notable
engineering practices include a custom frameless window and splash screen,
a three-layer UI model, main-process-authoritative configuration, a built-in
resource registry with memory management, and dual-channel (beta/run) auto-update.
> **Status**: under active development with public beta/run releases. The core
> framework and main flows are stable; a few UI modules are being polished
> (see [Roadmap](#roadmap)).
## Features
**Desktop experience**
- Custom **frameless window**: glassmorphism title bar and window controls,
automatic dark/light mode
- Standalone **splash screen** (480×320 transparent window, dependency-free,
`ready-to-show` + minimum-duration transition)
- **Three-layer UI** (background / content / system) with View Transitions
- Hidden **debug pages** (`debug-*` routes) for display, update, task, and
resource/memory inspection
**Game capabilities (via @xmcl)**
- **Config-driven unified launch**: the main process maps authoritative config
to `LaunchOption` (Java path, memory, GC, JVM args, window, pre-launch
commands), with launch event streaming and `afterLaunch` window handling
- **Java auto-detection & validation** (`java:scan` / `java:resolve`)
- **Instance system**: instances in the main library + bulk import from an
existing game directory (idempotent, auto-rescan on directory change)
- **Mod ecosystem**: Modrinth / CurseForge search & install modules
- **Accounts**: Koring account, offline login (spec-compliant offline UUID),
Microsoft OAuth core integrated
- **Task system**: install / download progress flows through a main-process
task queue (`task:*`) with a visible UI queue
**Configuration & personalization**
- **Main-process-authoritative config**: YAML (`Koring.yml`), deep merge +
sparse save + change broadcast
- **Settings center** (PCL2-inspired groups): game (account / Java / directory /
advanced), personalization (theme & background / UI / language / a11y),
network (download / security ID), etc.
- **Background system**: color / gradient / blur / custom wallpaper. Custom
wallpapers are downsampled to screen size and stored **on disk** by the main
process, streamed via the privileged `koring-res://` protocol (**no base64**),
and managed by a **resource registry** (ref-counting, budget + LRU eviction)
- **Onboarding flows**: first-launch OOBE wizard (language / agreement / login /
version) and post-update wizard (UPvP); “About this version” renders GitHub
release notes as categorized cards (added / fixed / improved / …)
## Screenshots
> Coming soon — contributions welcome: drop screenshots into `docs/screenshots/`
> and reference them here.
<!--
e.g. <img src="docs/screenshots/home.png" width="720" alt="Home" />
-->
## Quick Start
**End users**: download the latest installer from
[GitHub Releases](https://github.com/dream-pep/koring-launcher/releases) (NSIS,
custom install directory). The release pipeline currently publishes Windows
setups; macOS (DMG) and Linux (AppImage) targets are configured and will be
opened up in CI gradually.
**Development requirements**
| Dependency | Version | Notes |
| --- | --- | --- |
| OS | Windows 10/11 (x64) | Primary development & target platform |
| Node.js | ≥ 20.19 (CI uses 22) | Required by Vite 7 |
| pnpm | ≥ 9 (CI uses 11.7) | Package manager (pnpm workspace) |
> In China, `.npmrc` already redirects Electron binaries to the npmmirror
> mirror to avoid download timeouts.
```bash
pnpm install
pnpm dev # full app (frontend + electron)
pnpm dev:renderer # frontend only (vite, port 1420)
pnpm dev:main # electron main process only
# Full app dev (Vite :1420 + Electron)
pnpm dev
pnpm dev:renderer # renderer only (HMR, port 1420)
pnpm dev:main # main process only
```
## Build
## Development
Common scripts (full details in `DEV.md` and `AGENTS.md`):
| Command | Description |
| --- | --- |
| `pnpm dev` | Full dev environment (compile main, then Vite + Electron) |
| `pnpm dev:renderer` | Renderer only (Vite HMR, port 1420) |
| `pnpm dev:main` | Main process only (`tsc` then launch Electron) |
| `pnpm build:renderer:{dev,beta,run}` | Build renderer for a mode |
| `pnpm build:main` | Compile main-process TypeScript |
| `pnpm build:{dev,beta,run}` | Full build for a mode (renderer + main) |
| `pnpm preview` | Preview the Vite build |
| `pnpm version:set` | Set the version (`package.json` is the single source) |
| `pnpm pack` | `electron-builder --dir` (unpacked output) |
| `pnpm dist` / `dist:win` / `dist:mac` / `dist:linux` | Package for a target |
**Quick notes**
- `@/` maps to `src/` (in both `vite.config.ts` and `tsconfig.json`)
- Public assets in the renderer must use the `import.meta.env.BASE_URL` prefix
(absolute paths break when packaged)
- All `@xmcl/*`, filesystem and subprocess code lives in the main process; the
renderer calls via `ipcRenderer.invoke()``ipcMain.handle()`, and
directional events are pushed with `webContents.send()`
- Main-process config is authoritative — the renderer submits patches, never
writes files directly
- HeroUI 3 (built on react-aria) controls use `onChange` (`onValueChange`
no longer exists)
- Window dragging requires the inline style `WebkitAppRegion: "drag"`
(Electron only honors the CSS property)
## Build & Release
### Build modes
Three build modes are driven by env files (`.env.development` / `.env.beta` /
`.env.production`) and each ships its **own icon set** for the exe and
installer:
| Mode | Vite mode | Icon dir | Purpose |
| --- | --- | --- | --- |
| `dev` | `development` | `public/icons/dev/` | Daily development |
| `beta` | `beta` | `public/icons/beta/` | Beta release |
| `run` | `production` | `public/icons/run/` | Production release |
```bash
pnpm build # production build (vite + tsc)
pnpm dist:dev # dev icon + Windows installer
pnpm dist:beta # beta icon + Windows installer
pnpm dist:run # production icon + Windows installer
pnpm dist:mac # build macOS DMG
pnpm dist:linux # build Linux AppImage
pnpm dist:dev # build:dev icon:dev electron-builder --win
pnpm dist:beta # build:beta → icon:beta → electron-builder --win
pnpm dist:run # build:run icon:run → electron-builder --win
```
Pipeline: mode build → `scripts/switch-icon.js` copies the mode icons into
`build/` (electron-builders `buildResources`) → electron-builder produces an
NSIS installer at `dist-electron/koring-launcher-{version}-setup.exe`.
### Release pipeline (GitHub Actions)
`.github/workflows/release.yml` is triggered manually with a `beta` / `run` mode:
1. Read the base version from `package.json`, append a **BUILD ID**
(GitHub Run Number, strictly increasing): `{base}-beta.{buildId}` (beta) or
`{base}-{buildId}` (run)
2. `pnpm build:{mode}` + `pnpm icon:{mode}`
3. `electron-builder --win` with **SignPath remote signing** (auto-skipped when
`SIGNPATH_API_TOKEN` is absent; sha256-only to save quota)
4. Generate Chinese release notes and publish a GitHub Release: beta releases
are prereleases that also upload `latest-beta.yml`; run releases are stable
and upload `latest.yml` (the electron-updater manifest)
### Config storage (main-process authoritative)
- **`Koring.yml`**: stored under `app.getPath('userData')`
(`%APPDATA%/Koring Launcher/`) when packaged, or in the project root in dev;
legacy files next to the old exe are auto-migrated on first launch
- The in-memory cache in the main process is the single source of truth:
renderer submits a patch via `config:update` → deep merge → 300 ms debounced
**sparse write** (non-default values only) → broadcast `config:changed`
- **`koring-auth.json`** (account data) and crash logs follow the same policy
- ⚠️ Never store data files inside the install directory: NSIS reinstall /
upgrade removes the whole directory via the old uninstaller
## Architecture
```
src/ Frontend (React 19 + Vite 7 + Tailwind v4 + shadcn/ui + Zustand)
electron/ Main process (Node.js/TypeScript, @xmcl/* packages)
public/ Static assets (icons, fonts, images)
build/ Build resources (generated, gitignored)
┌────────────────────────── Renderer (React 19) ─────────────────────────┐
│ pages / components / stores (Zustand) resources (registry+LRU) │
│ │ ipcRenderer.invoke() ▲ ipcMain.handle() │
│ ▼ │ webContents.send() (directional) │
├────────────────────────── Main Process (Electron / Node) ──────────────┤
│ handlers/* ── config.ts (YAML authority) · auth.ts · updater.ts │
│ core/* ── @xmcl/core · @xmcl/installer · @xmcl/task │
│ launch-options (config → LaunchOption) │
│ resource-protocol.ts koring-res:// (whitelist streaming, realpath) │
└────────────────────────────────────────────────────────────────────────┘
```
**IPC Flow:**
Frontend → `ipcRenderer.invoke()``ipcMain.handle()` → main process → `webContents.send()` → Frontend
**Config storage (main-process authoritative):**
- `Koring.yml` — 打包后存 `app.getPath('userData')``%APPDATA%/Koring Launcher/`),开发模式在项目根目录;旧版 exe 旁文件首次启动自动迁移
- 主进程内存缓存为唯一权威:渲染进程通过 `config:update` 提交补丁,主进程深度合并 → 300ms debounce 稀疏写盘 → 广播 `config:changed` 同步渲染端镜像
- `koring-auth.json` / `koring-crash.log` 与配置同策略(打包后 userData)
- **IPC flow**: renderer `invoke` → main `handle` → result returns; the main
process pushes directional events (e.g. `config:changed`, `launch:event`)
via `webContents.send`
- **Mutable `win` reference**: `main.ts` keeps a mutable `win` object, and
handlers read `win.mainWindow` at runtime instead of capture time
- **`koring-res://`**: privileged custom protocol serving only whitelisted
`background-custom*` files inside userData (realpath double-check against
path traversal) so the renderer can stream local wallpapers by reference
- **Wallpaper pipeline**: custom wallpapers are copied into userData,
downsampled/re-encoded to screen size and written **to disk**; the config
stores only the file path
- **Resource registry** (`src/resources/`): acquire/release ref-counting,
budget + LRU eviction, onRelease callbacks; images are decoded at display
size and shared by list thumbnails
## Project Structure
```
src/
├── api/ # Frontend API layer (IPC wrappers)
├── ipc.ts # Core IPC utilities
├── background.ts # Background control
├── install.ts # Minecraft install
├─launch.ts # Game launch
├── auth.ts # Microsoft/offline auth
│ ├─ mods.ts # Modrinth/CurseForge
└── instance.ts # Instance management
├── stores/ # Zustand state stores
├── components/
├── background/ # Background layer (z-0)
├── system/ # Title bar + window controls (z-100)
│ └── ui/ # shadcn/ui components
├── layouts/
└── RootLayout.tsx # Three-layer page structure
├── pages/
├── Home.tsx # Main page
└── Debug.tsx # Debug tools
├── lib/
├── mode.ts # Build mode constants (DEFAULT_BG, LOGO_SVG, APP_ICON)
└── utils.ts # cn() helper
└── App.tsx # Root component with state router
electron/
├── main.ts # Electron entry, window management, splash→main transition
├── preload.ts # Context bridge (window.electronAPI)
├── config.ts # YAML config management (main-process authoritative, debounce sparse save)
├── auth.ts # Auth data persistence
├── core/ # @xmcl/* integrations
│ ├── auth.ts # Microsoft OAuth, Xbox Live, MC auth
│ ├── installer.ts # @xmcl/installer
│ ├── launcher.ts # Unified game launcher (@xmcl/core launch + config-driven)
│ ├── launch-options.ts # Config → LaunchOption mapping (parseArgs/buildLaunchOptions/resolveJavaPath)
│ ├── modrinth.ts # Modrinth/CurseForge API
│ └── instance.ts # Instance management
├── handlers/ # IPC handlers
│ ├── config.ts # Config load/save/update (config:get/update/save + config:changed broadcast)
│ ├── auth.ts # Auth operations
│ ├── install.ts # Install operations
│ ├── launch.ts # Unified game launch (launch:launch / launch:diagnose + afterLaunch)
│ ├── java.ts # Java detection (java:scan / java:resolve)
│ ├── mods.ts # Mod operations
│ ├── instance.ts # Instance operations
│ ├── background.ts # Background operations
│ ├── task.ts # Task system
│ ├── system.ts # System info
│ └── window.ts # Window controls + splash management
└── types/
└── electron.d.ts # TypeScript declarations
koring-launcher/
├─ electron/ # Main process (all @xmcl code runs here)
├─ main.ts # Entry: windows, splash → main transition
├─ preload.ts # contextBridge → window.electronAPI
├─ config.ts # YAML config (authoritative, sparse save, migrate)
─ auth.ts · updater.ts · resource-protocol.ts
├─ core/ # @xmcl integrations & business core
├─ auth.ts · koring-auth.ts · installer.ts · launcher.ts
│ ├─ launch-options.ts · modrinth.ts · instance.ts
│ │ ├─ task.ts · paths.ts · background-image.ts · crash-logger.ts
│ └─ handlers/ # IPC handlers (config/auth/install/launch/java/
# mods/instance/background/task/system/update/
# koring-auth/crash-monitor/window)
├─ src/ # Renderer (React 19)
│ ├─ api/ # Typed IPC wrappers
├─ components/ # background/ system/ setting/ task/
│ │ # about-version/ ui/ (shadcn) …
├─ pages/ # home · gallery · store · today · play-link ·
│ # setting (grouped subpages) · oobe · upvp ·
│ │ # update · task-queue · crash · debug
├─ resources/ # Resource registry (ref-count/budget/LRU) +
# image decode pipeline
│ ├─ stores/ # Zustand (config/auth/theme/route/launch…)
│ ├─ layouts/ · hooks/ · lib/ (mode/buildInfo/version) · assets/ · types/
│ ├─ App.tsx · main.tsx · index.css
├─ public/ # Static assets: icons/{dev,beta,run}/, bg, fonts
├─ scripts/ # switch-icon · version · gen-build-info ·
│ # signpath-sign · release-notes …
├─ docs/ · DEV.md · AGENTS.md # Docs & repository conventions
├─ electron-builder.yml # appId / targets / NSIS / signing
├─ vite.config.ts · tsconfig*.json · .env.{development,beta,production}
└─ package.json # Single source of truth for the version
```
## Three-Layer Page Structure
## Versioning & Auto-Update
```
┌──────────────────────────────────────┐
│ z-index: 100 System Layer │ pointer-events: none
│ ┌──────────────────────────────┐ │
│ │ TitleBar (frosted glass) │ │ pointer-events: auto
│ │ WindowControls (25px btns) │ │
│ └──────────────────────────────┘ │
├──────────────────────────────────────┤
│ z-index: 1 Content Layer │ pointer-events: auto
All page content │
├──────────────────────────────────────┤
│ z-index: 0 Background Layer │ pointer-events: none
│ Image / color / gradient / blur │
└──────────────────────────────────────┘
```
- **Single source of truth**: the `version` field in `package.json`
(`scripts/version.js` `get`/`set`) shared by local builds and CI
- **Version format**: `{base}` (package.json) gains a build suffix at release
time: `{base}-beta.{buildId}` (beta) / `{base}-{buildId}` (run)
- **Auto-update**: `electron-updater` with the GitHub provider; run reads
`latest.yml`, beta reads `latest-beta.yml`; the client enables
`allowPrerelease` so suffixed beta builds are detected
- ⚠️ Switching the build-ID scheme (timestamp → Run Number) makes old versions
numerically larger; raise the base version at the same time or old clients
will not upgrade (details: `docs/auto-update-plan.md`)
## Splash Screen
## Roadmap
- Standalone HTML/CSS (`splash.html`), no React/Vite dependency
- Loads instantly while Vite dev server starts
- Window: 480×320, no decorations, transparent, locked size
- Auto-adapts to system dark mode (`prefers-color-scheme`)
- Logo: `filter: invert(1)` in dark mode
- Startup: splash shows first → main loads behind → transition after `ready-to-show` + 1.5s minimum
- [ ] **Instance center / Resource center UI**: full implementations are ready
but temporarily behind placeholder pages (Minecraft versions / mods /
modpacks browse & install)
- [ ] **Microsoft account login UI**: OAuth / Xbox / Minecraft auth core is
integrated; UI entry is being polished
- [ ] **Ether / Tawa online play pages**: currently placeholders
- [ ] macOS (DMG) and Linux (AppImage) CI artifacts
- [ ] i18n rollout (`app.language` preference is defined; UI strings are being
externalized)
## Icon System
## Contributing
Three icon variants in `public/icons/`:
Issues and pull requests are welcome:
```
public/icons/
dev/icon.ico, icon.png # Development
beta/icon.ico, icon.png # Testing
run/icon.ico, icon.png # Production release
```
1. Fork the repository and branch from `master`
2. Follow the existing style (strict TypeScript, layered components/handlers);
prefer conventional commits (`feat:` / `fix:` / `docs:` / `refactor:` …)
3. Before touching the renderer, read [AGENTS.md](AGENTS.md) (architecture
constraints) and [DEV.md](DEV.md) (development notes)
4. Icon / installer changes should be verified across all three build modes
(`dist:dev/beta/run`)
**Build-time switching:**
```bash
pnpm icon:dev # copies public/icons/dev/ → build/
pnpm icon:beta # copies public/icons/beta/ → build/
pnpm icon:run # copies public/icons/run/ → build/
```
## License
`electron-builder.yml` reads icons from `build/` (`buildResources: build`).
This project is licensed under the custom **LingkeLice 1.0 (LL-1.0)**, ©
**Shenzhen Lingke Network Technology Co., Ltd.** In short: **non-commercial
use** (use, copy, modify, distribute, including incorporation into other
projects) is free; any **commercial use** — integrating into commercial
products, providing paid services, or earning direct/indirect revenue — requires
**prior written authorization** from the copyright holder. See
[LICENSE](LICENSE).
**Frontend usage:**
```tsx
import { APP_ICON, DEFAULT_BG, LOGO_SVG, BUILD_MODE, isDev } from "@/lib/mode";
<img src={APP_ICON} />
<img src={LOGO_SVG} />
<img src={DEFAULT_BG} />
{isDev && <span>Dev Mode</span>}
```
## IPC Handlers
- `config:get` / `config:update` / `config:save` / `config:changed` — 配置读写(主进程权威:update 深度合并 + debounce 稀疏写盘 + 广播)
- `auth:*` — Microsoft OAuth, offline login
- `install:*` — Minecraft install, mod loader, version lists
- `launch:launch` / `launch:diagnose` — 统一游戏启动:主进程读取权威配置自动应用 Java/内存/GC/JVM/游戏参数/窗口/启动前命令,事件经 `launch:event` 推送,window-ready 时按 `afterLaunch` 处理启动器窗口
- `java:scan` / `java:resolve` — Java 环境检测 / 路径校验
- `mods:*` — Modrinth/CurseForge search, install
- `instance:*` — Instance CRUD(安装/导入/诊断;启动统一走 `launch:launch`
- `background:*` — Background image/color/blur/animation/theme
- `task:*` — Task system progress
- `system:*` — System info
- `window:*` — Minimize/maximize/close + splash management
- `dialog:*` — File picker
## Key Gotchas
- **@xmcl packages run in main process**: `@xmcl/core`, `@xmcl/installer` require `fs`/`child_process`. All run in Electron main process.
- **Path alias**: `@/` maps to `src/`.
- **Window dragging**: Use CSS `WebkitAppRegion: "drag"` as inline style (Electron only respects CSS property, not HTML attributes).
- **Transparent windows**: `transparent: true` + `frame: false` in BrowserWindow options.
- **Mutable win ref**: `electron/main.ts` uses a mutable `win` object — all handlers read `win.mainWindow` at runtime (not captured at registration time).
- **Asset paths**: Use `import.meta.env.BASE_URL` prefix for public assets (e.g., `${import.meta.env.BASE_URL}background.png`). Absolute paths like `/background.png` break in packaged app.
- **Config**: YAML format (`Koring.yml`). 打包后存 `app.getPath('userData')`(开发模式在项目根目录);主进程内存缓存为唯一权威,渲染进程经 `config:update` 提交、`config:changed` 同步,不在渲染端直接写盘。Sparse save(只写非默认值)。
- **Auth**: JSON file (`koring-auth.json`),打包后存 userData(与配置同策略)。
- **Launch is config-driven**: `launch:launch` 由主进程读取权威配置映射为 `@xmcl/core``LaunchOption``buildLaunchOptions`),前端只传实例名 + 游戏根目录 + 账户档案。
- **HeroUI 3 基于 react-aria**Switch / RadioGroup 等使用 `onChange`(不是 `onValueChange`),`onValueChange` 在 HeroUI 3 中不存在且静默失效。
- **设置子页面统一原语**`src/components/setting/`SettingCard/SettingRow/SettingBadge/SettingListItem/controls.tsx + `fieldCls`),一套样式组合;Radio 必须显式渲染 `<Radio.Control><Radio.Indicator /></Radio.Control>` 才有圆点;HeroUI field 默认边框宽度为 0,输入框需叠加 `fieldCls`
- **游戏目录导入**:版本从**当前扫描目录**导入(`sourceGamePath`),实例建在主库;主目录变更自动重扫;批量导入幂等(已存在跳过);相对 `gameDir`(默认 `.minecraft`)由主进程 `resolveGamePath` 按 exe 目录/项目根归一化。
- **设置页结构(参考 PCL2)**:通用(主页/Koring 账户/关于/版权)与其他(服务与反馈/赞助/开发者)保留;游戏组(账户·离线登录、Java、目录、高级·含快速进入服务器)、个性化组(主题背景/主界面/语言/辅助)、网络组(下载/安全识别)已对接设置接口;以太/陶瓦联机页暂为占位。新增配置段:`app.language`(语言偏好)、`ui.showInstanceTitle/showTaskButton`(主界面元素)、`advanced.server`(快速进入服务器,启动自动加入)。
- **离线账号登录**`auth:offline-login` 处理器生成离线 UUIDMD5(OfflinePlayer:用户名));微软登录 UI 标注"开发中"。
## Tech Stack
| Layer | Technology |
|-------|-----------|
| Frontend | React 19, Vite 7, Tailwind CSS v4, shadcn/ui, Zustand |
| Main Process | Node.js, TypeScript, @xmcl/* packages |
| Build | pnpm, Vite, electron-builder |
For commercial licensing, contact Shenzhen Lingke Network Technology Co., Ltd.
+27 -14
View File
@@ -299,13 +299,14 @@ src/
**版本号与 BUILD ID2026-08-30 起)**
- BUILD ID = `github.run_number`(严格递增、无分钟级冲突)
- **beta**`{base}-beta.{buildId}`(如 `1.2.1-beta.13`tag `v1.2.1-beta.13`GitHub 标记 prerelease
- **run**`{base}`稳定版,`1.2.1`tag `v1.2.1`
- ⚠️ **为什么弃用数字标识**electron-updater 的 GitHub provider 频道逻辑只认 `alpha`/`beta`
字符串频道;`1.2.1-13` 的数字 prerelease 会被当作"自定义频道"导致更新检查
`No published versions on GitHub`(无法识别当前/最新版本)。字母标识 `beta` 解决此问题。
- **run**`{base}-{buildId}`(如 `1.2.1-13`tag `v1.2.1-13`**带构建尾号**,普通 release 不加 `--prerelease`
- ⚠️ **数字标识与频道**数字 prerelease 在 `allowPrerelease=true` 的通道逻辑里会被当作"自定义频道"
`beta`/`alpha` 之外)。但**正式版用户(woker)`allowPrerelease=false`**,走 `/releases/latest`
(按 GitHub 的 prerelease **标记**而非 semver)→ 只要 run 发布时不加 `--prerelease`
`1.2.1-13` 就是最新正式版,可正常识别与更新。**run 发布切勿加 `--prerelease`**。
- **兼容性**semver 数字 < 字母):`1.2.1-beta.13 > 1.2.1-12 > 1.2.1-2608271921 > 1.2.1`
所有旧数字版本(含时间 ID 与 Run Number 时代)都能平滑升级到新格式;beta 用户可被正式版(稳定)覆盖
- beta release 额外上传 `latest-beta.yml`GitHub provider 频道取件用,避免 404 回退)
所有旧数字版本(含时间 ID 与 Run Number 时代)都能平滑升级到新格式;beta 用户可被正式版覆盖
- beta release 额外上传 `latest-beta.yml`GitHub provider 频道取件兜底;实际先取 `beta.yml` 404 回退 `latest.yml`
- 构建元数据(commit / buildId)由 `scripts/gen-build-info.js` 写入 `src/lib/buildInfo.ts`
打包进渲染层,VersionCard / 关于页显示**构建来源 commit**(`scripts/version.js build ci` 负责统一设版本)
@@ -314,17 +315,25 @@ src/
- `## 更新了什么内容`:自上个 `v*` tag 以来的提交记录,每条默认折叠
`<details><summary>·Commit 1cf906d</summary>…</details>`
- 上传产物:setup.exe + latest.yml + release-notes.mdelectron-updater 更新清单)
- **Release 标题命名(仅展示名,tag/真实版本号不变)**:run → `{base}`(如 `1.2.1`),
beta → `BETA {base}`(如 `BETA 1.2.1`
- **Release 标题命名(仅展示名,tag/真实版本号不变)**:run → `{full}`(如 `1.2.1-13`),
beta → `BETA {full}`(如 `BETA 1.2.1-beta.13`,带 Run Num
**⚠️ 版本语义注意(electron-updater2026-08-30 已修复并落地)**
- **根因**GitHub provider 用 Atom feed + 频道逻辑选版本,频道只认 `alpha`/`beta` 字符串标识。
数字 prerelease`1.2.1-4`会被当作"自定义频道" → 永远选不出版本(`No published versions on GitHub`
`allowPrerelease` 由当前版本自动决定(含 prerelease 即开启)。
- **修复**beta 用 `{base}-beta.{buildId}`(频道 `beta`allowPrerelease 自动开启 → 正常识别);
run 用稳定版 `{base}`allowPrerelease=false → 走 releases/latest,正常识别)。
- 同格式升级:`1.2.1-beta.14 > 1.2.1-beta.13`(数值比较)✓;`1.2.1 > 1.2.1-beta.x`(稳定版覆盖 beta)✓
- **无需手动设置 allowPrerelease**electron-updater 按当前版本自动判定(见 AppUpdater.js:218)。
数字 prerelease`1.2.1-4``allowPrerelease=true` 的通道循环里会被当作"自定义频道"跳过
`allowPrerelease` 由当前版本自动决定(含 prerelease 即开启)。
- **修复**beta 用 `{base}-beta.{buildId}`(频道 `beta`,正常识别);run 用 `{base}-{buildId}`
且发布**不带** `--prerelease` → 正式版用户(`allowPrerelease=false`)走 `/releases/latest`
按 GitHub 标记取最新正式版,正常识别。
- 同格式升级:`1.2.1-beta.14 > 1.2.1-beta.13`(数值比较)✓;`1.2.1-14 > 1.2.1-13`
- **runner 通道显式设置 `autoUpdater.channel = "beta"`**:否则当前版本为数字尾号稳定版
(如 `1.2.1-13`)时,`prerelease[0]="13"` 会被当自定义频道 → 通道循环无匹配,
正式版切跑步模式检测不到 beta(已修复,见 updater.ts applyChannel);woker 恢复 `latest`
- **新旧判定覆盖 electron-updater 的纯 semver(项目版本序)**semver 认为同 base 下
`beta.N > N`(字母标识优先)→ 会把 `1.2.5-beta.16` 误判为 `1.2.5-17` 的新版本。
updater.ts `correctAvailability()` 在每次 check 后按**构建号优先**复核:
候选构建号(忽略 beta 前缀)不大于当前 → 回退 not-availabledownload() 同样复核。
语义:base 相同比构建号;beta/正式只是通道标记不参与新旧排序;无构建号视为构建 -1
- 所有旧数字版本(`1.2.1-12` / `1.2.1-2608271921`)都小于 `1.2.1-beta.13`,平滑升级,无需提升 base
## 14. M2 主进程更新模块(2026-08-28UI 待做)
@@ -379,6 +388,10 @@ idle → checking → available → downloading → downloaded → quitAndInstal
- **发布说明切换**:默认显示当前版本;检测到可用更新后自动切到最新版本(`getReleaseNotes(v{version})`),
退出重进回到当前版本
- **进度持久化**:每次状态/进度变化写入 `Koring.yml``update`
- **版本引导(upvp**`Koring.yml` 增加 `appVersion` 字段(启动时不自动刷新)。
程序版本 ≠ `appVersion` → 进入 `upvp` 引导:更新已完成 → 检查版本
(程序版本更大 →「已更新至 X」;更小 → 版本倒退警告)→ 测试版更新需同意
Beta 协议(复用 OOBE 协议页)→ 结束页写入 `appVersion = 程序版本` 后进主页
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
+25 -3
View File
@@ -2,14 +2,19 @@ import * as fs from 'fs';
import * as path from 'path';
import * as yaml from 'js-yaml';
import electron from 'electron';
import { createLogger } from './core/logger';
const { app } = electron;
const log = createLogger('config');
const CONFIG_FILE = 'Koring.yml';
const CURRENT_VERSION = 1;
/**
*
* - userData Program Files
* - userData
* NSIS / RMDir /r
* /KEEP_APP_DATA %APPDATA%userData
* - 便
*/
export function configPath(): string {
@@ -130,7 +135,10 @@ export interface UpdateConfig {
}
export interface AppConfig {
/** 配置结构版本(迁移用) */
version: number;
/** 当前应用版本号(app.getVersion(),每次启动刷新;配置文件自述用) */
appVersion: string;
oobe: boolean;
app: AppInfoConfig;
theme: ThemeConfig;
@@ -148,6 +156,7 @@ export interface AppConfig {
const DEFAULTS: AppConfig = {
version: CURRENT_VERSION,
appVersion: '',
oobe: true,
app: { language: 'zh-CN' },
theme: { darkMode: 'auto', parallax: true },
@@ -220,12 +229,20 @@ export function loadConfig(): AppConfig {
}
}
export function saveConfig(config: AppConfig): void {
/**
*
* - force=true使
* - config:reset / factoryReset
*/
export function saveConfig(config: AppConfig, force = false): void {
const filePath = configPath();
const sparse = diffValue(config, DEFAULTS) as Record<string, unknown> | undefined;
log.debug(`saveConfig → ${filePath} (force=${force}, 稀疏键=${sparse ? Object.keys(sparse).length : 0})`);
if (!sparse || Object.keys(sparse).length === 0) {
try { fs.unlinkSync(filePath); } catch {}
if (force) {
fs.writeFileSync(filePath, yaml.dump(config, { lineWidth: -1 }), 'utf-8');
}
return;
}
@@ -256,6 +273,8 @@ function mergeDeep<T>(base: T, patch: unknown): T {
export function getConfig(): AppConfig {
if (!current) {
current = loadConfig();
// 注意:不在此处刷新 appVersion —— 升级后保持旧版本号,
// 由渲染端 upvp(版本更新引导)流程完成后写入新版本号
}
return current;
}
@@ -277,6 +296,7 @@ export function flushConfig(): void {
saveTimer = null;
}
if (current) {
log.debug('flushConfig:内存配置落盘');
saveConfig(current);
}
}
@@ -285,6 +305,7 @@ export function flushConfig(): void {
export function updateConfig(patch: Record<string, unknown>): AppConfig {
const base = getConfig();
current = mergeDeep(base, patch) as AppConfig;
log.debug('config:update 补丁顶层键', Object.keys(patch ?? {}));
scheduleSave();
return current;
}
@@ -295,6 +316,7 @@ export function deleteConfigKey(key: string): AppConfig {
const next = { ...(base as unknown as Record<string, unknown>) };
delete next[key];
current = next as unknown as AppConfig;
log.debug(`config:delete 顶层键 ${key}`);
scheduleSave();
return current;
}
+281
View File
@@ -0,0 +1,281 @@
/**
*
*
*
* 1. /****
* ****使 BASE64 dataURL
* 2. / `koring-res://` 使
* 访 userData `background-custom*` 穿
*/
import electron from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import { createLogger } from './logger';
const { nativeImage } = electron;
const log = createLogger('background-image');
export interface OptimizedBackground {
/** 实际使用的文件路径(优化后文件;无需优化时为原始缓存文件) */
filePath: string;
bytes: number;
width: number;
height: number;
/** 是否发生了降采样/重编码 */
optimized: boolean;
}
const EXT_MIME: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.gif': 'image/gif',
'.bmp': 'image/bmp',
};
const MIME_TO_EXT: Record<string, string> = {
'image/png': '.png',
'image/jpeg': '.jpg',
'image/webp': '.webp',
'image/gif': '.gif',
'image/bmp': '.bmp',
};
/**
* BASE64 dataURL userData
* dataURL `background-custom-<唯一后缀><ext>`
*
*/
export function recoverBackgroundFromDataUrl(dataUrl: string, userDataDir: string): string | null {
const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=]+)$/.exec(dataUrl.trim());
if (!match) return null;
const ext = MIME_TO_EXT[match[1].toLowerCase()] ?? '.png';
try {
const buffer = Buffer.from(match[2], 'base64');
if (!buffer || buffer.length === 0) return null;
if (!fs.existsSync(userDataDir)) fs.mkdirSync(userDataDir, { recursive: true });
clearStaleBackgroundFiles(userDataDir, []);
const rawPath = path.join(userDataDir, `background-custom-${uniqueSuffix()}${ext}`);
fs.writeFileSync(rawPath, buffer);
return rawPath;
} catch {
return null;
}
}
/** 透明通道源格式:优化时用 PNG 无损编码,避免破坏透明背景 */
const TRANSPARENT_MIMES = new Set(['image/png', 'image/webp', 'image/gif']);
export function mimeForExt(ext: string): string {
return EXT_MIME[ext.toLowerCase()] || 'image/png';
}
export function mimeForFile(filePath: string): string {
return mimeForExt(path.extname(filePath));
}
function statSize(p: string): number {
try {
return fs.statSync(p).size;
} catch {
return 0;
}
}
/** 生成唯一后缀:每次导入的文件名都不同,保证渲染端 URL/配置路径变化以触发实时刷新与渐入动效 */
function uniqueSuffix(): string {
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
}
/** 删除 userData 目录里旧的壁纸缓存文件(保留 keep 中列出的完整路径) */
export function clearStaleBackgroundFiles(userDataDir: string, keep: string[]): void {
try {
const entries = fs.readdirSync(userDataDir);
const keepSet = new Set(keep.map((p) => path.basename(p)));
for (const name of entries) {
if (!name.startsWith('background-custom')) continue;
if (keepSet.has(name)) continue;
const full = path.join(userDataDir, name);
try {
fs.unlinkSync(full);
} catch {
// 忽略删除失败(其它文件占用等)
}
}
} catch {
// userData 目录不存在等
}
}
/**
* userData
* - maxEdge 100%
* - > maxEdge resize JPEG(q0.9) PNG(/)
* `background-custom-opt-<唯一后缀>.<ext>`便
* opt
* - GIF /
*/
export function optimizeBackgroundFile(rawFilePath: string, maxEdge = 4096): OptimizedBackground {
const fail = (filePath: string): OptimizedBackground => {
let width = 0;
let height = 0;
try {
const s = nativeImage.createFromPath(filePath).getSize();
width = s.width || 0;
height = s.height || 0;
} catch {
// 无法读取尺寸时按 0 处理
}
return {
filePath,
bytes: statSize(filePath),
width,
height,
optimized: false,
};
};
const outDir = path.dirname(rawFilePath);
const rawExt = path.extname(rawFilePath).toLowerCase();
const mime = mimeForExt(rawExt);
try {
// 动画 GIFnativeImage 只能解码首帧,不处理,保持原样
if (mime === 'image/gif') {
return fail(rawFilePath);
}
const image = nativeImage.createFromPath(rawFilePath);
if (image.isEmpty()) return fail(rawFilePath);
const size = image.getSize();
const longEdge = Math.max(size.width, size.height);
if (longEdge <= maxEdge || size.width <= 0 || size.height <= 0) {
// 无需优化:清掉历史遗留的旧 opt/其它扩展名缓存后原样返回
clearStaleBackgroundFiles(outDir, [rawFilePath]);
return {
filePath: rawFilePath,
bytes: statSize(rawFilePath),
width: size.width,
height: size.height,
optimized: false,
};
}
const scale = maxEdge / longEdge;
const w = Math.max(1, Math.round(size.width * scale));
const h = Math.max(1, Math.round(size.height * scale));
const output = image.resize({ width: w, height: h, quality: 'best' });
if (output.isEmpty()) return fail(rawFilePath);
const outSize = output.getSize();
const hasTransparency = TRANSPARENT_MIMES.has(mime);
const outExt = hasTransparency ? '.png' : '.jpg';
const outMime = hasTransparency ? 'image/png' : 'image/jpeg';
let buffer: Buffer;
if (hasTransparency) {
buffer = output.toPNG();
} else {
buffer = output.toJPEG(90);
}
if (!buffer || buffer.length === 0) return fail(rawFilePath);
// 先清理旧文件,再原子写入新文件(文件名带唯一后缀,保证每次导入路径都不同)
clearStaleBackgroundFiles(outDir, [rawFilePath]);
const optPath = path.join(outDir, `background-custom-opt-${uniqueSuffix()}${outExt}`);
const tmpPath = `${optPath}.${process.pid}.tmp`;
fs.writeFileSync(tmpPath, buffer);
try {
fs.renameSync(tmpPath, optPath);
} catch {
fs.unlinkSync(tmpPath);
fs.writeFileSync(optPath, buffer);
}
return {
filePath: optPath,
bytes: buffer.length,
width: outSize.width,
height: outSize.height,
optimized: true,
};
} catch {
return fail(rawFilePath);
}
}
/**
* userData background-custom-<唯一后缀><ext>
*
*
*/
export function importUserBackground(srcPath: string, maxEdge = 4096): OptimizedBackground | null {
try {
if (!fs.existsSync(srcPath) || !fs.statSync(srcPath).isFile()) return null;
const userDataDir = electron.app.getPath('userData');
if (!fs.existsSync(userDataDir)) fs.mkdirSync(userDataDir, { recursive: true });
const ext = path.extname(srcPath).toLowerCase() || '.png';
const rawPath = path.join(userDataDir, `background-custom-${uniqueSuffix()}${ext}`);
// 删除旧的原始缓存
clearStaleBackgroundFiles(userDataDir, []);
fs.copyFileSync(srcPath, rawPath);
const result = optimizeBackgroundFile(rawPath, maxEdge);
if (result) {
log.info(`导入壁纸完成 → ${path.basename(result.filePath)} (${result.width}x${result.height}, ${result.bytes}B, optimized=${result.optimized})`);
}
return result;
} catch (e) {
log.error('导入壁纸失败:', e);
return null;
}
}
/** 找到 userData 中最近的壁纸缓存原始文件(不含 opt 变体) */
export function findCachedBackgroundRaw(userDataDir: string): string | null {
try {
const entries = fs.readdirSync(userDataDir);
const files = entries
.filter((n) => n.startsWith('background-custom') && !n.includes('-opt'))
.map((n) => path.join(userDataDir, n))
.filter((p) => {
try {
return fs.statSync(p).isFile();
} catch {
return false;
}
});
if (files.length === 0) return null;
files.sort((a, b) => {
try {
return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs;
} catch {
return 0;
}
});
return files[0];
} catch {
return null;
}
}
/** 目标路径是否真实地位于 root 之内(realpath 后比较,防符号链接/目录穿越) */
export function isPathInside(root: string, target: string): boolean {
try {
const realRoot = fs.realpathSync(root);
const realTarget = fs.realpathSync(target);
const rel = path.relative(realRoot, realTarget);
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
} catch {
return false;
}
}
/** 是否为受管壁纸文件(供协议处理器白名单使用) */
export function isManagedBackgroundFile(fileName: string): boolean {
return fileName.startsWith('background-custom');
}
+119
View File
@@ -0,0 +1,119 @@
// 设备唯一标识(组合指纹 + 回退 MachineGuid
//
// 用途:为“设备识别码”提供尽量唯一且稳定的标识(授权/激活/统计)。
// 来源优先级(均为硬件级,重装系统不变):
// 1. board — 主板 UUIDWin32_ComputerSystemProduct.UUID
// 2. disk — 硬盘序列号(Win32_DiskDrive 首个物理盘)
// 3. bios — BIOS 序列号(Win32_BIOS;存在大量 OEM 默认值,故置后)
// 4. machine— 注册表 MachineGuid(系统安装级,作为最终回退)
// 取第一个有效值 → SHA-256 → 前 32 位十六进制 → UUID 样式分段展示。
import { execSync } from 'child_process';
import { createHash } from 'crypto';
export type DeviceIdSource = 'board' | 'disk' | 'bios' | 'machine' | 'none';
export interface DeviceIdentity {
deviceId: string | null;
source: DeviceIdSource;
}
/** OEM/虚拟机常见的占位序列号,视为无效 */
const INVALID_VALUES = [
'to be filled',
'o.e.m',
'default string',
'system serial number',
'not specified',
'not available',
'none',
'n/a',
'unknown',
'unspecified',
'innotek',
'bochs',
'vmware',
];
function isValid(value?: string | null): value is string {
if (!value) return false;
const v = value.trim();
if (!v) return false;
if (/^0+$/.test(v)) return false; // 全 0
if (/^0{8}-0{4}-0{4}-0{4}-0{12}$/i.test(v)) return false; // 00000000-... 全空 UUID
const low = v.toLowerCase();
return !INVALID_VALUES.some((bad) => low.includes(bad));
}
/** 读取四类候选标识(Windows 一条 PowerShell 完成,逐项容错) */
function probeSources(): { board: string; disk: string; bios: string; machine: string } {
const empty = { board: '', disk: '', bios: '', machine: '' };
if (process.platform !== 'win32') return empty;
const ps = [
'-NoProfile -Command',
"$ErrorActionPreference='SilentlyContinue';",
'$b=(Get-CimInstance Win32_ComputerSystemProduct).UUID;',
'$d=(Get-CimInstance Win32_DiskDrive|Select-Object -First 1).SerialNumber;',
'$i=(Get-CimInstance Win32_BIOS).SerialNumber;',
"$g=(Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Cryptography').MachineGuid;",
'[pscustomobject]@{board=[string]$b;disk=[string]$d;bios=[string]$i;machine=[string]$g}|ConvertTo-Json -Compress',
].join(' ');
try {
const output = execSync(`powershell ${ps}`, { encoding: 'utf-8', timeout: 8000, windowsHide: true });
const parsed = JSON.parse(output.trim() || '{}');
return {
board: String(parsed.board ?? ''),
disk: String(parsed.disk ?? ''),
bios: String(parsed.bios ?? ''),
machine: String(parsed.machine ?? ''),
};
} catch {
return empty;
}
}
/** 32 位十六进制 → UUID 样式(8-4-4-4-12,小写) */
function toUuidLike(hex32: string): string {
const h = hex32.toLowerCase();
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
}
function compute(seed: string): string {
const hex = createHash('sha256').update(`koring-device:${seed}`, 'utf-8').digest('hex');
return toUuidLike(hex.slice(0, 32));
}
let cached: DeviceIdentity | null = null;
/** 获取设备唯一标识(进程内缓存,只探测一次) */
export function getDeviceId(): DeviceIdentity {
if (cached) return cached;
const { board, disk, bios, machine } = probeSources();
let seed: string | undefined;
let source: DeviceIdSource = 'none';
if (isValid(board)) {
seed = board;
source = 'board';
} else if (isValid(disk)) {
seed = disk;
source = 'disk';
} else if (isValid(bios)) {
seed = bios;
source = 'bios';
} else if (isValid(machine)) {
seed = machine;
source = 'machine';
}
cached = seed
? { deviceId: compute(seed), source }
: { deviceId: null, source: 'none' };
return cached;
}
/** 调试用:清空缓存(下次调用重新探测) */
export function resetDeviceIdCache(): void {
cached = null;
}
+6 -3
View File
@@ -1,5 +1,8 @@
import * as https from 'https';
import { readAuth, writeAuth } from '../auth';
import { createLogger } from './logger';
const log = createLogger('core/koring-auth');
const CLIENT_ID = '547qe8ky1pr69f08b71kj';
const DEVICE_AUTH_URL = 'https://oac.lingke.ink/oidc/device/auth';
@@ -36,8 +39,8 @@ function postForm(url: string, data: Record<string, string>): Promise<any> {
const params = new URLSearchParams(data);
const body = params.toString().replace(/\+/g, '%20');
const urlObj = new URL(url);
console.log(`[koring-auth] POST ${url}`);
console.log(`[koring-auth] body: ${body}`);
log.info(`[koring-auth] POST ${url}`);
log.info(`[koring-auth] body: ${body}`);
const req = https.request(
{
hostname: urlObj.hostname,
@@ -53,7 +56,7 @@ function postForm(url: string, data: Record<string, string>): Promise<any> {
let raw = '';
res.on('data', (chunk) => (raw += chunk));
res.on('end', () => {
console.log(`[koring-auth] response (${res.statusCode}): ${raw}`);
log.info(`[koring-auth] response (${res.statusCode}): ${raw}`);
try {
resolve(JSON.parse(raw));
} catch {
+212
View File
@@ -0,0 +1,212 @@
/**
*
*
*
* - debugwarn/error/info debug
* - config.advanced.debugMode
* debug userData/koring.log 5MB .old
* - /stdout/stderr
* - `log:write`ipcRenderer.send/
*/
import electron from 'electron';
import * as fs from 'fs';
import * as path from 'path';
const { ipcMain, app } = electron;
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
export interface Logger {
debug: (msg: string, ...args: unknown[]) => void;
info: (msg: string, ...args: unknown[]) => void;
warn: (msg: string, ...args: unknown[]) => void;
error: (msg: string, ...args: unknown[]) => void;
}
const MAX_LOG_BYTES = 5 * 1024 * 1024;
/** 开发(未打包)运行:直接跑在终端里,日志写 stdout/stderr 用户即可实时看到 */
const isDevRun = !app.isPackaged;
let debugModeProvider: () => boolean = () => false;
export function setDebugModeProvider(fn: () => boolean): void {
debugModeProvider = fn;
}
export function isDebugMode(): boolean {
try {
return debugModeProvider();
} catch {
return false;
}
}
let logStream: fs.WriteStream | null = null;
let cachedFilePath: string | null = null;
export function getLogFilePath(): string | null {
if (!isDebugMode()) return null;
if (!cachedFilePath) cachedFilePath = path.join(app.getPath('userData'), 'koring.log');
return cachedFilePath;
}
function openLogStream(): void {
if (logStream) return;
const filePath = getLogFilePath();
if (!filePath) return;
try {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
if (fs.existsSync(filePath) && fs.statSync(filePath).size > MAX_LOG_BYTES) {
try {
fs.renameSync(filePath, `${filePath}.old`);
} catch {
// 轮转失败不阻塞
}
}
} catch {
// 目录不可用等
}
logStream = fs.createWriteStream(filePath, { flags: 'a', encoding: 'utf8' });
logStream.on('error', () => {
logStream = null;
});
}
function pad(n: number): string {
return String(n).padStart(2, '0');
}
function timestamp(): string {
const d = new Date();
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${String(d.getMilliseconds()).padStart(3, '0')}`;
}
function truncateString(value: string, max: number): string {
if (value.length <= max) return value;
return `${value.slice(0, max)}…(+${value.length - max}字符)`;
}
function summarizeArg(value: unknown): unknown {
if (typeof value === 'string') return truncateString(value, 300);
if (value instanceof Error) return truncateString(value.message, 300);
if (typeof value === 'object' && value !== null) {
try {
const json = JSON.stringify(value, (_key, v) => {
if (typeof v === 'string') return truncateString(v, 160);
if (v instanceof Error) return truncateString(v.message, 160);
if (Array.isArray(v) && v.length > 20) return `[Array(${v.length})]`;
return v;
});
return truncateString(json ?? String(value), 400);
} catch {
return truncateString(String(value), 300);
}
}
return value;
}
function write(scope: string, level: LogLevel, args: unknown[]): void {
const msg = args.map((a) => {
const s = summarizeArg(a);
return typeof s === 'string' ? s : String(s);
}).join(' ');
const line = `[${timestamp()}][${scope}][${level.toUpperCase()}] ${msg}`;
const enabled = isDebugMode();
// debug 仅在调试模式可见;info/warn/error 始终输出
const show = level !== 'debug' || enabled;
if (show) {
if (isDevRun) {
// dev(未打包,pnpm dev / electron .):直接写进程 stdout/stderr
// 让详细日志实时出现在启动它的终端里(不依赖外部控制台捕获)。
const text = `${line}\n`;
if (level === 'error' || level === 'warn') process.stderr.write(text);
else process.stdout.write(text);
} else if (level === 'error') console.error(line);
else if (level === 'warn') console.warn(line);
else if (level === 'info') console.info(line);
else console.debug(line);
}
if (!enabled) return;
openLogStream();
if (logStream) {
logStream.write(`${line}\n`);
}
}
function makeLogger(scope: string): Logger {
const bound = (level: LogLevel) => (msg: string, ...args: unknown[]) => write(scope, level, [msg, ...args]);
return {
debug: bound('debug'),
info: bound('info'),
warn: bound('warn'),
error: bound('error'),
};
}
export function createLogger(scope: string): Logger {
return makeLogger(scope);
}
// ---------------- IPC 日志(全局包装 ipcMain.handle ----------------
function isResultLike(value: unknown): value is { success?: boolean; error?: unknown } {
return typeof value === 'object' && value !== null && 'success' in value;
}
function summarize(payload: unknown[]): unknown[] {
return payload.map(summarizeArg);
}
/**
* ipcMain.handle channel
* handler main.ts
*/
export function installIpcLogging(): void {
const rawHandle = ipcMain.handle.bind(ipcMain);
const log = makeLogger('ipc');
(ipcMain as unknown as { handle: typeof ipcMain.handle }).handle = (
channel: string,
listener: (event: Electron.IpcMainInvokeEvent, ...args: unknown[]) => unknown,
) => {
const wrapped = async (event: Electron.IpcMainInvokeEvent, ...args: unknown[]): Promise<unknown> => {
log.debug(`${channel}`, summarize(args));
const started = Date.now();
try {
const result = await listener(event, ...args);
const ms = Date.now() - started;
const failed = isResultLike(result) && result.success === false;
if (failed) {
log.error(`${channel} (${ms}ms)`, isResultLike(result) ? (result.error ?? 'unknown error') : 'failed');
} else {
log.debug(`${channel} ok (${ms}ms)`, summarize([result]));
}
return result;
} catch (err) {
const ms = Date.now() - started;
log.error(`! ${channel} 异常 (${ms}ms)`, err);
throw err;
}
};
return rawHandle(channel, wrapped as never);
};
}
/** 渲染进程日志桥:ipcRenderer.send('log:write', { level, scope, message }) 汇入统一日志 */
export function registerRendererLogBridge(): void {
ipcMain.on('log:write', (_event, payload: unknown) => {
try {
const p = payload as { level?: string; scope?: string; message?: string } | null;
if (!p || typeof p.message !== 'string') return;
const level = (p.level === 'debug' || p.level === 'info' || p.level === 'warn' || p.level === 'error') ? p.level : 'info';
const scope = typeof p.scope === 'string' && p.scope ? p.scope : 'renderer';
write(`renderer/${scope}`, level, [p.message]);
} catch {
// 日志桥异常不抛给渲染进程
}
});
ipcMain.handle('log:getInfo', () => ({
filePath: isDebugMode() ? getLogFilePath() : null,
debugMode: isDebugMode(),
}));
}
+19 -6
View File
@@ -1,17 +1,30 @@
// 路径归一化:相对 gameDir(默认 `.minecraft`)在打包后依赖进程 cwd,不可靠。
// 统一按与 runStartupChecks 一致的基准解析(打包 → exe 目录;开发 → 项目根)。
// 统一按与 runStartupChecks 一致的基准解析(见 dataBasePath)。
import * as path from 'path';
import electron from 'electron';
const { app } = electron;
function baseDataPath(): string {
if (app.isPackaged) {
return path.dirname(app.getPath('exe'));
/**
* `.minecraft`
* -
* - Linux / AppImage userDataexe /tmp/.mount_*
* - Windows exe 沿
*/
export function dataBasePath(): string {
if (!app.isPackaged) {
return path.join(__dirname, '..', '..');
}
return path.join(__dirname, '..', '..');
if (process.platform === 'linux') {
return app.getPath('userData');
}
return path.dirname(app.getPath('exe'));
}
/** 相对路径 → 绝对(基准 = exe 目录/项目根);绝对路径原样返回 */
function baseDataPath(): string {
return dataBasePath();
}
/** 相对路径 → 绝对(基准 = dataBasePath);绝对路径原样返回 */
export function resolveGamePath(gamePath: string): string {
if (!gamePath || path.isAbsolute(gamePath)) {
return gamePath;
+44
View File
@@ -2,6 +2,8 @@ import electron from 'electron';
import fs from 'fs';
import path from 'path';
import { loadConfig, saveConfig } from '../config';
import { importUserBackground, isManagedBackgroundFile, isPathInside } from '../core/background-image';
import { backgroundResourceUrl } from '../resource-protocol';
const { ipcMain } = electron;
@@ -117,4 +119,46 @@ export function registerBackgroundHandlers() {
return null;
}
});
// 导入自选壁纸:复制到 userData → 按屏幕所需尺寸降采样落盘,
// 返回【文件路径】(配置文件以路径存储,不再使用 BASE64 dataURL
ipcMain.handle('background:import', async (_event, payload: { srcPath: string; maxEdge?: number }) => {
try {
const result = importUserBackground(payload.srcPath, payload.maxEdge || 4096);
return { success: true, data: result, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
// 把配置/Store 中的「壁纸值」解析为可显示的资源引用:
// - data:/http(s):/file: 及 /、./ 相对 URL → 原样返回(本身可直接用于 CSS);
// - userData 内的受管壁纸文件路径 → koring-res:// 协议 URL(流式读取,全程无 base64 副本);
// - 其它(越权路径/不存在等)→ data:null,调用方回退默认背景。
ipcMain.handle('background:resolve', async (_event, payload: { value?: string }) => {
try {
const value = typeof payload?.value === 'string' ? payload.value.trim() : '';
if (!value) return { success: true, data: null, error: null };
if (/^(data:|https?:|file:|\.\.\/|\/|\.\/)/i.test(value)) {
return { success: true, data: { url: value, bytes: 0 }, error: null };
}
const userDataDir = electron.app.getPath('userData');
if (!path.isAbsolute(value) || !isPathInside(userDataDir, value)) {
return { success: true, data: null, error: null };
}
const fileName = path.basename(value);
if (!isManagedBackgroundFile(fileName)) {
return { success: true, data: null, error: null };
}
const stat = fs.statSync(value);
if (!stat.isFile()) return { success: true, data: null, error: null };
return {
success: true,
data: { url: backgroundResourceUrl(fileName), bytes: stat.size },
error: null,
};
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
}
+15 -2
View File
@@ -26,13 +26,26 @@ export function registerConfigHandlers(win: WinRef) {
}
});
// 广播防抖:输入框/滑块每键触发 update 时,不立刻全树广播(避免整页重渲染打断交互),
// 合并到 250ms 后只广播一次最新配置;渲染端乐观更新保证即时反馈。
let broadcastTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleBroadcast = () => {
if (broadcastTimer) clearTimeout(broadcastTimer);
broadcastTimer = setTimeout(() => {
broadcastTimer = null;
if (win.mainWindow && !win.mainWindow.isDestroyed()) {
win.mainWindow.webContents.send('config:changed', getConfig());
}
}, 250);
};
// 主进程权威更新:渲染进程提交 { section, patch } 补丁,
// 主进程深度合并到内存配置 → debounce 稀疏写盘 → 广播完整配置给所有渲染进程
// 主进程深度合并到内存配置 → debounce 稀疏写盘 → 防抖广播完整配置给所有渲染进程
ipcMain.handle('config:update', (_event, payload: { section: string; patch: unknown }) => {
try {
const { section, patch } = payload;
const config = updateConfig({ [section]: patch } as Record<string, unknown>);
win.mainWindow?.webContents.send('config:changed', config);
scheduleBroadcast();
return { success: true, data: config, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
+11 -8
View File
@@ -4,9 +4,12 @@ import fs from 'fs';
import { writeCrashLog, readCrashLog, clearCrashLog, type CrashEntry } from '../core/crash-logger';
import { configPath } from '../config';
import { authPath } from '../auth';
import { createLogger } from '../core/logger';
const { app, ipcMain, BrowserWindow } = electron;
const log = createLogger('crash-monitor');
const isDev = !app.isPackaged;
let crashWin: electron.BrowserWindow | null = null;
@@ -99,14 +102,14 @@ export function setupCrashListeners(mainWindow: electron.BrowserWindow) {
if (window.__crashToolsLoaded) return;
window.__crashToolsLoaded = true;
console.log('%c[崩溃工具] 已加载', 'color: #f59e0b; font-weight: bold; font-size: 14px;');
console.log('%c可用命令:', 'color: #3b82f6; font-weight: bold;');
console.log('%c crash.simulate() %c— 模拟渲染进程崩溃', 'color: #ef4444; font-weight: bold;', 'color: inherit;');
console.log('%c crash.testDialog() %c— 测试崩溃弹窗', 'color: #ef4444; font-weight: bold;', 'color: inherit;');
console.log('%c crash.readLog() %c— 读取崩溃日志', 'color: #ef4444; font-weight: bold;', 'color: inherit;');
console.log('%c crash.factoryReset()%c— 强还原配置', 'color: #ef4444; font-weight: bold;', 'color: inherit;');
console.log('%c crash.restart() %c— 重启应用', 'color: #ef4444; font-weight: bold;', 'color: inherit;');
console.log('');
log.info('%c[崩溃工具] 已加载', 'color: #f59e0b; font-weight: bold; font-size: 14px;');
log.info('%c可用命令:', 'color: #3b82f6; font-weight: bold;');
log.info('%c crash.simulate() %c— 模拟渲染进程崩溃', 'color: #ef4444; font-weight: bold;', 'color: inherit;');
log.info('%c crash.testDialog() %c— 测试崩溃弹窗', 'color: #ef4444; font-weight: bold;', 'color: inherit;');
log.info('%c crash.readLog() %c— 读取崩溃日志', 'color: #ef4444; font-weight: bold;', 'color: inherit;');
log.info('%c crash.factoryReset()%c— 强还原配置', 'color: #ef4444; font-weight: bold;', 'color: inherit;');
log.info('%c crash.restart() %c— 重启应用', 'color: #ef4444; font-weight: bold;', 'color: inherit;');
log.info('');
window.crash = {
simulate: function() { window.electronAPI?.simulateCrash(); },
+4 -1
View File
@@ -9,6 +9,9 @@ import {
deleteKoringAuth,
} from '../core/koring-auth';
import { getConfig, updateConfig, deleteConfigKey } from '../config';
import { createLogger } from '../core/logger';
const log = createLogger('koring-auth');
export function registerKoringAuthHandlers() {
ipcMain.handle('koring-auth:request-device-code', async () => {
@@ -39,7 +42,7 @@ export function registerKoringAuthHandlers() {
},
});
} catch (e) {
console.error('[koring-auth] failed to save user to config:', e);
log.error('[koring-auth] failed to save user to config:', e);
}
return { success: true, data: { user }, error: null };
+52
View File
@@ -1,9 +1,17 @@
import electron from 'electron';
import { execSync } from 'child_process';
import * as os from 'os';
import { getDeviceId } from '../core/device-id';
const { ipcMain, app, shell } = electron;
interface ProcessMemorySample {
type: string;
pid: number;
workingSetSize: number; // KB
peakWorkingSetSize: number; // KB
}
function getBiosId(): string {
if (process.platform !== 'win32') return 'N/A (non-Windows)';
try {
@@ -61,6 +69,50 @@ export function registerSystemHandlers() {
}
});
// 设备唯一标识(组合指纹 + 回退 MachineGuid;进程内缓存,首次调用探测一次)
ipcMain.handle('system:deviceId', () => {
try {
const identity = getDeviceId();
return { success: true, data: identity, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
// 进程内存快照(资源/内存调试面板使用;纯读取,无副作用)
ipcMain.handle('system:memory', async () => {
try {
const metrics: ProcessMemorySample[] = app.getAppMetrics().map((m) => ({
type: String(m.type),
pid: m.pid,
workingSetSize: m.memory?.workingSetSize ?? 0,
peakWorkingSetSize: m.memory?.peakWorkingSetSize ?? 0,
}));
let mainProcess: { workingSetSize: number; privateBytes: number } | null = null;
try {
const info = await process.getProcessMemoryInfo();
mainProcess = {
workingSetSize: info.residentSet ?? 0,
privateBytes: info.private ?? 0,
};
} catch {
// 个别平台不支持 getProcessMemoryInfo,忽略
}
return {
success: true,
data: {
app_version: app.getVersion(),
timestamp: Date.now(),
metrics,
mainProcess,
},
error: null,
};
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
// 在系统文件管理器中打开指定路径(用于"打开游戏目录"等操作)
ipcMain.handle('system:open-path', async (_event, payload: { path: string }) => {
try {
+3 -2
View File
@@ -62,9 +62,10 @@ export function registerUpdateHandlers() {
}
});
ipcMain.handle('update:quitAndInstall', () => {
ipcMain.handle('update:quitAndInstall', async () => {
try {
updateService.quitAndInstall();
// 未核验通过时内部会先弹确认框(继续安装 / 取消并删除安装包)
await updateService.quitAndInstall();
return { success: true, data: null, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
+96 -28
View File
@@ -17,48 +17,65 @@ 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';
import { findCachedBackgroundRaw, optimizeBackgroundFile, recoverBackgroundFromDataUrl } from './core/background-image';
import { registerResourceSchemePrivileges, registerResourceProtocol } from './resource-protocol';
import { createLogger, setDebugModeProvider, installIpcLogging, registerRendererLogBridge } from './core/logger';
import { dataBasePath } from './core/paths';
const { app } = electron;
const isDev = !app.isPackaged;
// 统一日志:debug 模式(config.advanced.debugMode)→ 控制台 + userData/koring.log
// dev(未打包)运行下直接写进程 stdout/stderr,日志实时输出到启动它的终端;否则仅控制台
const log = createLogger('main');
setDebugModeProvider(() => {
try {
return getConfig()?.advanced?.debugMode === true;
} catch {
return false;
}
});
// 全局包装 ipcMain.handle:所有 IPC 流程自动带 channel/耗时/成败日志(须在 handler 注册前调用)
installIpcLogging();
// GPU acceleration flags
app.commandLine.appendSwitch('enable-gpu-rasterization');
app.commandLine.appendSwitch('enable-zero-copy');
// 自定义资源协议(koring-res://)特权注册:必须在 app ready 之前完成
registerResourceSchemePrivileges();
const win: { mainWindow: electron.BrowserWindow | null; splashWindow: electron.BrowserWindow | null } = {
mainWindow: null,
splashWindow: null,
};
// 迁移旧版「可执行文件旁」存储 → userData(仅打包模式)。
// 首次启动标记(由 runStartupChecks 决定);窗口每次加载/刷新时随 config:preload 一起推送
let isFirstLaunchFlag = false;
// 迁移旧版「安装目录」存储 → userData(仅打包模式)。配置必须存 userData 才能跨重装/更新
// 存活:NSIS 每次重装/升级会经旧卸载器删除整个安装目录(RMDir /r),/KEEP_APP_DATA 只保护 %APPDATA%。
// 复制而非移动,避免破坏用户已有文件;userData 已有目标文件则跳过。
function migrateLegacyFiles(): void {
if (!app.isPackaged) return;
const exeDir = path.dirname(app.getPath('exe'));
const pairs: { name: string; dest: string }[] = [
{ name: 'Koring.yml', dest: configPath() },
{ name: 'koring-auth.json', dest: authPath() },
];
for (const { name, dest } of pairs) {
if (fs.existsSync(dest)) continue;
const src = path.join(exeDir, name);
if (!fs.existsSync(src)) continue;
try {
fs.copyFileSync(src, dest);
console.log(`[migrate] copied ${name}${dest}`);
} catch (e) {
console.error(`[migrate] failed to copy ${name}:`, e);
}
const src = path.join(exeDir, 'Koring.yml');
if (!fs.existsSync(src)) return;
const dest = path.join(app.getPath('userData'), 'Koring.yml');
if (fs.existsSync(dest)) return;
try {
fs.copyFileSync(src, dest);
log.info(`[migrate] 已复制 Koring.yml ${src}${dest}`);
} catch (e) {
log.error('[migrate] 复制 Koring.yml 失败:', e);
}
}
// Startup checks: .minecraft dir + config file + first launch detection
function runStartupChecks(): { isFirstLaunch: boolean; config: ReturnType<typeof getConfig> } {
const dataPath = app.isPackaged
? path.dirname(app.getPath('exe'))
: path.join(__dirname, '..');
// 数据基准:开发→项目根;Windows 打包→exe 目录;Linux/AppImage→userDataexe 目录是只读挂载)
const dataPath = dataBasePath();
// 1. Ensure .minecraft directory exists
const minecraftDir = path.join(dataPath, '.minecraft');
@@ -73,12 +90,35 @@ function runStartupChecks(): { isFirstLaunch: boolean; config: ReturnType<typeof
// 3. Load (or create) configgetConfig 会缓存到主进程内存,成为唯一权威)
const config = getConfig();
if (isFirstLaunch) {
saveConfig(config);
// 首次启动:全量写入,确保配置文件在安装目录可见(稀疏保存下全默认不落盘)
saveConfig(config, true);
}
return { isFirstLaunch, config };
}
// 迁移旧版「配置里存 BASE64 dataURL」→ 「存文件路径」:
// 优先使用 userData 里已有的原始缓存;没有缓存时直接把 dataURL 解码落盘,
// 保证任意旧配置都能改写成文件路径(显示内容与原 dataURL 完全一致)。
function migrateBackgroundDataUrlToPath(config: ReturnType<typeof getConfig>): void {
const bg = config?.background;
if (!bg || bg.bgType !== 'image' || typeof bg.image !== 'string' || !bg.image.startsWith('data:')) {
return;
}
try {
const userDataDir = app.getPath('userData');
const raw = findCachedBackgroundRaw(userDataDir) ?? recoverBackgroundFromDataUrl(bg.image, userDataDir);
if (!raw) return;
const result = optimizeBackgroundFile(raw, 4096);
if (!result || !result.filePath) return;
bg.image = result.filePath;
saveConfig(config);
log.info(`[background] 迁移:dataURL → 文件路径 ${result.filePath}`);
} catch (e) {
log.error('[background] 背景配置迁移失败:', e);
}
}
// 根据调试/运行状态自动选择窗口图标:
// - 开发调试(未打包):直接使用 public/icons/dev/ 下的 dev 图标
// - 打包运行:electron-builder 构建时 switch-icon 已把对应模式图标复制到 build/
@@ -144,6 +184,21 @@ function createMainWindow(): electron.BrowserWindow {
main.loadFile(path.join(__dirname, '../dist/index.html'));
}
// 每次页面加载完成(含 Ctrl+R / F5 刷新、HMR 全量重载)都推送【最新权威配置】给渲染端:
// - 用 getConfig()(主进程内存权威)而非启动时的快照,刷新后拿到的是当前值而不是过期快照
// - 监听器挂在具体窗口实例上,后续重建的窗口(如 macOS activate)也能收到
main.webContents.on('did-finish-load', () => {
if (main.isDestroyed()) return;
main.webContents.send('config:preload', { config: getConfig(), isFirstLaunch: isFirstLaunchFlag });
// Linux 打包但并非以 AppImage 方式运行(无 APPIMAGE 环境变量)→ 提示影响更新组件
if (app.isPackaged && process.platform === 'linux' && !process.env.APPIMAGE) {
main.webContents.send('runtime:notice', {
kind: 'linux-appimage-unpacked',
message: '您并未解包安装,这可能会影响更新组件的运行',
});
}
});
main.on('maximize', () => {
main.webContents.send('window:resized');
});
@@ -173,26 +228,34 @@ function registerAllHandlers() {
}
app.whenReady().then(() => {
log.info('应用就绪,开始初始化主进程');
registerAllHandlers();
registerResourceProtocol();
registerRendererLogBridge();
log.info('IPC handlers / 资源协议 / 渲染日志桥注册完成');
// Migrate legacy exe-dir config/auth to userData before anything reads them
// Migrate legacy install-dir config back to userData before anything reads them
migrateLegacyFiles();
// Run startup checks before creating windows
const { isFirstLaunch, config } = runStartupChecks();
isFirstLaunchFlag = isFirstLaunch;
log.info(`启动检查完成 isFirstLaunch=${isFirstLaunch} 配置路径=${configPath()}`);
// 旧配置中 background.image 若是 BASE64 dataURL → 落盘优化并改写为文件路径
migrateBackgroundDataUrlToPath(config);
// 1. Show splash immediately
win.splashWindow = createSplashWindow();
log.info('Splash 窗口已创建');
// 2. Create main window in background
// config:preload 推送已内置于 createMainWindow 的 did-finish-load 监听,
// 每次加载/刷新都推送 getConfig() 的最新内存配置)
win.mainWindow = createMainWindow();
log.info('主窗口已创建(后台加载)');
// 3. Preload config into renderer before it renders
win.mainWindow.webContents.on('did-finish-load', () => {
win.mainWindow?.webContents.send('config:preload', { config, isFirstLaunch });
});
// 4. When main window finishes loading, wait a minimum time then transition
// 3. When main window finishes loading, wait a minimum time then transition
let mainReady = false;
let splashMinTimeDone = false;
@@ -201,10 +264,12 @@ app.whenReady().then(() => {
if (win.mainWindow && !win.mainWindow.isDestroyed()) {
win.mainWindow.show();
win.mainWindow.focus();
log.info('主窗口已显示,切换完成');
}
if (win.splashWindow && !win.splashWindow.isDestroyed()) {
win.splashWindow.close();
win.splashWindow = null;
log.info('Splash 窗口已关闭');
}
}
};
@@ -225,19 +290,22 @@ app.whenReady().then(() => {
// 延迟静默检查更新(避开启动加载,不抢带宽;开发模式在 updater.init 内自动跳过)
setTimeout(() => {
log.info('触发启动静默更新检查');
updateService.check(false).catch((e) => {
console.error('[updater] 启动静默检查失败:', e);
log.error('[updater] 启动静默检查失败:', e);
});
}, 12000);
});
app.on('window-all-closed', () => {
log.info('所有窗口已关闭,flush 配置并退出');
flushConfig();
app.quit();
});
app.on('activate', () => {
if (electron.BrowserWindow.getAllWindows().length === 0) {
log.info('activate:重建主窗口');
win.mainWindow = createMainWindow();
}
});
+43 -31
View File
@@ -1,26 +1,13 @@
import electron from 'electron';
import path from 'path';
import fs from 'fs';
const { contextBridge, ipcRenderer } = electron;
const MIME_MAP: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.gif': 'image/gif',
'.bmp': 'image/bmp',
};
function getFileAsDataUrl(filePath: string): string | null {
try {
const buffer = fs.readFileSync(filePath);
const ext = path.extname(filePath).toLowerCase();
const mime = MIME_MAP[ext] || 'image/png';
return `data:${mime};base64,${buffer.toString('base64')}`;
} catch {
return null;
}
// 按当前窗口实际像素需求计算壁纸长边上限(含高分屏余量),
// 主进程据此把大图压到「屏幕可见」尺寸后落盘(配置只存文件路径,不存 BASE64)。
function computeMaxEdge(): number {
const dpr = window.devicePixelRatio || 1;
const css = Math.max(window.innerWidth || 1280, window.innerHeight || 800);
const target = Math.round(Math.max(css, 1920) * dpr * 1.1);
return Math.min(4096, Math.max(1920, target));
}
contextBridge.exposeInMainWorld('electronAPI', {
@@ -66,24 +53,49 @@ contextBridge.exposeInMainWorld('electronAPI', {
return () => ipcRenderer.removeListener('config:changed', handler);
},
// Background image — pick file, copy to userData, return base64 data URL
// 主进程运行时提示(如 Linux 未以 AppImage 方式运行,影响更新组件)
onRuntimeNotice: (callback: (notice: { kind: string; message: string }) => void) => {
const handler = (_event: Electron.IpcRendererEvent, notice: { kind: string; message: string }) => callback(notice);
ipcRenderer.on('runtime:notice', handler);
return () => ipcRenderer.removeListener('runtime:notice', handler);
},
// 背景图 — 选择本地图片:主进程复制到 userData、按窗口尺寸优化并落盘,
// 返回【文件路径】(配置/Store 以路径保存,不使用 BASE64)。
pickBackgroundImage: async (): Promise<string | null> => {
const result = await ipcRenderer.invoke('dialog:openFile', {
filters: [{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'] }],
});
if (!result) return null;
const { srcPath, ext } = result as { srcPath: string; ext: string };
// Copy to userData via main process
const destPath = await ipcRenderer.invoke('background:copyToUserData', srcPath, ext);
if (!destPath) return null;
return getFileAsDataUrl(destPath);
const { srcPath } = result as { srcPath: string };
const imported = await ipcRenderer.invoke('background:import', {
srcPath,
maxEdge: computeMaxEdge(),
});
if (imported && imported.success && typeof imported.data?.filePath === 'string' && imported.data.filePath) {
return imported.data.filePath;
}
return null;
},
// Get cached background as base64 data URL
getBackgroundDataUrl: async (): Promise<string | null> => {
const filePath = await ipcRenderer.invoke('background:getCachedPath');
if (!filePath) return null;
return getFileAsDataUrl(filePath);
// 把配置文件/Store 中的壁纸值解析为可显示的资源引用:
// data:/http(s):/file: 及 /、./ 相对 URL → 原样返回;
// userData 内受管壁纸文件路径 → koring-res:// 协议 URL(流式读取,无 base64 副本)。
resolveBackgroundResource: async (value: string): Promise<{ url: string | null; bytes: number }> => {
try {
const result = await ipcRenderer.invoke('background:resolve', { value });
if (result && result.success && typeof result.data?.url === 'string' && result.data.url) {
return { url: result.data.url, bytes: typeof result.data.bytes === 'number' ? result.data.bytes : 0 };
}
} catch {
// fallthrough
}
return { url: null, bytes: 0 };
},
// 日志:渲染端经 IPC 汇入主进程统一日志(debug 模式写文件;否则仅控制台)
log: (level: 'debug' | 'info' | 'warn' | 'error', scope: string, message: string) => {
ipcRenderer.send('log:write', { level, scope, message });
},
// Open external URL in system browser
+104
View File
@@ -0,0 +1,104 @@
/**
* `koring-res://` 使
* BASE64 dataURL /
*
* /
* - host `userdata` app.getPath('userData')
* - `background-custom`
* - realpath userData 穿 /
* - stream base64
*/
import electron from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import { Readable } from 'stream';
import { isManagedBackgroundFile, isPathInside, mimeForFile } from './core/background-image';
import { createLogger } from './core/logger';
const { protocol, app } = electron;
const log = createLogger('resource-protocol');
export const RESOURCE_SCHEME = 'koring-res';
/** 必须在 app ready 之前调用(privileged scheme 注册) */
export function registerResourceSchemePrivileges(): void {
protocol.registerSchemesAsPrivileged([
{
scheme: RESOURCE_SCHEME,
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
corsEnabled: true,
stream: true,
},
},
]);
}
/** app ready 之后调用:注册协议处理器 */
export function registerResourceProtocol(): void {
protocol.handle(RESOURCE_SCHEME, async (request) => {
try {
const url = new URL(request.url);
if (url.host !== 'userdata') {
log.warn(`拒绝非 userdata 主机: ${url.host}`);
return new Response('Forbidden', { status: 403 });
}
let relative: string;
try {
relative = decodeURIComponent(url.pathname).replace(/^[/\\]+/, '');
} catch {
return new Response('Bad Request', { status: 400 });
}
// 路径穿越 / 绝对路径直接拒绝
if (!relative || relative.includes('..') || path.isAbsolute(relative)) {
log.warn(`拒绝非法路径: ${relative}`);
return new Response('Forbidden', { status: 403 });
}
const fileName = path.basename(relative);
if (fileName !== relative || !isManagedBackgroundFile(fileName)) {
log.warn(`拒绝白名单外文件: ${fileName}`);
return new Response('Forbidden', { status: 403 });
}
const userDataDir = app.getPath('userData');
const target = path.join(userDataDir, fileName);
if (!isPathInside(userDataDir, target)) {
log.warn(`拒绝越权文件: ${target}`);
return new Response('Forbidden', { status: 403 });
}
const stat = await fs.promises.stat(target);
if (!stat.isFile()) {
log.warn(`文件不存在: ${target}`);
return new Response('Not Found', { status: 404 });
}
const body = Readable.toWeb(fs.createReadStream(target)) as unknown as BodyInit;
log.debug(`服务资源 ${fileName} (${stat.size}B, ${mimeForFile(target)})`);
return new Response(body, {
status: 200,
headers: {
'content-type': mimeForFile(target),
'content-length': String(stat.size),
'cache-control': 'no-store',
},
});
} catch (e) {
log.error(`协议请求处理失败: ${request.url}`, e);
return new Response('Not Found', { status: 404 });
}
});
}
/** 生成 userData 壁纸文件的协议 URLfileName 需已通过白名单校验) */
export function backgroundResourceUrl(fileName: string): string {
return `${RESOURCE_SCHEME}://userdata/${encodeURIComponent(fileName)}`;
}
+300 -82
View File
@@ -1,11 +1,16 @@
import { autoUpdater, CancellationToken, type ProgressInfo } from 'electron-updater';
import electron from 'electron';
import * as fs from 'fs';
import * as crypto from 'crypto';
import * as os from 'os';
import semver from 'semver';
import { getConfig, updateConfig, flushConfig } from './config';
import { createLogger } from './core/logger';
const { app } = electron;
const log = createLogger('updater');
export type UpdateState =
| 'idle'
| 'checking'
@@ -33,6 +38,8 @@ export interface UpdateStatusPayload {
source?: string;
/** 当前更新通道(woker / runner */
channel?: string;
/** 安装包是否已通过本地核验(sha512 / 大小) */
verified?: boolean;
error?: string;
}
@@ -97,25 +104,66 @@ function getMirrors(): string[] {
return DEFAULT_MIRRORS;
}
/** 版本格式分类 */
type TagKind = 'stable' | 'beta' | 'oldbeta' | 'plain';
interface ParsedTag {
base: number[];
/** 构建号(渠道标记不参与排序) */
num: number;
kind: TagKind;
}
/**
* tag v1.2.1-5 / v1.2.1 / v1.2.0-2608271921 a - b
* {base}-{buildId}base X.Y.Z buildId
* buildId base prerelease semver
* 2026-
* - run {base}-{N} 1.2.6-25
* - beta {base}-{N}.beta 1.2.6-16.betabeta
* - {base}-beta.{N} 1.2.6-beta.16
* - {base} 1.2.5 base
*/
function parseTag(tag: string): ParsedTag {
const s = tag.replace(/^v/i, '');
const dash = s.indexOf('-');
const baseStr = dash === -1 ? s : s.slice(0, dash);
const tail = dash === -1 ? '' : s.slice(dash + 1);
const nums = baseStr.split('.').map((n) => parseInt(n, 10) || 0);
while (nums.length < 3) nums.push(0);
if (tail === '') return { base: nums, num: -1, kind: 'plain' };
const mStable = /^(\d+)$/.exec(tail);
if (mStable) return { base: nums, num: parseInt(mStable[1], 10), kind: 'stable' };
const mBeta = /^(\d+)\.beta$/i.exec(tail);
if (mBeta) return { base: nums, num: parseInt(mBeta[1], 10), kind: 'beta' };
const mOldBeta = /^beta\.(\d+)$/i.exec(tail);
if (mOldBeta) return { base: nums, num: parseInt(mOldBeta[1], 10), kind: 'oldbeta' };
// 无法识别 → 视为未知旧格式(候选阶段一并屏蔽),构建号取 0
return { base: nums, num: 0, kind: 'oldbeta' };
}
/** 该 tag 是否为"屏蔽的旧格式"(旧 beta-beta.N 或无法识别的尾巴) */
function isMaskedLegacyTag(tag: string): boolean {
return parseTag(tag).kind === 'oldbeta';
}
/** 候选是否允许当前通道使用(woker=仅正式;runner=正式+新版测试版;旧格式一律屏蔽) */
function isCandidateAllowed(tag: string, allowPrerelease: boolean): boolean {
if (isMaskedLegacyTag(tag)) return false;
const kind = parseTag(tag).kind;
return allowPrerelease || kind === 'stable' || kind === 'plain';
}
/**
* tag a - b
* base base beta/
* -beta.N使/
* isCandidateAllowed
*/
function compareVersionTags(a: string, b: string): number {
const parse = (t: string): { base: number[]; build: number } => {
const s = t.replace(/^v/i, '');
const [base, build = ''] = s.split('-');
const nums = base.split('.').map((n) => parseInt(n, 10) || 0);
while (nums.length < 3) nums.push(0);
return { base: nums, build: build === '' ? Number.MAX_SAFE_INTEGER : parseInt(build, 10) || 0 };
};
const pa = parse(a);
const pb = parse(b);
const pa = parseTag(a);
const pb = parseTag(b);
for (let i = 0; i < 3; i++) {
if (pa.base[i] !== pb.base[i]) return pa.base[i] - pb.base[i];
}
return pa.build - pb.build;
return pa.num - pb.num;
}
/**
@@ -138,6 +186,12 @@ class UpdateService {
private suppressErrors = false;
/** 当前下载的取消令牌(暂停/取消时 cancel) */
private downloadToken: CancellationToken | null = null;
/** 目标安装包是否已通过本地核验(sha512 + 大小),核验通过前不允许安装 */
private verified = false;
/** update-available 时记录的期望 sha512(来自 latest.yml */
private expectedSha512 = '';
/** 期望文件大小(字节) */
private expectedSize = 0;
/** 当前更新通道(woker 慢走 / runner 跑步;从配置读取,可运行时切换) */
private channelKey: UpdateChannelKey = 'woker';
@@ -146,7 +200,7 @@ class UpdateService {
this.currentVersion = app.getVersion();
if (!app.isPackaged) {
console.log('[updater] 开发模式:跳过自动更新');
log.info('[updater] 开发模式:跳过自动更新');
this.emit();
return;
}
@@ -160,12 +214,14 @@ class UpdateService {
// 应用能启动即说明上次安装已完成/已结束,清理持久化的进行中状态
const persisted = getConfig().update;
if (persisted && persisted.state && persisted.state !== 'idle') {
console.log(`[updater] 上次更新状态 ${persisted.state} (v${persisted.version}),已重置`);
log.info(`[updater] 上次更新状态 ${persisted.state} (v${persisted.version}),已重置`);
this.persistIdleConfig();
}
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;
// 安装一律走我们受控的 quitAndInstall(含核验与确认弹窗),
// 禁止 electron-updater 在退出时静默自动安装(否则未核验/核验失败的包可能被直接装上)
autoUpdater.autoInstallOnAppQuit = false;
this.applyChannel();
autoUpdater.logger = console;
autoUpdater.on('checking-for-update', () => {
@@ -176,11 +232,19 @@ class UpdateService {
this.state = 'available';
this.version = info.version;
this.error = undefined;
this.verified = false;
// 记录期望安装包校验值(来自 latest.yml 的 files[0]
const anyInfo = info as unknown as { files?: Array<{ sha512?: string; size?: number }> };
const first = Array.isArray(anyInfo?.files) ? anyInfo.files[0] : null;
this.expectedSha512 = String(first?.sha512 ?? '');
this.expectedSize = Number(first?.size ?? 0);
log.info(`[updater] 可用更新 ${info.version},期望 sha512=${this.expectedSha512.slice(0, 12)}… size=${this.expectedSize}`);
this.emit();
});
autoUpdater.on('update-not-available', () => {
this.state = 'not-available';
this.version = undefined;
this.verified = false;
this.emit();
});
autoUpdater.on('download-progress', (p) => {
@@ -188,14 +252,31 @@ class UpdateService {
this.progress = p;
this.emit();
});
autoUpdater.on('update-downloaded', (info) => {
// 下载完成 → 本地核验安装包(sha512 + 大小)。
// · 通过:进入"已下载可安装"verified=true
// · 失败:仍进入"已下载",但 verified=false + 记录原因 —— 点"安装"会弹确认框
// (继续安装 / 取消并删除安装包),绝不静默安装未核验包
autoUpdater.on('update-downloaded', async (info) => {
const errMsg = await this.verifyDownloadedPackage();
if (errMsg) {
log.error(`[updater] 安装包核验失败: ${errMsg}`);
this.state = 'downloaded';
this.verified = false;
this.version = info.version;
this.error = errMsg;
this.emit();
return;
}
log.info('[updater] 安装包核验通过(sha512 + 大小)');
this.state = 'downloaded';
this.verified = true;
this.version = info.version;
this.error = undefined;
this.emit();
});
autoUpdater.on('error', (err: Error) => {
const message = String(err?.message ?? err);
console.warn(`[updater] electron-updater error: ${message}`);
log.warn(`[updater] electron-updater error: ${message}`);
if (this.suppressErrors) return; // 兜底循环内,忽略
if (this.downloadToken?.cancelled) return; // 主动暂停/取消,忽略
this.state = 'error';
@@ -224,10 +305,46 @@ class UpdateService {
bytesPerSecond: this.progress?.bytesPerSecond,
source: this.source,
channel: this.channelKey,
verified: this.verified,
error: this.error,
};
}
/**
*
* 1)
* 2) latest.yml
* 3) sha512 latest.yml /
* null = = error
*/
private async verifyDownloadedPackage(): Promise<string | null> {
const helper = (autoUpdater as unknown as { downloadedUpdateHelper?: { file?: string } }).downloadedUpdateHelper;
const filePath = helper?.file;
if (!filePath) return '未找到已下载的安装包';
try {
const stat = await fs.promises.stat(filePath);
if (this.expectedSize > 0 && stat.size !== this.expectedSize) {
return `安装包大小不符(期望 ${this.expectedSize} 字节,实际 ${stat.size} 字节)`;
}
if (this.expectedSha512) {
const hash = crypto.createHash('sha512');
await new Promise<void>((resolve, reject) => {
const stream = fs.createReadStream(filePath);
stream.on('data', (chunk) => hash.update(chunk));
stream.on('end', () => resolve());
stream.on('error', reject);
});
const actual = hash.digest('hex').toLowerCase();
if (actual !== this.expectedSha512.toLowerCase()) {
return '安装包校验和不符(sha512 不匹配),文件可能已损坏或被篡改';
}
}
return null;
} catch (e) {
return `安装包核验失败:${String((e as Error)?.message ?? e)}`;
}
}
/** 将当前状态与下载进度写入配置(下载/安装进度落盘) */
private persist(payload: UpdateStatusPayload): void {
try {
@@ -244,7 +361,7 @@ class UpdateService {
},
});
} catch (e) {
console.warn('[updater] 更新进度写入配置失败:', e);
log.warn('[updater] 更新进度写入配置失败:', e);
}
}
@@ -258,13 +375,19 @@ class UpdateService {
}
}
/** 按当前通道应用 electron-updater 的 allowPrereleasewoker=只收正式版 / runner=可收预览版) */
/**
* electron-updater
* {base}-{N} / {base}-{N}.beta
* electron-updater GitHub -N.beta prerelease[0]
* check fetchCandidates/pickCandidate generic feed
* channel='latest'generic feed latest.yml / latest-linux.yml
* setter allowDowngrade semver "编号更大但 semver 偏旧"
*/
private applyChannel(): void {
const def = getChannelDef(this.channelKey);
// runner(跑步) 强制开启 allowPrerelease → GitHub provider 走 Atom feed 频道逻辑可收 beta
// woker(慢走) 关闭 → 走 releases/latest 只认稳定版,不被预览版污染。
autoUpdater.allowPrerelease = def.allowPrerelease;
console.log(`[updater] 更新通道: ${def.label}${def.key}allowPrerelease=${def.allowPrerelease}`);
autoUpdater.channel = 'latest';
log.info(`[updater] 更新通道: ${def.label}${def.key}allowPrerelease=${def.allowPrerelease}`);
}
/** 通道定义列表(UI 动态渲染;可扩展) */
@@ -275,7 +398,7 @@ class UpdateService {
/** 切换更新通道(校验 + 持久化 + 立即生效,下次检查生效) */
setChannel(key: string): UpdateStatusPayload {
if (!UPDATE_CHANNELS.some((c) => c.key === key)) {
console.warn(`[updater] 未知更新通道: ${key}`);
log.warn(`[updater] 未知更新通道: ${key}`);
return this.buildPayload();
}
if (this.channelKey === key) return this.buildPayload();
@@ -284,7 +407,7 @@ class UpdateService {
try {
updateConfig({ update: { channel: key } });
} catch (e) {
console.warn('[updater] 通道写入配置失败:', e);
log.warn('[updater] 通道写入配置失败:', e);
}
this.emit();
return this.buildPayload();
@@ -301,7 +424,7 @@ class UpdateService {
setTestVersion(version: string): UpdateStatusPayload {
const v = semver.valid(version.trim());
if (!v) {
console.warn(`[updater] 无效测试版本号: ${version}`);
log.warn(`[updater] 无效测试版本号: ${version}`);
return this.buildPayload();
}
this.currentVersion = v;
@@ -309,13 +432,37 @@ class UpdateService {
// currentVersion 在类型声明中为 readonly,但运行时可直接赋值(测试工具用)
(autoUpdater as unknown as { currentVersion: unknown }).currentVersion = semver.parse(v);
} catch (e) {
console.warn('[updater] 设置 autoUpdater.currentVersion 失败:', e);
log.warn('[updater] 设置 autoUpdater.currentVersion 失败:', e);
}
console.log(`[updater] 测试版本号 → ${v}`);
log.info(`[updater] 测试版本号 → ${v}`);
this.emit();
return this.buildPayload();
}
/**
* candidate current
* compareVersionTags base beta/
*/
private isNewerCandidate(current: string, candidate: string): boolean {
return compareVersionTags(candidate, current) > 0;
}
/**
* electron-updater "新版本"
* electron-updater semverAppUpdater.isUpdateAvailable semver.gt
* semver base > v1.2.5-17 v1.2.5-beta.16
* 退 not-available
*/
private correctAvailability(): void {
if (this.state !== 'available' || !this.version) return;
if (this.isNewerCandidate(this.currentVersion, this.version)) return;
log.warn(`[updater] ${this.version} 不是 ${this.currentVersion} 的新版本(项目版本序,忽略 beta 通道标记),回退为无更新`);
this.state = 'not-available';
this.version = undefined;
this.error = undefined;
this.emit();
}
/** 版本比对(semver 规则,支持 v 前缀与 prerelease */
compareVersions(a: string, b: string): { a: string; b: string; result: string; detail: string } {
const va = semver.valid(a.trim());
@@ -338,7 +485,12 @@ class UpdateService {
}
/**
* GitHub
* {base}-{N} / {base}-{N}.beta / -beta.{N}
* 1. GitHub release
* 2. woker= / runner=+
* 3. "比当前新" tag tag generic feed electron-updater
* // available
* electron-updater GitHub -N.beta
*/
async check(manual = false): Promise<UpdateStatusPayload> {
if (!this.ready) return this.buildPayload();
@@ -348,42 +500,44 @@ class UpdateService {
this.manual = manual;
this.suppressErrors = true;
// 1) GitHub 官方(app-update.yml 内置 github provider
this.source = 'github';
const allowPrerelease = getChannelDef(this.channelKey).allowPrerelease;
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()) {
const sources: { label: string; base: string }[] = [
{ label: 'github', base: '' },
...getMirrors().map((m) => ({ label: m, base: `${m}/` })),
];
for (const { label, base } of sources) {
try {
const tag = await this.discoverLatestTag(mirror);
const candidates = await this.fetchCandidates(base);
const tag = this.pickCandidate(candidates, allowPrerelease);
if (!tag) {
console.warn(`[updater] ${mirror}法发现最新版本,跳过`);
continue;
log.info(`[updater] ${label}:符合条件的更新候选(通道/格式/版本序)`);
continue; // 该源没有更新,尝试下一个源
}
const feedUrl = `${mirror}/https://github.com/${OWNER}/${REPO}/releases/download/${tag}/`;
console.log(`[updater] 切换加速源: ${mirror} (feed: ${feedUrl})`);
const feedUrl = `${base}https://github.com/${OWNER}/${REPO}/releases/download/${tag}/`;
log.info(`[updater] 检查源 ${label},命中 ${tag} (feed: ${feedUrl})`);
autoUpdater.setFeedURL({ provider: 'generic', url: feedUrl });
this.source = mirror;
// generic feed 取 latest.yml / latest-linux.ymlchannel='latest' 同时开启 allowDowngrade
// 使 semver 门接受"编号更大但 semver 判定偏旧"的候选(如旧 -beta.N 当前 → 新格式)
autoUpdater.allowPrerelease = allowPrerelease;
autoUpdater.channel = 'latest';
this.source = label;
this.state = 'checking';
this.emit();
await autoUpdater.checkForUpdates();
this.suppressErrors = false;
// 镜像若反馈无更新(可能发现的是旧 tag / latest.yml 不匹配),
// 不要就此返回 not-available,继续尝试下一个源
const mirrorResult = this.buildPayload();
if (mirrorResult.state !== 'not-available') return mirrorResult;
console.warn(`[updater] ${mirror} 反馈无可用更新,尝试下一个源`);
// 项目版本序复核:候选确为更新才保留 available
this.correctAvailability();
const result = this.buildPayload();
if (result.state !== 'not-available') {
this.suppressErrors = false;
return result;
}
log.warn(`[updater] ${label} 反馈无可用更新,尝试下一个源`);
} catch (err) {
console.warn(`[updater] 加速${mirror} 检查失败: ${String((err as Error)?.message ?? err)}`);
log.warn(`[updater] 更新${label} 检查失败: ${String((err as Error)?.message ?? err)}`);
}
}
@@ -395,19 +549,17 @@ class UpdateService {
}
/**
* release tag
* 1) /releases/latest HTML tag
* prereleaserelease prerelease
* 2) GitHub API prerelease
* buildId tag prerelease
* release tag
* base='' GitHub base=`${mirror}/` https://)。
* API release+ /releases/latest HTML
*/
private async discoverLatestTag(mirror: string): Promise<string | null> {
private async fetchCandidates(base: string): Promise<string[]> {
const candidates: string[] = [];
// 方式 1HTML 页面
const web = `${base}https://github.com/${OWNER}/${REPO}`;
const api = `${base}https://api.github.com/repos/${OWNER}/${REPO}`;
// 方式 1/releases/latest 页面(最新非 prerelease release 的 tagHTML 正则兜底)
try {
const pageUrl = `${mirror}/https://github.com/${OWNER}/${REPO}/releases/latest`;
const res = await fetch(pageUrl, {
const res = await fetch(`${web}/releases/latest`, {
headers: { 'User-Agent': 'koring-launcher-updater' },
signal: AbortSignal.timeout(DISCOVER_TIMEOUT_MS),
});
@@ -419,31 +571,42 @@ class UpdateService {
} catch {
/* 尝试下一种方式 */
}
// 方式 2GitHub API(经加速源代理)
// 方式 2GitHub API release 列表
try {
const apiUrl = `${mirror}/https://api.github.com/repos/${OWNER}/${REPO}/releases?per_page=20`;
const res = await fetch(apiUrl, {
const res = await fetch(`${api}/releases?per_page=40`, {
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();
for (const r of releases ?? []) {
if (!r.draft && r.tag_name) candidates.push(r.tag_name);
if (r.draft || !r.tag_name) continue;
candidates.push(r.tag_name);
}
}
} catch {
/* ignore */
}
return candidates;
}
if (candidates.length === 0) return null;
candidates.sort(compareVersionTags);
const latest = candidates[candidates.length - 1];
if (candidates.length > 1) {
console.log(`[updater] ${mirror} 候选版本: ${candidates.join(', ')} → 取 ${latest}`);
}
return latest;
/** 从候选里挑出允许当前通道、且按项目版本序比当前更新的最新 tag;没有返回 null */
private pickCandidate(candidates: string[], allowPrerelease: boolean): string | null {
const allowed = candidates
.filter((c) => isCandidateAllowed(c, allowPrerelease))
.filter((c) => compareVersionTags(c, this.currentVersion) > 0);
if (allowed.length === 0) return null;
allowed.sort(compareVersionTags);
return allowed[allowed.length - 1];
}
/** 不限当前版本:返回某来源允许通道/格式的最新 tag(发布说明回退用);没有返回 null */
private async fetchBestCandidate(base: string, allowPrerelease: boolean): Promise<string | null> {
const candidates = await this.fetchCandidates(base);
const allowed = candidates.filter((c) => isCandidateAllowed(c, allowPrerelease));
if (allowed.length === 0) return null;
allowed.sort(compareVersionTags);
return allowed[allowed.length - 1];
}
/**
@@ -455,9 +618,9 @@ class UpdateService {
const found = await this.fetchNotesForTag(tag);
if (found) return found;
// 回退:最新版本(通过 /releases/latest 页面发现 tag
// 回退:最新版本(发现 tag 后取发布说明
for (const mirror of getMirrors()) {
const latestTag = await this.discoverLatestTag(mirror);
const latestTag = await this.fetchBestCandidate(`${mirror}/`, true);
if (latestTag && latestTag !== tag) {
const foundLatest = await this.fetchNotesForTag(latestTag);
if (foundLatest) {
@@ -487,7 +650,7 @@ class UpdateService {
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})`);
log.info(`[updater] 发布说明来源: ${source} (${tag})`);
return { tag, version: tag.replace(/^v/, ''), notes, source, isLatest: false };
}
} catch {
@@ -504,9 +667,19 @@ class UpdateService {
async download(): Promise<void> {
if (!this.ready) return;
if (this.state === 'downloading' || this.state === 'downloaded' || this.state === 'installing') return;
// 非"可用/已暂停"状态直接忽略(防陈旧 updateInfoAndProvider 被误用)
if (this.state !== 'available' && this.state !== 'paused') return;
// 复核目标版本确为当前版本的新版本(项目版本序),否则回退为无更新
if (this.state === 'available' && this.version && !this.isNewerCandidate(this.currentVersion, this.version)) {
log.warn(`[updater] 下载被拒:${this.version} 不是 ${this.currentVersion} 的新版本`);
this.state = 'not-available';
this.version = undefined;
this.emit();
return;
}
if (this.state === 'paused') {
// 继续下载(可能从断点续传,也可能重新开始,取决于 electron-updater 缓存)
console.log('[updater] 继续下载');
log.info('[updater] 继续下载');
}
this.state = 'downloading';
this.progress = null;
@@ -547,9 +720,42 @@ class UpdateService {
/**
* 退NSIS
* 退 debounce
* verified=false
* /
*/
quitAndInstall(): void {
async quitAndInstall(): Promise<void> {
if (!this.ready || this.state !== 'downloaded') return;
if (!this.verified) {
const { dialog, BrowserWindow } = electron;
const parent = BrowserWindow.getAllWindows().find((w) => w.isVisible()) ?? null;
const opts: electron.MessageBoxOptions = {
type: 'warning',
title: '版本校验异常',
message: '请注意,版本校验异常,可能是文件损坏或者被替换,因此您会看到此弹窗,您可以选择继续安装或取消并删除安装包',
detail: this.error ? `核验详情:${this.error}` : '核验详情:sha512 校验和与发布记录不一致',
buttons: ['继续安装', '取消并删除安装包'],
defaultId: 1,
cancelId: 1,
noLink: true,
};
const { response } = parent
? await dialog.showMessageBox(parent, opts)
: await dialog.showMessageBox(opts);
if (response !== 0) {
// 取消并删除安装包
log.warn('[updater] 用户取消安装并删除校验异常包');
await this.removeDownloadedPackage().catch((e) => log.warn('[updater] 删除安装包失败:', e));
this.state = 'idle';
this.version = undefined;
this.error = undefined;
this.verified = false;
this.emit();
return;
}
log.warn('[updater] 用户确认继续安装(校验异常但已确认)');
}
this.state = 'installing';
this.emit();
try {
@@ -557,7 +763,19 @@ class UpdateService {
} catch {
/* ignore */
}
autoUpdater.quitAndInstall();
// 静默安装(/S)+ 安装完成后自动重启(--force-run
autoUpdater.quitAndInstall(true, true);
}
/** 删除已下载(校验失败)的安装包 */
private async removeDownloadedPackage(): Promise<void> {
const helper = (autoUpdater as unknown as { downloadedUpdateHelper?: { file?: string } }).downloadedUpdateHelper;
const filePath = helper?.file;
if (!filePath) return;
if (fs.existsSync(filePath)) {
await fs.promises.unlink(filePath);
log.info(`[updater] 已删除安装包: ${filePath}`);
}
}
/** 获取系统下载临时目录(用于清理提示,暂未启用) */
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "koring-launcher",
"private": true,
"version": "1.2.1",
"version": "1.2.6",
"description": "Koring Launcher - Minecraft launcher built with Electron + React",
"author": "Shenzhen Lingke Network Technology Co., Ltd.",
"license": "LL-1.0",
Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 216 KiB

+15 -5
View File
@@ -27,11 +27,21 @@ $rec = [char]0x1e # 记录分隔符(每个 commit 一条)
$sep = [char]0x1f # 字段分隔符(hash / subject / body
$format = "%x1f%H%x1f%s%x1f%b%x1e"
# 自上个 release tag(v*)以来的提交;无 tag 则取全部提交
$lastTag = git tag --sort=-version:refname 2>$null | Where-Object { $_ -match '^v' } | Select-Object -First 1
if ($lastTag) {
Write-Host "Commits since tag: $lastTag"
$raw = git log --format=$format "$lastTag..HEAD"
# 提交范围(base tag):按 tag 创建时间从新到旧取"上一个 release"。
# run(正式版)→ 上一个【正式版】tag:把上一个正式版之后所有 beta 的提交也写进正式版更新内容
# beta → 上一个 release tag(任意通道)
# 注意用 creatordate 而非 git 版本序:git 对 -beta.N / -N 混排不可靠(会把 beta.16 排到 -17 之前)。
$tagsNewestFirst = git for-each-ref --sort=-creatordate --format '%(refname:short)' refs/tags 2>$null |
Where-Object { $_ -match '^v' }
$baseTag = if ($Mode -eq 'run') {
($tagsNewestFirst | Where-Object { $_ -notmatch '-beta\.' } | Select-Object -First 1)
} else {
($tagsNewestFirst | Select-Object -First 1)
}
if ($baseTag) {
Write-Host "Commits since tag: $baseTag (mode=$Mode)"
$raw = git log --format=$format "$baseTag..HEAD"
} else {
Write-Host "No release tags found, listing all commits"
$raw = git log --format=$format "HEAD"
+30 -14
View File
@@ -31,6 +31,10 @@ if (!mode || !validModes.includes(mode)) {
process.exit(1);
}
// NSIS 专属资源(ico / 侧边横幅 BMP / 自定义脚本 / license)仅 Windows 打包需要;
// LinuxAppImage 等)只需 build/icon.png,且不能调用 PowerShell。
const isWin = process.platform === 'win32';
const root = join(__dirname, '..');
const srcDir = join(root, 'public', 'icons', mode);
const buildDir = join(root, 'build');
@@ -45,13 +49,17 @@ const licenseSrc = join(
mode === 'beta' ? 'protocol-beta.txt' : 'protocol-user.txt'
);
// 必选文件存在性检查
// 必选文件存在性检查Windows 全量;Linux 仅 icon.png
const requiredFiles = [
{ path: png, label: 'icon.png' },
{ path: ico, label: 'icon.ico' },
{ path: installerHeader, label: 'installer-header.png' },
{ path: nsisCustom, label: 'installer-custom.nsh' },
{ path: licenseSrc, label: 'license 协议文件' },
...(isWin
? [
{ path: ico, label: 'icon.ico' },
{ path: installerHeader, label: 'installer-header.png' },
{ path: nsisCustom, label: 'installer-custom.nsh' },
{ path: licenseSrc, label: 'license 协议文件' },
]
: []),
];
for (const file of requiredFiles) {
if (!existsSync(file.path)) {
@@ -112,19 +120,27 @@ function writeLicenseWithBom(src, out) {
mkdirSync(buildDir, { recursive: true });
// 1. 图标
// 1. 图标(各平台通用:AppImage / Linux 需要 build/icon.png
cpSync(png, join(buildDir, 'icon.png'), { overwrite: true });
cpSync(ico, join(buildDir, 'icon.ico'), { overwrite: true });
// 2. 安装程序欢迎页左侧大图(installerSidebar,由横幅 PNG 适配生成)
createSidebarBmp(installerHeader, join(buildDir, 'installer-header.bmp'));
// Windows 专属:NSIS 安装器资源
if (isWin) {
cpSync(ico, join(buildDir, 'icon.ico'), { overwrite: true });
// 3. NSIS 自定义脚本
cpSync(nsisCustom, join(buildDir, 'installer-custom.nsh'), { overwrite: true });
// 2. 安装程序欢迎页左侧大图(installerSidebar,由横幅 PNG 适配生成)
createSidebarBmp(installerHeader, join(buildDir, 'installer-header.bmp'));
// 4. 协议文件(带 UTF-8 BOM,防止中文乱码)
writeLicenseWithBom(licenseSrc, join(buildDir, 'license.txt'));
// 3. NSIS 自定义脚本
cpSync(nsisCustom, join(buildDir, 'installer-custom.nsh'), { overwrite: true });
// 4. 协议文件(带 UTF-8 BOM,防止中文乱码)
writeLicenseWithBom(licenseSrc, join(buildDir, 'license.txt'));
}
console.log(
`[switch-icon] Mode: ${mode} → build/icon.png + build/icon.ico + build/installer-header.bmp (sidebar 164x314) + build/installer-custom.nsh + build/license.txt updated`
`[switch-icon] Mode: ${mode} → build/icon.png` +
(isWin
? ' + build/icon.ico + build/installer-header.bmp (sidebar 164x314) + build/installer-custom.nsh + build/license.txt'
: 'Linux:仅 icon.png') +
' updated'
);
+79 -12
View File
@@ -10,13 +10,12 @@ import { useAuthStore } from "./stores/authStore";
import { useKoringAuthStore } from "./stores/koringAuthStore";
import type { AppConfig } from "./api/config";
import { Home } from "./pages/home";
import { Store } from "./pages/store";
import { Today } from "./pages/today";
import { PlayLink } from "./pages/play-link";
import { Setting } from "./pages/setting";
import { SettingLogin } from "./pages/setting/login";
import { Gallery } from "./pages/gallery";
import { TaskQueue } from "./pages/task-queue";
import { GalleryPlaceholder, StorePlaceholder } from "./pages/placeholder";
import { UpdatePage } from "./pages/update";
import { Debug } from "./pages/debug";
import { SplashDebug } from "./pages/debug/splash-debug";
@@ -25,25 +24,35 @@ import { VersionCardDebug } from "./pages/debug/version-card-debug";
import { UpdateDebug } from "./pages/debug/update-debug";
import { TaskDebug } from "./pages/debug/task-debug";
import { CrashDebug } from "./pages/debug/crash-debug";
import { ResourceDebug } from "./pages/debug/resource-debug";
import { RoutesDebug } from "./pages/debug/routes-debug";
import { Oobe } from "./pages/oobe";
import { OobeLanguage } from "./pages/oobe/step-language";
import { OobeAgreement } from "./pages/oobe/step-agreement";
import { OobeLogin } from "./pages/oobe/step-login";
import { OobeWelcome } from "./pages/oobe/step-welcome";
import { OobeVersion } from "./pages/oobe/step-version";
import { OobeAboutVersion } from "./pages/oobe/about-version";
import { OobeBetaTest } from "./pages/oobe/step-beta-test";
import { OobeFinish } from "./pages/oobe/step-finish";
import { OobeLegal } from "./pages/oobe/step-legal";
import { OobeAboutInfo } from "./pages/oobe/about-info";
import { UpvpComplete } from "./pages/upvp/step-complete";
import { UpvpVersion } from "./pages/upvp/step-version";
import { UpvpAboutVersion } from "./pages/upvp/about-version";
import { UpvpCheck } from "./pages/upvp/step-check";
import { UpvpBetaTest } from "./pages/upvp/step-beta-test";
import { UpvpFinish } from "./pages/upvp/step-finish";
import { VERSION } from "./lib/version";
const pageMap = {
home: Home,
store: Store,
store: StorePlaceholder,
today: Today,
"play-link": PlayLink,
setting: Setting,
"setting/login": SettingLogin,
gallery: Gallery,
gallery: GalleryPlaceholder,
"task-queue": TaskQueue,
update: UpdatePage,
oobe: Oobe,
@@ -52,10 +61,18 @@ const pageMap = {
"oobe/login": OobeLogin,
"oobe/welcome": OobeWelcome,
"oobe/version": OobeVersion,
"oobe/about-version": OobeAboutVersion,
"oobe/beta-test": OobeBetaTest,
"oobe/finish": OobeFinish,
"oobe/about-info": OobeAboutInfo,
"oobe/legal": OobeLegal,
upvp: UpvpComplete,
"upvp/complete": UpvpComplete,
"upvp/version": UpvpVersion,
"upvp/about-version": UpvpAboutVersion,
"upvp/check": UpvpCheck,
"upvp/beta-test": UpvpBetaTest,
"upvp/finish": UpvpFinish,
debug: Debug,
"debug-splash": SplashDebug,
"debug-display": DisplayDebug,
@@ -63,6 +80,8 @@ const pageMap = {
"debug-update": UpdateDebug,
"debug-task": TaskDebug,
"debug-crash": CrashDebug,
"debug-resource": ResourceDebug,
"debug-routes": RoutesDebug,
} as const;
function App() {
@@ -71,25 +90,73 @@ function App() {
const Page = pageMap[current];
useEffect(() => {
// Listen for preloaded config from main process
const unsub = window.electronAPI?.onConfigPreload((data) => {
const { config, isFirstLaunch } = data;
const cfg = config as AppConfig;
let done = false;
let cancelled = false;
// 配置引导(幂等,最多执行一次):
// 主进程的 config:preload 推送 与 config:get 兜底拉取,谁先完成谁引导,
// 避免重复同步派生 store / 重复导航 / 重复补写版本号。
const finishBoot = (cfg: AppConfig, isFirstLaunch: boolean) => {
if (done || cancelled) return;
done = true;
useConfigStore.getState().applyPreloaded(cfg, isFirstLaunch);
// 语言偏好 → <html lang>
document.documentElement.lang = (cfg as AppConfig).app?.language ?? "zh-CN";
document.documentElement.lang = cfg.app?.language ?? "zh-CN";
syncThemeFromConfig();
syncA11yFromConfig();
syncBackgroundFromConfig();
useAuthStore.getState().initFromRegistry();
useKoringAuthStore.getState().initFromDisk();
// Navigate to OOBE on first launch or if oobe not completed
// 首次启动 / 未完成 OOBE → OOBE
if (isFirstLaunch || cfg.oobe) {
useRouteStore.getState().navigate("oobe");
return;
}
});
return () => { unsub?.(); };
// 配置里没有版本记录 → 补写当前版本(新装/旧配置迁移),直接进主页
if (!cfg.appVersion) {
useConfigStore.getState().setAppVersion(VERSION);
return;
}
// 程序版本 ≠ 配置版本 → 进入更新引导(upvp):
// 升级后 appVersion 仍是旧版本号(主进程不自动刷新),由 upvp 流程完成时写入新版本
if (cfg.appVersion !== VERSION) {
useRouteStore.getState().navigate("upvp/complete");
}
};
const api = window.electronAPI;
let unsub: (() => void) | undefined;
if (api) {
// 主进程在每次 did-finish-load(含 Ctrl+R / F5 刷新)时推送的最新权威配置
unsub = api.onConfigPreload((data) => {
finishBoot(data.config as AppConfig, data.isFirstLaunch);
});
// 兜底:config:preload 是主进程在页面加载完成后的一次性推送,
// 若它早于本订阅到达(页面刷新/快速重载时的竞态)就会被丢弃,store 将永远停在默认值。
// 此时主动走 config:get 拉取主进程权威配置,保证刷新后配置一定能被读到。
if (!useConfigStore.getState().loaded) {
useConfigStore
.getState()
.init()
.then(() => {
if (cancelled) return;
const { config, isFirstLaunch } = useConfigStore.getState();
finishBoot(config, isFirstLaunch);
})
.catch((e) => {
console.error("[config] fallback init failed:", e);
});
}
}
return () => {
cancelled = true;
unsub?.();
};
}, []);
// 主进程权威配置广播 → 覆盖本地镜像并同步派生 store
+2
View File
@@ -107,6 +107,8 @@ export interface InstanceMeta {
export interface AppConfig {
version: number;
/** 当前应用版本号(写入配置时由主进程刷新) */
appVersion: string;
oobe: boolean;
app: AppInfoConfig;
theme: ThemeConfig;
+37
View File
@@ -11,6 +11,43 @@ export async function getSystemInfo(): Promise<SystemInfo> {
return ipcInvoke<SystemInfo>('system:info');
}
/** 设备唯一标识(组合指纹 + 回退 MachineGuid */
export interface DeviceIdentity {
deviceId: string | null;
source: 'board' | 'disk' | 'bios' | 'machine' | 'none';
}
export async function getDeviceId(): Promise<DeviceIdentity> {
return ipcInvoke<DeviceIdentity>('system:deviceId');
}
// ---- 进程内存快照(资源/内存调试面板用)----
/** app.getAppMetrics() 的进程项;workingSetSize 单位为 KB */
export interface ProcessMemoryMetric {
type: string;
pid: number;
workingSetSize: number; // KB
peakWorkingSetSize: number; // KB
}
/** process.getProcessMemoryInfo()(主进程);单位为 KB */
export interface MainProcessMemory {
workingSetSize: number; // KBresidentSet
privateBytes: number; // KBprivate
}
export interface SystemMemorySnapshot {
app_version: string;
timestamp: number;
metrics: ProcessMemoryMetric[];
mainProcess: MainProcessMemory | null;
}
export async function getMemorySnapshot(): Promise<SystemMemorySnapshot> {
return ipcInvoke<SystemMemorySnapshot>('system:memory');
}
// 在系统文件管理器中打开指定路径(用于"打开游戏目录"等操作)
export async function openPath(targetPath: string): Promise<{ success: boolean; error?: string }> {
return ipcInvoke<{ success: boolean; error?: string }>('system:open-path', { path: targetPath });
+2
View File
@@ -37,6 +37,8 @@ export interface UpdateStatusPayload {
source?: string;
/** 当前更新通道(woker / runner */
channel?: string;
/** 安装包是否已通过本地核验(sha512 / 大小;核验通过前不允许安装) */
verified?: boolean;
error?: string;
}
+25
View File
@@ -0,0 +1,25 @@
import { useEffect } from "react";
import { toast } from "sonner";
/**
* kind
* Linux AppImage
*/
export function RuntimeNotices() {
useEffect(() => {
const unsub = window.electronAPI?.onRuntimeNotice?.((notice) => {
if (!notice?.message) return;
// id 固定 → 同一提示只出现一次(页面刷新也不会重复弹)
toast.warning(notice.message, {
id: `runtime-notice:${notice.kind ?? "generic"}`,
duration: Infinity,
closeButton: true,
});
});
return () => {
unsub?.();
};
}, []);
return null;
}
+136
View File
@@ -0,0 +1,136 @@
import { useEffect, useRef } from "react";
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { onUpdateStatus, getUpdateState } from "@/api/update";
import { useRouteStore } from "@/stores/routeStore";
import { useUpdateDialogStore } from "@/stores/updateDialogStore";
import { BUILD_MODE } from "@/lib/mode";
import { VERSION } from "@/lib/version";
import { VersionCard } from "@/components/VersionCard";
// 与 VersionCard 一致的构建模式配色(dev 橙 / beta 绿 / run 蓝),用于主按钮底色
const modeGradients: Record<string, string> = {
dev: "linear-gradient(135deg, #F59E0B, #D97706)",
beta: "linear-gradient(135deg, #10B981, #059669)",
run: "linear-gradient(135deg, #3B82F6, #2563EB)",
};
/**
* "发现新版本" RootLayout
* - available
* - useUpdateDialogStore.show(version)
* - VersionCard + Silk + Logo
* - /
*/
export function UpdateAvailableDialog() {
const open = useUpdateDialogStore((s) => s.open);
const version = useUpdateDialogStore((s) => s.version);
const hide = useUpdateDialogStore((s) => s.hide);
const show = useUpdateDialogStore((s) => s.show);
const prevStateRef = useRef<string>("idle");
const currentRouteRef = useRef<string>(useRouteStore.getState().current);
useEffect(() => {
// 跟随路由(更新页自身不弹,避免打扰已在该页操作的用户)
return useRouteStore.subscribe(() => {
currentRouteRef.current = useRouteStore.getState().current;
});
}, []);
useEffect(() => {
const unsub = onUpdateStatus((status) => {
// 仅在「进入 available」这一跳变时自动弹出(checking→available / 再次手动检查会重新触发)
const alreadyOpen = useUpdateDialogStore.getState().open;
if (
status.state === "available" &&
prevStateRef.current !== "available" &&
currentRouteRef.current !== "update" &&
!alreadyOpen
) {
show(status.version);
}
prevStateRef.current = status.state;
});
return unsub;
}, [show]);
// 兜底:挂载时拉一次状态快照 —— 若启动静默检查的 available 广播早于本组件订阅
// (渲染慢/竞态)会漏弹,这里补一次判定
useEffect(() => {
let cancelled = false;
getUpdateState()
.then((status) => {
if (cancelled) return;
const alreadyOpen = useUpdateDialogStore.getState().open;
if (status.state === "available" && currentRouteRef.current !== "update" && !alreadyOpen) {
show(status.version);
}
prevStateRef.current = status.state;
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [show]);
const goUpdate = () => {
hide();
// 等关闭动画结束后再切换路由,避免弹窗关闭与 view transition 快照抢帧导致无过渡
window.setTimeout(() => {
useRouteStore.getState().navigate("update");
}, 220);
};
const gradient = modeGradients[BUILD_MODE] ?? modeGradients.run;
const targetVersion = version || VERSION;
return (
<AlertDialog open={open} onOpenChange={(o) => !o && hide()}>
<AlertDialogContent
className="gap-0 overflow-hidden rounded-2xl p-0"
style={{ width: "min(720px, calc(100vw - 2rem))", maxWidth: "min(720px, calc(100vw - 2rem))" }}
>
{/* 统一内边距容器:卡片与正文共用同一水平宽度(安全区不粘连边框) */}
<div className="flex flex-col p-2.5 sm:p-3">
{/* 上半部分:直接复用 VersionCard(全宽;关闭共享过渡名,避免打断路由切换动画) */}
<VersionCard noViewTransition className="w-full" />
{/* 下半部分:与版本卡同宽的说明 + 按钮 */}
<div className="flex flex-col gap-4 px-1 pt-4 pb-1">
<div>
<AlertDialogTitle className="font-heading text-base font-semibold text-foreground">
</AlertDialogTitle>
<AlertDialogDescription className="mt-1.5 text-[13px] leading-relaxed">
v{VERSION} v{targetVersion}
</AlertDialogDescription>
</div>
<div className="flex gap-2.5">
<button
onClick={hide}
className="flex-1 h-10 rounded-lg text-[13px] font-medium bg-foreground/[0.05] hover:bg-foreground/[0.1] text-foreground/70 hover:text-foreground transition-colors cursor-pointer"
>
</button>
<button
onClick={goUpdate}
className="flex-[1.4] h-10 rounded-lg text-[13px] font-semibold text-white transition-colors cursor-pointer"
style={{ background: gradient, boxShadow: "0 2px 10px rgba(0,0,0,0.12)" }}
onMouseEnter={(e) => (e.currentTarget.style.filter = "brightness(1.08)")}
onMouseLeave={(e) => (e.currentTarget.style.filter = "none")}
>
</button>
</div>
</div>
</div>
</AlertDialogContent>
</AlertDialog>
);
}
+67 -64
View File
@@ -3,11 +3,10 @@ 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";
import clsx from "clsx";
import { type ReactNode, Suspense } from "react";
import { ChevronRight } from "lucide-react";
import { Suspense, useEffect, useRef, useState } from "react";
const modeColors: Record<string, string> = {
dev: "#F59E0B",
@@ -34,8 +33,18 @@ interface VersionCardProps {
overrideMode?: string | null;
overrideState?: UpdateState | null;
simple?: boolean;
/** OOBE 模式:不显示 Silk 动画,按钮仅展示样式不做实际操作 */
/** OOBE 模式:仅展示样式,不显示「查看更新」入口 */
oobe?: boolean;
/**
* /
* false
*/
isSettingPage?: boolean;
/**
* view-transition-name/ VersionCard
* VersionCard
*/
noViewTransition?: boolean;
}
export function VersionCard({
@@ -44,48 +53,66 @@ export function VersionCard({
overrideState,
simple = false,
oobe = false,
isSettingPage = false,
noViewTransition = false,
}: VersionCardProps) {
const color = modeColors[overrideMode ?? BUILD_MODE] ?? modeColors.run;
const gradient = modeGradients[overrideMode ?? BUILD_MODE] ?? modeGradients.run;
const label = modeLabels[overrideMode ?? BUILD_MODE] ?? modeLabels.run;
const { checking, downloading, installed, update, check, install } = useUpdateStore();
const { update, installed } = 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>) => (
<Button
size="sm"
variant="ghost"
className="text-white/90 hover:text-white hover:bg-white/10 border border-white/20"
{...props}
/>
);
// 设置页:整卡可点击,跳转检查更新页面(更新日志页自身不触发)
const clickable = isSettingPage && !oobe && current !== "update";
const handleCardClick = () => {
if (clickable) navigate("update");
};
// Silk(WebGL 动画)仅在卡片可见时运行:
// 设置子页 keep-alive 后隐藏页仍挂在 DOM,若动画照跑会白白占 GPU/rAF →
// 用 IntersectionObserver 按可见性挂载/卸载 Silk(隐藏/滚出视野即暂停,观感不变)
const cardRef = useRef<HTMLDivElement | null>(null);
const [silkVisible, setSilkVisible] = useState(true);
useEffect(() => {
if (simple || !cardRef.current || typeof IntersectionObserver === "undefined") return;
const io = new IntersectionObserver(
(entries) => {
for (const en of entries) setSilkVisible(en.isIntersecting);
},
{ root: null, threshold: 0.02 },
);
io.observe(cardRef.current);
return () => io.disconnect();
}, [simple]);
return (
<div
className={clsx("relative overflow-hidden rounded-xl border border-white/10 min-h-[200px]", className)}
ref={cardRef}
className={clsx(
"relative overflow-hidden rounded-xl border border-white/10 min-h-[200px]",
clickable && "cursor-pointer hover:scale-[1.01] active:scale-[0.99] transition-transform",
className,
)}
onClick={handleCardClick}
// 共享元素过渡:路由切换时(startViewTransition),新旧页面中同名 view-transition-name
// 的元素会从上一个位置平滑移动/形变到当前页面的位置
style={{ viewTransitionName: "version-card" } as React.CSSProperties}
// 的元素会从上一个位置平滑移动/形变到当前页面的位置
// 浮层/弹窗复用(noViewTransition)时关闭,避免与页面卡片重名打断过渡。
style={
noViewTransition
? undefined
: ({ viewTransitionName: "version-card" } as React.CSSProperties)
}
>
{/* 背景层 */}
<div className="absolute inset-0" style={{ background: gradient }} />
{/* Silk 动画层 (非 simple 模式) */}
{!simple && (
{/* Silk 动画层 (非 simple 模式;仅卡片可见时挂载,隐藏即暂停) */}
{!simple && silkVisible && (
<Suspense fallback={null}>
<div className="absolute inset-0 opacity-60 mix-blend-soft-light">
<Silk speed={3} scale={1.2} color={color} noiseIntensity={1.2} rotation={0.3} />
@@ -93,7 +120,7 @@ export function VersionCard({
</Suspense>
)}
{/* 内容(移除颜色遮罩层,保留底部渐变与 Silk 动画渲染) */}
{/* 内容 */}
<div className="relative z-10 flex flex-col items-center justify-center px-6 py-10 gap-4">
<span className="text-[11px] font-bold tracking-wider text-white/70">{label}</span>
<img
@@ -113,43 +140,19 @@ export function VersionCard({
</p>
)}
{/* 更新日志页内不显示任何按钮(更新操作由页面底部遮罩负责 */}
{!isOnUpdatePage && (
<>
{/* OOBE 模式:仅显示查看亮点按钮 */}
{oobe ? (
<div className="flex items-center gap-2 mt-1">
<Btn></Btn>
</div>
) : (
<div className="flex items-center gap-2 mt-1">
{effectiveState === "latest" && (
<>
<Btn onPress={handleCheck} isDisabled={checking}>
{checking ? "检查中..." : "检查更新"}
</Btn>
<Btn></Btn>
</>
)}
{effectiveState === "hasUpdate" && (
<>
<span className="text-[12px] text-white/80 mr-1"></span>
<Btn onPress={install} isDisabled={downloading}>
{downloading ? "下载中..." : "下载更新"}
</Btn>
</>
)}
{effectiveState === "installed" && (
<>
<span className="text-[12px] text-white/80 mr-1"></span>
<Btn onPress={relaunchApp}></Btn>
</>
)}
</div>
{/* 更新状态提示(仅信息,不提供按钮;下载/安装操作在更新页底部完成 */}
{effectiveState !== "latest" && (
<p className="text-[12px] text-white/80">
{effectiveState === "hasUpdate" ? "有新的版本可用" : "更新已下载"}
</p>
)}
</>
{/* 设置页:整卡可点击跳转更新页,显示入口小字 */}
{clickable && (
<span className="inline-flex items-center gap-0.5 text-[11px] text-white/60 transition-colors group-hover:text-white">
<ChevronRight className="w-3 h-3" />
</span>
)}
</div>
</div>
+167
View File
@@ -0,0 +1,167 @@
// AboutVersion:展示当前版本的更新内容。
// 数据源:getReleaseNotes()GitHub release-notes.md,主进程自动切加速源,回退最新版)。
// 不直接渲染 Markdown —— 解析后按提交类型(新增/修复/优化/重构/文档/其他)分组,
// 每组配图标,用卡片展示。
import { useCallback, useEffect, useState } from "react";
import { Sparkles, Bug, Gauge, RefreshCw, FileText, Wrench, ExternalLink, GitCommitHorizontal } from "lucide-react";
import { getReleaseNotes, type ReleaseNotesResult } from "@/api/update";
import { VERSION } from "@/lib/version";
import { parseReleaseNotes, type ChangeType, type VersionChange } from "./parse";
const GITHUB_RELEASES = "https://github.com/dream-pep/koring-launcher/releases";
const CATEGORY_ORDER: ChangeType[] = ["feat", "fix", "perf", "refactor", "docs", "other"];
const CATEGORY_META: Record<ChangeType, { label: string; icon: React.ComponentType<{ className?: string }>; iconCls: string; dotCls: string }> = {
feat: { label: "新增功能", icon: Sparkles, iconCls: "text-sky-600 dark:text-sky-400 bg-sky-500/10", dotCls: "bg-sky-500" },
fix: { label: "修复", icon: Bug, iconCls: "text-red-600 dark:text-red-400 bg-red-500/10", dotCls: "bg-red-500" },
perf: { label: "性能优化", icon: Gauge, iconCls: "text-emerald-600 dark:text-emerald-400 bg-emerald-500/10", dotCls: "bg-emerald-500" },
refactor: { label: "重构", icon: RefreshCw, iconCls: "text-violet-600 dark:text-violet-400 bg-violet-500/10", dotCls: "bg-violet-500" },
docs: { label: "文档", icon: FileText, iconCls: "text-amber-600 dark:text-amber-400 bg-amber-500/10", dotCls: "bg-amber-500" },
other: { label: "其他", icon: Wrench, iconCls: "text-foreground/60 bg-foreground/[0.06]", dotCls: "bg-foreground/40" },
};
/** 一条变更(含 commit 标)+ 短分隔条 */
function ChangeItem({ change }: { change: VersionChange }) {
return (
<div className="flex items-start gap-2.5 px-4 py-2.5 first:pt-3 last:pb-3">
{change.commit && (
<span className="inline-flex items-center gap-1 shrink-0 mt-[3px] font-mono text-[10px] px-1.5 py-0.5 rounded bg-foreground/[0.05] dark:bg-white/[0.05] text-muted-foreground/80">
<GitCommitHorizontal className="w-3 h-3" />
{change.commit}
</span>
)}
<div className="flex-1 min-w-0">
<p className="text-[13px] font-medium text-foreground leading-snug">{change.title}</p>
{change.description && (
<p className="text-[12px] text-muted-foreground/80 leading-relaxed mt-0.5">{change.description}</p>
)}
</div>
</div>
);
}
/** 一个分类卡片:图标头部 + 该类型的变更列表 */
function CategoryCard({ type, items }: { type: ChangeType; items: VersionChange[] }) {
const meta = CATEGORY_META[type];
const Icon = meta.icon;
return (
<div className="rounded-xl overflow-hidden border border-black/[0.06] dark:border-white/[0.07] bg-white/85 dark:bg-black/45 backdrop-blur-[12px]">
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-black/[0.05] dark:border-white/[0.06]">
<span className={`flex items-center justify-center w-6 h-6 rounded-md ${meta.iconCls}`}>
<Icon className="w-3.5 h-3.5" />
</span>
<span className="text-[13px] font-semibold text-foreground">{meta.label}</span>
<span className="text-[11px] text-muted-foreground/60 ml-auto tabular-nums">{items.length} </span>
</div>
<div className="divide-y divide-black/[0.04] dark:divide-white/[0.05]">
{items.map((c, i) => (
<ChangeItem key={`${c.commit ?? ""}-${i}`} change={c} />
))}
</div>
</div>
);
}
function EmptyCard({ text }: { text: string }) {
return (
<div className="rounded-xl border border-black/[0.06] dark:border-white/[0.07] bg-white/85 dark:bg-black/45 backdrop-blur-[12px] px-5 py-10 text-center">
<p className="text-[13px] text-muted-foreground">{text}</p>
</div>
);
}
export function AboutVersion({ className }: { className?: string }) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<ReleaseNotesResult | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const r = await getReleaseNotes();
setResult(r);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
const parsed = result ? parseReleaseNotes(result.notes, result.version, result.tag) : null;
const grouped = parsed
? CATEGORY_ORDER.map((t) => ({ type: t, items: parsed.changes.filter((c) => c.type === t) })).filter(
(g) => g.items.length > 0,
)
: [];
return (
<div className={className}>
{/* 头部:版本 + 来源 */}
<div className="flex items-center justify-between mb-2 px-1">
<span className="text-[12px] text-muted-foreground/80">
{parsed ? (
<>
v{parsed.version}
{result?.isLatest && parsed.version !== VERSION && (
<span className="ml-2 opacity-70"></span>
)}
</>
) : (
<> v{VERSION}</>
)}
</span>
</div>
{loading ? (
<div className="space-y-2">
{[0, 1, 2].map((i) => (
<div key={i} className="h-[76px] rounded-xl bg-white/50 dark:bg-white/[0.04] animate-pulse" />
))}
</div>
) : error ? (
<EmptyCard text="获取此版本的更新内容失败" />
) : !result || !parsed ? (
<EmptyCard text="未能获取到发布说明(GitHub 与加速源均不可用,或该版本尚未发布)" />
) : (
<div className="space-y-2.5">
{parsed.noCommits && parsed.changes.length === 0 ? (
<EmptyCard text="此版本暂无变更记录" />
) : grouped.length === 0 ? (
<EmptyCard text="此版本暂无变更记录" />
) : (
grouped.map((g) => <CategoryCard key={g.type} type={g.type} items={g.items} />)
)}
</div>
)}
{/* 底部操作 */}
<div className="flex items-center gap-2 mt-3 px-1">
{error && (
<>
<p className="text-[11px] text-destructive/80 flex-1 truncate">{error}</p>
<button
onClick={load}
className="text-[12px] font-medium text-primary hover:underline shrink-0"
>
</button>
</>
)}
<button
onClick={() => window.electronAPI?.openExternal(GITHUB_RELEASES)}
className="inline-flex items-center gap-0.5 text-[12px] text-muted-foreground hover:text-foreground transition-colors ml-auto shrink-0"
>
GitHub Releases
<ExternalLink className="w-3 h-3" />
</button>
</div>
</div>
);
}
+158
View File
@@ -0,0 +1,158 @@
// 版本发布说明(release-notes.md / GitHub Release body)解析器。
// 目标:不渲染原始 Markdown,把每条变更提取出来并按提交类型(conventional commit)分类。
//
// 受控格式约定(与 CI release 模板对齐):
// # Koring Launcher Releases x
// ## 版本信息
// 当前版本 x
// ...
// ## 更新了什么内容
// <details>
// <summary>·Commit abc1234</summary>
//
// fix(updater): 标题
//
// 详细说明…
// </details>
// 或:· 无提交记录
export type ChangeType = "feat" | "fix" | "perf" | "refactor" | "docs" | "other";
export interface VersionChange {
/** commit 短 hash(无则省略) */
commit?: string;
type: ChangeType;
/** 去除 type(scope): 前缀后的标题 */
title: string;
/** 详细说明(纯文本,已剥离 md 记号) */
description?: string;
}
export interface ParsedRelease {
version: string;
tag: string;
/** notes 中含「无提交记录」 */
noCommits: boolean;
changes: VersionChange[];
}
/** conventional commit type → 业务分类(大小写不敏感) */
const TYPE_ALIAS: Record<string, ChangeType> = {
feat: "feat",
feature: "feat",
add: "feat",
fix: "fix",
bugfix: "fix",
perf: "perf",
optimize: "perf",
performance: "perf",
improve: "perf",
refactor: "refactor",
docs: "docs",
doc: "docs",
chore: "other",
ci: "other",
build: "other",
style: "other",
test: "other",
revert: "other",
};
/** 去掉行内的常见 markdown 记号,得到纯文本 */
function stripInlineMarkdown(text: string): string {
return text
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // 链接 [t](url) → t
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1") // 图片 ![t](url) → t
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/\*([^*]+)\*/g, "$1")
.replace(/`([^`]+)`/g, "$1")
.replace(/__([^_]+)__/g, "$1")
.replace(/[_~]+/g, "")
.replace(/\s+/g, " ")
.trim();
}
/** 解析 conventional commit 首行:type(scope): subject */
function parseCommitTitle(line: string): { type: ChangeType; title: string } {
const m = line.trim().match(/^([a-zA-Z][\w-]*)(?:\(([^)]*)\))?!?:\s*(.*)$/);
if (!m) {
return { type: "other", title: stripInlineMarkdown(line) };
}
const rawType = m[1].toLowerCase();
const subject = stripInlineMarkdown(m[3] || m[2] || line);
return { type: TYPE_ALIAS[rawType] ?? "other", title: subject };
}
/** 提取一个 <details> 块中的 summary sha 与正文 */
function splitDetailsBlock(block: string): { commit?: string; content: string } {
const summary = block.match(/<summary>\s*[·•-]?\s*Commit\s*([0-9a-fA-F]{4,40})?/i);
const content = block
.replace(/<summary>[\s\S]*?<\/summary>/i, "")
.replace(/<\/?details>/gi, "")
.trim();
return { commit: summary?.[1]?.slice(0, 7), content };
}
/** 把一段文本按行解析为一条变更 */
function parseChangeLines(text: string): VersionChange {
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
const head = lines[0] || "";
const { type, title } = parseCommitTitle(head);
const rest = lines.slice(1);
// 剩余行通常为换行后的说明;合并(保留相对短行),剥离剩余 md 记号
const description = rest.length > 0 ? stripInlineMarkdown(rest.join(" ")) : undefined;
return { type, title, description: description || undefined };
}
/**
* notes
* <details> details
*/
export function parseReleaseNotes(notes: string, version: string, tag: string): ParsedRelease {
const noCommits = /无提交记录|no commits?/i.test(notes);
// 定位「更新了什么内容」节(找不到则用整篇)
const sectionIdx = notes.search(/^##\s*更新了什么内容/m);
const body = sectionIdx >= 0 ? notes.slice(sectionIdx) : notes;
const changes: VersionChange[] = [];
// 1) <details> 块切分(受控格式)
const detailRe = /<details[^>]*>([\s\S]*?)<\/details>/gi;
let match: RegExpExecArray | null;
let detailsFound = false;
while ((match = detailRe.exec(body)) !== null) {
detailsFound = true;
const { commit, content } = splitDetailsBlock(match[1]);
if (!content) continue;
const change = parseChangeLines(content);
if (commit) change.commit = commit;
changes.push(change);
}
// 2) 兜底:无 <details> 时按行扫描「· Commit / - 」开头的条目
if (!detailsFound) {
const lines = body
.split("\n")
.map((l) => l.trim())
.filter((l) => l && !/^#/.test(l));
let current: VersionChange | null = null;
for (const line of lines) {
const entry = line.match(/^[·•\-]\s*(?:Commit\s*)?([0-9a-fA-F]{7,40})?\s*(.*)$/i);
if (entry) {
const { type, title } = parseCommitTitle(entry[2] || entry[1] || line);
current = { type, title };
if (entry[1]) current.commit = entry[1].slice(0, 7);
changes.push(current);
} else if (current) {
// 说明行并入上一条
const desc = stripInlineMarkdown(line);
if (desc) current.description = current.description ? `${current.description} ${desc}` : desc;
} else {
// 游离正文行(版本信息等)跳过
}
}
}
return { version, tag, noCommits, changes };
}
+149 -27
View File
@@ -1,9 +1,20 @@
import { useEffect, useRef, useCallback, useState } from "react";
import { useEffect, useRef, useCallback, useState, useMemo } from "react";
import { useBackgroundStore } from "@/stores/backgroundStore";
import { useThemeStore } from "@/stores/themeStore";
import { useRouteStore } from "@/stores/routeStore";
import { useDevStore } from "@/stores/devStore";
import { DEFAULT_BG } from "@/lib/mode";
import { resourceRegistry } from "@/resources/registry";
import { estimateDataUrlBytes } from "@/resources/image";
import { createRendererLogger } from "@/lib/logger";
const log = createRendererLogger("BackgroundLayer");
/** 可直接用于 CSS 的源(默认图/相对 URL/http(s)/file:/data: */
const isCssSource = (v: string) => /^(data:|https?:|file:|\/|\.\/|\.\.\/)/i.test(v);
/** 壁纸切换渐入时长(ms);与 .bg-fade-in 动画时长保持一致 */
const FADE_MS = 600;
export function BackgroundLayer() {
const { type, image, blur, opacity } = useBackgroundStore();
@@ -11,19 +22,116 @@ export function BackgroundLayer() {
const route = useRouteStore((s) => s.current);
const forceDisableContentBlur = useDevStore((s) => s.forceDisableContentBlur);
const showContentBlur = route !== "home";
const [bgImage, setBgImage] = useState(image);
// bgImage:最终可显示的资源引用(CSS url 或 koring-res:// URL 或颜色)。
// 配置里若存的是本地文件路径(自定义壁纸),先置空等待主进程解析成 koring-res:// 引用。
const [bgImage, setBgImage] = useState<string | null>(() => {
if (type === "color" || !image) return image ?? null;
return isCssSource(image) ? image : null;
});
/** 解析后的文件字节(供资源注册表统计) */
const [bgBytes, setBgBytes] = useState(0);
// 双层背景做切换渐入:previous 为旧壁纸(保留至淡出结束),active 为新壁纸(带 fade-in
const [active, setActive] = useState<string | null>(bgImage);
const [previous, setPrevious] = useState<string | null>(null);
const [fading, setFading] = useState(false);
const committedRef = useRef<string | null>(bgImage);
const activeRef = useRef<string | null>(bgImage);
const bgRef = useRef<HTMLDivElement>(null);
// 解析背景源:默认图/颜色/data: 等直接用;
// 本地文件路径 → 主进程校验后返回 koring-res:// 协议引用(每次导入文件名唯一 → URL 变化)。
useEffect(() => {
if (image && image !== DEFAULT_BG && !image.startsWith("data:")) {
(window as any).electronAPI?.getBackgroundDataUrl?.().then((dataUrl: string | null) => {
if (dataUrl) setBgImage(dataUrl);
});
return;
let cancelled = false;
if (type === "color" || !image) {
setBgImage(image ?? null);
setBgBytes(0);
return () => {
cancelled = true;
};
}
setBgImage(image);
}, [image]);
if (image === DEFAULT_BG || isCssSource(image)) {
setBgImage(image);
setBgBytes(0);
return () => {
cancelled = true;
};
}
window.electronAPI?.resolveBackgroundResource?.(image).then((res) => {
if (cancelled || !res) return;
if (res.url) {
log.info(`背景资源解析成功:${image}${res.url} (${res.bytes}B)`);
setBgImage(res.url);
setBgBytes(res.bytes || 0);
} else {
// 文件不可用/越权:回退默认背景(文件已失效,回退优于显示破损背景)
log.warn(`背景资源解析失败/越权,回退默认背景:${image}`);
setBgImage(DEFAULT_BG);
setBgBytes(0);
}
});
return () => {
cancelled = true;
};
}, [image, type]);
// 提交新背景到「双层交叉淡化」状态机(相同 URL 忽略;旧背景保留至淡出结束)
useEffect(() => {
if (bgImage === committedRef.current) return;
committedRef.current = bgImage;
const oldActive = activeRef.current;
setPrevious(oldActive);
setActive(bgImage);
activeRef.current = bgImage;
log.debug(`背景切换 ${oldActive ?? "(无)"}${bgImage ?? "(无)"}`);
// 已有旧背景且确实发生了内容切换(首次出现不渐入)
if (oldActive != null && bgImage != null && oldActive !== bgImage) {
setFading(true);
}
}, [bgImage]);
useEffect(() => {
if (!fading) return;
const timer = setTimeout(() => setFading(false), FADE_MS + 120);
return () => clearTimeout(timer);
}, [fading]);
// 当前生效的背景登记进资源注册表(估算字节、单持有者语义):
// 背景切换时旧条目被释放丢弃,大 dataURL/引用不再被缓存层额外持有。
const trackedKey = useMemo(() => {
if (!bgImage) return null;
if (bgImage.startsWith("data:")) {
return `background:current:${bgImage.length}:${bgImage.slice(0, 96)}`;
}
if (bgImage.startsWith("koring-res:")) {
return `background:current:${bgImage}`;
}
return null;
}, [bgImage]);
useEffect(() => {
if (!trackedKey) return;
let cancelled = false;
const isFileRef = bgImage?.startsWith("koring-res:");
const bytes = isFileRef ? bgBytes : estimateDataUrlBytes(bgImage);
resourceRegistry
.acquire<string>(trackedKey, "background", {
bytes,
cache: false,
load: async () => bgImage ?? "",
})
.then(() => {
if (!cancelled) {
resourceRegistry.setBytes(trackedKey, bytes);
}
});
return () => {
cancelled = true;
resourceRegistry.release(trackedKey);
};
}, [trackedKey, bgImage, bgBytes]);
const handleMouseMove = useCallback(
(e: MouseEvent) => {
@@ -44,11 +152,11 @@ export function BackgroundLayer() {
return () => window.removeEventListener("mousemove", handleMouseMove);
}, [parallax, handleMouseMove]);
const bgUrl = bgImage || DEFAULT_BG;
const contentBlur = showContentBlur && !forceDisableContentBlur;
const getBackgroundStyle = (): React.CSSProperties => {
const base: React.CSSProperties = {
// 外层容器:定位/不透明度/模糊滤镜/视差变换;图片内容由内部双层承载
const containerStyle = (): React.CSSProperties => {
const style: React.CSSProperties = {
position: "fixed",
inset: parallax ? -20 : 0,
zIndex: 0,
@@ -56,30 +164,44 @@ export function BackgroundLayer() {
opacity,
transition: "filter 0.4s cubic-bezier(0.4, 0, 0.2, 1), transform 0.1s ease-out",
};
const filters: string[] = [];
if (blur > 0) filters.push(`blur(${blur}px)`);
if (contentBlur) filters.push("blur(24px) saturate(120%)");
if (filters.length) base.filter = filters.join(" ");
if (filters.length) style.filter = filters.join(" ");
if (type === "color") {
return {
...base,
backgroundColor: bgUrl,
};
style.backgroundColor = active ?? image ?? DEFAULT_BG;
}
return {
...base,
backgroundImage: `url(${bgUrl})`,
backgroundSize: "cover",
backgroundPosition: "center",
};
return style;
};
const imageLayerStyle = (url: string): React.CSSProperties => ({
position: "absolute",
inset: 0,
pointerEvents: "none", // 背景层永不接收任何指针事件
backgroundImage: `url(${url})`,
backgroundSize: "cover",
backgroundPosition: "center",
});
const showPrevious = fading && previous != null && previous !== active;
const isImageType = type !== "color";
return (
<>
<div ref={bgRef} style={getBackgroundStyle()} />
<div ref={bgRef} style={containerStyle()}>
{isImageType && (
<>
{showPrevious && previous && <div style={imageLayerStyle(previous)} />}
{active && (
<div
key={active}
className={showPrevious ? "bg-fade-in" : undefined}
style={imageLayerStyle(active)}
/>
)}
</>
)}
</div>
<div
className="content-blur-overlay"
style={{ opacity: contentBlur ? 1 : 0 }}
@@ -0,0 +1,30 @@
// 定制反馈按钮(HeroUI):点击后用系统浏览器打开 YouTrack 反馈表单直链。
import { ComponentProps } from "react";
import { Button } from "@heroui/react";
import { MessageSquareHeart, ExternalLink } from "lucide-react";
import { toast } from "sonner";
const FORM_DIRECT_URL = "https://lingke.youtrack.cloud/form/aa6a005d-2559-4e92-a0f5-d3898f377ef8";
type FeedbackButtonProps = {
/** 按钮文案,默认「意见反馈」 */
label?: string;
} & Omit<ComponentProps<typeof Button>, "children">;
export function FeedbackButton({ label = "意见反馈", ...buttonProps }: FeedbackButtonProps) {
const openFeedback = async () => {
try {
await window.electronAPI?.openExternal(FORM_DIRECT_URL);
} catch {
toast.error("无法打开反馈页面,请稍后重试");
}
};
return (
<Button variant="primary" size="md" onPress={openFeedback} {...buttonProps}>
<MessageSquareHeart className="w-4 h-4" />
{label}
<ExternalLink className="w-3 h-3 opacity-70" />
</Button>
);
}
+9 -7
View File
@@ -11,11 +11,11 @@ import {
Radio,
RadioGroup,
Select,
Switch,
TextArea,
} from "@heroui/react";
import { Check, ChevronDown, FolderOpen, FolderSearch, Loader2 } from "lucide-react";
import { ipcInvoke } from "@/api/ipc";
import { Switch } from "@/components/ui/switch";
import { SettingRow } from "./SettingRow";
export interface SettingOption {
@@ -51,6 +51,7 @@ export function SettingSelect({
return (
<SettingRow label={label} desc={desc}>
<Select.Root
aria-label={label}
selectedKey={selectedKey}
onSelectionChange={(keys) => {
// RAC 单选时可能传 Key | null,也可能传 Set<Key>;两种形状都兼容
@@ -119,6 +120,7 @@ export function SettingNumberField({
<SettingRow label={label} desc={desc}>
<div className={`flex items-center gap-1.5 ${className ?? ""}`}>
<NumberField.Root
aria-label={label}
value={value}
onChange={onChange}
minValue={min}
@@ -162,12 +164,12 @@ export function SettingSwitch({
}) {
return (
<SettingRow label={label} desc={desc}>
{/* 注:HeroUI 3 基于 react-ariaSwitch 使用 onChange 而非 onValueChange */}
<Switch isSelected={checked} onChange={onChange}>
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
</Switch>
{/* shadcn Switch@base-ui/react/switch):替代 HeroUI 3 Switch(鼠标点击不触发 change */}
<Switch
aria-label={label}
checked={checked}
onCheckedChange={onChange}
/>
</SettingRow>
);
}
+11 -1
View File
@@ -1,6 +1,6 @@
/* eslint-disable react/no-unknown-property */
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import { forwardRef, useRef, useMemo, useLayoutEffect } from "react";
import { forwardRef, useRef, useMemo, useLayoutEffect, useEffect } from "react";
import { Color, type Mesh, type ShaderMaterial } from "three";
const hexToNormalizedRGB = (hex: string) => {
@@ -83,7 +83,17 @@ const SilkPlane = forwardRef(function SilkPlane({ uniforms }: SilkPlaneProps, re
invalidate();
}, [ref, viewport, invalidate]);
// 窗口隐藏/最小化时停帧(无可见画面,零视觉影响);恢复可见后立即刷新一帧重启动画
useEffect(() => {
const onVisibilityChange = () => {
if (!document.hidden) invalidate();
};
document.addEventListener("visibilitychange", onVisibilityChange);
return () => document.removeEventListener("visibilitychange", onVisibilityChange);
}, [invalidate]);
useFrame((_, delta) => {
if (document.hidden) return; // 不可见时不再推进 uTime / 请求帧,避免 GPU 空转
if (ref && typeof ref === "object" && ref.current) {
(ref.current.material as ShaderMaterial).uniforms.uTime.value += 0.1 * delta;
invalidate();
+202
View File
@@ -0,0 +1,202 @@
"use client"
/**
* shadcn Select @base-ui/react/select switch/radio/slider
*
* <Select value={v} onValueChange={setV}>
* <SelectTrigger aria-label="x"><SelectValue placeholder="请选择" /></SelectTrigger>
* <SelectContent>
* <SelectItem value="a">A</SelectItem>
* </SelectContent>
* </Select>
*/
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
function Select({
className,
...props
}: SelectPrimitive.Root.Props<string>) {
return (
<SelectPrimitive.Root
data-slot="select"
className={cn("", className)}
{...props}
/>
)
}
function SelectValue({
className,
placeholder,
...props
}: SelectPrimitive.Value.Props & { placeholder?: string }) {
if (placeholder) {
return (
<SelectPrimitive.Value
placeholder={placeholder}
data-slot="select-value"
className={cn("text-[13px] text-foreground data-[placeholder]:text-muted-foreground/60", className)}
{...props}
/>
)
}
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("text-[13px] text-foreground", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
...props
}: SelectPrimitive.Trigger.Props & { size?: "sm" | "default" }) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"inline-flex h-8 w-full shrink-0 items-center justify-between gap-2 rounded-lg border border-border/40 bg-white/60 px-3 text-[13px] text-foreground outline-none transition-colors select-none dark:bg-black/30 dark:border-white/[0.08] focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 hover:border-ring/50",
className
)}
{...props}
/>
)
}
function SelectIcon({ className, ...props }: SelectPrimitive.Icon.Props) {
return (
<SelectPrimitive.Icon
data-slot="select-icon"
className={cn("shrink-0 text-muted-foreground/60", className)}
{...props}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="m6 9 6 6 6-6" />
</svg>
</SelectPrimitive.Icon>
)
}
function SelectContent({
className,
position = "popper",
side = "bottom",
sideOffset = 4,
...props
}: SelectPrimitive.Popup.Props & {
position?: "popper" | "item-aligned"
side?: "top" | "bottom" | "left" | "right"
sideOffset?: number
}) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
className={cn(
"z-50",
position === "popper" &&
"w-[var(--anchor-width)] min-w-[10rem]"
)}
>
<SelectPrimitive.Popup
data-slot="select-content"
className={cn(
"relative max-h-72 min-w-[10rem] overflow-y-auto scroll-area rounded-xl border border-border/50 bg-background p-1.5 text-[13px] text-foreground shadow-xl outline-none",
className
)}
{...props}
/>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"group/item relative flex w-full cursor-pointer items-center gap-2 rounded-lg py-1.5 pr-8 pl-2.5 text-[13px] text-foreground outline-none select-none",
"data-highlighted:bg-muted data-selected:bg-primary/10 data-selected:text-primary",
"data-disabled:pointer-events-none data-disabled:opacity-50",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="min-w-0 flex-1 truncate">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator className="absolute right-2 text-primary">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M20 6 9 17l-5-5" />
</svg>
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectLabel({ className, ...props }: SelectPrimitive.Label.Props) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2.5 py-1.5 text-[12px] text-muted-foreground", className)}
{...props}
/>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("my-1 h-px bg-border", className)}
{...props}
/>
)
}
export {
Select,
SelectValue,
SelectTrigger,
SelectIcon,
SelectContent,
SelectItem,
SelectLabel,
SelectSeparator,
}
+11
View File
@@ -293,6 +293,17 @@
animation: none !important;
}
/* ============================================
壁纸切换渐入BackgroundLayer 双层淡入
============================================ */
@keyframes bg-crossfade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.bg-fade-in {
animation: bg-crossfade-in 0.6s ease-out both;
}
.reduce-transparency {
--titlebar-bg: var(--background);
}
+9
View File
@@ -2,6 +2,8 @@ import { type ReactNode } from "react";
import { BackgroundLayer } from "@/components/background/BackgroundLayer";
import { SystemLayer } from "@/components/system/SystemLayer";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { UpdateAvailableDialog } from "@/components/UpdateAvailableDialog";
import { RuntimeNotices } from "@/components/RuntimeNotices";
import { Toaster } from "sonner";
import { useA11yStore } from "@/stores/a11yStore";
import clsx from "clsx";
@@ -38,6 +40,7 @@ export function RootLayout({
{/* Layer 1: Content */}
<div
id="app-content-scroll"
className="absolute z-[1] left-0 right-0 bottom-0 top-[40px] overflow-auto"
style={{ viewTransitionName: "content" } as React.CSSProperties}
>
@@ -54,6 +57,12 @@ export function RootLayout({
{/* Global confirm dialog */}
<ConfirmDialog />
{/* 全局:发现新版本弹窗(检查到新版本时自动弹出) */}
<UpdateAvailableDialog />
{/* 运行时提示(Linux AppImage 未解包安装等) */}
<RuntimeNotices />
{/* Sonner toaster */}
<Toaster
position="bottom-right"
+71
View File
@@ -0,0 +1,71 @@
/**
*
*
* electron/core/logger.ts
* - debugerror/warn/info DevToolsdebug
* - config.advanced.debugModedebug
* electronAPI.log log:write
* dev stdout/stderr userData/koring.log
*/
import { useConfigStore } from "@/stores/configStore";
export type LogLevel = "debug" | "info" | "warn" | "error";
export interface RendererLogger {
debug: (msg: string, ...args: unknown[]) => void;
info: (msg: string, ...args: unknown[]) => void;
warn: (msg: string, ...args: unknown[]) => void;
error: (msg: string, ...args: unknown[]) => void;
}
function serialize(value: unknown): string {
if (typeof value === "string") {
return value.length > 800 ? `${value.slice(0, 800)}…(+${value.length - 800})` : value;
}
if (value instanceof Error) return value.message;
if (typeof value === "object" && value !== null) {
try {
return JSON.stringify(value) ?? String(value);
} catch {
return String(value);
}
}
return String(value);
}
function isDebugMode(): boolean {
try {
return useConfigStore.getState().config?.advanced?.debugMode === true;
} catch {
return false;
}
}
function emit(scope: string, level: LogLevel, msg: string, args: unknown[]): void {
const text = args.length ? `${msg} ${args.map(serialize).join(" ")}` : msg;
const line = `[${scope}][${level.toUpperCase()}] ${text}`;
const debugOn = isDebugMode();
if (level === "error") console.error(line);
else if (level === "warn") console.warn(line);
else if (level === "info") console.info(line);
else if (debugOn) console.debug(line);
if (!debugOn) return;
window.electronAPI?.log?.(level, scope, line);
}
function make(scope: string): RendererLogger {
const bound = (level: LogLevel) => (msg: string, ...args: unknown[]) => emit(scope, level, msg, args);
return {
debug: bound("debug"),
info: bound("info"),
warn: bound("warn"),
error: bound("error"),
};
}
export function createRendererLogger(scope: string): RendererLogger {
return make(scope);
}
+17 -1
View File
@@ -1,7 +1,15 @@
import { useRouteStore } from "@/stores/routeStore";
import { Monitor, Paintbrush, CreditCard, ListTodo, ChevronRight, FlaskConical, Rocket, AlertTriangle, RefreshCw, SquareTerminal } from "lucide-react";
import { Waypoints, Monitor, Paintbrush, CreditCard, ListTodo, ChevronRight, FlaskConical, Rocket, AlertTriangle, RefreshCw, SquareTerminal, MemoryStick } from "lucide-react";
const debugPages = [
{
key: "debug-routes" as const,
icon: Waypoints,
title: "页面跳转",
desc: "列出所有已注册的页面(含隐藏页),点击即可快速跳转预览",
color: "text-sky-500",
bg: "bg-sky-500/10",
},
{
key: "debug-update" as const,
icon: RefreshCw,
@@ -50,6 +58,14 @@ const debugPages = [
color: "text-cyan-500",
bg: "bg-cyan-500/10",
},
{
key: "debug-resource" as const,
icon: MemoryStick,
title: "资源与内存",
desc: "监控渲染进程 JS 堆、进程工作集与资源注册表占用,验证内存优化",
color: "text-violet-500",
bg: "bg-violet-500/10",
},
{
key: "oobe" as const,
icon: Rocket,
+372
View File
@@ -0,0 +1,372 @@
import { useEffect, useRef, useState } from "react";
import { RefreshCw, Eraser, RotateCcw } from "lucide-react";
import { GlassCard, PageHeader, SettingRow } from "./components";
import { getMemorySnapshot, type ProcessMemoryMetric, type SystemMemorySnapshot } from "@/api/system";
import { useResourceStore } from "@/resources/store";
import type { ResourceKind } from "@/resources/types";
interface JsHeapInfo {
used: number; // bytes
total: number; // bytes
limit: number; // bytes
}
function readJsHeap(): JsHeapInfo | null {
const m = (performance as unknown as { memory?: { usedJSHeapSize: number; totalJSHeapSize: number; jsHeapSizeLimit: number } }).memory;
if (!m) return null;
return { used: m.usedJSHeapSize, total: m.totalJSHeapSize, limit: m.jsHeapSizeLimit };
}
const fmtMB = (bytes: number, fraction = 1): string => `${(bytes / 1024 / 1024).toFixed(fraction)} MB`;
const KIND_LABELS: Record<ResourceKind, string> = {
background: "背景图",
image: "图片/图标",
blob: "Blob",
text: "文本",
};
export function ResourceDebug() {
const [proc, setProc] = useState<SystemMemorySnapshot | null>(null);
const [heap, setHeap] = useState<JsHeapInfo | null>(null);
const [domNodes, setDomNodes] = useState(0);
const [logInfo, setLogInfo] = useState<{ filePath: string | null; debugMode: boolean } | null>(null);
const [hitInfo, setHitInfo] = useState<string[] | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const snapshot = useResourceStore((s) => s.snapshot);
const budgets = useResourceStore((s) => s.budgets);
const clearFree = useResourceStore((s) => s.clearFree);
const resetCounters = useResourceStore((s) => s.resetCounters);
/** 诊断「控件无法点击」:找出全屏覆盖且 pointer-events≠none 的元素,并采样几个点位的最上层元素 */
const runHitTest = () => {
const lines: string[] = [];
const all = document.querySelectorAll<HTMLElement>("body *");
// 1) 疑似全屏拦截层
const seen = new Set<HTMLElement>();
all.forEach((el) => {
const rect = el.getBoundingClientRect();
const cs = getComputedStyle(el);
const covers =
rect.width >= window.innerWidth * 0.97 && rect.height >= window.innerHeight * 0.97;
const clickable = cs.pointerEvents !== "none";
if (!covers || !clickable || seen.has(el)) return;
seen.add(el);
const tag = el.tagName.toLowerCase();
lines.push(
`[全屏可点] <${tag}${el.id ? `#${el.id}` : ""}> pos=${cs.position} z=${cs.zIndex} class="${String(el.className).slice(0, 120)}"`,
);
});
// 2) 采样几个位置的最上层元素
const points: Array<[number, number, string]> = [
[0.5, 0.5, "中央"],
[0.5, 0.12, "标题栏下沿"],
[0.25, 0.6, "内容区"],
[0.75, 0.85, "内容区右下"],
];
for (const [fx, fy, label] of points) {
const el = document.elementFromPoint(Math.floor(innerWidth * fx), Math.floor(innerHeight * fy));
if (!el || el === document.body) {
lines.push(`[${label}] (${fx},${fy}) → 无元素/body`);
continue;
}
const target = el as HTMLElement;
const cs = getComputedStyle(target);
const chain: string[] = [];
let node: HTMLElement | null = target;
for (let i = 0; node && i < 5; i++) {
chain.push(
`${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ""}${node.className ? `.${String(node.className).split(/\s+/).filter(Boolean).slice(0, 2).join(".")}` : ""}`,
);
node = node.parentElement;
}
lines.push(`[${label}] (${fx},${fy}) → ${chain.join(" < ")} | pe=${cs.pointerEvents}`);
}
if (lines.length === 0) lines.push("未发现明显拦截层(可再多点几个位置)");
setHitInfo(lines);
};
const sample = async () => {
setHeap(readJsHeap());
setDomNodes(document.querySelectorAll("*").length);
try {
const data = await getMemorySnapshot();
setProc(data);
} catch {
setProc(null);
}
try {
const info = (await window.electronAPI?.invoke?.("log:getInfo")) as
| { filePath: string | null; debugMode: boolean }
| undefined;
if (info) setLogInfo(info);
} catch {
// 忽略日志状态查询失败
}
};
useEffect(() => {
sample();
timerRef.current = setInterval(sample, 1000);
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const byKind = snapshot.stats.byKind;
const kinds = Object.keys(KIND_LABELS) as ResourceKind[];
const processes = [...(proc?.metrics ?? [])].sort((a, b) => b.workingSetSize - a.workingSetSize);
return (
<div className="max-w-3xl mx-auto p-8">
<PageHeader
title="资源与内存"
desc="启动器程序本体资源/内存监控(仅调试页可见):JS 堆、进程工作集、资源注册表占用"
/>
<div className="space-y-6">
{/* 渲染进程堆 */}
<div>
<h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider mb-3">
JS 1s
</h3>
<div className="space-y-3">
<GlassCard>
<div className="flex flex-wrap gap-x-8 gap-y-2">
<div>
<p className="text-[12px] text-muted-foreground"></p>
<p className="text-lg font-semibold text-foreground tabular-nums">
{heap ? fmtMB(heap.used) : "不可用"}
</p>
</div>
<div>
<p className="text-[12px] text-muted-foreground"></p>
<p className="text-lg font-semibold text-foreground tabular-nums">
{heap ? fmtMB(heap.limit) : "不可用"}
</p>
</div>
<div>
<p className="text-[12px] text-muted-foreground">DOM </p>
<p className="text-lg font-semibold text-foreground tabular-nums">{domNodes}</p>
</div>
<div>
<p className="text-[12px] text-muted-foreground"></p>
<p className="text-lg font-semibold text-foreground tabular-nums">
{proc ? new Date(proc.timestamp).toLocaleTimeString() : "--"}
</p>
</div>
</div>
</GlassCard>
</div>
</div>
{/* 统一日志状态 */}
<div>
<h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider mb-3">
debug
</h3>
<GlassCard>
<div className="flex flex-wrap gap-x-8 gap-y-2">
<div>
<p className="text-[12px] text-muted-foreground"></p>
<p className="text-lg font-semibold text-foreground">
{logInfo?.debugMode ? "开启" : "关闭"}
</p>
</div>
<div>
<p className="text-[12px] text-muted-foreground"></p>
<p className="text-sm font-medium text-foreground break-all">
{logInfo?.filePath ?? "(未开启 → 仅输出到控制台)"}
</p>
</div>
<div>
<p className="text-[12px] text-muted-foreground"></p>
<p className="text-sm font-medium text-foreground"> </p>
</div>
</div>
</GlassCard>
</div>
{/* 点击拦截诊断 */}
<div>
<h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider mb-3">
使
</h3>
<GlassCard>
<div className="flex items-center justify-between gap-2 mb-2">
<p className="text-[13px] text-muted-foreground">
4
</p>
<button
onClick={runHitTest}
className="inline-flex items-center gap-1 rounded-md border border-foreground/10 px-2 py-1 text-[12px] text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
</button>
</div>
{hitInfo && (
<pre className="max-h-56 overflow-auto rounded-md bg-foreground/[0.04] p-3 text-[12px] leading-relaxed font-mono whitespace-pre-wrap text-foreground/80">
{hitInfo.join("\n")}
</pre>
)}
</GlassCard>
</div>
{/* 进程工作集 */}
<div>
<h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider mb-3">
Electron KB
</h3>
<GlassCard>
{proc === null ? (
<p className="text-[13px] text-muted-foreground"> Electron </p>
) : processes.length === 0 ? (
<p className="text-[13px] text-muted-foreground"></p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-[13px]">
<thead>
<tr className="text-left text-muted-foreground/70">
<th className="py-1 pr-4 font-medium"></th>
<th className="py-1 pr-4 font-medium text-right">PID</th>
<th className="py-1 pr-4 font-medium text-right"></th>
<th className="py-1 font-medium text-right"></th>
</tr>
</thead>
<tbody>
{processes.map((p: ProcessMemoryMetric) => (
<tr key={`${p.type}-${p.pid}`} className="border-t border-foreground/5">
<td className="py-1.5 pr-4 text-foreground">{p.type}</td>
<td className="py-1.5 pr-4 text-right text-muted-foreground tabular-nums">{p.pid}</td>
<td className="py-1.5 pr-4 text-right tabular-nums">{fmtMB(p.workingSetSize * 1024)}</td>
<td className="py-1.5 text-right text-muted-foreground tabular-nums">
{fmtMB(p.peakWorkingSetSize * 1024)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</GlassCard>
</div>
{/* 资源注册表 */}
<div>
<h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider mb-3">
background:current:* = dataURL
</h3>
<div className="space-y-3">
<GlassCard>
<div className="flex flex-wrap gap-x-8 gap-y-2 mb-3">
<div>
<p className="text-[12px] text-muted-foreground"></p>
<p className="text-lg font-semibold text-foreground tabular-nums">{snapshot.stats.entries}</p>
</div>
<div>
<p className="text-[12px] text-muted-foreground"></p>
<p className="text-lg font-semibold text-foreground tabular-nums">{fmtMB(snapshot.stats.totalBytes)}</p>
</div>
<div>
<p className="text-[12px] text-muted-foreground"> / </p>
<p className="text-lg font-semibold text-foreground tabular-nums">
{snapshot.stats.hits} / {snapshot.stats.misses}
</p>
</div>
<div>
<p className="text-[12px] text-muted-foreground">LRU </p>
<p className="text-lg font-semibold text-foreground tabular-nums">{snapshot.stats.evictions}</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
{kinds.map((kind) => {
const group = byKind[kind];
return (
<span
key={kind}
className="inline-flex items-center gap-1.5 rounded-full border border-foreground/10 px-3 py-1 text-[12px]"
>
<span className="text-muted-foreground">{KIND_LABELS[kind]}</span>
<span className="tabular-nums font-medium">
{group ? `${group.count} / ${fmtMB(group.bytes)}` : "0 / 0 MB"}
</span>
<span className="text-muted-foreground/50"> {fmtMB(budgets[kind])}</span>
</span>
);
})}
</div>
</GlassCard>
<GlassCard>
<div className="flex items-center justify-between gap-2 mb-2">
<p className="text-[13px] font-medium text-foreground"></p>
<div className="flex items-center gap-2">
<button
onClick={() => useResourceStore.getState().refresh()}
className="inline-flex items-center gap-1 rounded-md border border-foreground/10 px-2 py-1 text-[12px] text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
<RefreshCw className="w-3 h-3" />
</button>
<button
onClick={clearFree}
className="inline-flex items-center gap-1 rounded-md border border-foreground/10 px-2 py-1 text-[12px] text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
<Eraser className="w-3 h-3" />
</button>
<button
onClick={resetCounters}
className="inline-flex items-center gap-1 rounded-md border border-foreground/10 px-2 py-1 text-[12px] text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
<RotateCcw className="w-3 h-3" />
</button>
</div>
</div>
{snapshot.entries.length === 0 ? (
<p className="text-[13px] text-muted-foreground"> background:current:*</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-[13px]">
<thead>
<tr className="text-left text-muted-foreground/70">
<th className="py-1 pr-4 font-medium">Key</th>
<th className="py-1 pr-4 font-medium"></th>
<th className="py-1 pr-4 font-medium text-right"></th>
<th className="py-1 font-medium text-right"></th>
</tr>
</thead>
<tbody>
{snapshot.entries.slice(0, 40).map((e) => (
<tr key={e.key} className="border-t border-foreground/5">
<td className="py-1.5 pr-4 font-mono text-[12px] text-foreground/80 max-w-[420px] truncate">
{e.key}
</td>
<td className="py-1.5 pr-4 text-muted-foreground">{KIND_LABELS[e.kind] ?? e.kind}</td>
<td className="py-1.5 pr-4 text-right tabular-nums">{fmtMB(e.bytes)}</td>
<td className="py-1.5 text-right tabular-nums">{e.refs}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</GlassCard>
</div>
</div>
<GlassCard>
<SettingRow
label="优化效果验证指引"
desc="在首页空闲时记录 JS 堆与进程工作集 → 在设置里选择一张 ≥3MB 的大图作背景 → 观察 background:current 估算字节与 JS 堆;窗口最小化 Silk 动画停帧(GPU 占用回落)。"
>
<span aria-hidden="true" />
</SettingRow>
</GlassCard>
</div>
</div>
);
}
+137
View File
@@ -0,0 +1,137 @@
import { useMemo, useState } from "react";
import clsx from "clsx";
import { ChevronRight, Search } from "lucide-react";
import { allRoutes, routes, useRouteStore } from "@/stores/routeStore";
import type { RouteKey } from "@/stores/routeStore";
import { Input } from "@/components/ui/input";
import { PageHeader } from "./components";
type GroupKey = "main" | "app" | "debug" | "oobe" | "upvp";
const GROUPS: { key: GroupKey; title: string; desc: string }[] = [
{ key: "main", title: "主导航", desc: "顶部导航栏显示的页面" },
{ key: "app", title: "功能子页", desc: "从主导航进入的二级页面" },
{ key: "debug", title: "调试工具", desc: "开发者工具页面" },
{ key: "oobe", title: "OOBE 引导", desc: "首次启动开箱引导流程页面" },
{ key: "upvp", title: "更新引导", desc: "版本更新引导流程页面" },
];
/** 顶部导航的页面集合 */
const topLevelKeys = new Set<RouteKey>(routes.map((r) => r.key));
function groupOf(key: RouteKey): GroupKey {
if (topLevelKeys.has(key)) return "main";
if (key === "oobe" || key.startsWith("oobe/")) return "oobe";
if (key === "upvp" || key.startsWith("upvp/")) return "upvp";
if (key === "debug" || key.startsWith("debug-")) return "debug";
return "app";
}
/** 页面跳转:列出全部已注册页面,点击即可跳转 */
export function RoutesDebug() {
const current = useRouteStore((s) => s.current);
const navigate = useRouteStore((s) => s.navigate);
const [query, setQuery] = useState("");
const grouped = useMemo(() => {
const q = query.trim().toLowerCase();
const buckets = new Map<GroupKey, typeof allRoutes>();
for (const route of allRoutes) {
if (q) {
const hay = `${route.label} ${route.key} ${route.path}`.toLowerCase();
if (!hay.includes(q)) continue;
}
const g = groupOf(route.key);
const list = buckets.get(g) ?? [];
list.push(route);
buckets.set(g, list);
}
return GROUPS.map((g) => ({
...g,
items: buckets.get(g.key) ?? [],
})).filter((g) => g.items.length > 0);
}, [query]);
const total = allRoutes.length;
return (
<div className="max-w-2xl mx-auto p-8">
<PageHeader
title="页面跳转"
desc={`启动器当前注册了 ${total} 个页面,点击任意条目即可跳转预览`}
/>
{/* 搜索过滤 */}
<div className="relative mb-6">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground/40" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索页面名称 / 路由 key / 路径…"
className="pl-9"
/>
</div>
<div className="space-y-7">
{grouped.map((group) => (
<div key={group.key}>
<h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider">
{group.title}
<span className="ml-2 normal-case font-normal text-[12px] text-muted-foreground/60">
{group.desc}
</span>
</h3>
<div className="mt-2 space-y-1.5">
{group.items.map((route) => {
const active = route.key === current;
return (
<button
key={route.key}
onClick={() => navigate(route.key)}
disabled={active}
className={clsx(
"glass-card w-full px-4 py-3 text-left flex items-center gap-3 transition-all",
!active &&
"hover:scale-[1.01] active:scale-[0.99] cursor-pointer group",
active && "opacity-80 cursor-default",
)}
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground flex items-center gap-2">
{route.label}
{route.hidden && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-foreground/[0.06] text-muted-foreground/70">
</span>
)}
{active && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-foreground/[0.08] text-foreground/70">
</span>
)}
</p>
<p className="font-mono text-[11px] text-muted-foreground/70 mt-0.5 truncate">
{route.key} · {route.path}
</p>
</div>
<ChevronRight className="w-4 h-4 shrink-0 text-foreground/25 group-hover:text-foreground/50" />
</button>
);
})}
</div>
</div>
))}
{grouped.length === 0 && (
<p className="text-sm text-muted-foreground/60 text-center py-10">
{query.trim()}
</p>
)}
</div>
<p className="text-[12px] text-muted-foreground/50 mt-8 text-center">
OOBE /
</p>
</div>
);
}
+22 -1
View File
@@ -18,6 +18,7 @@ import {
type UpdateStatusPayload,
type VersionCompareResult,
} from "@/api/update";
import { useUpdateDialogStore } from "@/stores/updateDialogStore";
/** 更新功能测试:版本识别 / 检查 / 介绍 / 比对 / 下载 */
export function UpdateDebug() {
@@ -71,7 +72,27 @@ export function UpdateDebug() {
</GlassCard>
</div>
{/* 2. 设置版本号 */}
{/* 2. 新版本弹窗预览(唤起全局"发现新版本"弹窗) */}
<div>
<h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider mb-3">
</h3>
<GlassCard>
<div className="flex flex-wrap gap-2 items-center">
<Button size="sm" onClick={() => useUpdateDialogStore.getState().show(s?.version || "9.9.9")}>
</Button>
<Button size="sm" variant="outline" onClick={() => useUpdateDialogStore.getState().hide()}>
</Button>
<span className="text-[12px] text-muted-foreground font-mono ml-1">
{s?.version || "9.9.9"} 9.9.9
</span>
</div>
</GlassCard>
</div>
{/* 3. 设置版本号 */}
<div>
<h3 className="text-sm font-semibold text-foreground/40 uppercase tracking-wider mb-3">
+31
View File
@@ -0,0 +1,31 @@
import { useRouteStore } from "@/stores/routeStore";
import { BUILD_MODE } from "@/lib/mode";
import { VERSION } from "@/lib/version";
import { OobeLayout } from "./layout";
import { NextButton } from "./next-button";
import { AboutVersion } from "@/components/about-version";
/** 版本卡片之后:关于此版本(展示当前版本更新内容,引用 AboutVersion 组件) */
export function OobeAboutVersion() {
const navigate = useRouteStore((s) => s.navigate);
const isTestBuild = BUILD_MODE === "dev" || BUILD_MODE === "beta";
// 与原本 step-version 的分流一致:测试版需先同意 Beta 测试协议
const nextRoute = isTestBuild ? "oobe/beta-test" : "oobe/finish";
return (
<OobeLayout>
<div className="w-full max-w-lg flex flex-col items-center px-6">
<h2 className="text-lg font-bold text-foreground mb-0.5"></h2>
<p className="text-[12px] text-muted-foreground mb-4"> v{VERSION} </p>
{/* 内容滚动区:限高避免遮挡底部下一步按钮 */}
<div className="w-full max-h-[58vh] overflow-y-auto scroll-area pr-1 -mr-1 min-h-[140px]">
<AboutVersion />
</div>
</div>
<NextButton onClick={() => navigate(nextRoute)} />
</OobeLayout>
);
}
+10 -1
View File
@@ -1,3 +1,4 @@
import { useEffect, useState } from "react";
import { useRouteStore } from "@/stores/routeStore";
import { useConfigStore } from "@/stores/configStore";
import { OobeLayout } from "./layout";
@@ -7,6 +8,13 @@ export function OobeFinish() {
const navigate = useRouteStore((s) => s.navigate);
const setOobe = useConfigStore((s) => s.setOobe);
// 「前往首页」按钮先隐藏,hello 动画播完后(4 秒)渐入浮现
const [btnVisible, setBtnVisible] = useState(false);
useEffect(() => {
const t = setTimeout(() => setBtnVisible(true), 4000);
return () => clearTimeout(t);
}, []);
const handleFinish = () => {
setOobe(false);
navigate("home");
@@ -21,7 +29,8 @@ export function OobeFinish() {
<div className="absolute bottom-12">
<button
onClick={handleFinish}
className="h-12 px-6 rounded-full bg-foreground/[0.06] hover:bg-foreground/[0.12] flex items-center justify-center text-foreground/60 hover:text-foreground transition-all duration-200 text-sm font-medium"
className="h-12 px-6 rounded-full bg-foreground/[0.06] hover:bg-foreground/[0.12] flex items-center justify-center text-foreground/60 hover:text-foreground transition-opacity duration-700 ease-out text-sm font-medium"
style={{ opacity: btnVisible ? 1 : 0, pointerEvents: btnVisible ? "auto" : "none" }}
>
</button>
+4 -2
View File
@@ -9,8 +9,10 @@ export function OobeVersion() {
const isTestBuild = BUILD_MODE === "dev" || BUILD_MODE === "beta";
// 临时跳过 Koring 账户登录环节:正式版也直接进入用户协议
const nextRoute = isTestBuild ? "oobe/beta-test" : "oobe/agreement";
// 到达本页前已依次经过 协议(agreement) → 法律(legal) → 欢迎(welcome)
// 先进入「关于此版本」查看当前版本更新内容,再按构建类型结束或进入 Beta 测试协议。
// (不要跳回 agreement——那会形成 agreement → legal → welcome → version → agreement 死循环)
const nextRoute = "oobe/about-version";
return (
<OobeLayout>
+21
View File
@@ -0,0 +1,21 @@
import { UnderConstruction } from "@/components/UnderConstruction";
/** 实例页占位(与资讯页相同的占位组件,临时使用) */
export function GalleryPlaceholder() {
return (
<UnderConstruction
pageName="实例"
description="管理您的游戏实例、版本与存档"
/>
);
}
/** 资源页占位(与资讯页相同的占位组件,临时使用) */
export function StorePlaceholder() {
return (
<UnderConstruction
pageName="资源"
description="Minecraft 游戏版本、MOD 与整合包下载"
/>
);
}
+1 -16
View File
@@ -3,7 +3,6 @@ import { useConfigStore } from "@/stores/configStore";
import {
SettingCard,
SettingSelect,
SettingSwitch,
SettingNumberField,
SettingFilePicker,
fieldCls,
@@ -28,7 +27,7 @@ export function AdvancedSetting() {
return (
<div>
<PageHeader title="高级设置" desc="游戏高级启动参数、调试选项与实验性功能" />
<PageHeader title="高级设置" desc="游戏高级启动参数与实验性功能" />
<div className="space-y-6">
{/* 启动行为 */}
@@ -163,20 +162,6 @@ export function AdvancedSetting() {
</div>
</div>
{/* 调试 */}
<div>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<SettingCard>
<SettingSwitch
label="调试模式"
desc="启用后附加 -Dkoring.debugMode=true 并在控制台输出详细日志,可能影响性能"
checked={adv.debugMode}
onChange={(v) => setAdvanced({ debugMode: v })}
/>
</SettingCard>
</div>
</div>
</div>
</div>
);
+45 -2
View File
@@ -4,7 +4,8 @@ import { BUILD_MODE } from "@/lib/mode";
import { BUILD_COMMIT, BUILD_ID } from "@/lib/buildInfo";
import { ExternalLink, GitFork, RotateCcw, ChevronDown } from "lucide-react";
import { Link, Select, ListBox, ListBoxItem } from "@heroui/react";
import { SettingCard, SettingRow, PageHeader, SectionTitle } from "@/components/setting";
import { useConfigStore } from "@/stores/configStore";
import { SettingCard, SettingRow, SettingSwitch, PageHeader, SectionTitle } from "@/components/setting";
import { Button } from "@/components/ui/button";
import {
getUpdateChannels,
@@ -14,6 +15,7 @@ import {
type UpdateChannelDef,
} from "@/api/update";
import { toast } from "sonner";
import { getDeviceId, type DeviceIdentity } from "@/api/system";
import {
AlertDialog,
AlertDialogTrigger,
@@ -46,6 +48,25 @@ export function AboutSetting() {
const [countdown, setCountdown] = useState(5);
const canConfirm = countdown <= 0;
const adv = useConfigStore((s) => s.config.advanced);
const setAdvanced = useConfigStore((s) => s.setAdvanced);
// 设备识别码(组合指纹:主板/硬盘/BIOS → 回退系统安装标识)
const [device, setDevice] = useState<DeviceIdentity | null>(null);
useEffect(() => {
let cancelled = false;
getDeviceId()
.then((d) => {
if (!cancelled) setDevice(d);
})
.catch(() => {
if (!cancelled) setDevice(null);
});
return () => {
cancelled = true;
};
}, []);
// 更新通道(下拉框,选项来自主进程通道注册表)
const [channels, setChannels] = useState<UpdateChannelDef[]>([]);
const [activeChannel, setActiveChannel] = useState("woker");
@@ -103,7 +124,8 @@ export function AboutSetting() {
<PageHeader title="关于" desc="查看版本信息、更新状态与项目相关链接" />
<div className="space-y-6">
<VersionCard />
{/* 设置页:整卡可点击跳转更新页,显示「查看更新」 */}
<VersionCard isSettingPage />
<div>
<SectionTitle></SectionTitle>
@@ -131,6 +153,13 @@ export function AboutSetting() {
<span className="text-[13px] text-muted-foreground">Node.js</span>
</SettingRow>
</SettingCard>
<SettingCard>
<SettingRow label="设备识别码" desc="设备追踪ID">
<span className="text-[13px] text-muted-foreground font-mono">
{device?.deviceId ?? "—"}
</span>
</SettingRow>
</SettingCard>
</div>
</div>
@@ -176,6 +205,20 @@ export function AboutSetting() {
</div>
</div>
<div>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<SettingCard>
<SettingSwitch
label="调试模式"
desc="在控制台输出详细日志,可能影响性能"
checked={adv?.debugMode ?? false}
onChange={(v) => setAdvanced({ debugMode: v })}
/>
</SettingCard>
</div>
</div>
<div>
<SectionTitle></SectionTitle>
<div className="space-y-3">
+4 -4
View File
@@ -1,5 +1,4 @@
import {
UserCircle,
Gamepad2,
Palette,
Download,
@@ -37,7 +36,8 @@ function ShortcutTile({ icon, label, desc, navKey, onClick }: ShortcutItem & { o
}
const shortcuts: ShortcutItem[] = [
{ icon: <UserCircle className="w-4 h-4" />, label: "Koring 账户", desc: "同步数据、皮肤与个人配置", navKey: "account" },
// Koring 账户(暂时隐藏)
// { icon: <UserCircle className="w-4 h-4" />, label: "Koring 账户", desc: "同步数据、皮肤与个人配置", navKey: "account" },
{ icon: <Gamepad2 className="w-4 h-4" />, label: "游戏账户与档案", desc: "管理游戏内账户和档案配置", navKey: "game-account" },
{ icon: <Palette className="w-4 h-4" />, label: "主题与背景", desc: "深色模式、背景图片与视差", navKey: "theme-bg" },
{ icon: <Download className="w-4 h-4" />, label: "下载设置", desc: "下载线程数与存储路径", navKey: "download" },
@@ -56,8 +56,8 @@ export function HomeSetting({ onNavigate }: HomeSettingProps) {
<PageHeader title="主页" desc="版本信息与常用设置的快捷入口" />
<div className="space-y-6">
{/* 与"关于"页使用相同的版本卡片 */}
<VersionCard />
{/* 与"关于"页使用相同的版本卡片;设置页整卡可点击跳转更新页 */}
<VersionCard isSettingPage />
<div>
<SectionTitle></SectionTitle>
+26 -13
View File
@@ -1,7 +1,6 @@
import { useState, useCallback, type ReactNode } from "react";
import {
Home,
UserCircle,
Info,
Copyright,
Gamepad2,
@@ -23,7 +22,7 @@ import {
import { useRouteStore } from "@/stores/routeStore";
import { HomeSetting } from "./general/home";
import { AccountSetting } from "./general/account";
// import { AccountSetting } from "./general/account"; // Koring 账户(暂时隐藏)
import { AboutSetting } from "./general/about";
import { CopyrightSetting } from "./general/copyright";
import { GameAccountSetting } from "./game/game-account";
@@ -65,7 +64,8 @@ function buildMenuData(
title: "通用",
items: [
{ key: "home", label: "主页", icon: <Home className={iconCls} />, component: <HomeSetting onNavigate={switchPage} /> },
{ key: "account", label: "Koring 账户", icon: <UserCircle className={iconCls} />, component: <AccountSetting /> },
// Koring 账户(暂时隐藏)
// { key: "account", label: "Koring 账户", icon: <UserCircle className={iconCls} />, component: <AccountSetting /> },
{ key: "about", label: "关于", icon: <Info className={iconCls} />, component: <AboutSetting /> },
{ key: "copyright", label: "版权", icon: <Copyright className={iconCls} />, component: <CopyrightSetting /> },
],
@@ -110,14 +110,22 @@ function buildMenuData(
export function Setting() {
const [selected, setSelected] = useState("home");
const [animKey, setAnimKey] = useState(0);
const [animOn, setAnimOn] = useState(true);
// 已访问过的子页缓存(keep-alive):切换时不再全量卸载/重挂载,
// 避免每个子页的 VersionCard/Silk、发布说明请求等重活反复执行;隐藏页不卸载。
const [visited, setVisited] = useState<Record<string, boolean>>({ home: true });
const routeNavigate = useRouteStore((s) => s.navigate);
const switchPage = useCallback((key: string) => {
if (key === selected) return;
setSelected(key);
setAnimKey((k) => k + 1);
}, [selected]);
setSelected((prev) => {
if (prev === key) return prev;
setVisited((v) => ({ ...v, [key]: true }));
// 两步重放进入动画:先移除类再回加(CSS 动画重新触发,页面无需重挂载)
setAnimOn(false);
requestAnimationFrame(() => setAnimOn(true));
return key;
});
}, []);
const handleItemClick = useCallback((item: MenuItem) => {
if (item.onSelect) {
@@ -129,7 +137,7 @@ export function Setting() {
const menuData = buildMenuData(switchPage, routeNavigate);
const allItems = menuData.flatMap((g) => g.items);
const current = allItems.find((i) => i.key === selected);
const cachedItems = allItems.filter((i) => visited[i.key]);
return (
<div className="flex h-full">
@@ -172,11 +180,16 @@ export function Setting() {
</nav>
</aside>
{/* 内容区 */}
{/* 内容区(已访问页缓存保活;仅活动页可见并重放进入动画) */}
<main className="scroll-area flex-1 h-full overflow-y-auto p-8">
<div key={animKey} className="setting-page-enter">
{current?.component}
</div>
{cachedItems.map((item) => {
const active = selected === item.key;
return (
<div key={item.key} className={active ? (animOn ? "setting-page-enter" : undefined) : "hidden"}>
{item.component}
</div>
);
})}
</main>
</div>
);
+3 -6
View File
@@ -1,6 +1,7 @@
import { useCallback } from "react";
import { useConfigStore } from "@/stores/configStore";
import { Switch, Input } from "@heroui/react";
import { Input } from "@heroui/react";
import { Switch } from "@/components/ui/switch";
import { ShieldCheck } from "lucide-react";
import { SettingCard, SettingRow, PageHeader, SectionTitle, fieldCls } from "@/components/setting";
@@ -30,11 +31,7 @@ export function SecurityIdSetting() {
label="启用第三方认证"
desc="使用自定义认证服务器替代 Microsoft 认证(适用于离线服务器)"
>
<Switch isSelected={enabled} onChange={handleToggle}>
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
</Switch>
<Switch aria-label="启用第三方认证" checked={enabled} onCheckedChange={handleToggle} />
</SettingRow>
</SettingCard>
+24 -5
View File
@@ -1,15 +1,34 @@
import { EmptyState } from "@heroui/react";
import { MessageSquareHeart } from "lucide-react";
import { PageHeader } from "@/components/setting";
import { FeedbackButton } from "@/components/feedback/FeedbackButton";
export function FeedbackSetting() {
return (
<div>
<PageHeader title="服务与反馈" desc="提交问题反馈、功能建议与联系开发团队" />
<EmptyState className="py-16">
<MessageSquareHeart className="w-10 h-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground mt-3"></p>
</EmptyState>
<div className="space-y-6">
<div className="rounded-xl border border-black/[0.06] dark:border-white/[0.07] bg-white/85 dark:bg-black/45 backdrop-blur-[12px] px-5 py-8 flex flex-col items-center text-center gap-3">
<div className="w-12 h-12 rounded-full bg-foreground/[0.05] dark:bg-white/[0.05] flex items-center justify-center">
<MessageSquareHeart className="w-6 h-6 text-muted-foreground/60" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-foreground"></p>
<p className="text-[12.5px] text-muted-foreground max-w-sm leading-relaxed">
</p>
</div>
<FeedbackButton label="填写反馈表单" className="mt-1" />
</div>
<div className="rounded-xl border border-black/[0.06] dark:border-white/[0.07] bg-white/85 dark:bg-black/45 backdrop-blur-[12px] px-5 py-4">
<p className="text-sm font-medium text-foreground"></p>
<ul className="mt-2 space-y-1.5 text-[12.5px] text-muted-foreground leading-relaxed list-disc pl-4">
<li> YouTrack 线</li>
<li></li>
</ul>
</div>
</div>
</div>
);
}
+4 -16
View File
@@ -1,5 +1,5 @@
import { useA11yStore } from "@/stores/a11yStore";
import { Switch } from "@heroui/react";
import { Switch } from "@/components/ui/switch";
import { SettingCard, SettingRow, PageHeader, SectionTitle } from "@/components/setting";
export function A11ySetting() {
@@ -15,31 +15,19 @@ export function A11ySetting() {
<div className="space-y-3">
<SettingCard>
<SettingRow label="减少动画" desc="关闭页面切换动画和背景动效">
<Switch isSelected={reduceMotion} onChange={setReduceMotion}>
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
</Switch>
<Switch aria-label="减少动画" checked={reduceMotion} onCheckedChange={setReduceMotion} />
</SettingRow>
</SettingCard>
<SettingCard>
<SettingRow label="减少透明度" desc="将磨砂玻璃效果替换为纯色背景,提升可读性">
<Switch isSelected={reduceTransparency} onChange={setReduceTransparency}>
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
</Switch>
<Switch aria-label="减少透明度" checked={reduceTransparency} onCheckedChange={setReduceTransparency} />
</SettingRow>
</SettingCard>
<SettingCard>
<SettingRow label="高对比度" desc="增强文字与背景的对比度,改善可读性">
<Switch isSelected={highContrast} onChange={setHighContrast}>
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
</Switch>
<Switch aria-label="高对比度" checked={highContrast} onCheckedChange={setHighContrast} />
</SettingRow>
</SettingCard>
</div>
+47 -14
View File
@@ -1,9 +1,17 @@
import { useEffect, useState } from "react";
import { useThemeStore, type DarkMode } from "@/stores/themeStore";
import { useBackgroundStore } from "@/stores/backgroundStore";
import { Switch, Button, Slider } from "@heroui/react";
import { Button, Slider } from "@heroui/react";
import { Switch } from "@/components/ui/switch";
import { DEFAULT_BG } from "@/lib/mode";
import clsx from "clsx";
import { SettingCard, SettingRow, PageHeader, SectionTitle } from "@/components/setting";
import { createRendererLogger } from "@/lib/logger";
const log = createRendererLogger("theme-bg");
/** 可直接用于 CSS/`<img>` 的源(data:/http(s):/file:/相对 URL */
const isCssSource = (v: string) => /^(data:|https?:|file:|\/|\.\/|\.\.\/)/i.test(v);
function ThemePreviewCard({ mode, selected, onClick }: { mode: DarkMode; selected: boolean; onClick: () => void }) {
const isDark = mode === "dark";
@@ -85,11 +93,36 @@ function ThemePreviewCard({ mode, selected, onClick }: { mode: DarkMode; selecte
export function ThemeBgSetting() {
const { darkMode, setDarkMode, parallax, setParallax } = useThemeStore();
const { image, opacity, setOpacity, blur, setBlur, setImage, reset } = useBackgroundStore();
// 预览图:配置文件里存的是文件路径 → 主进程解析为 koring-res:// 引用再显示
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
if (!image || image === DEFAULT_BG || isCssSource(image)) {
setPreviewUrl(null);
return () => {
cancelled = true;
};
}
window.electronAPI?.resolveBackgroundResource?.(image).then((res) => {
if (!cancelled) {
setPreviewUrl(res?.url ?? null);
log.debug(`预览资源解析 ${image}${res?.url ?? "(失败)"}`);
}
});
return () => {
cancelled = true;
};
}, [image]);
const handlePickImage = async () => {
const dataUrl = await (window as any).electronAPI?.pickBackgroundImage();
if (dataUrl) {
setImage(dataUrl);
// 返回的是 userData 内的文件路径(配置/Store 以路径保存,不使用 BASE64)
const filePath = await window.electronAPI?.pickBackgroundImage?.();
if (filePath) {
log.info(`选择壁纸完成 → ${filePath}`);
setImage(filePath);
} else {
log.warn("选择壁纸未返回路径(取消或导入失败)");
}
};
@@ -124,11 +157,15 @@ export function ThemeBgSetting() {
</SettingRow>
{image && image !== DEFAULT_BG && (
<div className="mt-3 rounded-lg overflow-hidden border border-border/50">
<img
src={image}
alt="背景预览"
className="w-full h-[120px] object-cover"
/>
{previewUrl ? (
<img src={previewUrl} alt="背景预览" className="w-full h-[120px] object-cover" />
) : isCssSource(image) ? (
<img src={image} alt="背景预览" className="w-full h-[120px] object-cover" />
) : (
<div className="w-full h-[120px] grid place-items-center text-[12px] text-muted-foreground/70 bg-foreground/[0.03]">
</div>
)}
</div>
)}
</SettingCard>
@@ -171,11 +208,7 @@ export function ThemeBgSetting() {
<SettingCard>
<SettingRow label="背景图片视差" desc="背景图片随窗口滚动产生视差位移">
<Switch isSelected={parallax} onChange={setParallax}>
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
</Switch>
<Switch aria-label="背景图片视差" checked={parallax} onCheckedChange={setParallax} />
</SettingRow>
</SettingCard>
+46 -5
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import { VersionCard } from "@/components/VersionCard";
import { AboutVersion } from "@/components/about-version";
import { SectionTitle, SettingCard } from "@/components/setting";
import { Progress } from "@/components/ui/progress";
import { BUILD_MODE } from "@/lib/mode";
@@ -19,6 +20,7 @@ import {
import { ExternalLink, Loader2, RefreshCw } from "lucide-react";
import { Button, Link } from "@heroui/react";
import { toast } from "sonner";
import clsx from "clsx";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeRaw from "rehype-raw";
@@ -50,7 +52,7 @@ function formatMB(bytes?: number): string {
/**
* layout
* - + routeStore titleInBar
* - VersionCard
* - VersionCard
* -
* - + //
* - / Koring.ymlupdate
@@ -99,6 +101,36 @@ export function UpdatePage() {
const isDownloading = st === "downloading";
const isPaused = st === "paused";
// 底部遮罩滚动感知:向下滚动(阅读发布说明)自动收起,向上滚动/回顶恢复。
// 下载/暂停/安装/检查等需要常驻进度条的状态下始终显示。
const [barVisible, setBarVisible] = useState(true);
useEffect(() => {
const el = document.getElementById("app-content-scroll");
if (!el) return;
const busy = st === "downloading" || st === "paused" || st === "installing" || st === "checking";
let lastY = el.scrollTop;
let ticking = false;
const onScroll = () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
const y = el.scrollTop;
const delta = y - lastY;
if (busy) {
setBarVisible(true);
} else if (delta > 16 && y > 160) {
setBarVisible(false);
} else if (delta < -16 || y < 80) {
setBarVisible(true);
}
lastY = y;
ticking = false;
});
};
el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll);
}, [st]);
const handleCheck = async () => {
try {
await checkForUpdates(true);
@@ -164,7 +196,9 @@ export function UpdatePage() {
: st === "paused"
? `下载已暂停(${pct.toFixed(0)}%`
: st === "downloaded"
? "更新已下载完成"
? status?.verified
? "更新已下载完成(安装包已核验,点击安装)"
: "版本校验异常:可能是文件损坏或被替换,点击安装将弹出确认框"
: st === "installing"
? "正在安装更新,应用即将重启..."
: st === "error"
@@ -176,6 +210,9 @@ export function UpdatePage() {
<div className="space-y-6">
<VersionCard />
{/* 版本卡片下方:当前版本更新内容速览(分类卡片) */}
<AboutVersion />
<div>
<SectionTitle></SectionTitle>
@@ -234,9 +271,13 @@ export function UpdatePage() {
</div>
</div>
{/* 底部遮罩:fixed 吸附底部,样式与顶栏一致;驱动整个更新流程 */}
{/* fixed
translate-y-full/ */}
<div
className="fixed bottom-0 left-0 right-0 z-20"
className={clsx(
"fixed bottom-0 left-0 right-0 z-20 transition-transform duration-300",
barVisible ? "translate-y-0" : "translate-y-full",
)}
style={{
background: "var(--titlebar-bg)",
backdropFilter: "blur(3px)",
@@ -276,7 +317,7 @@ export function UpdatePage() {
variant="primary"
fullWidth
style={BUTTON_STYLE}
isDisabled={st === "checking" || st === "installing" || st === "not-available"}
isDisabled={st === "checking" || st === "installing"}
onPress={primaryAction}
>
{st === "checking" && (
+26
View File
@@ -0,0 +1,26 @@
import { useRouteStore } from "@/stores/routeStore";
import { VERSION } from "@/lib/version";
import { UpvpLayout } from "./layout";
import { NextButton } from "./next-button";
import { AboutVersion } from "@/components/about-version";
/** 版本卡片之后:关于此版本(展示当前版本更新内容,引用 AboutVersion 组件) */
export function UpvpAboutVersion() {
const navigate = useRouteStore((s) => s.navigate);
return (
<UpvpLayout>
<div className="w-full max-w-lg flex flex-col items-center px-6">
<h2 className="text-lg font-bold text-foreground mb-0.5"></h2>
<p className="text-[12px] text-muted-foreground mb-4"> v{VERSION} </p>
{/* 内容滚动区:限高避免遮挡底部下一步按钮 */}
<div className="w-full max-h-[58vh] overflow-y-auto scroll-area pr-1 -mr-1 min-h-[140px]">
<AboutVersion />
</div>
</div>
<NextButton onClick={() => navigate("upvp/check")} />
</UpvpLayout>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { type ReactNode } from "react";
interface UpvpLayoutProps {
children: ReactNode;
}
/** 与 OOBE 相同的全屏居中布局框架 */
export function UpvpLayout({ children }: UpvpLayoutProps) {
return (
<div className="h-full flex flex-col items-center justify-center relative">
{children}
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
interface NextButtonProps {
onClick: () => void;
disabled?: boolean;
}
/** 与 OOBE 相同的下一步圆形按钮 */
export function NextButton({ onClick, disabled = false }: NextButtonProps) {
return (
<div className="absolute bottom-12">
<button
onClick={onClick}
disabled={disabled}
className="w-12 h-12 rounded-full bg-foreground/[0.06] hover:bg-foreground/[0.12] flex items-center justify-center text-foreground/60 hover:text-foreground transition-all duration-200 text-xl disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-foreground/[0.06] disabled:hover:text-foreground/60"
>
</button>
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
import { useState, useEffect } from "react";
import { useRouteStore } from "@/stores/routeStore";
import { UpvpLayout } from "./layout";
import { NextButton } from "./next-button";
import { Loader2 } from "lucide-react";
/** 第三步:Beta 测试协议(仅测试版更新需要同意;框架与 OOBE 一致) */
export function UpvpBetaTest() {
const navigate = useRouteStore((s) => s.navigate);
const [loading, setLoading] = useState(true);
const [text, setText] = useState("");
const [checked, setChecked] = useState(false);
useEffect(() => {
const timer = setTimeout(() => {
fetch(`${import.meta.env.BASE_URL}protocol-beta.txt`)
.then((r) => r.text())
.then(setText)
.catch(() => setText("无法加载协议内容"))
.finally(() => setLoading(false));
}, 1500);
return () => clearTimeout(timer);
}, []);
if (loading) {
return (
<UpvpLayout>
<div className="flex flex-col items-center gap-3">
<Loader2 className="w-6 h-6 animate-spin text-foreground/40" />
<span className="text-sm text-muted-foreground">...</span>
</div>
</UpvpLayout>
);
}
return (
<UpvpLayout>
<div className="w-full max-w-lg flex flex-col items-center gap-4 px-6">
{/* 标题 */}
<div className="text-center space-y-1">
<h2 className="text-lg font-bold text-foreground">Koring APP Beta </h2>
<p className="text-xs text-muted-foreground"></p>
</div>
{/* 协议内容 */}
<div className="w-full h-[300px] rounded-xl bg-foreground/[0.03] border border-border/50 p-4 overflow-y-auto">
<pre className="text-xs text-foreground/70 whitespace-pre-wrap font-sans leading-relaxed">
{text}
</pre>
</div>
{/* 勾选框 */}
<label className="flex items-start gap-2.5 cursor-pointer select-none group">
<div className="relative mt-0.5">
<input
type="checkbox"
checked={checked}
onChange={(e) => setChecked(e.target.checked)}
className="sr-only peer"
/>
<div className="w-4 h-4 rounded border border-border/60 bg-foreground/[0.03] peer-checked:bg-primary peer-checked:border-primary transition-colors flex items-center justify-center">
{checked && (
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="2 6 5 9 10 3" />
</svg>
)}
</div>
</div>
<span className="text-xs text-muted-foreground leading-relaxed group-hover:text-foreground/70 transition-colors">
</span>
</label>
</div>
<NextButton onClick={() => navigate("upvp/finish")} disabled={!checked} />
</UpvpLayout>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { useEffect, useState } from "react";
import { useRouteStore } from "@/stores/routeStore";
import { useConfigStore } from "@/stores/configStore";
import { VERSION } from "@/lib/version";
import { BUILD_MODE } from "@/lib/mode";
import { compareVersions } from "@/api/update";
import { UpvpLayout } from "./layout";
import { NextButton } from "./next-button";
import { Loader2, ArrowUpCircle, AlertTriangle } from "lucide-react";
/** 第三步:检查版本(程序版本 vs 配置文件 appVersion */
export function UpvpCheck() {
const navigate = useRouteStore((s) => s.navigate);
const appVersion = useConfigStore((s) => s.config.appVersion);
const [state, setState] = useState<"loading" | "updated" | "rollback">("loading");
useEffect(() => {
let cancelled = false;
compareVersions(VERSION, appVersion)
.then((r) => {
if (cancelled) return;
// a>b → 已更新;a<b → 版本倒退;相等/无效 → 视为已更新(正常流程)
setState(r.result === "a<b" ? "rollback" : "updated");
})
.catch(() => {
if (!cancelled) setState("updated");
});
return () => {
cancelled = true;
};
}, [appVersion]);
const isTestBuild = BUILD_MODE === "dev" || BUILD_MODE === "beta";
const nextRoute = isTestBuild ? "upvp/beta-test" : "upvp/finish";
return (
<UpvpLayout>
<div className="w-full max-w-lg flex flex-col items-center gap-4 px-6">
{state === "loading" ? (
<div className="flex flex-col items-center gap-3 py-4">
<Loader2 className="w-6 h-6 animate-spin text-foreground/40" />
<span className="text-sm text-muted-foreground">...</span>
</div>
) : state === "updated" ? (
<div className="flex flex-col items-center gap-3 text-center">
<ArrowUpCircle className="w-14 h-14 text-emerald-500" />
<h2 className="text-lg font-bold text-foreground">{VERSION}</h2>
<div className="text-xs text-muted-foreground space-y-1 font-mono">
<p>{VERSION}</p>
<p>{appVersion || "(无记录)"}</p>
</div>
</div>
) : (
<div className="flex flex-col items-center gap-3 text-center">
<AlertTriangle className="w-14 h-14 text-amber-500" />
<h2 className="text-lg font-bold text-foreground">退</h2>
<p className="text-xs text-muted-foreground leading-relaxed max-w-sm">
退
</p>
<div className="text-xs text-muted-foreground space-y-1 font-mono">
<p>{VERSION}</p>
<p>{appVersion}</p>
</div>
</div>
)}
</div>
{state !== "loading" && <NextButton onClick={() => navigate(nextRoute)} />}
</UpvpLayout>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { useRouteStore } from "@/stores/routeStore";
import { UpvpLayout } from "./layout";
import { NextButton } from "./next-button";
import { CheckCircle2 } from "lucide-react";
/** 第一步:更新已完成 */
export function UpvpComplete() {
const navigate = useRouteStore((s) => s.navigate);
return (
<UpvpLayout>
<div className="flex flex-col items-center gap-4">
<CheckCircle2 className="w-16 h-16 text-emerald-500" />
<h2 className="text-2xl font-bold text-foreground"></h2>
<p className="text-sm text-muted-foreground">Koring Launcher </p>
</div>
<NextButton onClick={() => navigate("upvp/version")} />
</UpvpLayout>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { useEffect, useState } from "react";
import { useRouteStore } from "@/stores/routeStore";
import { useConfigStore } from "@/stores/configStore";
import { VERSION } from "@/lib/version";
import { compareVersions } from "@/api/update";
import { UpvpLayout } from "./layout";
import { AppleHelloEnglishEffect } from "@/components/ui/apple-hello-effect";
/**
* OOBE
* appVersion
* - > appVersion appVersion
* - < appVersion退
*/
export function UpvpFinish() {
const navigate = useRouteStore((s) => s.navigate);
const appVersion = useConfigStore((s) => s.config.appVersion);
const setAppVersion = useConfigStore((s) => s.setAppVersion);
const [ready, setReady] = useState(false);
const [shouldWrite, setShouldWrite] = useState(false);
// 「前往首页」按钮先隐藏,hello 动画播完后(4 秒)渐入浮现
const [btnVisible, setBtnVisible] = useState(false);
useEffect(() => {
const t = setTimeout(() => setBtnVisible(true), 4000);
return () => clearTimeout(t);
}, []);
useEffect(() => {
let cancelled = false;
compareVersions(VERSION, appVersion)
.then((r) => {
if (cancelled) return;
// 仅版本倒退(a<b)保持原样;相等/无效也写入(无害,值为当前版本)
setShouldWrite(r.result !== "a<b");
})
.catch(() => {
if (!cancelled) setShouldWrite(true);
})
.finally(() => {
if (!cancelled) setReady(true);
});
return () => {
cancelled = true;
};
}, [appVersion]);
const handleFinish = () => {
if (shouldWrite) {
// 正常升级:记录当前版本到配置,下次启动程序版本与配置版本一致 → 正常进入主页
setAppVersion(VERSION);
}
// 版本倒退:不修改 appVersion,下次启动仍会进入 upvp 提醒
navigate("home");
};
return (
<UpvpLayout>
<div className="flex flex-col items-center gap-6">
<AppleHelloEnglishEffect className="text-foreground" />
</div>
<div className="absolute bottom-12">
<button
onClick={handleFinish}
disabled={!ready}
className="h-12 px-6 rounded-full bg-foreground/[0.06] hover:bg-foreground/[0.12] flex items-center justify-center text-foreground/60 hover:text-foreground transition-opacity duration-700 ease-out text-sm font-medium disabled:opacity-40 disabled:cursor-not-allowed"
style={{ opacity: btnVisible ? (ready ? 1 : undefined) : 0, pointerEvents: btnVisible ? "auto" : "none" }}
>
</button>
</div>
</UpvpLayout>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { useRouteStore } from "@/stores/routeStore";
import { BUILD_MODE } from "@/lib/mode";
import { VersionCard } from "@/components/VersionCard";
import { UpvpLayout } from "./layout";
import { NextButton } from "./next-button";
/** 第二步:版本展示(与 OOBE 版本页一致,纯展示版本卡片;检查在下一步) */
export function UpvpVersion() {
const navigate = useRouteStore((s) => s.navigate);
const isTestBuild = BUILD_MODE === "dev" || BUILD_MODE === "beta";
return (
<UpvpLayout>
<div className="w-full max-w-lg flex flex-col items-center gap-4 px-6">
{/* 版本卡片(OOBE 模式:仅展示,无按钮) */}
<VersionCard oobe className="w-full" />
{/* 测试版警告(与 OOBE 版本页一致) */}
{isTestBuild && (
<p className="text-xs text-muted-foreground text-center max-w-sm leading-relaxed">
使
</p>
)}
</div>
<NextButton onClick={() => navigate("upvp/about-version")} />
</UpvpLayout>
);
}
+39
View File
@@ -0,0 +1,39 @@
/**
* ManagedImage// <img>
* UI
* 线
*/
import { type ImgHTMLAttributes, type ReactNode } from "react";
import { useManagedImage } from "./hooks";
export interface ManagedImageProps
extends Omit<ImgHTMLAttributes<HTMLImageElement>, "src"> {
/** 图源:http(s) / blob: / data: */
source: string;
/** 目标长边上限(超过则降采样,默认 1024) */
maxDimension?: number;
/** 加载中占位(默认无) */
loadingFallback?: ReactNode;
/** 失败占位(默认无) */
errorFallback?: ReactNode;
}
export function ManagedImage({
source,
maxDimension,
loadingFallback = null,
errorFallback = null,
alt,
...rest
}: ManagedImageProps) {
const { status, url } = useManagedImage(source, { maxDimension });
if (status === "idle" || status === "loading") {
return <>{loadingFallback}</>;
}
if (status === "error" || !url) {
return <>{errorFallback}</>;
}
return <img src={url} alt={alt ?? ""} loading="lazy" decoding="async" {...rest} />;
}
+94
View File
@@ -0,0 +1,94 @@
/**
* React hooks
*/
import { useEffect, useState } from "react";
import { resourceRegistry } from "./registry";
import { decodeImageSource } from "./image";
import type { ImageDecodeOptions } from "./image";
export type ManagedImageStatus = "idle" | "loading" | "ready" | "error";
export interface ManagedImageValue {
status: ManagedImageStatus;
/** 可直接用于 <img src> 的 URLready 时有效) */
url: string | null;
}
interface DecodedPayload {
url: string;
width: number;
height: number;
bytes: number;
}
export interface UseManagedImageOptions extends ImageDecodeOptions {
/** release 后是否缓存解码结果;默认 true */
cache?: boolean;
}
/**
* hook/
* revokeObjectURL
* effect 使maxDimension/cache options
*/
export function useManagedImage(
source: string | null | undefined,
options: UseManagedImageOptions = {},
): ManagedImageValue {
const maxDimension = options.maxDimension ?? 1024;
const cache = options.cache ?? true;
const [value, setValue] = useState<ManagedImageValue>({
status: source ? "loading" : "idle",
url: null,
});
useEffect(() => {
if (!source) {
setValue({ status: "idle", url: null });
return;
}
let alive = true;
const key = `image:${maxDimension}:${source}`;
setValue({ status: "loading", url: null });
resourceRegistry
.acquire<DecodedPayload>(key, "image", {
cache,
bytes: 0,
load: async () => {
const decoded = await decodeImageSource(source, { maxDimension });
if (!decoded) return null;
return {
url: decoded.url,
width: decoded.width,
height: decoded.height,
bytes: decoded.bytes,
};
},
onRelease: (payload) => {
try {
URL.revokeObjectURL(payload.url);
} catch {
// 释放失败可忽略
}
},
})
.then((payload) => {
if (!alive) return;
if (payload) {
resourceRegistry.setBytes(key, payload.bytes);
setValue({ status: "ready", url: payload.url });
} else {
setValue({ status: "error", url: null });
}
});
return () => {
alive = false;
resourceRegistry.release(key);
};
}, [source, maxDimension, cache]);
return value;
}
+121
View File
@@ -0,0 +1,121 @@
/**
* 线
*
* //Blob <img>/CSS 使 URL
*
*
* UI/
* Minecraft ManagedImage 使
* 线
*/
export interface ImageDecodeResult {
/** 可直接用于 <img src> / CSS 的 Blob 对象 URL;用完需 revoke */
url: string;
width: number;
height: number;
/** 产物编码后字节数(估算内存占用用) */
bytes: number;
/** 是否实际发生了降采样重编码(false = 原样返回) */
downscaled: boolean;
}
export interface ImageDecodeOptions {
/** 目标长边上限(CSS 像素);小于源图长边时降采样 */
maxDimension?: number;
}
const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));
/** 读取资源并解析为 Blobhttp(s)/blob:/data: 均支持) */
export async function fetchBlob(source: string): Promise<Blob | null> {
try {
const response = await fetch(source);
if (!response.ok) return null;
return await response.blob();
} catch {
return null;
}
}
async function decodeToBitmap(blob: Blob): Promise<ImageBitmap | null> {
try {
return await createImageBitmap(blob);
} catch {
return null;
}
}
function isOpaqueMime(type: string): boolean {
return type !== "image/png" && type !== "image/webp" && type !== "image/gif";
}
/**
* maxDimension URL
* - URL 100%
* - JPEG/PNG URL
* close()
* null
*/
export async function decodeImageSource(
source: string,
options: ImageDecodeOptions = {},
): Promise<ImageDecodeResult | null> {
const maxDimension = clamp(options.maxDimension ?? 1024, 64, 8192);
try {
const blob = await fetchBlob(source);
if (!blob) return null;
const bitmap = await decodeToBitmap(blob);
if (!bitmap) return null;
const { width, height } = bitmap;
if (width <= 0 || height <= 0) {
bitmap.close();
return null;
}
const longEdge = Math.max(width, height);
if (longEdge <= maxDimension) {
bitmap.close();
const url = URL.createObjectURL(blob);
return { url, width, height, bytes: blob.size, downscaled: false };
}
const scale = maxDimension / longEdge;
const targetWidth = Math.max(1, Math.round(width * scale));
const targetHeight = Math.max(1, Math.round(height * scale));
const canvas = new OffscreenCanvas(targetWidth, targetHeight);
const ctx = canvas.getContext("2d");
if (!ctx) {
bitmap.close();
return null;
}
ctx.drawImage(bitmap, 0, 0, targetWidth, targetHeight);
bitmap.close();
const opaque = isOpaqueMime(blob.type);
const outBlob = await canvas.convertToBlob({
type: opaque ? "image/jpeg" : "image/png",
quality: opaque ? 0.9 : undefined,
});
const url = URL.createObjectURL(outBlob);
return { url, width: targetWidth, height: targetHeight, bytes: outBlob.size, downscaled: true };
} catch {
return null;
}
}
/** 估算 dataURL/字符串占用字节(面板统计用) */
export function estimateDataUrlBytes(value: string | null | undefined): number {
if (!value) return 0;
if (value.startsWith("data:")) {
const comma = value.indexOf(",");
if (comma > 0) {
const base64 = value.slice(comma + 1);
return Math.floor((base64.length * 3) / 4);
}
}
return value.length;
}
+12
View File
@@ -0,0 +1,12 @@
/**
*
* Blob
* Minecraft
*/
export * from "./types";
export * from "./registry";
export * from "./store";
export * from "./image";
export * from "./hooks";
export * from "./ManagedImage";
+284
View File
@@ -0,0 +1,284 @@
/**
*
*
* /LRU
* - key acquire load
* - LRU = 0
* - / onRelease revokeObjectURL / ImageBitmap.close
*
* - React subscribe /
*/
import {
DEFAULT_BUDGETS,
type RegistrySnapshot,
type RegistryStats,
type ResourceEntrySnapshot,
type ResourceKind,
} from "./types";
import { createRendererLogger } from "@/lib/logger";
const log = createRendererLogger("resourceRegistry");
export interface AcquireOptions<T> {
/** 估算占用字节数(用于预算与面板统计;未提供则记 0) */
bytes?: number;
/** 资源加载器;同一 key 并发时只会执行一次 */
load: () => Promise<T | null>;
/** 条目被逐出/清除时回调(用于真正释放底层资源) */
onRelease?: (payload: T) => void;
/** 是否在 release 后仍缓存结果供复用;默认 true。false 表示「当前唯一持有者」语义(如背景图) */
cache?: boolean;
}
interface InternalEntry {
key: string;
kind: ResourceKind;
bytes: number;
refs: number;
lastUsed: number;
settled: boolean;
payload: unknown;
inFlight: Promise<unknown> | null;
cache: boolean;
onRelease?: (payload: unknown) => void;
}
type Listener = () => void;
class ResourceRegistry {
private entries = new Map<string, InternalEntry>();
private budgets: Record<ResourceKind, number> = { ...DEFAULT_BUDGETS };
private hits = 0;
private misses = 0;
private evictions = 0;
private listeners = new Set<Listener>();
private pendingEmit: ReturnType<typeof setTimeout> | null = null;
private lastEmitAt = 0;
/** 调整某类资源的预算(字节) */
setBudget(kind: ResourceKind, bytes: number): void {
this.budgets[kind] = Math.max(0, Math.floor(bytes));
this.evict();
this.emitNow();
}
getBudget(kind: ResourceKind): number {
return this.budgets[kind];
}
subscribe(listener: Listener): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
/**
* load Promise
* release(key)
*/
acquire<T>(key: string, kind: ResourceKind, opts: AcquireOptions<T>): Promise<T | null> {
const existing = this.entries.get(key);
if (existing) {
existing.refs += 1;
existing.lastUsed = Date.now();
if (existing.settled) {
this.hits += 1;
this.emit();
log.debug(`acquire 命中 ${kind}:${key} (refs=${existing.refs})`);
return Promise.resolve(existing.payload as T | null);
}
return existing.inFlight as Promise<T | null>;
}
this.misses += 1;
log.debug(`acquire 创建 ${kind}:${key}`);
const entry: InternalEntry = {
key,
kind,
bytes: Math.max(0, Math.floor(opts.bytes ?? 0)),
refs: 1,
lastUsed: Date.now(),
settled: false,
payload: null,
inFlight: null,
cache: opts.cache ?? true,
};
this.entries.set(key, entry);
// 用桥接闭包把调用方的 (payload: T) => void 适配为内部 (payload: unknown) => void
entry.onRelease = opts.onRelease ? (payload: unknown): void => opts.onRelease?.(payload as T) : undefined;
const run = async (): Promise<T | null> => {
let value: T | null = null;
try {
value = await opts.load();
} catch {
value = null;
}
entry.payload = value;
entry.settled = true;
entry.inFlight = null;
if (entry.refs <= 0) {
// 加载期间所有引用都已释放:直接丢弃,不保留缓存
this.drop(entry);
} else {
this.evict();
}
this.emit();
return value;
};
entry.inFlight = run();
return entry.inFlight as Promise<T | null>;
}
/** 释放一次引用。cache=false 且引用归零时立即丢弃条目。 */
release(key: string): void {
const entry = this.entries.get(key);
if (!entry) return;
entry.refs = Math.max(0, entry.refs - 1);
entry.lastUsed = Date.now();
log.debug(`release ${key} (refs=${entry.refs})`);
if (!entry.cache && entry.refs === 0) {
this.drop(entry);
this.emit();
return;
}
if (entry.refs === 0 && entry.settled) {
this.evict();
}
this.emit();
}
/** 是否持有(含加载中)某 key */
has(key: string): boolean {
return this.entries.has(key);
}
/** 加载完成后按实际占用修正估算字节(如解码产物实际大小) */
setBytes(key: string, bytes: number): void {
const entry = this.entries.get(key);
if (!entry) return;
entry.bytes = Math.max(0, Math.floor(bytes));
if (entry.settled) {
this.evict();
}
this.emit();
}
/** 释放全部「引用数为 0」的缓存条目(监控面板「释放缓存」按钮) */
clearFree(): void {
let dropped = 0;
for (const entry of [...this.entries.values()]) {
if (entry.refs <= 0 && entry.settled) {
this.drop(entry);
dropped += 1;
}
}
log.debug(`clearFree 释放缓存条目 ${dropped}`);
if (dropped) this.emitNow();
}
/** 逐出超过预算的条目(LRU,仅引用数为 0 的已就绪条目) */
evict(): void {
const budgets = this.budgets;
for (const kind of Object.keys(budgets) as ResourceKind[]) {
const settled = [...this.entries.values()].filter((e) => e.kind === kind && e.settled);
let bytes = settled.reduce((sum, e) => sum + e.bytes, 0);
if (bytes <= budgets[kind]) continue;
const free = settled
.filter((e) => e.refs === 0)
.sort((a, b) => a.lastUsed - b.lastUsed);
for (const entry of free) {
if (bytes <= budgets[kind]) break;
bytes -= entry.bytes;
this.drop(entry);
this.evictions += 1;
log.debug(`LRU 逐出 ${entry.kind}:${entry.key}`);
}
}
}
stats(): RegistryStats {
const byKind: RegistryStats["byKind"] = {};
let totalBytes = 0;
let entries = 0;
for (const entry of this.entries.values()) {
if (!entry.settled) continue;
entries += 1;
totalBytes += entry.bytes;
const group = (byKind[entry.kind] ??= { count: 0, bytes: 0 });
group.count += 1;
group.bytes += entry.bytes;
}
return {
entries,
totalBytes,
hits: this.hits,
misses: this.misses,
evictions: this.evictions,
byKind,
};
}
snapshot(): RegistrySnapshot {
const entrySnapshots: ResourceEntrySnapshot[] = [];
for (const entry of this.entries.values()) {
if (!entry.settled) continue;
entrySnapshots.push({
key: entry.key,
kind: entry.kind,
bytes: entry.bytes,
refs: entry.refs,
});
}
entrySnapshots.sort((a, b) => b.bytes - a.bytes);
return { stats: this.stats(), entries: entrySnapshots };
}
resetCounters(): void {
this.hits = 0;
this.misses = 0;
this.evictions = 0;
this.emitNow();
}
private drop(entry: InternalEntry): void {
this.entries.delete(entry.key);
log.debug(`释放资源 ${entry.kind}:${entry.key} (${entry.bytes}B, settled=${entry.settled})`);
if (entry.settled && entry.payload != null) {
try {
entry.onRelease?.(entry.payload);
} catch {
// 释放回调失败不影响主流程
}
}
}
private emit(): void {
const now = Date.now();
if (now - this.lastEmitAt >= 200) {
this.emitNow();
return;
}
if (this.pendingEmit) return;
this.pendingEmit = setTimeout(() => {
this.pendingEmit = null;
this.emitNow();
}, 200);
}
private emitNow(): void {
this.lastEmitAt = Date.now();
for (const listener of this.listeners) {
try {
listener();
} catch {
// 单个监听器异常不影响其它监听器
}
}
}
}
/** 全局单例(程序本体资源管理服务) */
export const resourceRegistry = new ResourceRegistry();
+73
View File
@@ -0,0 +1,73 @@
/**
* zustand
*
*/
import { create } from "zustand";
import { resourceRegistry } from "./registry";
import type { RegistrySnapshot } from "./types";
export interface ResourceStoreState {
snapshot: RegistrySnapshot;
refreshedAt: number;
refresh: () => void;
/** 释放全部「无引用」缓存条目(调试面板「释放缓存」按钮) */
clearFree: () => void;
resetCounters: () => void;
setBudget: (kind: "background" | "image" | "blob" | "text", bytes: number) => void;
budgets: Record<"background" | "image" | "blob" | "text", number>;
}
function emptySnapshot(): RegistrySnapshot {
return {
stats: { entries: 0, totalBytes: 0, hits: 0, misses: 0, evictions: 0, byKind: {} },
entries: [],
};
}
export const useResourceStore = create<ResourceStoreState>((set) => ({
snapshot: emptySnapshot(),
refreshedAt: 0,
budgets: {
background: resourceRegistry.getBudget("background"),
image: resourceRegistry.getBudget("image"),
blob: resourceRegistry.getBudget("blob"),
text: resourceRegistry.getBudget("text"),
},
refresh: () =>
set({
snapshot: resourceRegistry.snapshot(),
refreshedAt: Date.now(),
budgets: {
background: resourceRegistry.getBudget("background"),
image: resourceRegistry.getBudget("image"),
blob: resourceRegistry.getBudget("blob"),
text: resourceRegistry.getBudget("text"),
},
}),
clearFree: () => {
resourceRegistry.clearFree();
useResourceStore.getState().refresh();
},
resetCounters: () => {
resourceRegistry.resetCounters();
useResourceStore.getState().refresh();
},
setBudget: (kind, bytes) => {
resourceRegistry.setBudget(kind, bytes);
useResourceStore.getState().refresh();
},
}));
let subscribed = false;
/** 幂等订阅(任意模块首次 import 后生效) */
function ensureSubscribed(): void {
if (subscribed) return;
subscribed = true;
resourceRegistry.subscribe(() => {
useResourceStore.getState().refresh();
});
}
ensureSubscribed();
+38
View File
@@ -0,0 +1,38 @@
/**
*
*
*
* dataURL/Blob
* Minecraft
*/
export type ResourceKind = "background" | "image" | "blob" | "text";
/** 每种资源的默认内存预算(字节),超过后按 LRU 逐出未占用项 */
export const DEFAULT_BUDGETS: Record<ResourceKind, number> = {
background: 16 * 1024 * 1024, // 背景图(同时只应有一张活跃)
image: 64 * 1024 * 1024, // 缩略图 / 图标位图
blob: 32 * 1024 * 1024, // 通用二进制
text: 4 * 1024 * 1024, // 文本 / JSON 片段
};
export interface ResourceEntrySnapshot {
key: string;
kind: ResourceKind;
bytes: number;
refs: number;
}
export interface RegistryStats {
entries: number;
totalBytes: number;
hits: number;
misses: number;
evictions: number;
byKind: Partial<Record<ResourceKind, { count: number; bytes: number }>>;
}
export interface RegistrySnapshot {
stats: RegistryStats;
entries: ResourceEntrySnapshot[];
}
+5 -1
View File
@@ -23,6 +23,10 @@ const DEFAULT: { type: BackgroundType; image: string; blur: number; opacity: num
opacity: 1,
};
/** /background.pngdev file://
* DEFAULT_BGBASE_URL dev/ */
const normalizeDefaultBg = (url: string): string => (url === "/background.png" ? DEFAULT_BG : url);
export const useBackgroundStore = create<BackgroundState>((set) => ({
...DEFAULT,
@@ -56,7 +60,7 @@ export function syncBackgroundFromConfig() {
const bg = useConfigStore.getState().config.background;
useBackgroundStore.setState({
type: bg.bgType as BackgroundType,
image: bg.image,
image: normalizeDefaultBg(bg.image),
blur: bg.blur,
opacity: bg.opacity / 100,
});
+9
View File
@@ -44,10 +44,13 @@ interface ConfigState {
setUi: (patch: Partial<UiConfig>) => void;
setInstances: (instances: InstanceMeta[]) => void;
setOobe: (value: boolean) => void;
/** 记录当前应用版本号到配置(升级引导完成后由 upvp 流程写入) */
setAppVersion: (version: string) => void;
}
const DEFAULT_CONFIG: AppConfig = {
version: 1,
appVersion: "",
oobe: true,
app: { language: "zh-CN" },
theme: { darkMode: "auto", parallax: true },
@@ -167,4 +170,10 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
set({ config: { ...config, oobe: value } });
submit("oobe", value);
},
setAppVersion: (version) => {
const { config } = get();
set({ config: { ...config, appVersion: version } });
submit("appVersion", version);
},
}));
+23 -3
View File
@@ -14,20 +14,29 @@ export type RouteKey =
| "oobe/language"
| "oobe/agreement"
| "oobe/version"
| "oobe/about-version"
| "oobe/beta-test"
| "oobe/login"
| "oobe/welcome"
| "oobe/finish"
| "oobe/about-info"
| "oobe/legal"
| "upvp"
| "upvp/complete"
| "upvp/version"
| "upvp/about-version"
| "upvp/check"
| "upvp/beta-test"
| "upvp/finish"
| "debug"
| "debug-splash"
| "debug-display"
| "debug-version-card"
| "debug-update"
| "debug-task"
| "debug-crash";
| "debug-resource"
| "debug-crash"
| "debug-routes";
export type TitleBarMode = "default" | "sub" | "window" | "oobe";
export type TransitionDirection = "forward" | "backward";
@@ -58,24 +67,35 @@ export const allRoutes: RouteItem[] = [
{ key: "oobe/language", label: "语言设置", path: "/oobe/language", hidden: true },
{ key: "oobe/agreement", label: "同意协议", path: "/oobe/agreement", hidden: true },
{ key: "oobe/version", label: "当前版本", path: "/oobe/version", hidden: true },
{ key: "oobe/about-version", label: "关于此版本", path: "/oobe/about-version", hidden: true },
{ key: "oobe/beta-test", label: "测试协议", path: "/oobe/beta-test", hidden: true },
{ key: "oobe/login", label: "登录", path: "/oobe/login", hidden: true },
{ key: "oobe/welcome", label: "欢迎", path: "/oobe/welcome", hidden: true },
{ key: "oobe/finish", label: "完成", path: "/oobe/finish", hidden: true },
{ key: "oobe/about-info", label: "关于信息", path: "/oobe/about-info", hidden: true, backable: true },
{ key: "oobe/legal", label: "法律信息", path: "/oobe/legal", hidden: true },
{ key: "upvp", label: "更新引导", path: "/upvp", hidden: true },
{ key: "upvp/complete", label: "更新已完成", path: "/upvp/complete", hidden: true },
{ key: "upvp/version", label: "当前版本", path: "/upvp/version", hidden: true },
{ key: "upvp/about-version", label: "关于此版本", path: "/upvp/about-version", hidden: true },
{ key: "upvp/check", label: "检查版本", path: "/upvp/check", hidden: true },
{ key: "upvp/beta-test", label: "测试协议", path: "/upvp/beta-test", hidden: true },
{ key: "upvp/finish", label: "完成", path: "/upvp/finish", hidden: true },
{ key: "debug", label: "调试", path: "/debug", hidden: true },
{ key: "debug-splash", label: "启动动画调试", path: "/debug/splash", hidden: true },
{ key: "debug-display", label: "显示效果调试", path: "/debug/display", hidden: true },
{ key: "debug-version-card", label: "版本卡片调试", path: "/debug/version-card", hidden: true },
{ key: "debug-update", label: "更新功能测试", path: "/debug/update", hidden: true },
{ key: "debug-task", label: "任务队列调试", path: "/debug/task", hidden: true },
{ key: "debug-resource", label: "资源与内存", path: "/debug/resource", hidden: true },
{ key: "debug-crash", label: "崩溃测试", path: "/debug/crash", hidden: true },
{ key: "debug-routes", label: "页面跳转", path: "/debug/routes", hidden: true },
];
const topLevelKeys = new Set(routes.map((r) => r.key));
function getRouteTitleBarMode(key: RouteKey): TitleBarMode {
if (key === "oobe" || key.startsWith("oobe/")) return "oobe";
if (key === "oobe" || key.startsWith("oobe/") || key === "upvp" || key.startsWith("upvp/")) return "oobe";
return topLevelKeys.has(key) ? "default" : "sub";
}
+21
View File
@@ -0,0 +1,21 @@
import { create } from "zustand";
/**
* "发现新版本"
* - UpdateAvailableDialog available
*
*/
interface UpdateDialogState {
open: boolean;
/** 目标(新)版本号;为空时展示当前版本 */
version: string;
show: (version?: string) => void;
hide: () => void;
}
export const useUpdateDialogStore = create<UpdateDialogState>((set) => ({
open: false,
version: "",
show: (version = "") => set({ open: true, version }),
hide: () => set({ open: false }),
}));
+9
View File
@@ -16,6 +16,8 @@ interface ElectronAPI {
onConfigChanged: (callback: (config: unknown) => void) => () => void;
onRuntimeNotice: (callback: (notice: { kind: string; message: string }) => void) => () => void;
openExternal: (url: string) => Promise<void>;
// Crash monitoring
@@ -25,6 +27,13 @@ interface ElectronAPI {
// Config reset
resetConfig: () => Promise<void>;
// 渲染端日志 → 主进程统一日志(debug 模式写文件)
log: (level: "debug" | "info" | "warn" | "error", scope: string, message: string) => void;
// 壁纸(文件路径存储 → koring-res:// 资源引用,不使用 BASE64
pickBackgroundImage: () => Promise<string | null>;
resolveBackgroundResource: (value: string) => Promise<{ url: string | null; bytes: number }>;
// Auto-update
checkForUpdates: (manual?: boolean) => Promise<unknown>;
downloadUpdate: () => Promise<unknown>;