mirror of
https://github.com/dream-pep/koring-launcher.git
synced 2026-09-12 05:45:18 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74512bf935 | ||
|
|
aef49b173f | ||
|
|
4acdad6535 | ||
|
|
3b627389c9 | ||
|
|
caf52b9a3d | ||
|
|
37a5bb7538 | ||
|
|
ffac581018 | ||
|
|
1785d36797 | ||
|
|
bef1bbd85b | ||
|
|
74b7456886 | ||
|
|
dfda648147 | ||
|
|
a11e51d62c | ||
|
|
e861661a55 | ||
|
|
d5004b61d8 | ||
|
|
744494576e | ||
|
|
bcac166a47 | ||
|
|
3a760167d6 | ||
|
|
8b7c90b730 | ||
|
|
9eaa482a4d | ||
|
|
fad8f612bf | ||
|
|
cd4578de5a | ||
|
|
7d8b7bd433 | ||
|
|
3f04218059 | ||
|
|
2c33015926 | ||
|
|
0415799803 | ||
|
|
ec9346b087 |
@@ -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.yml(electron-updater 更新清单)
|
||||
# - 上传产物(Windows job):koring-launcher-{full}-setup.exe + latest.yml
|
||||
# - Linux(AppImage)由 build-linux job 追加:*.AppImage + latest-linux.yml(beta 另加 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:
|
||||
@@ -118,7 +119,7 @@ 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 ci "$buildId"
|
||||
}
|
||||
@@ -176,8 +177,8 @@ jobs:
|
||||
run: |
|
||||
$tag = "v${{ steps.version.outputs.full }}"
|
||||
# Release 标题命名(仅展示名;tag / 真实版本号是 {base}-beta.{id} 或 {base}-{id}):
|
||||
# 正式/预览版 → "1.2.5-13"(带构建尾号);测试版 → "BETA 1.2.5"
|
||||
$title = if ("${{ inputs.mode }}" -eq "beta") { "BETA ${{ steps.version.outputs.base }}" } else { "${{ steps.version.outputs.full }}" }
|
||||
# 正式/预览版 → "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.yml:GitHub provider 频道取件(latest-beta)不再 404 回退)
|
||||
$assets = @(
|
||||
@@ -206,3 +207,99 @@ jobs:
|
||||
$ghArgs += "--prerelease"
|
||||
}
|
||||
gh release create @ghArgs
|
||||
|
||||
# ==================== Linux(AppImage)====================
|
||||
# electron-updater 对 Linux 只原生支持 AppImage 自动更新(deb/rpm 走系统包管理器)。
|
||||
# Windows job 创建 Release 后,本 job 把 AppImage + latest-linux.yml 上传到同一 Release。
|
||||
# ⚠️ Linux 清单文件带 -linux 前缀:provider 取 latest-linux.yml(beta 先试 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.yml(provider 先取该文件,避免 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 ', ')"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
scene: git_message
|
||||
---
|
||||
|
||||
在此处编写规则,自定义 AI 生成提交信息的风格。
|
||||
@@ -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"`
|
||||
|
||||
@@ -107,6 +107,11 @@ koring-launcher/
|
||||
│ │ │ ├── SectionTitle.tsx # PageHeader/SectionTitle(HeroUI 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
-1
@@ -1,2 +1,4 @@
|
||||
appVersion: 1.2.1
|
||||
appVersion: 1.2.6
|
||||
oobe: false
|
||||
advanced:
|
||||
preLaunchCmd: '123'
|
||||
|
||||
@@ -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 安装程序,
|
||||
macOS(DMG)与 Linux(AppImage)构建目标已配置,将在 CI 中逐步开放。
|
||||
|
||||
**开发环境要求**
|
||||
|
||||
| 依赖 | 版本 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| OS | Windows 10/11(x64) | 主要开发与目标平台 |
|
||||
| Node.js | ≥ 20.19(CI 使用 22) | Vite 7 要求 |
|
||||
| pnpm | ≥ 9(CI 使用 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 Release:beta 为 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 provider;run 通道读 `latest.yml`,
|
||||
beta 通道读 `latest-beta.yml`;客户端开启 `allowPrerelease` 以接收带构建号的
|
||||
beta 更新
|
||||
- ⚠️ 若切换构建号方案(时间戳 → Run Number),旧版数值更大,老用户不会自动
|
||||
升级,需同步提升 base 版本(详见 `docs/auto-update-plan.md`)
|
||||
|
||||
## 路线图
|
||||
|
||||
- [ ] **实例中心 / 资源中心 UI**:完整实现已就绪,暂以占位页收尾打磨后放开
|
||||
(Minecraft 版本 / Mod / 整合包浏览与安装)
|
||||
- [ ] **微软账号登录 UI**:OAuth / Xbox / Minecraft 认证核心已接入,入口 UI 完善中
|
||||
- [ ] **以太 / 陶瓦联机页**:目前为占位
|
||||
- [ ] macOS(DMG)与 Linux(AppImage)CI 发布产物
|
||||
- [ ] 多语言(`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.0(LL-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-builder’s `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` 处理器生成离线 UUID(MD5(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.
|
||||
|
||||
@@ -316,7 +316,7 @@ src/
|
||||
(`<details><summary>·Commit 1cf906d</summary>…</details>`)
|
||||
- 上传产物:setup.exe + latest.yml + release-notes.md(electron-updater 更新清单)
|
||||
- **Release 标题命名(仅展示名,tag/真实版本号不变)**:run → `{full}`(如 `1.2.1-13`),
|
||||
beta → `BETA {base}`(如 `BETA 1.2.1`)
|
||||
beta → `BETA {full}`(如 `BETA 1.2.1-beta.13`,带 Run Num)
|
||||
|
||||
**⚠️ 版本语义注意(electron-updater,2026-08-30 已修复并落地)**:
|
||||
- **根因**:GitHub provider 用 Atom feed + 频道逻辑选版本,频道只认 `alpha`/`beta` 字符串标识。
|
||||
@@ -326,8 +326,14 @@ src/
|
||||
且发布**不带** `--prerelease` → 正式版用户(`allowPrerelease=false`)走 `/releases/latest`
|
||||
按 GitHub 标记取最新正式版,正常识别。
|
||||
- 同格式升级:`1.2.1-beta.14 > 1.2.1-beta.13`(数值比较)✓;`1.2.1-14 > 1.2.1-13` ✓
|
||||
- 注意:当前版本为 `1.2.1-13`(数字尾号)时切到 runner 通道,通道逻辑会把 "13" 当自定义频道 → 无匹配,
|
||||
属预期边缘情况(数字尾号构建请使用 woker 通道);`1.2.1-beta.x` 的 runner 用户不受影响。
|
||||
- **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-available;download() 同样复核。
|
||||
语义:base 相同比构建号;beta/正式只是通道标记不参与新旧排序;无构建号视为构建 -1
|
||||
- 所有旧数字版本(`1.2.1-12` / `1.2.1-2608271921`)都小于 `1.2.1-beta.13`,平滑升级,无需提升 base
|
||||
|
||||
## 14. M2 主进程更新模块(2026-08-28,UI 待做)
|
||||
|
||||
File diff suppressed because one or more lines are too long
+11
-3
@@ -2,20 +2,24 @@ 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;
|
||||
|
||||
/**
|
||||
* 配置文件路径:
|
||||
* - 打包后 → 安装目录(可执行文件旁)。默认 per-user 安装(%LOCALAPPDATA%\Programs)可写;
|
||||
* 若当初选择 per-machine 安装(Program Files)会无写权限,属已知限制
|
||||
* - 打包后 → 系统用户数据目录(userData)。
|
||||
* ⚠️ 不能放安装目录:NSIS 每次重装/升级都会经旧卸载器 RMDir /r 删除整个安装目录,
|
||||
* /KEEP_APP_DATA 只保护 %APPDATA%(userData),安装目录内的配置文件会被清空
|
||||
* - 开发模式 → 项目根目录(与旧行为一致,方便调试)
|
||||
*/
|
||||
export function configPath(): string {
|
||||
if (app.isPackaged) {
|
||||
return path.join(path.dirname(app.getPath('exe')), CONFIG_FILE);
|
||||
return path.join(app.getPath('userData'), CONFIG_FILE);
|
||||
}
|
||||
return path.join(__dirname, '..', CONFIG_FILE);
|
||||
}
|
||||
@@ -233,6 +237,7 @@ export function loadConfig(): AppConfig {
|
||||
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) {
|
||||
if (force) {
|
||||
@@ -291,6 +296,7 @@ export function flushConfig(): void {
|
||||
saveTimer = null;
|
||||
}
|
||||
if (current) {
|
||||
log.debug('flushConfig:内存配置落盘');
|
||||
saveConfig(current);
|
||||
}
|
||||
}
|
||||
@@ -299,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;
|
||||
}
|
||||
@@ -309,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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
// 动画 GIF:nativeImage 只能解码首帧,不处理,保持原样
|
||||
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');
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// 设备唯一标识(组合指纹 + 回退 MachineGuid)
|
||||
//
|
||||
// 用途:为“设备识别码”提供尽量唯一且稳定的标识(授权/激活/统计)。
|
||||
// 来源优先级(均为硬件级,重装系统不变):
|
||||
// 1. board — 主板 UUID(Win32_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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* 统一日志(主进程)。
|
||||
*
|
||||
* 规则:
|
||||
* - 默认(非 debug):warn/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
@@ -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 → userData:exe 目录是只读挂载(/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;
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) };
|
||||
|
||||
@@ -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(); },
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) };
|
||||
|
||||
+91
-18
@@ -17,42 +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 { 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 dest = path.join(exeDir, 'Koring.yml');
|
||||
if (fs.existsSync(dest)) return;
|
||||
const src = path.join(app.getPath('userData'), 'Koring.yml');
|
||||
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);
|
||||
console.log(`[migrate] copied Koring.yml ${src} → ${dest}`);
|
||||
log.info(`[migrate] 已复制 Koring.yml ${src} → ${dest}`);
|
||||
} catch (e) {
|
||||
console.error(`[migrate] failed to copy Koring.yml:`, 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→userData(exe 目录是只读挂载)
|
||||
const dataPath = dataBasePath();
|
||||
|
||||
// 1. Ensure .minecraft directory exists
|
||||
const minecraftDir = path.join(dataPath, '.minecraft');
|
||||
@@ -74,6 +97,28 @@ function runStartupChecks(): { isFirstLaunch: boolean; config: ReturnType<typeof
|
||||
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/
|
||||
@@ -139,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');
|
||||
});
|
||||
@@ -168,26 +228,34 @@ function registerAllHandlers() {
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
log.info('应用就绪,开始初始化主进程');
|
||||
registerAllHandlers();
|
||||
registerResourceProtocol();
|
||||
registerRendererLogBridge();
|
||||
log.info('IPC handlers / 资源协议 / 渲染日志桥注册完成');
|
||||
|
||||
// Migrate legacy userData config to install dir 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;
|
||||
|
||||
@@ -196,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 窗口已关闭');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -220,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
@@ -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
|
||||
|
||||
@@ -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 壁纸文件的协议 URL(fileName 需已通过白名单校验) */
|
||||
export function backgroundResourceUrl(fileName: string): string {
|
||||
return `${RESOURCE_SCHEME}://userdata/${encodeURIComponent(fileName)}`;
|
||||
}
|
||||
+298
-100
@@ -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,44 +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-13 / v1.2.1-beta.14 / v1.2.0-2608271921),返回 a - b。
|
||||
* 项目版本为 {base}-{buildId}:base 按 X.Y.Z 数值比较;buildId 按 semver 规则:
|
||||
* - 无 buildId(稳定版)> 任意 prerelease
|
||||
* - 字母标识(beta.N)> 数字标识(N)——与 semver 一致:数字标识永远低于字母标识,
|
||||
* 且不受数字大小影响(任何 beta.N 都大于任何 -N)
|
||||
* - 同标识类型按数值比较
|
||||
* 项目版本格式(2026- 起):
|
||||
* - 正式版(run): {base}-{N} 如 1.2.6-25
|
||||
* - 测试版(beta): {base}-{N}.beta 如 1.2.6-16.beta(编号在前、beta 在后)
|
||||
* - 旧测试版(废弃):{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[]; pre: { kind: 0 | 1; num: number } | null } => {
|
||||
const s = t.replace(/^v/i, '');
|
||||
const [base, buildStr = ''] = s.split('-');
|
||||
const nums = base.split('.').map((n) => parseInt(n, 10) || 0);
|
||||
while (nums.length < 3) nums.push(0);
|
||||
let pre: { kind: 0 | 1; num: number } | null = null;
|
||||
if (buildStr !== '') {
|
||||
const beta = /^beta\.(\d+)$/i.exec(buildStr);
|
||||
// kind: 1 = 字母标识(beta),0 = 数字标识(数字优先级低于字母)
|
||||
pre = beta
|
||||
? { kind: 1, num: parseInt(beta[1], 10) || 0 }
|
||||
: { kind: 0, num: parseInt(buildStr, 10) || 0 };
|
||||
}
|
||||
return { base: nums, pre };
|
||||
};
|
||||
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];
|
||||
}
|
||||
// 稳定版(无 prerelease)> 任意 prerelease
|
||||
if (pa.pre !== null && pb.pre === null) return -1;
|
||||
if (pa.pre === null && pb.pre !== null) return 1;
|
||||
if (pa.pre === null && pb.pre === null) return 0;
|
||||
// 上面已覆盖全部 null 组合,此处仅用于类型收窄(运行时不可达)
|
||||
if (pa.pre === null || pb.pre === null) return 0;
|
||||
// 字母标识 > 数字标识(与数字大小无关)
|
||||
if (pa.pre.kind !== pb.pre.kind) return pa.pre.kind - pb.pre.kind;
|
||||
return pa.pre.num - pb.pre.num;
|
||||
return pa.num - pb.num;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,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';
|
||||
|
||||
@@ -165,7 +200,7 @@ class UpdateService {
|
||||
this.currentVersion = app.getVersion();
|
||||
|
||||
if (!app.isPackaged) {
|
||||
console.log('[updater] 开发模式:跳过自动更新');
|
||||
log.info('[updater] 开发模式:跳过自动更新');
|
||||
this.emit();
|
||||
return;
|
||||
}
|
||||
@@ -179,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', () => {
|
||||
@@ -195,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) => {
|
||||
@@ -207,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';
|
||||
@@ -243,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 {
|
||||
@@ -263,7 +361,7 @@ class UpdateService {
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[updater] 更新进度写入配置失败:', e);
|
||||
log.warn('[updater] 更新进度写入配置失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,13 +375,19 @@ class UpdateService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 按当前通道应用 electron-updater 的 allowPrerelease(woker=只收正式版 / 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 动态渲染;可扩展) */
|
||||
@@ -294,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();
|
||||
@@ -303,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();
|
||||
@@ -320,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;
|
||||
@@ -328,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 用纯 semver(AppUpdater.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());
|
||||
@@ -357,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();
|
||||
@@ -367,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.yml;channel='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)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,19 +549,17 @@ class UpdateService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过加速源发现最新 release tag:
|
||||
* 1) /releases/latest 页面 HTML 中提取 tag(多数下载型加速源可代理该页面;
|
||||
* ⚠️ 该页面只指向最新「非 prerelease」release,本项目所有版本都是 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[] = [];
|
||||
|
||||
// 方式 1:HTML 页面
|
||||
const web = `${base}https://github.com/${OWNER}/${REPO}`;
|
||||
const api = `${base}https://api.github.com/repos/${OWNER}/${REPO}`;
|
||||
// 方式 1:/releases/latest 页面(最新非 prerelease release 的 tag,HTML 正则兜底)
|
||||
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),
|
||||
});
|
||||
@@ -438,31 +571,42 @@ class UpdateService {
|
||||
} catch {
|
||||
/* 尝试下一种方式 */
|
||||
}
|
||||
|
||||
// 方式 2:GitHub API(经加速源代理)
|
||||
// 方式 2:GitHub 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];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -474,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) {
|
||||
@@ -506,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 {
|
||||
@@ -523,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;
|
||||
@@ -566,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 {
|
||||
@@ -580,6 +767,17 @@ class UpdateService {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取系统下载临时目录(用于清理提示,暂未启用) */
|
||||
getCacheDir(): string {
|
||||
return os.tmpdir();
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "koring-launcher",
|
||||
"private": true,
|
||||
"version": "1.2.5",
|
||||
"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 |
@@ -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
@@ -31,6 +31,10 @@ if (!mode || !validModes.includes(mode)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// NSIS 专属资源(ico / 侧边横幅 BMP / 自定义脚本 / license)仅 Windows 打包需要;
|
||||
// Linux(AppImage 等)只需 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'
|
||||
);
|
||||
|
||||
+49
-7
@@ -24,18 +24,22 @@ 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";
|
||||
@@ -57,6 +61,7 @@ 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,
|
||||
@@ -64,6 +69,7 @@ const pageMap = {
|
||||
upvp: UpvpComplete,
|
||||
"upvp/complete": UpvpComplete,
|
||||
"upvp/version": UpvpVersion,
|
||||
"upvp/about-version": UpvpAboutVersion,
|
||||
"upvp/check": UpvpCheck,
|
||||
"upvp/beta-test": UpvpBetaTest,
|
||||
"upvp/finish": UpvpFinish,
|
||||
@@ -74,6 +80,8 @@ const pageMap = {
|
||||
"debug-update": UpdateDebug,
|
||||
"debug-task": TaskDebug,
|
||||
"debug-crash": CrashDebug,
|
||||
"debug-resource": ResourceDebug,
|
||||
"debug-routes": RoutesDebug,
|
||||
} as const;
|
||||
|
||||
function App() {
|
||||
@@ -82,13 +90,18 @@ 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();
|
||||
@@ -112,9 +125,38 @@ function App() {
|
||||
if (cfg.appVersion !== VERSION) {
|
||||
useRouteStore.getState().navigate("upvp/complete");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return () => { unsub?.(); };
|
||||
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
|
||||
|
||||
@@ -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; // KB(residentSet)
|
||||
privateBytes: number; // KB(private)
|
||||
}
|
||||
|
||||
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 });
|
||||
|
||||
@@ -37,6 +37,8 @@ export interface UpdateStatusPayload {
|
||||
source?: string;
|
||||
/** 当前更新通道(woker / runner) */
|
||||
channel?: string;
|
||||
/** 安装包是否已通过本地核验(sha512 / 大小;核验通过前不允许安装) */
|
||||
verified?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { useRouteStore } from "@/stores/routeStore";
|
||||
import Silk from "@/components/silk/Silk";
|
||||
import clsx from "clsx";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Suspense } from "react";
|
||||
import { Suspense, useEffect, useRef, useState } from "react";
|
||||
|
||||
const modeColors: Record<string, string> = {
|
||||
dev: "#F59E0B",
|
||||
@@ -40,6 +40,11 @@ interface VersionCardProps {
|
||||
* 并显示「查看更新」小字提示;其他页面为 false
|
||||
*/
|
||||
isSettingPage?: boolean;
|
||||
/**
|
||||
* 关闭共享元素过渡(view-transition-name):弹窗/浮层里复用 VersionCard 时开启,
|
||||
* 避免与页面上的 VersionCard 重名导致路由切换过渡失效
|
||||
*/
|
||||
noViewTransition?: boolean;
|
||||
}
|
||||
|
||||
export function VersionCard({
|
||||
@@ -49,6 +54,7 @@ export function VersionCard({
|
||||
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;
|
||||
@@ -67,8 +73,26 @@ export function VersionCard({
|
||||
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
|
||||
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",
|
||||
@@ -76,14 +100,19 @@ export function VersionCard({
|
||||
)}
|
||||
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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
.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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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-aria,Switch 使用 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 渲染端统一日志。
|
||||
*
|
||||
* 规则(与主进程 electron/core/logger.ts 对齐):
|
||||
* - 默认(非 debug):error/warn/info 输出到控制台(DevTools),debug 不输出;
|
||||
* - 用户开启「调试模式」(config.advanced.debugMode)后:debug 也输出,
|
||||
* 并经由 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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
设置测试版本号
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -10,9 +10,9 @@ export function OobeVersion() {
|
||||
const isTestBuild = BUILD_MODE === "dev" || BUILD_MODE === "beta";
|
||||
|
||||
// 到达本页前已依次经过 协议(agreement) → 法律(legal) → 欢迎(welcome),
|
||||
// 因此正式版下一步直接结束;测试版需先同意 Beta 测试协议。
|
||||
// 先进入「关于此版本」查看当前版本更新内容,再按构建类型结束或进入 Beta 测试协议。
|
||||
// (不要跳回 agreement——那会形成 agreement → legal → welcome → version → agreement 死循环)
|
||||
const nextRoute = isTestBuild ? "oobe/beta-test" : "oobe/finish";
|
||||
const nextRoute = "oobe/about-version";
|
||||
|
||||
return (
|
||||
<OobeLayout>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
@@ -132,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>
|
||||
|
||||
@@ -177,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">
|
||||
|
||||
@@ -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" },
|
||||
|
||||
+26
-13
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -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)",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,13 @@ export function UpvpFinish() {
|
||||
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)
|
||||
@@ -58,7 +65,8 @@ export function UpvpFinish() {
|
||||
<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-all duration-200 text-sm font-medium disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
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>
|
||||
|
||||
@@ -24,7 +24,7 @@ export function UpvpVersion() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<NextButton onClick={() => navigate("upvp/check")} />
|
||||
<NextButton onClick={() => navigate("upvp/about-version")} />
|
||||
</UpvpLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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> 的 URL(ready 时有效) */
|
||||
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;
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
/** 读取资源并解析为 Blob(http(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;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 启动器程序本体「资源管理」子系统入口。
|
||||
* 管理对象:运行时渲染资源(背景图、缩略图、Blob、文本缓存等),
|
||||
* 与 Minecraft 游戏内容无关。
|
||||
*/
|
||||
|
||||
export * from "./types";
|
||||
export * from "./registry";
|
||||
export * from "./store";
|
||||
export * from "./image";
|
||||
export * from "./hooks";
|
||||
export * from "./ManagedImage";
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -23,6 +23,10 @@ const DEFAULT: { type: BackgroundType; image: string; blur: number; opacity: num
|
||||
opacity: 1,
|
||||
};
|
||||
|
||||
/** 旧版主进程默认值用的绝对路径 /background.png(dev 可显示,打包 file:// 下指向文件系统根→黑屏)。
|
||||
* 统一归一化为渲染端 DEFAULT_BG(BASE_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,
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ export type RouteKey =
|
||||
| "oobe/language"
|
||||
| "oobe/agreement"
|
||||
| "oobe/version"
|
||||
| "oobe/about-version"
|
||||
| "oobe/beta-test"
|
||||
| "oobe/login"
|
||||
| "oobe/welcome"
|
||||
@@ -23,6 +24,7 @@ export type RouteKey =
|
||||
| "upvp"
|
||||
| "upvp/complete"
|
||||
| "upvp/version"
|
||||
| "upvp/about-version"
|
||||
| "upvp/check"
|
||||
| "upvp/beta-test"
|
||||
| "upvp/finish"
|
||||
@@ -32,8 +34,9 @@ export type RouteKey =
|
||||
| "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";
|
||||
@@ -64,6 +67,7 @@ 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 },
|
||||
@@ -73,6 +77,7 @@ export const allRoutes: RouteItem[] = [
|
||||
{ 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 },
|
||||
@@ -82,6 +87,9 @@ export const allRoutes: RouteItem[] = [
|
||||
{ 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));
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
Vendored
+9
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user