feat: 发布 v1.2.0 版本,新增多项核心功能

- 引入 electron-updater 实现自动更新功能
- 新增 Java 环境扫描与校验的 IPC 处理逻辑
- 实现离线账号登录功能
- 新增配置变更跨进程广播机制
- 重构游戏启动逻辑,使用主进程内存配置作为唯一权威来源
- 新增界面显示与语言设置的配置页面
- 添加 Windows 平台自动发布 CI 流水线
- 迁移旧版配置/认证文件到用户数据目录
- 修复崩溃日志路径、表单控件等多项 bug
- 重构设置页组件系统统一界面样式
This commit is contained in:
2026-08-28 02:08:12 +08:00
parent 1cf906d0db
commit 3653e26a09
60 changed files with 2908 additions and 961 deletions
+85
View File
@@ -0,0 +1,85 @@
# Koring Launcher Windows 发布流水线:构建 → SignPath 签名 → 发布 GitHub Releases
#
# 触发方式:
# 1) 推送 tag v*(推荐):先本地升版(node scripts/version.js 1.2.1)并打 tag v1.2.1
# 推送后本流水线构建、签名并把产物发布到该 tag 对应的 GitHub Release。
# 2) 手动触发 workflow_dispatch:填版本号,构建产物发布到 v<版本号> Release。
#
# 需要的仓库 SecretsSettings -> Secrets and variables -> Actions):
# SIGNPATH_API_TOKEN SignPath API Token(必填;放在 Environment "BUILDER" 的环境 Secrets 中,
# 本 job 声明 environment: BUILDER 后才能读取)
# SIGNPATH_ARTIFACT_CONFIG_SLUG 产物配置 slug(项目只有一个配置时可留空;仓库级 Secret)
#
# 组织 ID / slug 非密钥,直接写在下方 env(SignPath 后台确认值):
# OrganizationId 31ecd033-d59e-492b-a70b-b00a54bbc7c2
# ProjectSlug Koring_Launcher
# SigningPolicySlug Koring_Launcher_Dev_builder
#
# ⚠️ 签名策略说明:
# - 审批流程:SignPath 后台已关闭人工审批(自动批准),CI 可全自动。
# - 证书:当前策略 Purpose 为 Release signing(测试证书),用户机器默认不信任
# SmartScreen / 杀软警告),正式对外发布需生产证书(OV/EV)+ 对应生产签名策略,
# 届时只需更换下方 SIGNPATH_SIGNING_POLICY_SLUG。
#
# 说明:
# - electron-builder 的 win.sign 会调用 scripts/signpath-sign.js 逐个文件远程签名,
# 签名后自动生成的 latest.yml / blockmap 哈希即对应签名后的安装包。
# - 发布使用 GITHUB_TOKEN(自动注入,需 contents: write 权限创建/更新 Release)。
name: Release Windows (SignPath)
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
version:
description: 'Version to build and publish (e.g. 1.2.1)'
required: true
permissions:
contents: write
jobs:
build-sign-publish:
runs-on: windows-latest
# 声明使用 Environment "BUILDER",才能读取其中的环境 Secret SIGNPATH_API_TOKEN
environment: BUILDER
env:
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
SIGNPATH_ORG_ID: 31ecd033-d59e-492b-a70b-b00a54bbc7c2
SIGNPATH_PROJECT_SLUG: Koring_Launcher
SIGNPATH_SIGNING_POLICY_SLUG: Koring_Launcher_Dev_builder
SIGNPATH_ARTIFACT_CONFIG_SLUG: ${{ secrets.SIGNPATH_ARTIFACT_CONFIG_SLUG }}
steps:
- uses: actions/checkout@v4
- 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
# 手动触发时按输入升版(tag 触发时版本已在 package.json
- name: Set version (manual runs only)
if: github.event_name == 'workflow_dispatch' && inputs.version != ''
run: node scripts/version.js ${{ inputs.version }}
- name: Build renderer + main (production)
run: pnpm build:run
- name: Switch production icons
run: pnpm icon:run
# 关键步骤:win.sign 自定义签名(SignPath)在打包过程中逐个签名内部 exe 与 setup.exe
- name: Package, sign and publish to GitHub Releases
run: pnpm exec electron-builder --win --publish always
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+3
View File
@@ -13,6 +13,9 @@ dist-ssr
dist-electron
*.local
# pnpm 11 project-local store
.pnpm-store/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
+151 -21
View File
@@ -98,6 +98,15 @@ koring-launcher/
│ │ │ ├── TaskButton.tsx # 标题栏任务指示器
│ │ │ └── TaskCard.tsx # 单个任务卡片
│ │ ├── ui/ # shadcn/ui 组件
│ │ ├── setting/ # 设置页原语(HeroUI 统一)
│ │ │ ├── SettingSurface.tsx # 卡片唯一原语(HeroUI Surface + 磨砂 + 圆角)
│ │ │ ├── SettingCard.tsx # 设置卡片(SettingSurface + 标准 padding
│ │ │ ├── SettingRow.tsx # 设置行(label/desc + 控件)
│ │ │ ├── SettingBadge.tsx # 统一徽章(neutral/primary/success/warning/info/error/violet
│ │ │ ├── SettingListItem.tsx # 列表项行(版本行/扫描行,支持选中态)
│ │ │ ├── SectionTitle.tsx # PageHeader/SectionTitleHeroUI Typography
│ │ │ ├── controls.tsx # 设置控件(Select/NumberField/Switch/Radio/TextArea/FilePicker + fieldCls
│ │ │ └── Surface.tsx # Surface 兼容别名(旧 API,样式与卡片统一)
│ │ ├── VersionCard.tsx # 版本/更新卡片
│ │ ├── UnderConstruction.tsx # "装修中" 占位组件
│ │ └── StartupPopup.tsx # 启动弹窗
@@ -117,11 +126,12 @@ koring-launcher/
│ │ └── devStore.ts # 开发者调试
│ ├── api/
│ │ ├── ipc.ts # 核心 IPC 工具 (invoke, onIpcEvent)
│ │ ├── config.ts # AppConfig 读写
│ │ ├── config.ts # AppConfig 读写 + updateConfig(section, patch)
│ │ ├── auth.ts # 登录 API
│ │ ├── background.ts # 背景控制
│ │ ├── install.ts # Minecraft 安装
│ │ ├── launch.ts # 游戏启动
│ │ ├── launch.ts # 统一游戏启动 (launchGame / onGameEvent)
│ │ ├── java.ts # Java 检测 (scanJava / resolveJava)
│ │ ├── mods.ts # Mod 搜索
│ │ ├── instance.ts # 实例 API
│ │ └── update.ts # 应用更新
@@ -142,15 +152,18 @@ koring-launcher/
│ │ ├── auth.ts # Microsoft OAuth, Xbox Live, MC auth
│ │ ├── installer.ts # @xmcl/installer
│ │ ├── launcher.ts # @xmcl/core 游戏启动
│ │ ├── launch-options.ts # 配置→LaunchOption 映射 (parseArgs/buildLaunchOptions/resolveJavaPath)
│ │ ├── paths.ts # 相对 gameDir 归一化 (resolveGamePath)
│ │ ├── modrinth.ts # Modrinth/CurseForge API
│ │ └── instance.ts # 实例管理
│ │ └── instance.ts # 实例管理(含 importExistingInstance sourceGamePath
│ ├── handlers/ # IPC 处理器
│ │ ├── config.ts # 配置读写
│ │ ├── config.ts # 配置读写 + config:update/config:changed
│ │ ├── auth.ts # 认证操作
│ │ ├── install.ts # 安装操作
│ │ ├── launch.ts # 游戏启动
│ │ ├── launch.ts # 统一游戏启动 + afterLaunch
│ │ ├── java.ts # Java 检测 (java:scan / java:resolve)
│ │ ├── mods.ts # Mod 操作
│ │ ├── instance.ts # 实例操作
│ │ ├── instance.ts # 实例操作gamePath 统一 resolveGamePath
│ │ ├── background.ts # 背景操作
│ │ ├── task.ts # 任务系统
│ │ ├── system.ts # 系统信息
@@ -185,14 +198,37 @@ koring-launcher/
| 数据类型 | 存储位置 | 格式 | 说明 |
|---------|---------|------|------|
| 用户设置 | 程序目录 `Koring.yml` | YAML | 所有可配置项 |
| 认证数据 | 程序目录 `koring-auth.json` | JSON | token/xboxProfile |
| 用户设置 | 打包:`app.getPath('userData')/Koring.yml`;开发:项目根 `Koring.yml` | YAML | 所有可配置项 |
| 认证数据 | 打包:`userData/koring-auth.json`;开发:项目根 | JSON | token/xboxProfile |
| 崩溃日志 | 打包:`userData/koring-crash.log`;开发:项目根 | JSONL | 崩溃记录,max 1000 行 |
| 任务历史 | localStorage `koring-task-history` | JSON | 临时,max 50 |
> 打包模式统一使用系统用户数据目录,避免安装到 Program Files 等只读目录时写入失败。
> 旧版「exe 旁」文件在启动时由 `migrateLegacyFiles()``electron/main.ts`)自动复制迁移,复制不删除。
### 主进程权威写入模型(single source of truth
```
渲染进程 setX(patch) ──config:update {section, patch}──▶ 主进程 updateConfig()
├─ 深度合并到内存缓存(getConfig()
├─ 300ms debounce 稀疏写盘(saveConfig
└─ 广播 config:changed(完整配置)
渲染进程 onConfigChanged ──▶ configStore.applyChanged() 覆盖本地镜像
```
- 主进程内存缓存(`electron/config.ts``current`)是唯一权威;**渲染进程不直接写盘**。
- 启动游戏(`launch:launch`)直接读内存 `getConfig()`,保证永远用最新配置,无磁盘竞争。
- 退出时 `flushConfig()``window-all-closed`)强制写盘。
- 认证(Koring 账户)同步进配置时同样走 `updateConfig({ koringUser })` / `deleteConfigKey('koringUser')`
### Koring.yml 结构
```yaml
version: 1
oobe: true # 首次启动引导完成标记
app:
language: zh-CN # zh-CN | en-US(语言包开发中,仅保存偏好 + <html lang>
theme:
darkMode: auto # auto | light | dark
@@ -215,6 +251,7 @@ game:
resourceDir: ""
savesDir: ""
instancesDir: .minecraft/instances
gameDirs: [] # 已添加的游戏目录列表
java:
javaPath: ""
@@ -231,6 +268,9 @@ advanced:
gameArgs: ""
preLaunchCmd: ""
debugMode: false
server: # 快速进入服务器(ip 空则不自动加入)
ip: ""
port: 25565
download:
fileSource: mirror # mirror | official | official-only
@@ -242,15 +282,20 @@ network:
securityId:
enabled: false
authUrl: ""
ui:
showInstanceTitle: true # 首页实例标题
showTaskButton: true # 标题栏任务队列按钮
```
### 向上兼容策略
1. **版本号**`version` 字段,每次结构变更递增
2. **默认值填充** — 加载时缺失字段自动补全,不丢数据
3. **迁移函数**`migrate_v0_to_v1()` 等,按版本链执行
2. **默认值填充** — 加载时缺失字段自动补全,不丢数据`loadConfig``DEFAULTS` 深度合并)
3. **迁移函数** 结构变更时在 `migrate()` 按版本链执行
4. **未知字段保留** — YAML 解析器保留不认识的字段
5. **Debounce 写入** — 300ms debounce 避免频繁 IO
5. **Debounce 写入** 主进程 300ms debounce 稀疏写盘,避免频繁 IO
6. **稀疏保存**`diffValue(config, DEFAULTS)` 只写非默认值;默认值改回后从文件移除
---
@@ -295,33 +340,67 @@ win.mainWindow?.webContents.send('install:progress', data);
| 频道 | 说明 |
|---|---|
| `config:get` / `config:save` | 配置读写 |
| `config:get` / `config:update` / `config:save` | 配置读写;`config:update` 提交 `{section, patch}`,主进程合并 + debounce 写盘 + 广播 `config:changed` |
| `config:changed`(事件) | 主进程广播完整配置,渲染端 `configStore.applyChanged` 覆盖镜像 |
| `config:preload`(事件) | 启动时推送初始配置 + isFirstLaunch |
| `auth:offline-login` / `auth:get` / `auth:save` / `auth:delete` | 认证操作 |
| `install:minecraft` / `install:mod-loader` / `install:version-list` | 安装操作 |
| `launch:launch` / `launch:diagnose` | 游戏启动 |
| `launch:launch` / `launch:diagnose` | 统一游戏启动`{instanceName, gamePath, profile, server?}`;主进程读权威配置 → `buildLaunchOptions``@xmcl/core launch`;事件经 `launch:event` 推送(stdout/stderr/window-ready/exit |
| `java:scan` / `java:resolve` | Java 环境检测(JAVA_HOME/PATH/常见目录)/ 路径校验 |
| `mods:search` / `mods:install` | Mod 操作 |
| `instance:create` / `instance:list` / `instance:delete` | 实例操作 |
| `instance:create` / `instance:list` / `instance:delete` / `instance:install` / `instance:import` | 实例操作(启动统一走 `launch:launch` |
| `background:set-image` / `background:set-color` / `background:reset` | 背景操作 |
| `task:progress` / `task:completed` | 任务进度 |
| `system:info` | 系统信息 |
| `system:info` / `system:open-path` | 系统信息 / 打开路径 |
| `window:minimize` / `window:maximize` / `window:close` | 窗口控制 |
| `window:openSplash` / `window:closeSplash` | Splash 管理 |
| `dialog:openFile` | 文件选择器 |
| `dialog:openFile` / `dialog:openFolder` | 文件/文件夹选择器 |
### 游戏启动链路(配置驱动)
```
launchStore.launch(instanceName, gamePath)
→ launchGame({instanceName, gamePath, profile, server?}) # src/api/launch.ts
→ ipc launch:launch # electron/handlers/launch.ts
→ getConfig() # 主进程内存权威配置
→ getInstanceInfo() # 实例 runtime / path / 健康检查
→ resolveJavaPath() # config.javaPath → 系统扫描 → PATH
→ buildLaunchOptions() # electron/core/launch-options.ts(配置→LaunchOption 映射)
→ @xmcl/core launch() + createMinecraftProcessWatcher
→ 事件 launch:eventstdout/stderr/window-ready/exit+ playtime 累计
→ window-ready 时按 advanced.afterLaunch 处理启动器窗口(close/minimize/keep
```
**配置 → 启动参数映射**`buildLaunchOptions`):
| 配置字段 | 映射 |
|---|---|
| `java.memMode=auto` | 实例 minMemory/maxMemory(未设则 1024/4096 |
| `java.memMode=custom` | `min=min(2,memGB)G``max=memGB G` |
| `java.gc=zgc/g1` | `-XX:+UseZGC` / `-XX:+UseG1GC` |
| `java.jvmArgs` | 引号感知 `parseArgs` 并入 `extraJVMArgs` |
| `advanced.gameArgs` | `parseArgs` 并入 `extraMCArgs` |
| `advanced.winMode` | `resolution`fullscreen / custom 宽高) |
| `advanced.preLaunchCmd` | `prependCommand`Windows 批处理需 `cmd /c` 前缀) |
| `advanced.debugMode` | `-Dkoring.debugMode=true` + 日志流 |
| profile / server | `gameProfile`+`accessToken` / `server` |
---
## Zustand Stores
### configStore (统一配置中心)
### configStore (主进程权威模型的渲染端镜像)
所有用户设置的单一数据源。读写通过 IPC 与 `Koring.yml` 同步
配置的渲染端镜像。所有 setter 只向主进程提交 `{section, patch}``config:update`),不直接写盘;主进程合并后广播 `config:changed``applyChanged` 以广播为准覆盖本地
```ts
config: AppConfig // 完整配置
config: AppConfig // 完整配置(镜像)
loaded: boolean // 是否已加载
init() // 从主进程加载配置
setTheme(patch) // 部分更新 + debounce 300ms 写回
init() // 兜底:从主进程加载配置config:get
applyPreloaded() // 启动预载(config:preload
applyChanged() // 主进程广播覆盖(config:changed
setTheme(patch) // 乐观更新本地 + submit("theme", patch)
setA11y(patch)
setBackground(patch)
setGame(patch)
@@ -329,6 +408,8 @@ setJava(patch)
setAdvanced(patch)
setDownload(patch)
setNetwork(patch)
setInstances(list) // 数组整体替换
setOobe(value)
```
### themeStore (委托 configStore)
@@ -432,3 +513,52 @@ z-200 StartupPopup 启动弹窗 (环境变量控制)
- AbortController 取消机制
- localStorage 持久化历史 (max 50)
- 任务类型: `install` / `download` / `update` / `launch` / `auth` / `sync` / `custom`
### 设置原语与控件规范(HeroUI 3)
所有设置子页面共用 `src/components/setting/` 原语,**一套样式组合**
| 原语 | 说明 |
|---|---|
| `SettingSurface` | 卡片唯一原语(HeroUI Surface + 磨砂 blur + rounded-xl + 统一边框/背景) |
| `SettingCard` | 设置卡片(= SettingSurface + px-5 py-4 |
| `SettingRow` | 设置行(label/desc 用 HeroUI Typography + 右侧控件) |
| `SectionTitle` / `PageHeader` | HeroUI Typography.Heading / Paragraph |
| `SettingBadge` | 统一徽章:neutral / primary / success / warning / info / error / violet |
| `SettingListItem` | 列表项行(版本行/扫描行,支持 selected 高亮) |
| `SettingSelect` / `SettingNumberField` / `SettingSwitch` / `SettingRadioGroup` / `SettingTextArea` / `SettingFilePicker` | HeroUI 控件封装 |
| `fieldCls` | 输入框统一样式类(见下方注意) |
**HeroUI 3 控件要点(易踩坑)**
1. **事件用 `onChange` 而非 `onValueChange`**——HeroUI 3 基于 react-aria`onValueChange` 不存在且静默失效(全项目已统一修复)。
2. **Radio 必须显式渲染圆点**`<Radio.Control><Radio.Indicator /></Radio.Control>`,否则无选中指示器。
3. **field 默认无边框**HeroUI 默认主题 `--field-border-width: 0px`Input/TextArea/NumberField/Radio 需叠加 `fieldCls`(或等价边框类)保证视觉完整;Select.Trigger 已内置边框类。
4. **Select 单选**`selectedKey` + `onSelectionChange`(回调可能是 `Key | null``Set<Key>`,控件内已做兼容)。
5. **NumberField**Group 内需显式渲染 Increment/Decrement 按钮。
### 游戏目录版本识别(设置页 → 导入)
- 扫描任意目录(主目录 / 已添加目录)→ `instance:scan-dir``scanGameDirectories`(读 `versions/` 子目录,检测 JSON/JAR 健康度与 Forge/Fabric/Quilt/OptiFine 加载器)。
- **导入源 = 当前扫描目录**`handleImport`/`handleImportAll``scanTarget` 作为 `sourceGamePath`,实例仍创建在主库 `gameDir/instances/`
- **主目录变更自动重扫**`useEffect([gameDir])`),旧结果先清空。
- **批量导入幂等**:先取 `listInstances` 名集合,已存在实例跳过并计入「跳过」。
- **相对 gameDir 归一化**`electron/core/paths.ts``resolveGamePath` 在 IPC 边界(instance/launch/task)统一应用,相对路径按 exe 目录(打包)/ 项目根(开发)解析,与 `.minecraft` 创建位置一致。
### 设置子页面状态(参考 PCL2 组织)
| 分组 | 页面 | 状态 | 对接 |
|---|---|---|---|
| 通用 | 主页 / Koring 账户 / 关于 / 版权 | 保留 | — |
| 游戏 | 游戏账户&档案 | 实装 | `auth:offline-login`(离线账号);微软按钮占位 |
| 游戏 | Java虚拟机与内存 | 实装 | `java:scan` / `java:resolve` / `java.*` |
| 游戏 | 游戏目录 | 实装 | `instance:scan-dir` / `instance:import`sourceGamePath |
| 游戏 | 高级设置 | 实装 | `advanced.*`(含快速进入服务器 `advanced.server` |
| 个性化 | 主题与背景 | 实装 | `theme.*` / `background.*` |
| 个性化 | 主界面 | 实装 | `ui.showInstanceTitle` / `ui.showTaskButton`(应用到 InstanceTitle / WindowControls |
| 个性化 | 语言 | 实装 | `app.language`(保存 + `<html lang>`;语言包开发中) |
| 个性化 | 辅助功能 | 实装 | `a11y.*` |
| 网络 | 下载 | 实装 | `download.*` |
| 网络 | 安全识别服务 | 实装 | `network.securityId` |
| 网络 | 以太联机 / 陶瓦联机 | 占位 | 无后端接口 |
| 其他 | 服务与反馈 / 赞助我们 / 开发者选项 | 保留 | — |
+23 -9
View File
@@ -34,6 +34,11 @@ build/ Build resources (generated, gitignored)
**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)
## Project Structure
```
@@ -64,19 +69,21 @@ src/
electron/
├── main.ts # Electron entry, window management, splash→main transition
├── preload.ts # Context bridge (window.electronAPI)
├── config.ts # YAML config management
├── 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 # @xmcl/core game launcher
│ ├── 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
│ ├── config.ts # Config load/save/update (config:get/update/save + config:changed broadcast)
│ ├── auth.ts # Auth operations
│ ├── install.ts # Install operations
│ ├── launch.ts # Game launch
│ ├── 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
@@ -146,12 +153,13 @@ import { APP_ICON, DEFAULT_BG, LOGO_SVG, BUILD_MODE, isDev } from "@/lib/mode";
## IPC Handlers
- `config:*` — Config load/save
- `config:get` / `config:update` / `config:save` / `config:changed` — 配置读写(主进程权威:update 深度合并 + debounce 稀疏写盘 + 广播)
- `auth:*` — Microsoft OAuth, offline login
- `install:*` — Minecraft install, mod loader, version lists
- `launch:*` — Game launch, diagnose
- `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
- `instance:*` — Instance CRUD(安装/导入/诊断;启动统一走 `launch:launch`
- `background:*` — Background image/color/blur/animation/theme
- `task:*` — Task system progress
- `system:*` — System info
@@ -166,8 +174,14 @@ import { APP_ICON, DEFAULT_BG, LOGO_SVG, BUILD_MODE, isDev } from "@/lib/mode";
- **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`) 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`). 打包后存 `app.getPath('userData')`(开发模式在项目根目录);主进程内存缓存为唯一权威,渲染进程经 `config:update` 提交、`config:changed` 同步,不在渲染端直接写盘。Sparse save(只写非默认值)。
- **Auth**: JSON file (`koring-auth.json`),打包后存 userData(与配置同策略)。
- **Launch is config-driven**: `launch:launch` 由主进程读取权威配置映射为 `@xmcl/core``LaunchOption``buildLaunchOptions`),前端只传实例名 + 游戏根目录 + 账户档案。
- **HeroUI 3 基于 react-aria**Switch / RadioGroup 等使用 `onChange`(不是 `onValueChange`),`onValueChange` 在 HeroUI 3 中不存在且静默失效。
- **设置子页面统一原语**`src/components/setting/`SettingCard/SettingRow/SettingBadge/SettingListItem/controls.tsx + `fieldCls`),一套样式组合;Radio 必须显式渲染 `<Radio.Control><Radio.Indicator /></Radio.Control>` 才有圆点;HeroUI field 默认边框宽度为 0,输入框需叠加 `fieldCls`
- **游戏目录导入**:版本从**当前扫描目录**导入(`sourceGamePath`),实例建在主库;主目录变更自动重扫;批量导入幂等(已存在跳过);相对 `gameDir`(默认 `.minecraft`)由主进程 `resolveGamePath` 按 exe 目录/项目根归一化。
- **设置页结构(参考 PCL2)**:通用(主页/Koring 账户/关于/版权)与其他(服务与反馈/赞助/开发者)保留;游戏组(账户·离线登录、Java、目录、高级·含快速进入服务器)、个性化组(主题背景/主界面/语言/辅助)、网络组(下载/安全识别)已对接设置接口;以太/陶瓦联机页暂为占位。新增配置段:`app.language`(语言偏好)、`ui.showInstanceTitle/showTaskButton`(主界面元素)、`advanced.server`(快速进入服务器,启动自动加入)。
- **离线账号登录**`auth:offline-login` 处理器生成离线 UUIDMD5(OfflinePlayer:用户名));微软登录 UI 标注"开发中"。
## Tech Stack
+277
View File
@@ -0,0 +1,277 @@
# Koring Launcher 自动更新方案(v1 规划稿)
> 状态:**M1 已完成(2026-08-28**M2 待开始
> 目标平台:**Windows 优先**NSIS exe 安装包),macOS/Linux 后续复用同一套架构
> 决策记录:
> - 安装器模式:**保持 assisted 安装器(`oneClick: false` + 可改安装目录)→ 每次更新整包下载**
> - 更新托管:**GitHub Releases**
> - 当前交付:**M1 基础设施已完成**electron-updater 依赖 + publish 配置 + 1.2.0 打包验证)
---
## 1. 背景与目标
- 项目:Electron 33 + React 19 + TS + electron-builder 25Windows NSIS 安装包,`dist:dev/beta/run` 三模式打包)
- 现状:`package.json` 版本 1.1.2,无任何更新机制;用户需手动下载新版安装包
- 目标:客户端内自动检查新版本 → 下载 → 静默重装 → 自动重启,先跑通 Windows
## 2. 技术选型
**选 electron-updater**electron-builder 官方配套,与现有打包链无缝衔接)。
| 方案 | 说明 | 结论 |
|---|---|---|
| electron-updater | 自动生成 `latest.yml` 清单、SHA512 校验、进度事件、失败重试、断点续传 | ✅ 选用 |
| electron-simple-updater | 只支持替换 asar,不支持 NSIS 重装 | ❌ |
| update-electron-app | 只面向 GitHub,定制性差 | ❌ |
| 自研差分/自建服务 | 灰度、强制更新、统计需要时再评估 | 后期可选 |
**版本兼容**electron-builder 25.1.8 ↔ electron-updater ^6.x(官方同仓库发布,6.x 与 24/25/26 打包器配套)。
## 3. 关键约束(已确认)
### 3.1 assisted 安装器 → 整包下载
当前 `electron-builder.yml` 为 assisted 安装器:
```yaml
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
```
electron-updater 的 NSIS **差分更新(blockmap 增量)只支持一键安装(per-user oneClick**
assisted 安装器安装目录不固定,**退化为每次下载完整 `koring-launcher-<version>-setup.exe`**。
**已决策:接受整包下载**,不改安装体验。含义:
- 每次更新下载全量安装包(预计几十 MB 级别),CDN/带宽按此评估;
- 后续若用户量大、包体过大,可再评估改 oneClick 启用差分,或自研增量(代价高,不优先)。
### 3.2 代码签名(重要)
Windows 下 electron-updater 会对下载的安装包做 Authenticode 校验:当前 exe 有签名时,要求新安装包发布者一致;
当前 exe 未签名时校验会被跳过(记警告)。
- **未签名**:开发/内测可跑通全流程,但国内杀软对未签名 exe 误报率高;
- **正式对外发布前必须做代码签名**(OV 证书起步,EV 更佳),并让发布流水线对 setup.exe 签名;
- 签名后 electron-builder 默认会校验一致性,无需额外配置,但要保证**发布流水线签名证书与产物一致**。
## 4. 整体架构
```
┌───────────────────────────── 客户端 ─────────────────────────────┐
│ React UI (src/) ⇄ preload.ts (contextBridge) ⇄ 主进程 │
│ │ electronAPI.onUpdateStatus() │ │
│ └────────── IPC ────────────────┘ │
│ 主进程 electron/updater.ts(封装 electron-updater
│ electron/handlers/update.tsIPC handler
└──────────────────────────────┬──────────────────────────────────┘
│ HTTPS
┌───────────────────────────┐
│ GitHub Releases (仓库) │
│ · koring-launcher-1.2.1 │
│ -setup.exe │
│ · latest.yml │
└───────────────────────────┘
(国内网络差 → 见 §5.3 加速对策)
```
## 5. 服务端:GitHub Releases
### 5.1 发布产物
electron-builder 配置 `publish: { provider: github }` 后,`electron-builder --publish always`
(或 `--publish` 搭配 CI token)会自动:
1. 打包 Windows 产物;
2. 创建/更新 GitHub Releasetag 取自版本号,如 `v1.2.1`);
3. 上传 `koring-launcher-1.2.1-setup.exe` + `latest.yml`+ `latest.yml.blockmap`,assisted 模式下不用但会生成)。
`latest.yml` 是更新清单(版本、文件路径、大小、sha512),electron-updater 靠它发现新版本并校验完整性:
```yaml
version: 1.2.1
files:
- url: koring-launcher-1.2.1-setup.exe
sha512: <base64-sha512>
size: 81234567
path: koring-launcher-1.2.1-setup.exe
sha512: <base64-sha512>
releaseDate: '2025-01-01T00:00:00.000Z'
```
### 5.2 配置示例(electron-builder.yml 增加段)
```yaml
publish:
provider: github
owner: <GitHub 用户名/组织>
repo: <仓库名>
# releaseType: release # 默认 releasebeta 通道可设 draft/prerelease
```
打包时 electron-builder 会把 `app-update.yml`(含 provider 信息)写进 `resources/`
electron-updater 在运行时读取它——**没有 publish 配置就不会生成 app-update.yml,更新会直接报错**M1 验收点)。
### 5.3 国内网络注意(务必先评估)
electron-updater 的 GitHub provider 走 `api.github.com`(发现版本)与 `github.com/.../releases/download/...`(下载),
国内部分网络环境访问慢或不稳定。对策(按需选):
- **现状接受**:很多应用直接走 GitHub,配合失败重试 + 手动"下载最新版"兜底按钮;
- **CDN 加速(推荐后续做)**:改为 `generic` provider,把 `latest.yml` + exe 同步到
OSS/COS + CDN(或 ghproxy 类代理),`app-update.yml` 指向 CDN URL
- **自建/商业 CDN 代理 GitHub Releases**:等用户量上来再评估。
> 方案设计上保持 provider 可切换:`electron/updater.ts` 只面向 electron-updater 统一 API
> 未来从 `github` 切 `generic` 只需改 electron-builder.yml + 重新打包,业务代码不动。
## 6. Windows 更新流程(整包下载版)
```
应用启动
├─ 加载完成且空闲后(延迟 10~15s)→ 静默 checkForUpdates()
│ (避开启动加载与 Minecraft 下载抢带宽;首启/开发模式跳过)
├─ 无更新 → 结束,静默
└─ 有更新 → 发 update:status{state:'available', version}
前端提示"发现新版本 vX.Y.Z"(非强制,可忽略/稍后)
│ 用户点"下载更新"
autoUpdater.downloadUpdate()
├─ download-progress → update:status{state:'downloading', percent, speed, ...}
├─ 下载完成 → SHA512 校验(latest.yml)→ update:status{state:'downloaded'}
前端提示"重启并安装"(可暂缓;退出时 autoInstallOnAppQuit 兜底)
│ 用户确认
autoUpdater.quitAndInstall()
├─ 应用退出 → NSIS 静默安装(不弹 UI,沿用原安装目录)
└─ 安装完成自动重启 → 运行新版本
失败路径:
· 网络失败 → 提示重试(electron-updater 自带断点续传/重试)
· 校验失败 → 清除缓存重下;仍失败则提示手动下载最新版
· 静默安装失败 → 提示手动下载;保留旧版本可用
```
### 更新状态机(前端 store 用)
```
idle → checking → available → downloading → downloaded → installing → relaunch
└─not-available→ idle └─ error → idle(可重试)
```
## 7. 代码结构规划
```
electron/
updater.ts # 新增:封装 electron-updater
# · 初始化(读 app-update.yml、设日志)
# · 守卫:!app.isPackaged 时跳过(开发模式)
# · check() / download() / quitAndInstall() / 状态查询
# · 订阅 checking/available/not-available/download-progress/downloaded/error
# · 转发为 update:status 事件(带完整 payload
handlers/update.ts # 新增:IPC handler
# · update:check → 触发检查
# · update:download → 触发下载
# · update:quitAndInstall → 重启安装
# · update:getState → 查询当前状态/版本信息
main.ts # 改:import { registerUpdateHandlers };注册;启动后延迟静默检查
preload.ts # 改:暴露
# · checkForUpdates() / downloadUpdate() / quitAndInstall()
# · onUpdateStatus(cb) → 订阅 update:status(沿用现有 on() 模式)
src/
api/update.ts # 新增:IPC 封装 + TS 类型(UpdateStatus
stores/update.ts # 新增:zustand store,维护状态机
components/settings/... # 更新 UI:设置页"关于/更新"区块
# · 当前版本号 + 检查更新按钮
# · 下载进度条(百分比/速度/剩余大小)
# · "重启并安装"确认
components/... # 可选:新版可用 toast / 弹窗(非强制打扰)
```
**依赖注意**`electron-updater` 必须放 **`dependencies`**(不能是 devDependencies),
否则 asar 打包后运行时找不到模块——最常见事故,M1 验收必须覆盖。
## 8. 版本与多通道
- 沿用现有 `scripts/version.js` 升版(`pnpm version:set 1.2.0`),版本必须严格 semver 递增;
- electron-updater 只认 package.json 的 `version`,升版后需重新打包发布;
- **dev / beta / run 三模式的通道隔离放加固阶段(M5)**:
- GitHub provider 多通道依赖 tag 约定(如 `v1.2.1-beta.1`),行为略绕,先只用默认 latest 通道跑通;
- 届时按产品需要决定:beta 走 prerelease/draft releaserun 走正式 release,或切 generic + 不同 URL。
## 9. 里程碑与验收
| 阶段 | 内容 | 验收标准 |
|---|---|---|
| **M1 基础设施** | ① `dependencies` 加 electron-updater;② electron-builder.yml 加 publish(github);③ 升版打包 `pnpm dist:run` | 产物目录出现 `latest.yml`;解包 asar 的 `resources/app-update.yml` 存在;electron-updater 在 asar 内可 require |
| **M2 主进程** | updater.ts + handlers/update.ts + preload 暴露 | 手动触发 `update:check` 能返回状态;事件能推送到渲染进程(dev 下用 `forceDevUpdateConfig` 或打包版验证) |
| **M3 前端 UI** | update store + 设置页更新区块 + 启动静默检查 | 完整交互:检查/提示/进度/重启安装/失败重试 |
| **M4 联调冒烟** | 发布 v1.2.0 → 再发布 v1.2.1,真机从 1.2.0 升到 1.2.1 | Windows 真机全流程通过,含取消下载、断网重试、校验失败场景 |
| **M5 加固** | ~~代码签名~~SignPath 远程签名已接入 CI,见 §12);国内加速(CDN/generic)评估;通道隔离;强制更新开关;临时文件清理 | 可对外发布 |
## 10. 风险与对策
| 风险 | 影响 | 对策 |
|---|---|---|
| electron-updater 误放 devDependencies | 打包后更新直接报错 | M1 验收强制检查 asar 内依赖 |
| 未配置 publish → 无 app-update.yml | 运行时报 "app-update.yml not found" | M1 验收点;文档示例已给 |
| 未签名 exe 被杀软拦截 | 下载/安装被拦,更新失败 | 正式发布前签名(M5);内测接受现状 |
| assisted 静默安装弹 UI / 请求管理员 | 更新中断 | M4 用现有 `build/installer-custom.nsh` 专门联调;必要时加 `runAfterFinish`/静默参数处理 |
| api.github.com 国内访问差 | 检查/下载慢或失败 | 失败重试 + 手动下载兜底;后续 CDN 加速(§5.3) |
| 检查频率过高 | 浪费带宽、触发限流 | 启动延迟 10~15s + 手动按钮;间隔建议 ≥1h |
| 磁盘满 / %LOCALAPPDATA% 权限 | 下载/解压失败 | 失败回退提示,清理 `updaterCacheDirName` 缓存 |
| 版本号不递增 | 永远查不到更新 | CI/脚本校验 `version:set` 只允许升版 |
| 更新失败后应用状态异常 | 用户卡在旧版本 | 保持旧版可用;UI 提供手动下载入口;错误上报(可复用现有 crash 体系) |
## 11. 后续待决策(不阻塞 M1~M4)
- 是否需要强制更新 / 最低版本策略
- 国内加速方案何时落地(generic + OSS/COS/CDN
- macOS / Linux 更新的排期(架构已预留,届时分别补 zip/dmg 与 AppImage 产物与签名)
## 12. SignPath 代码签名接入(2026-08-28 落地)
**方案**electron-builder 自定义签名(`win.sign: scripts/signpath-sign.js`),
构建时对内部 exe`Koring Launcher.exe`、elevate.exe、卸载器)与最终 `setup.exe`
逐个提交 SignPath 远程签名,签名发生在 blockmap / latest.yml 生成之前,
**清单 sha512 与 blockmap 自动对应签名后产物**electron-updater 校验无缝。
**SignPath 后台准备(已确认值)**
- Organization ID`31ecd033-d59e-492b-a70b-b00a54bbc7c2`(已写入 workflow env
- API Token:用户详情页生成(CI 用途),放入 GitHub Secrets `SIGNPATH_API_TOKEN`
- slug:项目 `Koring_Launcher`;签名策略 `Koring_Launcher_Dev_builder`(均写入 workflow env);
产物配置 DEFAULT 在项目仅一个配置时可省略 `SIGNPATH_ARTIFACT_CONFIG_SLUG`
**⚠️ 待办约束**
- **测试证书**:当前策略 Purpose 为 **Test signing**(测试证书),用户机器默认不信任
SmartScreen / 杀软警告;`signtool verify` 会报"不受信任"——预期现象),
正式对外发布需生产证书(OV/EV)+ 对应生产签名策略(届时只换 `SIGNPATH_SIGNING_POLICY_SLUG`)。
- 审批流程:已在 SignPath 后台关闭人工审批(自动批准),CI 可全自动;策略 Purpose 已改为 Release signing。
**新增/改动文件**
- `scripts/signpath-sign.js` — 签名模块(无 token 自动跳过,本地构建不受影响)
- `.github/workflows/release.yml` — 推送 tag `v*`(或手动填版本)→ 构建 → 签名 → `electron-builder --publish always` 发布 GitHub Releases
- `electron-builder.yml``win.sign` 挂载
**CI Secrets**:仅 `SIGNPATH_API_TOKEN`(必填)+ `SIGNPATH_ARTIFACT_CONFIG_SLUG`(可选);
组织 ID 与 slugs 已在 workflow 中写死。
**2026-08-28 实测结果**(与官方 PowerShell 模块对齐后的真实签名):
- 协议:`POST {base}/v1/{orgId}/SigningRequests`**multipart/form-data**,字段 ProjectSlug /
SigningPolicySlug / ArtifactConfigurationSlug? / Description / 文件部件 **Artifact**)→
响应 **Location 头** = 请求 URL → 轮询 `status`/`isFinalStatus` → 完成后取 **signedArtifactLink** 下载;
认证 `Authorization: Bearer <token>`
- ✅ 98.3MB setup.exe 提交成功(无大小限制问题);自动批准生效(InProgress → Completed 无人工介入)
- ✅ 小文件端到端签名成功,`signtool verify` 显示签名链 **Issued to: Lingke Network**
- ⚠️ 本机到 SignPath 下载 100MB+ 产物极慢(20min+),CIGitHub Actions)网络环境不受影响;
模块已加下载重试(3 次)+ 临时文件替换
**验证点(首次 CI 运行)**:确认 SignPath 请求全部 Completed、`latest.yml` 的 sha512
与上传的 signed setup.exe 一致、签名链显示 "Lingke Network"。
File diff suppressed because one or more lines are too long
+7
View File
@@ -3,6 +3,10 @@ productName: Koring Launcher
directories:
output: dist-electron
buildResources: build
publish:
provider: github
owner: dream-pep
repo: koring-launcher
compression: maximum
asar: true
files:
@@ -13,6 +17,9 @@ win:
icon: build/icon.ico
artifactName: "koring-launcher-${version}-setup.${ext}"
requestedExecutionLevel: asInvoker
# SignPath 远程签名:scripts/signpath-sign.js
# 无 SIGNPATH_API_TOKEN 时自动跳过(本地构建不受影响)
sign: scripts/signpath-sign.js
mac:
target: dmg
icon: build/icon.png
+11 -6
View File
@@ -11,15 +11,20 @@ export interface AuthData {
xboxProfile: string;
}
const authFile = (): string => {
/**
* 认证文件路径(与配置一致):
* - 打包后 → 系统用户数据目录(userData)
* - 开发模式 → 项目根目录
*/
export function authPath(): string {
if (app.isPackaged) {
return path.join(path.dirname(app.getPath('exe')), 'koring-auth.json');
return path.join(app.getPath('userData'), 'koring-auth.json');
}
return path.join(__dirname, '..', 'koring-auth.json');
};
}
export function readAuth(): AuthData {
const filePath = authFile();
const filePath = authPath();
if (!fs.existsSync(filePath)) {
return { username: '', uuid: '', accessToken: '', refreshToken: '', xboxProfile: '' };
}
@@ -32,12 +37,12 @@ export function readAuth(): AuthData {
}
export function writeAuth(auth: AuthData): void {
const filePath = authFile();
const filePath = authPath();
fs.writeFileSync(filePath, JSON.stringify(auth, null, 2), 'utf-8');
}
export function deleteAuth(): void {
const filePath = authFile();
const filePath = authPath();
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
+99 -3
View File
@@ -7,9 +7,14 @@ const { app } = electron;
const CONFIG_FILE = 'Koring.yml';
const CURRENT_VERSION = 1;
/**
* 配置文件路径:
* - 打包后 → 系统用户数据目录(userData),避免安装到 Program Files 等只读目录时写入失败
* - 开发模式 → 项目根目录(与旧行为一致,方便调试)
*/
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);
}
@@ -38,6 +43,8 @@ export interface GameConfig {
resourceDir: string;
savesDir: string;
instancesDir: string;
/** 已添加的游戏目录列表 */
gameDirs: string[];
}
export interface JavaConfig {
@@ -48,6 +55,11 @@ export interface JavaConfig {
jvmArgs: string;
}
export interface ServerConfig {
ip: string;
port: number;
}
export interface AdvancedConfig {
afterLaunch: string;
winMode: string;
@@ -56,6 +68,20 @@ export interface AdvancedConfig {
gameArgs: string;
preLaunchCmd: string;
debugMode: boolean;
/** 快速进入服务器(启动后自动加入;ip 为空则不自动加入) */
server: ServerConfig;
}
export interface AppInfoConfig {
/** 界面语言偏好(zh-CN | en-US);语言包开发中,暂仅保存并设置 <html lang> */
language: string;
}
export interface UiConfig {
/** 首页实例标题显示 */
showInstanceTitle: boolean;
/** 标题栏任务队列按钮显示 */
showTaskButton: boolean;
}
export interface DownloadConfig {
@@ -89,6 +115,7 @@ export interface NetworkConfig {
export interface AppConfig {
version: number;
oobe: boolean;
app: AppInfoConfig;
theme: ThemeConfig;
a11y: A11yConfig;
background: BackgroundConfig;
@@ -97,20 +124,23 @@ export interface AppConfig {
advanced: AdvancedConfig;
download: DownloadConfig;
network: NetworkConfig;
ui: UiConfig;
instances: InstanceMeta[];
}
const DEFAULTS: AppConfig = {
version: CURRENT_VERSION,
oobe: true,
app: { language: 'zh-CN' },
theme: { darkMode: 'auto', parallax: true },
a11y: { reduceMotion: false, reduceTransparency: false, highContrast: false, contentBlurOpacity: 50 },
background: { bgType: 'image', image: '/background.png', blur: 0, opacity: 100 },
game: { gameDir: '.minecraft', resourceDir: '', savesDir: '', instancesDir: '.minecraft/instances' },
game: { gameDir: '.minecraft', resourceDir: '', savesDir: '', instancesDir: '.minecraft/instances', gameDirs: [] },
java: { javaPath: '', memMode: 'auto', memGB: 4, gc: 'auto', jvmArgs: '' },
advanced: { afterLaunch: 'close', winMode: 'default', customWidth: 854, customHeight: 480, gameArgs: '', preLaunchCmd: '', debugMode: false },
advanced: { afterLaunch: 'close', winMode: 'default', customWidth: 854, customHeight: 480, gameArgs: '', preLaunchCmd: '', debugMode: false, server: { ip: '', port: 25565 } },
download: { fileSource: 'mirror', versionSource: 'mirror', threads: 16, speedLimit: 0 },
network: { securityId: { enabled: false, authUrl: '' } },
ui: { showInstanceTitle: true, showTaskButton: true },
instances: [],
};
@@ -183,3 +213,69 @@ export function saveConfig(config: AppConfig): void {
const yamlStr = yaml.dump(sparse, { lineWidth: -1 });
fs.writeFileSync(filePath, yamlStr, 'utf-8');
}
// ==================== 主进程权威配置模型 ====================
// 主进程内存缓存是唯一权威(single source of truth):
// 渲染进程通过 config:update 提交补丁 → updateConfig 合并 → debounce 稀疏写盘
// 启动游戏时直接读内存缓存,保证永远是最新配置(无磁盘竞争)。
let current: AppConfig | null = null;
function mergeDeep<T>(base: T, patch: unknown): T {
if (patch === null || patch === undefined) return base;
if (typeof base !== 'object' || typeof patch !== 'object' || Array.isArray(base) || Array.isArray(patch)) {
return patch as T;
}
const result: Record<string, unknown> = { ...(base as Record<string, unknown>) };
for (const key of Object.keys(patch as Record<string, unknown>)) {
result[key] = mergeDeep(result[key], (patch as Record<string, unknown>)[key]);
}
return result as T;
}
/** 读取当前配置(内存优先,未加载则从磁盘读取) */
export function getConfig(): AppConfig {
if (!current) {
current = loadConfig();
}
return current;
}
let saveTimer: ReturnType<typeof setTimeout> | null = null;
function scheduleSave(): void {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
saveTimer = null;
flushConfig();
}, 300);
}
/** 立即将内存配置写盘(应用退出前调用) */
export function flushConfig(): void {
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
if (current) {
saveConfig(current);
}
}
/** 深度合并补丁到内存配置并返回合并结果(300ms debounce 写盘) */
export function updateConfig(patch: Record<string, unknown>): AppConfig {
const base = getConfig();
current = mergeDeep(base, patch) as AppConfig;
scheduleSave();
return current;
}
/** 删除内存配置中的指定顶层键(如 koringUser),300ms debounce 写盘 */
export function deleteConfigKey(key: string): AppConfig {
const base = getConfig();
const next = { ...(base as unknown as Record<string, unknown>) };
delete next[key];
current = next as unknown as AppConfig;
scheduleSave();
return current;
}
+1 -1
View File
@@ -8,7 +8,7 @@ const MAX_LINES = 1000;
function logPath(): string {
if (app.isPackaged) {
return path.join(path.dirname(app.getPath('exe')), LOG_FILE);
return path.join(app.getPath('userData'), LOG_FILE);
}
return path.join(__dirname, '..', LOG_FILE);
}
+7 -72
View File
@@ -1,6 +1,6 @@
import * as fs from 'fs';
import * as path from 'path';
import { MinecraftFolder, Version, launch, createMinecraftProcessWatcher, type ResolvedVersion } from '@xmcl/core';
import { Version, type ResolvedVersion } from '@xmcl/core';
import {
install as xmclInstall,
installForge,
@@ -308,74 +308,6 @@ export async function installInstanceGame(
return getInstanceInfo(name, gamePath);
}
export async function launchInstance(
name: string,
gamePath: string,
options: {
username: string;
uuid: string;
accessToken?: string;
javaPath?: string;
server?: { host: string; port?: number };
onEvent?: (event: { event: string; [key: string]: unknown }) => void;
}
): Promise<{ pid: number; version: string; username: string }> {
const instance = await getInstanceInfo(name, gamePath);
const { runtime } = instance.config;
const resolved: ResolvedVersion = await Version.parse(instance.path, runtime.minecraft);
const javaPath = options.javaPath || instance.config.java || 'java';
const mcProcess = await launch({
gameProfile: {
id: options.uuid,
name: options.username,
},
javaPath,
version: resolved,
gamePath: instance.path,
minMemory: instance.config.minMemory || 1024,
maxMemory: instance.config.maxMemory || 4096,
extraExecOption: { detached: true, stdio: 'ignore' },
server: options.server ? { ip: options.server.host, port: options.server.port } : undefined,
});
const watcher = createMinecraftProcessWatcher(mcProcess);
watcher.on('minecraft-window-ready', () => {
options.onEvent?.({ event: 'window-ready' });
});
watcher.on('minecraft-exit', ({ code }) => {
options.onEvent?.({ event: 'exit', code });
});
// Update playtime tracking
const startTime = Date.now();
mcProcess.on('exit', async () => {
const elapsed = Date.now() - startTime;
try {
const info = await getInstanceInfo(name, gamePath);
await updateInstance(name, gamePath, {
lastPlayedDate: Date.now(),
playtime: (info.config.playtime || 0) + elapsed,
});
} catch {
// Ignore errors during playtime update
}
});
// Update last access date
await updateInstance(name, gamePath, { lastAccessDate: Date.now() });
return {
pid: mcProcess.pid || 0,
version: runtime.minecraft,
username: options.username,
};
}
export async function diagnoseInstance(
name: string,
gamePath: string
@@ -503,6 +435,8 @@ export async function importExistingInstance(
java?: string;
minMemory?: number;
maxMemory?: number;
/** 版本文件来源目录(默认 = gamePath);扫描副目录导入时传 scanTarget */
sourceGamePath?: string;
}
): Promise<InstanceInfo> {
const instancePath = path.join(gamePath, 'instances', name);
@@ -510,10 +444,11 @@ export async function importExistingInstance(
throw new Error(`Instance already exists: ${name}`);
}
// 检查源版本目录是否存在
const srcVersionDir = path.join(gamePath, 'versions', versionId);
// 源版本目录:默认取 gamePath,扫描副目录时取 sourceGamePath
const srcGamePath = options?.sourceGamePath || gamePath;
const srcVersionDir = path.join(srcGamePath, 'versions', versionId);
if (!fs.existsSync(srcVersionDir)) {
throw new Error(`Version directory not found: ${versionId}`);
throw new Error(`Version directory not found: ${versionId}(来源:${srcGamePath}`);
}
// 读取源版本 JSON 获取类型信息
+148
View File
@@ -0,0 +1,148 @@
// __ __ __ __ ______ __ __ ______ __ __ ______ ______
// /\ \ /\ \ /\ "-.\ \ /\ ___\ /\ \/ / /\ ___\ /\ "-.\ \ /\ ___\ /\__ _\
// \ \ \____ \ \ \ \ \ \-. \ \ \ \__ \ \ \ _"-. \ \ __\ \ \ \-. \ \ \ __\ \/_/\ \/
// \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\ \ \_\ \_\ \ \_____\ \ \_\\"\_\ \ \_____\ \ \_\
// \/_____/ \/_/ \/_/ \/_/ \/_____/ \/_/\/_/ \/_____/ \/_/ \/_/ \/_____/ \/_/
//
// 所有权利归Lingke Network (china)所有 | dream_pep拥有其艺术改变权力
// 未经允许的情况下删除此版权头可能会受到民事指控
// 请勿在未经Lingke Network (china)允许的范围内修改代码并分发
import type { LaunchOption } from '@xmcl/core';
import { resolveJava, getPotentialJavaLocations } from '@xmcl/installer';
import type { AppConfig } from '../config';
import type { InstanceInfo } from './instance';
/** 游戏启动所需的账户档案 */
export interface LaunchProfile {
username: string;
uuid: string;
accessToken?: string;
}
/** 快速联机目标服务器 */
export interface LaunchServer {
ip: string;
port?: number;
}
/**
* 引号感知的命令行参数切分器。
* 支持双引号 / 单引号包裹的含空格参数与反斜杠转义。
* 用于 jvmArgs / gameArgs / preLaunchCmd 的解析。
*/
export function parseArgs(line: string): string[] {
const args: string[] = [];
const re = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|(\S+)/g;
let match: RegExpExecArray | null;
while ((match = re.exec(line)) !== null) {
if (match[1] !== undefined) {
args.push(match[1].replace(/\\(["\\])/g, '$1'));
} else if (match[2] !== undefined) {
args.push(match[2].replace(/\\(['\\])/g, '$1'));
} else {
args.push(match[3]);
}
}
return args;
}
/**
* 将启动器配置(AppConfig.java / AppConfig.advanced+ 实例信息 + 账户档案
* 映射为 @xmcl/core 的 LaunchOption。
*
* 配置 → 启动参数 对应关系:
* - java.memMode=auto → 实例 minMemory/maxMemory(未设置则 1024/4096
* - java.memMode=custom → min=min(2,memGB)Gmax=memGB G
* - java.gc=zgc/g1 → -XX:+UseZGC / -XX:+UseG1GC
* - java.jvmArgs → 逐行解析并入 extraJVMArgs
* - advanced.gameArgs → 解析并入 extraMCArgs
* - advanced.winMode → resolutionfullscreen / custom 宽高)
* - advanced.preLaunchCmd → prependCommandWindows 批处理需 `cmd /c` 前缀)
* - advanced.debugMode → -Dkoring.debugMode=true
*/
export function buildLaunchOptions(
config: AppConfig,
instance: InstanceInfo,
profile: LaunchProfile,
javaPath: string,
server?: LaunchServer,
): LaunchOption {
const java = config.java;
const adv = config.advanced;
// ---- 内存 ----
let minMemory = instance.config.minMemory ?? 1024;
let maxMemory = instance.config.maxMemory ?? 4096;
if (java.memMode === 'custom') {
const gb = Math.max(1, Math.min(16, java.memGB || 4));
minMemory = Math.min(2, gb) * 1024;
maxMemory = gb * 1024;
}
// ---- JVM 参数 ----
const extraJVMArgs: string[] = [];
if (java.gc === 'zgc') extraJVMArgs.push('-XX:+UseZGC');
else if (java.gc === 'g1') extraJVMArgs.push('-XX:+UseG1GC');
if (java.jvmArgs?.trim()) {
extraJVMArgs.push(...parseArgs(java.jvmArgs));
}
if (adv.debugMode) {
extraJVMArgs.push('-Dkoring.debugMode=true');
}
// ---- 游戏参数 ----
const extraMCArgs: string[] = [];
if (adv.gameArgs?.trim()) {
extraMCArgs.push(...parseArgs(adv.gameArgs));
}
// ---- 窗口 / 分辨率 ----
let resolution: { width?: number; height?: number; fullscreen?: boolean } | undefined;
if (adv.winMode === 'fullscreen') {
resolution = { fullscreen: true };
} else if (adv.winMode === 'custom') {
resolution = { width: adv.customWidth || 854, height: adv.customHeight || 480 };
}
return {
gameProfile: { name: profile.username, id: profile.uuid },
accessToken: profile.accessToken,
javaPath,
// version 由调用方在 Version.parse 后覆盖为 ResolvedVersion
version: instance.config.runtime.minecraft,
gamePath: instance.path,
minMemory,
maxMemory,
resolution,
extraJVMArgs,
extraMCArgs,
server,
prependCommand: adv.preLaunchCmd?.trim() ? parseArgs(adv.preLaunchCmd) : undefined,
launcherName: 'Koring Launcher',
launcherBrand: 'Koring',
};
}
/**
* 解析 Java 可执行文件路径,优先级:
* 1. 用户配置路径(resolveJava 校验,无效则继续向下)
* 2. 系统扫描(`where java` / `which java` 结果逐个 resolve
* 3. 兜底 PATH 中的 `java`
*/
export async function resolveJavaPath(configuredPath: string): Promise<string> {
if (configuredPath?.trim()) {
const info = await resolveJava(configuredPath.trim()).catch(() => undefined);
if (info) return info.path;
}
try {
const locations = await getPotentialJavaLocations();
for (const loc of locations) {
const info = await resolveJava(loc).catch(() => undefined);
if (info) return info.path;
}
} catch {
// 扫描失败则继续回退
}
return 'java';
}
+116 -135
View File
@@ -1,147 +1,129 @@
import { spawn, ChildProcess } from 'child_process';
// __ __ __ __ ______ __ __ ______ __ __ ______ ______
// /\ \ /\ \ /\ "-.\ \ /\ ___\ /\ \/ / /\ ___\ /\ "-.\ \ /\ ___\ /\__ _\
// \ \ \____ \ \ \ \ \ \-. \ \ \ \__ \ \ \ _"-. \ \ __\ \ \ \-. \ \ \ __\ \/_/\ \/
// \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\ \ \_\ \_\ \ \_____\ \ \_\\"\_\ \ \_____\ \ \_\
// \/_____/ \/_/ \/_/ \/_/ \/_____/ \/_/\/_/ \/_____/ \/_/ \/_/ \/_____/ \/_/
//
// 所有权利归Lingke Network (china)所有 | dream_pep拥有其艺术改变权力
// 未经允许的情况下删除此版权头可能会受到民事指控
// 请勿在未经Lingke Network (china)允许的范围内修改代码并分发
import * as fs from 'fs';
import * as path from 'path';
import { Version, launch, createMinecraftProcessWatcher } from '@xmcl/core';
import type { AppConfig } from '../config';
import { getInstanceInfo, updateInstance } from './instance';
import {
buildLaunchOptions,
resolveJavaPath,
type LaunchProfile,
type LaunchServer,
} from './launch-options';
interface LaunchOptions {
gamePath: string;
javaPath: string;
version: string;
username: string;
uuid: string;
accessToken?: string;
memory?: { min?: string; max?: string };
jvmArgs?: string[];
gameArgs?: string[];
server?: { ip: string; port?: number };
detached?: boolean;
onEvent?: (event: { event: string; [key: string]: unknown }) => void;
}
interface LaunchResult {
export interface GameLaunchResult {
pid: number;
version: string;
username: string;
}
const runningProcesses = new Map<string, ChildProcess>();
export async function launchMinecraft(options: LaunchOptions): Promise<LaunchResult> {
const versionJsonPath = path.join(options.gamePath, 'versions', options.version, `${options.version}.json`);
if (!fs.existsSync(versionJsonPath)) {
throw new Error(`Version JSON not found: ${versionJsonPath}`);
}
const versionJson = JSON.parse(fs.readFileSync(versionJsonPath, 'utf-8'));
const mainClass = versionJson.mainClass;
if (!mainClass) {
throw new Error('Main class not found in version JSON');
}
const args: string[] = [];
// Memory
const minMem = options.memory?.min || '512M';
const maxMem = options.memory?.max || '4G';
args.push(`-Xms${minMem}`);
args.push(`-Xmx${maxMem}`);
// JVM args
if (options.jvmArgs) {
args.push(...options.jvmArgs);
}
// Native libraries path
const nativesDir = path.join(options.gamePath, 'versions', options.version, `${options.version}-natives`);
if (fs.existsSync(nativesDir)) {
args.push(`-Djava.library.path=${nativesDir}`);
}
// Classpath
const libraries = versionJson.libraries || [];
const classpath = libraries
.filter((lib: { downloads?: { artifact?: { path: string } } }) => lib.downloads?.artifact?.path)
.map((lib: { downloads: { artifact: { path: string } } }) => path.join(options.gamePath, 'libraries', lib.downloads.artifact.path));
const clientJar = path.join(options.gamePath, 'versions', options.version, `${options.version}.jar`);
if (fs.existsSync(clientJar)) {
classpath.push(clientJar);
}
args.push('-cp');
args.push(classpath.join(path.delimiter));
args.push(mainClass);
// Game args
args.push(`--username`, options.username);
args.push(`--version`, options.version);
args.push(`--gameDir`, options.gamePath);
args.push(`--assetsDir`, path.join(options.gamePath, 'assets'));
args.push(`--assetIndex`, versionJson.assetIndex?.id || options.version);
args.push(`--uuid`, options.uuid);
if (options.accessToken) {
args.push(`--accessToken`, options.accessToken);
}
if (options.server) {
args.push(`--server`, options.server.ip);
if (options.server.port) {
args.push(`--port`, String(options.server.port));
}
}
if (options.gameArgs) {
args.push(...options.gameArgs);
}
const javaPath = options.javaPath || 'java';
return new Promise((resolve, reject) => {
const child = spawn(javaPath, args, {
cwd: options.gamePath,
detached: options.detached,
stdio: ['ignore', 'pipe', 'pipe'],
});
const requestId = `mc-${Date.now()}`;
runningProcesses.set(requestId, child);
child.stdout?.on('data', (data) => {
const line = data.toString().trim();
if (line) {
options.onEvent?.({ event: 'stdout', message: line });
}
});
child.stderr?.on('data', (data) => {
const line = data.toString().trim();
if (line) {
options.onEvent?.({ event: 'stderr', message: line });
}
});
child.on('error', (err) => {
runningProcesses.delete(requestId);
options.onEvent?.({ event: 'error', error: String(err) });
reject(err);
});
child.on('exit', (code) => {
runningProcesses.delete(requestId);
options.onEvent?.({ event: 'exit', code });
});
resolve({
pid: child.pid || 0,
version: options.version,
username: options.username,
});
});
export interface LaunchEvent {
event: string;
[key: string]: unknown;
}
export interface LaunchGameOptions {
/** 主进程权威配置(内存中最新值) */
config: AppConfig;
/** 实例名 */
instanceName: string;
/** 实例父目录(游戏根目录) */
gamePath: string;
/** 账户档案 */
profile: LaunchProfile;
/** 快速联机目标服务器 */
server?: LaunchServer;
/** 事件回调(stdout / stderr / window-ready / exit */
onEvent?: (event: LaunchEvent) => void;
}
/**
* 统一游戏启动入口:
* 实例信息 → 版本解析 → Java 解析 → 配置映射(buildLaunchOptions)→ @xmcl/core launch
* 启动后监听 window-ready / exit,并在退出时累计实例 playtime。
*/
export async function launchGame(options: LaunchGameOptions): Promise<GameLaunchResult> {
const { config, instanceName, gamePath, profile, server, onEvent } = options;
// 1. 读取实例信息并做健康检查
const instance = await getInstanceInfo(instanceName, gamePath);
if (!instance.healthy) {
const detail = instance.issues.join('') || '未知问题';
throw new Error(`实例「${instanceName}」未安装完整:${detail}。请先在资源中心安装或重新安装该实例。`);
}
// 2. 解析版本
const resolved = await Version.parse(instance.path, instance.config.runtime.minecraft);
// 3. 解析 Java 路径(配置路径 → 系统扫描 → PATH)
const javaPath = await resolveJavaPath(config.java.javaPath);
// 4. 配置 → LaunchOptionversion 覆盖为已解析版本)
const launchOption = buildLaunchOptions(config, instance, profile, javaPath, server);
launchOption.version = resolved;
// 5. 启动(detached:启动器关闭后游戏继续运行;pipe:转发 stdout/stderr
const mcProcess = await launch({
...launchOption,
extraExecOption: { detached: true, stdio: 'pipe' },
});
// 6. 事件监听
const watcher = createMinecraftProcessWatcher(mcProcess);
watcher.on('minecraft-window-ready', () => {
onEvent?.({ event: 'window-ready' });
});
watcher.on('minecraft-exit', ({ code }) => {
onEvent?.({ event: 'exit', code });
});
mcProcess.stdout?.on('data', (chunk: Buffer) => {
const message = chunk.toString();
if (message.trim()) onEvent?.({ event: 'stdout', message });
});
mcProcess.stderr?.on('data', (chunk: Buffer) => {
const message = chunk.toString();
if (message.trim()) onEvent?.({ event: 'stderr', message });
});
// 7. playtime 累计(游戏进程退出时)
const startTime = Date.now();
mcProcess.on('exit', async () => {
const elapsed = Date.now() - startTime;
try {
const info = await getInstanceInfo(instanceName, gamePath);
await updateInstance(instanceName, gamePath, {
lastPlayedDate: Date.now(),
playtime: (info.config.playtime || 0) + elapsed,
});
} catch {
// 忽略 playtime 更新失败
}
});
// 8. 更新最近访问时间
await updateInstance(instanceName, gamePath, { lastAccessDate: Date.now() }).catch(() => {});
return {
pid: mcProcess.pid || 0,
version: instance.config.runtime.minecraft,
username: profile.username,
};
}
/**
* 诊断指定游戏目录下某个版本的健康状态(JSON + JAR 是否存在)。
* 供 launch:diagnose 使用。
*/
export async function diagnoseVersion(gamePath: string, version: string): Promise<Record<string, unknown>> {
const issues: string[] = [];
const versionDir = path.join(gamePath, 'versions', version);
@@ -151,7 +133,6 @@ export async function diagnoseVersion(gamePath: string, version: string): Promis
if (!fs.existsSync(versionJsonPath)) {
issues.push(`Version JSON not found: ${versionJsonPath}`);
}
if (!fs.existsSync(jarPath)) {
issues.push(`Client JAR not found: ${jarPath}`);
}
+20
View File
@@ -0,0 +1,20 @@
// 路径归一化:相对 gameDir(默认 `.minecraft`)在打包后依赖进程 cwd,不可靠。
// 统一按与 runStartupChecks 一致的基准解析(打包 → exe 目录;开发 → 项目根)。
import * as path from 'path';
import electron from 'electron';
const { app } = electron;
function baseDataPath(): string {
if (app.isPackaged) {
return path.dirname(app.getPath('exe'));
}
return path.join(__dirname, '..', '..');
}
/** 相对路径 → 绝对(基准 = exe 目录/项目根);绝对路径原样返回 */
export function resolveGamePath(gamePath: string): string {
if (!gamePath || path.isAbsolute(gamePath)) {
return gamePath;
}
return path.join(baseDataPath(), gamePath);
}
+4 -1
View File
@@ -30,6 +30,7 @@ import {
mirrorFetch,
type InstanceRuntime,
} from './instance';
import { resolveGamePath } from './paths';
// 任务执行钩子:主进程用它向渲染进程广播日志
export interface TaskHooks {
@@ -204,7 +205,9 @@ executorRegistry.set('install-sim', (raw, hooks) => {
// 任务树:install.create → install.minecraft → (forge|neoforge|fabric|quilt) → install.dependencies
executorRegistry.set('install', (rawParams, hooks) => {
const p = rawParams as unknown as InstallTaskParams;
const { name, gamePath, runtime } = p;
const { name, runtime } = p;
// 相对 gameDir 归一化(与 runStartupChecks 基准一致)
const gamePath = resolveGamePath(p.gamePath);
const instancePath = path.join(gamePath, 'instances', name);
return task('install', async function () {
+18
View File
@@ -1,6 +1,7 @@
import electron from 'electron';
const { ipcMain } = electron;
import { readAuth, writeAuth, deleteAuth } from '../auth';
import { offlineLogin } from '../core/auth';
export function registerAuthHandlers() {
ipcMain.handle('auth:get', () => {
@@ -29,4 +30,21 @@ export function registerAuthHandlers() {
return { success: false, data: null, error: String(e) };
}
});
// 离线账号登录(离线模式不需要微软 OAuth,用户名即可生成 UUID)
ipcMain.handle('auth:offline-login', async (_event, payload: { username: string }) => {
try {
const username = (payload?.username || '').trim();
if (!username) {
return { success: false, data: null, error: '用户名不能为空' };
}
if (username.length > 16) {
return { success: false, data: null, error: '用户名长度不能超过 16 个字符' };
}
const data = await offlineLogin(username);
return { success: true, data, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
}
+20 -4
View File
@@ -1,13 +1,16 @@
import electron from 'electron';
const { ipcMain, app } = electron;
import * as fs from 'fs';
import * as path from 'path';
import { loadConfig, saveConfig, type AppConfig, configPath } from '../config';
import { getConfig, saveConfig, updateConfig, type AppConfig, configPath } from '../config';
export function registerConfigHandlers() {
interface WinRef {
mainWindow: electron.BrowserWindow | null;
}
export function registerConfigHandlers(win: WinRef) {
ipcMain.handle('config:get', () => {
try {
const config = loadConfig();
const config = getConfig();
return { success: true, data: config, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
@@ -23,6 +26,19 @@ export function registerConfigHandlers() {
}
});
// 主进程权威更新:渲染进程提交 { section, patch } 补丁,
// 主进程深度合并到内存配置 → 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);
return { success: true, data: config, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('config:reset', () => {
try {
const filePath = configPath();
+8 -10
View File
@@ -2,6 +2,8 @@ import electron from 'electron';
import path from 'path';
import fs from 'fs';
import { writeCrashLog, readCrashLog, clearCrashLog, type CrashEntry } from '../core/crash-logger';
import { configPath } from '../config';
import { authPath } from '../auth';
const { app, ipcMain, BrowserWindow } = electron;
@@ -151,20 +153,16 @@ export function registerCrashHandlers() {
// Factory reset
ipcMain.handle('crash:factoryReset', () => {
const dataPath = app.isPackaged
? path.dirname(app.getPath('exe'))
: path.join(__dirname, '../..');
// Delete config
// Delete configuserData / 项目根目录,与 configPath 一致)
try {
const configPath = path.join(dataPath, 'Koring.yml');
if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
const config = configPath();
if (fs.existsSync(config)) fs.unlinkSync(config);
} catch {}
// Delete auth
// Delete authuserData / 项目根目录,与 authPath 一致)
try {
const authPath = path.join(dataPath, 'koring-auth.json');
if (fs.existsSync(authPath)) fs.unlinkSync(authPath);
const auth = authPath();
if (fs.existsSync(auth)) fs.unlinkSync(auth);
} catch {}
// Delete background cache in userData
+15 -43
View File
@@ -6,7 +6,6 @@ import {
deleteInstance,
updateInstance,
installInstanceGame,
launchInstance,
diagnoseInstance,
getMinecraftVersionList,
getForgeVersionList,
@@ -17,6 +16,7 @@ import {
type InstanceRuntime,
type InstanceConfig,
} from '../core/instance';
import { resolveGamePath } from '../core/paths';
const { ipcMain } = electron;
@@ -38,9 +38,10 @@ export function registerInstanceHandlers(win: WinRef) {
mcOptions?: string[];
}) => {
try {
const gamePath = resolveGamePath(payload.gamePath);
const data = await createInstance(
payload.name,
payload.gamePath,
gamePath,
payload.runtime,
{
author: payload.author,
@@ -60,7 +61,7 @@ export function registerInstanceHandlers(win: WinRef) {
ipcMain.handle('instance:list', async (_event, payload: { gamePath: string }) => {
try {
const data = await listInstances(payload.gamePath);
const data = await listInstances(resolveGamePath(payload.gamePath));
return { success: true, data, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
@@ -69,7 +70,7 @@ export function registerInstanceHandlers(win: WinRef) {
ipcMain.handle('instance:info', async (_event, payload: { name: string; gamePath: string }) => {
try {
const data = await getInstanceInfo(payload.name, payload.gamePath);
const data = await getInstanceInfo(payload.name, resolveGamePath(payload.gamePath));
return { success: true, data, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
@@ -78,7 +79,7 @@ export function registerInstanceHandlers(win: WinRef) {
ipcMain.handle('instance:delete', async (_event, payload: { name: string; gamePath: string }) => {
try {
const data = await deleteInstance(payload.name, payload.gamePath);
const data = await deleteInstance(payload.name, resolveGamePath(payload.gamePath));
return { success: true, data, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
@@ -91,7 +92,7 @@ export function registerInstanceHandlers(win: WinRef) {
patch: Partial<Omit<InstanceConfig, 'name' | 'creationDate'>>;
}) => {
try {
const data = await updateInstance(payload.name, payload.gamePath, payload.patch);
const data = await updateInstance(payload.name, resolveGamePath(payload.gamePath), payload.patch);
return { success: true, data, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
@@ -101,8 +102,9 @@ export function registerInstanceHandlers(win: WinRef) {
ipcMain.handle('instance:install', async (_event, payload: { name: string; gamePath: string }) => {
try {
const requestId = `install-${Date.now()}`;
const gamePath = resolveGamePath(payload.gamePath);
installInstanceGame(payload.name, payload.gamePath, {
installInstanceGame(payload.name, gamePath, {
onProgress: (progress) => {
win.mainWindow?.webContents.send('instance:progress', { requestId, ...progress });
},
@@ -118,42 +120,9 @@ export function registerInstanceHandlers(win: WinRef) {
}
});
ipcMain.handle('instance:launch', async (_event, payload: {
name: string;
gamePath: string;
username: string;
uuid: string;
accessToken?: string;
javaPath?: string;
server?: { host: string; port?: number };
}) => {
try {
const requestId = `launch-${Date.now()}`;
launchInstance(payload.name, payload.gamePath, {
username: payload.username,
uuid: payload.uuid,
accessToken: payload.accessToken,
javaPath: payload.javaPath,
server: payload.server,
onEvent: (event) => {
win.mainWindow?.webContents.send('instance:launch-event', { requestId, ...event });
},
}).then((data) => {
win.mainWindow?.webContents.send('instance:launch-complete', { requestId, data });
}).catch((err) => {
win.mainWindow?.webContents.send('instance:launch-error', { requestId, error: String(err) });
});
return { success: true, data: { requestId }, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('instance:diagnose', async (_event, payload: { name: string; gamePath: string }) => {
try {
const data = await diagnoseInstance(payload.name, payload.gamePath);
const data = await diagnoseInstance(payload.name, resolveGamePath(payload.gamePath));
return { success: true, data, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
@@ -200,7 +169,7 @@ export function registerInstanceHandlers(win: WinRef) {
// 扫描游戏目录中的已安装版本
ipcMain.handle('instance:scan-dir', async (_event, payload: { gamePath: string }) => {
try {
const versions = scanGameDirectories(payload.gamePath);
const versions = scanGameDirectories(resolveGamePath(payload.gamePath));
return { success: true, data: { versions }, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
@@ -216,17 +185,20 @@ export function registerInstanceHandlers(win: WinRef) {
java?: string;
minMemory?: number;
maxMemory?: number;
/** 版本文件来源目录(扫描副目录导入时传扫描目录) */
sourceGamePath?: string;
}) => {
try {
const data = await importExistingInstance(
payload.name,
payload.gamePath,
resolveGamePath(payload.gamePath),
payload.versionId,
{
description: payload.description,
java: payload.java,
minMemory: payload.minMemory,
maxMemory: payload.maxMemory,
sourceGamePath: payload.sourceGamePath ? resolveGamePath(payload.sourceGamePath) : undefined,
}
);
return { success: true, data, error: null };
+62
View File
@@ -0,0 +1,62 @@
import electron from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import { scanLocalJava, resolveJava } from '@xmcl/installer';
const { ipcMain } = electron;
// 常见 Java 安装目录(Windows),按一层子目录枚举 bin/java.exe
function scanCommonJavaDirs(): string[] {
const exe = process.platform === 'win32' ? 'java.exe' : 'java';
const roots = [
'C:\\Program Files\\Java',
'C:\\Program Files (x86)\\Java',
'C:\\Program Files\\Eclipse Adoptium',
'C:\\Program Files\\Microsoft',
'C:\\Program Files\\Zulu',
'C:\\Program Files\\Amazon Corretto',
];
const out: string[] = [];
for (const root of roots) {
try {
const entries = fs.readdirSync(root);
for (const e of entries) {
out.push(path.join(root, e, 'bin', exe));
}
} catch {
// 目录不存在则跳过
}
out.push(path.join(root, 'bin', exe));
}
return out;
}
export function registerJavaHandlers() {
// 扫描系统已安装的 JavaJAVA_HOME / PATH / 常见安装目录)
ipcMain.handle('java:scan', async () => {
try {
const candidates = scanCommonJavaDirs();
const list = await scanLocalJava(candidates);
// 按路径去重(同一安装可能被多个来源发现)
const seen = new Set<string>();
const javaList = list.filter((j) => {
if (seen.has(j.path)) return false;
seen.add(j.path);
return true;
});
return { success: true, data: { javaList }, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
// 校验指定路径是否为可用的 Java 可执行文件
ipcMain.handle('java:resolve', async (_event, payload: { path: string }) => {
try {
const java = await resolveJava(payload.path);
return { success: true, data: { java: java ?? null }, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
}
+27 -29
View File
@@ -8,7 +8,7 @@ import {
readKoringAuth,
deleteKoringAuth,
} from '../core/koring-auth';
import { loadConfig, saveConfig } from '../config';
import { getConfig, updateConfig, deleteConfigKey } from '../config';
export function registerKoringAuthHandlers() {
ipcMain.handle('koring-auth:request-device-code', async () => {
@@ -25,19 +25,19 @@ export function registerKoringAuthHandlers() {
const result = await pollForTokenOnce(deviceCode);
const user = saveKoringAuth(result);
// 同时写入配置文件
// 同步到配置文件(主进程权威模型:合并内存缓存 + debounce 写盘)
try {
const config = loadConfig();
(config as any).koringUser = {
sub: user.sub,
name: user.name,
username: user.username,
email: user.email,
picture: user.picture,
accessToken: result.access_token,
refreshToken: result.refresh_token,
};
saveConfig(config);
updateConfig({
koringUser: {
sub: user.sub,
name: user.name,
username: user.username,
email: user.email,
picture: user.picture,
accessToken: result.access_token,
refreshToken: result.refresh_token,
},
});
} catch (e) {
console.error('[koring-auth] failed to save user to config:', e);
}
@@ -58,17 +58,17 @@ export function registerKoringAuthHandlers() {
// 同步到配置文件
try {
const config = loadConfig();
(config as any).koringUser = {
sub: user.sub,
name: user.name,
username: user.username,
email: user.email,
picture: user.picture,
accessToken: result.access_token,
refreshToken: result.refresh_token,
};
saveConfig(config);
updateConfig({
koringUser: {
sub: user.sub,
name: user.name,
username: user.username,
email: user.email,
picture: user.picture,
accessToken: result.access_token,
refreshToken: result.refresh_token,
},
});
} catch {}
return { success: true, data: { user }, error: null };
@@ -80,10 +80,10 @@ export function registerKoringAuthHandlers() {
ipcMain.handle('koring-auth:get-user', () => {
try {
const stored = readKoringAuth();
// 也从配置文件读取
// 也从配置文件读取(内存权威)
if (!stored?.user?.sub) {
try {
const config = loadConfig();
const config = getConfig();
const ku = (config as any).koringUser;
if (ku?.sub) {
return { success: true, data: { user: ku, access_token: '', refresh_token: '', id_token: '', expires_at: 0 }, error: null };
@@ -101,9 +101,7 @@ export function registerKoringAuthHandlers() {
deleteKoringAuth();
// 清除配置文件中的用户数据
try {
const config = loadConfig();
delete (config as any).koringUser;
saveConfig(config);
deleteConfigKey('koringUser');
} catch {}
return { success: true, data: null, error: null };
} catch (e: unknown) {
+33 -22
View File
@@ -1,5 +1,7 @@
import electron from 'electron';
import { launchMinecraft, diagnoseVersion } from '../core/launcher';
import { launchGame, diagnoseVersion } from '../core/launcher';
import { resolveGamePath } from '../core/paths';
import { getConfig, type AppConfig } from '../config';
const { ipcMain } = electron;
@@ -7,36 +9,45 @@ interface WinRef {
mainWindow: electron.BrowserWindow | null;
}
// 游戏窗口就绪后按配置处理启动器窗口(afterLaunch
function applyAfterLaunch(config: AppConfig, win: WinRef): void {
const mode = config.advanced?.afterLaunch ?? 'close';
if (mode === 'close') {
win.mainWindow?.close();
} else if (mode === 'minimize') {
win.mainWindow?.minimize();
}
// 'keep' → 无操作
}
export function registerLaunchHandlers(win: WinRef) {
ipcMain.handle('launch:launch', async (_event, payload: {
instanceName: string;
gamePath: string;
javaPath: string;
version: string;
username: string;
uuid: string;
accessToken?: string;
memory?: { min?: string; max?: string };
jvmArgs?: string[];
gameArgs?: string[];
profile: { username: string; uuid: string; accessToken?: string };
server?: { ip: string; port?: number };
detached?: boolean;
}) => {
try {
const requestId = `launch-${Date.now()}`;
const result = await launchMinecraft({
gamePath: payload.gamePath,
javaPath: payload.javaPath,
version: payload.version,
username: payload.username,
uuid: payload.uuid,
accessToken: payload.accessToken,
memory: payload.memory,
jvmArgs: payload.jvmArgs,
gameArgs: payload.gameArgs,
server: payload.server,
detached: payload.detached,
// 使用主进程内存配置(唯一权威,永远是最新值,无磁盘竞争)
const config = getConfig();
// 快速进入服务器:UI 显式传入优先,否则使用配置中保存的 advanced.server
const server = payload.server
?? (config.advanced?.server?.ip ? config.advanced.server : undefined);
const result = await launchGame({
config,
instanceName: payload.instanceName,
gamePath: resolveGamePath(payload.gamePath),
profile: payload.profile,
server,
onEvent: (event) => {
// afterLaunch 副作用:窗口就绪后关闭/最小化启动器
if (event.event === 'window-ready') {
applyAfterLaunch(config, win);
}
win.mainWindow?.webContents.send('launch:event', { requestId, ...event });
},
});
+34 -5
View File
@@ -13,7 +13,9 @@ import { registerSystemHandlers } from './handlers/system';
import { registerWindowHandlers } from './handlers/window';
import { registerCrashHandlers, setupCrashListeners, testCrashDialog } from './handlers/crash-monitor';
import { registerKoringAuthHandlers } from './handlers/koring-auth';
import { loadConfig, saveConfig, configExists } from './config';
import { registerJavaHandlers } from './handlers/java';
import { saveConfig, configExists, getConfig, flushConfig, configPath } from './config';
import { authPath } from './auth';
const { app } = electron;
@@ -28,8 +30,30 @@ const win: { mainWindow: electron.BrowserWindow | null; splashWindow: electron.B
splashWindow: null,
};
// 迁移旧版「可执行文件旁」存储 → userData(仅打包模式)。
// 复制而非移动,避免破坏用户已有文件;userData 已有目标文件则跳过。
function migrateLegacyFiles(): void {
if (!app.isPackaged) return;
const exeDir = path.dirname(app.getPath('exe'));
const pairs: { name: string; dest: string }[] = [
{ name: 'Koring.yml', dest: configPath() },
{ name: 'koring-auth.json', dest: authPath() },
];
for (const { name, dest } of pairs) {
if (fs.existsSync(dest)) continue;
const src = path.join(exeDir, name);
if (!fs.existsSync(src)) continue;
try {
fs.copyFileSync(src, dest);
console.log(`[migrate] copied ${name}${dest}`);
} catch (e) {
console.error(`[migrate] failed to copy ${name}:`, e);
}
}
}
// Startup checks: .minecraft dir + config file + first launch detection
function runStartupChecks(): { isFirstLaunch: boolean; config: ReturnType<typeof loadConfig> } {
function runStartupChecks(): { isFirstLaunch: boolean; config: ReturnType<typeof getConfig> } {
const dataPath = app.isPackaged
? path.dirname(app.getPath('exe'))
: path.join(__dirname, '..');
@@ -44,8 +68,8 @@ function runStartupChecks(): { isFirstLaunch: boolean; config: ReturnType<typeof
const hasConfig = configExists();
const isFirstLaunch = !hasConfig;
// 3. Load (or create) config
const config = loadConfig();
// 3. Load (or create) config(getConfig 会缓存到主进程内存,成为唯一权威)
const config = getConfig();
if (isFirstLaunch) {
saveConfig(config);
}
@@ -130,7 +154,7 @@ function createMainWindow(): electron.BrowserWindow {
}
function registerAllHandlers() {
registerConfigHandlers();
registerConfigHandlers(win);
registerAuthHandlers();
registerInstallHandlers(win);
registerLaunchHandlers(win);
@@ -142,11 +166,15 @@ function registerAllHandlers() {
registerWindowHandlers(win);
registerCrashHandlers();
registerKoringAuthHandlers();
registerJavaHandlers();
}
app.whenReady().then(() => {
registerAllHandlers();
// Migrate legacy exe-dir config/auth to userData before anything reads them
migrateLegacyFiles();
// Run startup checks before creating windows
const { isFirstLaunch, config } = runStartupChecks();
@@ -194,6 +222,7 @@ app.whenReady().then(() => {
});
app.on('window-all-closed', () => {
flushConfig();
app.quit();
});
+7
View File
@@ -58,6 +58,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
return () => ipcRenderer.removeListener('config:preload', handler);
},
// Config changed broadcast (authoritative full config from main process)
onConfigChanged: (callback: (config: unknown) => void) => {
const handler = (_event: Electron.IpcRendererEvent, config: unknown) => callback(config);
ipcRenderer.on('config:changed', handler);
return () => ipcRenderer.removeListener('config:changed', handler);
},
// Background image — pick file, copy to userData, return base64 data URL
pickBackgroundImage: async (): Promise<string | null> => {
const result = await ipcRenderer.invoke('dialog:openFile', {
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "koring-launcher",
"private": true,
"version": "1.1.2",
"version": "1.2.0",
"description": "Koring Launcher - Minecraft launcher built with Electron + React",
"author": "Shenzhen Lingke Network Technology Co., Ltd.",
"license": "LL-1.0",
@@ -49,6 +49,7 @@
"@xmcl/task": "4.1.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"electron-updater": "^6.8.9",
"js-yaml": "^4.1.0",
"lucide-react": "^1.21.0",
"motion": "^12.42.2",
+218 -155
View File
@@ -22,10 +22,10 @@ importers:
version: 5.2.9
'@fontsource-variable/inter':
specifier: ^5.2.8
version: 5.2.8
version: 5.3.0
'@heroui/react':
specifier: ^3.2.2
version: 3.2.2(@react-spectrum/provider@3.11.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.1)
version: 3.2.2(@react-spectrum/provider@3.11.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@types/react-dom@19.2.5(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.1)
'@heroui/styles':
specifier: ^3.2.2
version: 3.2.2(tailwind-merge@3.6.0)(tailwindcss@4.3.1)
@@ -59,6 +59,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
electron-updater:
specifier: ^6.8.9
version: 6.8.9
js-yaml:
specifier: ^4.1.0
version: 4.2.0
@@ -82,7 +85,7 @@ importers:
version: 19.2.7(react@19.2.7)
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 2.0.8(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
tailwind-merge:
specifier: ^3.6.0
version: 3.6.0
@@ -107,10 +110,10 @@ importers:
version: 19.2.17
'@types/react-dom':
specifier: ^19.1.6
version: 19.2.3(@types/react@19.2.17)
version: 19.2.5(@types/react@19.2.17)
'@vitejs/plugin-react':
specifier: ^4.6.0
version: 4.7.0(supports-color@8.1.1)(vite@7.3.5(@types/node@26.0.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4))
version: 4.7.0(vite@7.3.5(@types/node@26.0.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4))
concurrently:
specifier: ^9.1.0
version: 9.2.3
@@ -119,7 +122,7 @@ importers:
version: 33.4.11(supports-color@8.1.1)
electron-builder:
specifier: ^25.1.8
version: 25.1.8(bluebird@3.7.2)(electron-builder-squirrel-windows@25.1.8)(supports-color@8.1.1)
version: 25.1.8(electron-builder-squirrel-windows@25.1.8)
shadcn:
specifier: ^4.11.0
version: 4.11.0(supports-color@8.1.1)(typescript@5.8.3)
@@ -705,8 +708,8 @@ packages:
'@fontsource-variable/geist@5.2.9':
resolution: {integrity: sha512-TP+QSBG3wxKGPE33CbMy/L0Nu3qvJ6Fy81Yc4LnQ95xH+i+cfEp8fyU8/kfV14YwszxIFPhnoMTbjL71waVpyQ==}
'@fontsource-variable/inter@5.2.8':
resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==}
'@fontsource-variable/inter@5.3.0':
resolution: {integrity: sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==}
'@formatjs/ecma402-abstract@2.3.6':
resolution: {integrity: sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==}
@@ -744,9 +747,6 @@ packages:
peerDependencies:
hono: ^4
'@internationalized/date@3.12.2':
resolution: {integrity: sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==}
'@internationalized/date@3.12.3':
resolution: {integrity: sha512-fuLX+3ZKLsxI73y8b01EG/WjHb6gE6weCqlfawPO27kBWGMh9G1yH6Csv1uU7/cac9H2GHmOMt6CjmuQ1aia4Q==}
@@ -1322,8 +1322,8 @@ packages:
'@types/plist@3.0.5':
resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==}
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
'@types/react-dom@19.2.5':
resolution: {integrity: sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==}
peerDependencies:
'@types/react': ^19.2.0
@@ -1603,6 +1603,10 @@ packages:
resolution: {integrity: sha512-6p/gfG1RJSQeIbz8TK5aPNkoztgY1q5TgmGFMAXcY8itsGW6Y2ld1ALsZ5UJn8rog7hKF3zHx5iQbNQ8uLcRlw==}
engines: {node: '>=12.0.0'}
builder-util-runtime@9.7.0:
resolution: {integrity: sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==}
engines: {node: '>=12.0.0'}
builder-util@25.1.7:
resolution: {integrity: sha512-7jPjzBwEGRbwNcep0gGNpLXG9P94VA3CPAZQCzxkFXiV2GMQKlziMbY//rXPI7WKfhsvGgFXjTcXdBEwgXw9ww==}
@@ -1996,6 +2000,9 @@ packages:
electron-to-chromium@1.5.376:
resolution: {integrity: sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==}
electron-updater@6.8.9:
resolution: {integrity: sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==}
electron@33.4.11:
resolution: {integrity: sha512-xmdAs5QWRkInC7TpXGNvzo/7exojubk+72jn1oJL7keNeIlw7xNglf8TGtJtkR4rWC5FJq0oXiIXPS9BcK2Irg==}
engines: {node: '>= 12.20.55'}
@@ -2299,19 +2306,23 @@ packages:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
glob@10.4.5:
resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==}
glob@10.5.0:
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
glob@7.2.0:
resolution: {integrity: sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
deprecated: Glob versions prior to v9 are no longer supported
glob@8.1.0:
resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==}
engines: {node: '>=12'}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
deprecated: Glob versions prior to v9 are no longer supported
global-agent@3.0.0:
resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==}
@@ -2750,9 +2761,16 @@ packages:
lodash.difference@4.5.0:
resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==}
lodash.escaperegexp@4.1.2:
resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==}
lodash.flatten@4.4.0:
resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==}
lodash.isequal@4.5.0:
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
@@ -3420,6 +3438,11 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
semver@7.7.4:
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
engines: {node: '>=10'}
hasBin: true
semver@7.8.4:
resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==}
engines: {node: '>=10'}
@@ -3505,11 +3528,15 @@ packages:
resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==}
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
sonner@2.0.7:
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
sonner@2.0.8:
resolution: {integrity: sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==}
peerDependencies:
'@types/react': ^18.0.0 || ^19.0.0
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
@@ -3647,6 +3674,9 @@ packages:
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tiny-typed-emitter@2.1.0:
resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==}
tinyglobby@0.2.17:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
@@ -3939,7 +3969,7 @@ snapshots:
'@adobe/react-spectrum@3.47.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@internationalized/date': 3.12.2
'@internationalized/date': 3.12.3
'@react-types/shared': 3.36.0(react@19.2.7)
'@spectrum-icons/ui': 3.7.1(@adobe/react-spectrum@3.47.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@spectrum-icons/workflow': 4.3.1(@adobe/react-spectrum@3.47.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -3962,16 +3992,16 @@ snapshots:
'@babel/compat-data@7.29.7': {}
'@babel/core@7.29.7(supports-color@8.1.1)':
'@babel/core@7.29.7':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/generator': 7.29.7
'@babel/helper-compilation-targets': 7.29.7
'@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)
'@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7)
'@babel/helpers': 7.29.7
'@babel/parser': 7.29.7
'@babel/template': 7.29.7
'@babel/traverse': 7.29.7(supports-color@8.1.1)
'@babel/traverse': 7.29.7
'@babel/types': 7.29.7
'@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0
@@ -4002,41 +4032,41 @@ snapshots:
lru-cache: 5.1.1
semver: 6.3.1
'@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)':
'@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7(supports-color@8.1.1)
'@babel/core': 7.29.7
'@babel/helper-annotate-as-pure': 7.29.7
'@babel/helper-member-expression-to-functions': 7.29.7(supports-color@8.1.1)
'@babel/helper-member-expression-to-functions': 7.29.7
'@babel/helper-optimise-call-expression': 7.29.7
'@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)
'@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@8.1.1)
'@babel/traverse': 7.29.7(supports-color@8.1.1)
'@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7)
'@babel/helper-skip-transparent-expression-wrappers': 7.29.7
'@babel/traverse': 7.29.7
semver: 6.3.1
transitivePeerDependencies:
- supports-color
'@babel/helper-globals@7.29.7': {}
'@babel/helper-member-expression-to-functions@7.29.7(supports-color@8.1.1)':
'@babel/helper-member-expression-to-functions@7.29.7':
dependencies:
'@babel/traverse': 7.29.7(supports-color@8.1.1)
'@babel/traverse': 7.29.7
'@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
'@babel/helper-module-imports@7.29.7(supports-color@8.1.1)':
'@babel/helper-module-imports@7.29.7':
dependencies:
'@babel/traverse': 7.29.7(supports-color@8.1.1)
'@babel/traverse': 7.29.7
'@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)':
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7(supports-color@8.1.1)
'@babel/helper-module-imports': 7.29.7(supports-color@8.1.1)
'@babel/core': 7.29.7
'@babel/helper-module-imports': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
'@babel/traverse': 7.29.7(supports-color@8.1.1)
'@babel/traverse': 7.29.7
transitivePeerDependencies:
- supports-color
@@ -4046,18 +4076,18 @@ snapshots:
'@babel/helper-plugin-utils@7.29.7': {}
'@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)':
'@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7(supports-color@8.1.1)
'@babel/helper-member-expression-to-functions': 7.29.7(supports-color@8.1.1)
'@babel/core': 7.29.7
'@babel/helper-member-expression-to-functions': 7.29.7
'@babel/helper-optimise-call-expression': 7.29.7
'@babel/traverse': 7.29.7(supports-color@8.1.1)
'@babel/traverse': 7.29.7
transitivePeerDependencies:
- supports-color
'@babel/helper-skip-transparent-expression-wrappers@7.29.7(supports-color@8.1.1)':
'@babel/helper-skip-transparent-expression-wrappers@7.29.7':
dependencies:
'@babel/traverse': 7.29.7(supports-color@8.1.1)
'@babel/traverse': 7.29.7
'@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
@@ -4077,53 +4107,53 @@ snapshots:
dependencies:
'@babel/types': 7.29.7
'@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))':
'@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7(supports-color@8.1.1)
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))':
'@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7(supports-color@8.1.1)
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)':
'@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7(supports-color@8.1.1)
'@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)
'@babel/core': 7.29.7
'@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7)
'@babel/helper-plugin-utils': 7.29.7
transitivePeerDependencies:
- supports-color
'@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))':
'@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7(supports-color@8.1.1)
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))':
'@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7(supports-color@8.1.1)
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)':
'@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7(supports-color@8.1.1)
'@babel/core': 7.29.7
'@babel/helper-annotate-as-pure': 7.29.7
'@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)
'@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7)
'@babel/helper-plugin-utils': 7.29.7
'@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@8.1.1)
'@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))
'@babel/helper-skip-transparent-expression-wrappers': 7.29.7
'@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7)
transitivePeerDependencies:
- supports-color
'@babel/preset-typescript@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)':
'@babel/preset-typescript@7.29.7(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7(supports-color@8.1.1)
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.29.7
'@babel/helper-validator-option': 7.29.7
'@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))
'@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)
'@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)
'@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7)
'@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7)
'@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7)
transitivePeerDependencies:
- supports-color
@@ -4135,7 +4165,7 @@ snapshots:
'@babel/parser': 7.29.7
'@babel/types': 7.29.7
'@babel/traverse@7.29.7(supports-color@8.1.1)':
'@babel/traverse@7.29.7':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/generator': 7.29.7
@@ -4208,7 +4238,7 @@ snapshots:
'@electron/asar@3.4.1':
dependencies:
commander: 5.1.0
glob: 7.2.3
glob: 7.2.0
minimatch: 3.1.5
'@electron/get@2.0.3(supports-color@8.1.1)':
@@ -4225,7 +4255,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@electron/notarize@2.5.0(supports-color@8.1.1)':
'@electron/notarize@2.5.0':
dependencies:
debug: 4.4.3(supports-color@8.1.1)
fs-extra: 9.1.0
@@ -4233,7 +4263,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@electron/osx-sign@1.3.1(supports-color@8.1.1)':
'@electron/osx-sign@1.3.1':
dependencies:
compare-version: 0.1.2
debug: 4.4.3(supports-color@8.1.1)
@@ -4244,7 +4274,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@electron/rebuild@3.6.1(bluebird@3.7.2)(supports-color@8.1.1)':
'@electron/rebuild@3.6.1':
dependencies:
'@malept/cross-spawn-promise': 2.0.0
chalk: 4.1.2
@@ -4254,9 +4284,9 @@ snapshots:
got: 11.8.6
node-abi: 3.92.0
node-api-version: 0.2.1
node-gyp: 9.4.1(bluebird@3.7.2)(supports-color@8.1.1)
node-gyp: 9.4.1
ora: 5.4.1
read-binary-file-arch: 1.0.6(supports-color@8.1.1)
read-binary-file-arch: 1.0.6
semver: 7.8.4
tar: 6.2.1
yargs: 17.7.3
@@ -4264,7 +4294,7 @@ snapshots:
- bluebird
- supports-color
'@electron/universal@2.0.1(supports-color@8.1.1)':
'@electron/universal@2.0.1':
dependencies:
'@electron/asar': 3.4.1
'@malept/cross-spawn-promise': 2.0.0
@@ -4451,7 +4481,7 @@ snapshots:
'@fontsource-variable/geist@5.2.9': {}
'@fontsource-variable/inter@5.2.8': {}
'@fontsource-variable/inter@5.3.0': {}
'@formatjs/ecma402-abstract@2.3.6':
dependencies:
@@ -4481,10 +4511,10 @@ snapshots:
'@gar/promisify@1.1.3': {}
'@heroui/react@3.2.2(@react-spectrum/provider@3.11.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.1)':
'@heroui/react@3.2.2(@react-spectrum/provider@3.11.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@types/react-dom@19.2.5(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.1)':
dependencies:
'@heroui/styles': 3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.3.1)
'@radix-ui/react-avatar': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-avatar': 1.1.11(@types/react-dom@19.2.5(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@react-aria/i18n': 3.13.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@react-aria/ssr': 3.10.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@react-aria/utils': 3.34.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -4524,10 +4554,6 @@ snapshots:
dependencies:
hono: 4.12.26
'@internationalized/date@3.12.2':
dependencies:
'@swc/helpers': 0.5.23
'@internationalized/date@3.12.3':
dependencies:
'@swc/helpers': 0.5.23
@@ -4577,7 +4603,7 @@ snapshots:
dependencies:
cross-spawn: 7.0.6
'@malept/flatpak-bundler@0.4.0(supports-color@8.1.1)':
'@malept/flatpak-bundler@0.4.0':
dependencies:
debug: 4.4.3(supports-color@8.1.1)
fs-extra: 9.1.0
@@ -4641,10 +4667,10 @@ snapshots:
'@pkgjs/parseargs@0.11.0':
optional: true
'@radix-ui/react-avatar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@radix-ui/react-avatar@1.1.11(@types/react-dom@19.2.5(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/react-context': 1.1.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.5(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@19.2.7)
@@ -4652,7 +4678,7 @@ snapshots:
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@types/react-dom': 19.2.5(@types/react@19.2.17)
'@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.17)(react@19.2.7)':
dependencies:
@@ -4666,14 +4692,14 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.5(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/react-slot': 1.2.4(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@types/react-dom': 19.2.5(@types/react@19.2.17)
'@radix-ui/react-slot@1.2.4(@types/react@19.2.17)(react@19.2.7)':
dependencies:
@@ -5043,7 +5069,7 @@ snapshots:
xmlbuilder: 15.1.1
optional: true
'@types/react-dom@19.2.3(@types/react@19.2.17)':
'@types/react-dom@19.2.5(@types/react@19.2.17)':
dependencies:
'@types/react': 19.2.17
@@ -5081,11 +5107,11 @@ snapshots:
dependencies:
'@types/node': 26.0.1
'@vitejs/plugin-react@4.7.0(supports-color@8.1.1)(vite@7.3.5(@types/node@26.0.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4))':
'@vitejs/plugin-react@4.7.0(vite@7.3.5(@types/node@26.0.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4))':
dependencies:
'@babel/core': 7.29.7(supports-color@8.1.1)
'@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))
'@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))
'@babel/core': 7.29.7
'@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7)
'@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7)
'@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5
react-refresh: 0.17.0
@@ -5139,7 +5165,7 @@ snapshots:
mime-types: 3.0.2
negotiator: 1.0.0
agent-base@6.0.2(supports-color@8.1.1):
agent-base@6.0.2:
dependencies:
debug: 4.4.3(supports-color@8.1.1)
transitivePeerDependencies:
@@ -5196,28 +5222,28 @@ snapshots:
app-builder-bin@5.0.0-alpha.10: {}
app-builder-lib@25.1.8(bluebird@3.7.2)(dmg-builder@25.1.8)(electron-builder-squirrel-windows@25.1.8)(supports-color@8.1.1):
app-builder-lib@25.1.8(dmg-builder@25.1.8)(electron-builder-squirrel-windows@25.1.8):
dependencies:
'@develar/schema-utils': 2.6.5
'@electron/notarize': 2.5.0(supports-color@8.1.1)
'@electron/osx-sign': 1.3.1(supports-color@8.1.1)
'@electron/rebuild': 3.6.1(bluebird@3.7.2)(supports-color@8.1.1)
'@electron/universal': 2.0.1(supports-color@8.1.1)
'@malept/flatpak-bundler': 0.4.0(supports-color@8.1.1)
'@electron/notarize': 2.5.0
'@electron/osx-sign': 1.3.1
'@electron/rebuild': 3.6.1
'@electron/universal': 2.0.1
'@malept/flatpak-bundler': 0.4.0
'@types/fs-extra': 9.0.13
async-exit-hook: 2.0.1
bluebird-lst: 1.0.9
builder-util: 25.1.7(supports-color@8.1.1)
builder-util-runtime: 9.2.10(supports-color@8.1.1)
builder-util: 25.1.7
builder-util-runtime: 9.2.10
chromium-pickle-js: 0.2.0
config-file-ts: 0.2.8-rc1
debug: 4.4.3(supports-color@8.1.1)
dmg-builder: 25.1.8(bluebird@3.7.2)(electron-builder-squirrel-windows@25.1.8)(supports-color@8.1.1)
dmg-builder: 25.1.8(electron-builder-squirrel-windows@25.1.8)
dotenv: 16.6.1
dotenv-expand: 11.0.7
ejs: 3.1.10
electron-builder-squirrel-windows: 25.1.8(bluebird@3.7.2)(dmg-builder@25.1.8)(supports-color@8.1.1)
electron-publish: 25.1.7(supports-color@8.1.1)
electron-builder-squirrel-windows: 25.1.8(dmg-builder@25.1.8)
electron-publish: 25.1.7
form-data: 4.0.6
fs-extra: 10.1.0
hosted-git-info: 4.1.0
@@ -5240,7 +5266,7 @@ snapshots:
archiver-utils@2.1.0:
dependencies:
glob: 7.2.3
glob: 7.2.0
graceful-fs: 4.2.11
lazystream: 1.0.1
lodash.defaults: 4.2.0
@@ -5383,26 +5409,33 @@ snapshots:
base64-js: 1.5.1
ieee754: 1.2.1
builder-util-runtime@9.2.10(supports-color@8.1.1):
builder-util-runtime@9.2.10:
dependencies:
debug: 4.4.3(supports-color@8.1.1)
sax: 1.6.0
transitivePeerDependencies:
- supports-color
builder-util@25.1.7(supports-color@8.1.1):
builder-util-runtime@9.7.0:
dependencies:
debug: 4.4.3(supports-color@8.1.1)
sax: 1.6.0
transitivePeerDependencies:
- supports-color
builder-util@25.1.7:
dependencies:
7zip-bin: 5.2.0
'@types/debug': 4.1.13
app-builder-bin: 5.0.0-alpha.10
bluebird-lst: 1.0.9
builder-util-runtime: 9.2.10(supports-color@8.1.1)
builder-util-runtime: 9.2.10
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.3(supports-color@8.1.1)
fs-extra: 10.1.0
http-proxy-agent: 7.0.2(supports-color@8.1.1)
https-proxy-agent: 7.0.6(supports-color@8.1.1)
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
is-ci: 3.0.1
js-yaml: 4.2.0
source-map-support: 0.5.21
@@ -5417,7 +5450,7 @@ snapshots:
bytes@3.1.2: {}
cacache@16.1.3(bluebird@3.7.2):
cacache@16.1.3:
dependencies:
'@npmcli/fs': 2.1.2
'@npmcli/move-file': 2.0.1
@@ -5432,7 +5465,7 @@ snapshots:
minipass-pipeline: 1.2.4
mkdirp: 1.0.4
p-map: 4.0.0
promise-inflight: 1.0.1(bluebird@3.7.2)
promise-inflight: 1.0.1
rimraf: 3.0.2
ssri: 9.0.1
tar: 6.2.1
@@ -5572,7 +5605,7 @@ snapshots:
config-file-ts@0.2.8-rc1:
dependencies:
glob: 10.4.5
glob: 10.5.0
typescript: 5.8.3
console-control-strings@1.1.0: {}
@@ -5711,11 +5744,11 @@ snapshots:
minimatch: 3.1.5
p-limit: 3.1.0
dmg-builder@25.1.8(bluebird@3.7.2)(electron-builder-squirrel-windows@25.1.8)(supports-color@8.1.1):
dmg-builder@25.1.8(electron-builder-squirrel-windows@25.1.8):
dependencies:
app-builder-lib: 25.1.8(bluebird@3.7.2)(dmg-builder@25.1.8)(electron-builder-squirrel-windows@25.1.8)(supports-color@8.1.1)
builder-util: 25.1.7(supports-color@8.1.1)
builder-util-runtime: 9.2.10(supports-color@8.1.1)
app-builder-lib: 25.1.8(dmg-builder@25.1.8)(electron-builder-squirrel-windows@25.1.8)
builder-util: 25.1.7
builder-util-runtime: 9.2.10
fs-extra: 10.1.0
iconv-lite: 0.6.3
js-yaml: 4.2.0
@@ -5794,24 +5827,24 @@ snapshots:
dependencies:
jake: 10.9.4
electron-builder-squirrel-windows@25.1.8(bluebird@3.7.2)(dmg-builder@25.1.8)(supports-color@8.1.1):
electron-builder-squirrel-windows@25.1.8(dmg-builder@25.1.8):
dependencies:
app-builder-lib: 25.1.8(bluebird@3.7.2)(dmg-builder@25.1.8)(electron-builder-squirrel-windows@25.1.8)(supports-color@8.1.1)
app-builder-lib: 25.1.8(dmg-builder@25.1.8)(electron-builder-squirrel-windows@25.1.8)
archiver: 5.3.2
builder-util: 25.1.7(supports-color@8.1.1)
builder-util: 25.1.7
fs-extra: 10.1.0
transitivePeerDependencies:
- bluebird
- dmg-builder
- supports-color
electron-builder@25.1.8(bluebird@3.7.2)(electron-builder-squirrel-windows@25.1.8)(supports-color@8.1.1):
electron-builder@25.1.8(electron-builder-squirrel-windows@25.1.8):
dependencies:
app-builder-lib: 25.1.8(bluebird@3.7.2)(dmg-builder@25.1.8)(electron-builder-squirrel-windows@25.1.8)(supports-color@8.1.1)
builder-util: 25.1.7(supports-color@8.1.1)
builder-util-runtime: 9.2.10(supports-color@8.1.1)
app-builder-lib: 25.1.8(dmg-builder@25.1.8)(electron-builder-squirrel-windows@25.1.8)
builder-util: 25.1.7
builder-util-runtime: 9.2.10
chalk: 4.1.2
dmg-builder: 25.1.8(bluebird@3.7.2)(electron-builder-squirrel-windows@25.1.8)(supports-color@8.1.1)
dmg-builder: 25.1.8(electron-builder-squirrel-windows@25.1.8)
fs-extra: 10.1.0
is-ci: 3.0.1
lazy-val: 1.0.5
@@ -5822,11 +5855,11 @@ snapshots:
- electron-builder-squirrel-windows
- supports-color
electron-publish@25.1.7(supports-color@8.1.1):
electron-publish@25.1.7:
dependencies:
'@types/fs-extra': 9.0.13
builder-util: 25.1.7(supports-color@8.1.1)
builder-util-runtime: 9.2.10(supports-color@8.1.1)
builder-util: 25.1.7
builder-util-runtime: 9.2.10
chalk: 4.1.2
fs-extra: 10.1.0
lazy-val: 1.0.5
@@ -5836,6 +5869,19 @@ snapshots:
electron-to-chromium@1.5.376: {}
electron-updater@6.8.9:
dependencies:
builder-util-runtime: 9.7.0
fs-extra: 10.1.0
js-yaml: 4.2.0
lazy-val: 1.0.5
lodash.escaperegexp: 4.1.2
lodash.isequal: 4.5.0
semver: 7.7.4
tiny-typed-emitter: 2.1.0
transitivePeerDependencies:
- supports-color
electron@33.4.11(supports-color@8.1.1):
dependencies:
'@electron/get': 2.0.3(supports-color@8.1.1)
@@ -6034,7 +6080,7 @@ snapshots:
range-parser: 1.2.1
router: 2.2.0(supports-color@8.1.1)
send: 1.2.1(supports-color@8.1.1)
serve-static: 2.2.1(supports-color@8.1.1)
serve-static: 2.2.1
statuses: 2.0.2
type-is: 2.1.0
vary: 1.1.2
@@ -6236,7 +6282,7 @@ snapshots:
dependencies:
is-glob: 4.0.3
glob@10.4.5:
glob@10.5.0:
dependencies:
foreground-child: 3.3.1
jackspeak: 3.4.3
@@ -6245,6 +6291,15 @@ snapshots:
package-json-from-dist: 1.0.1
path-scurry: 1.11.1
glob@7.2.0:
dependencies:
fs.realpath: 1.0.0
inflight: 1.0.6
inherits: 2.0.4
minimatch: 3.1.5
once: 1.4.0
path-is-absolute: 1.0.1
glob@7.2.3:
dependencies:
fs.realpath: 1.0.0
@@ -6333,15 +6388,15 @@ snapshots:
statuses: 2.0.2
toidentifier: 1.0.1
http-proxy-agent@5.0.0(supports-color@8.1.1):
http-proxy-agent@5.0.0:
dependencies:
'@tootallnate/once': 2.0.1
agent-base: 6.0.2(supports-color@8.1.1)
agent-base: 6.0.2
debug: 4.4.3(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
http-proxy-agent@7.0.2(supports-color@8.1.1):
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.4
debug: 4.4.3(supports-color@8.1.1)
@@ -6353,14 +6408,14 @@ snapshots:
quick-lru: 5.1.1
resolve-alpn: 1.2.1
https-proxy-agent@5.0.1(supports-color@8.1.1):
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.2(supports-color@8.1.1)
agent-base: 6.0.2
debug: 4.4.3(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
https-proxy-agent@7.0.6(supports-color@8.1.1):
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.4
debug: 4.4.3(supports-color@8.1.1)
@@ -6629,8 +6684,12 @@ snapshots:
lodash.difference@4.5.0: {}
lodash.escaperegexp@4.1.2: {}
lodash.flatten@4.4.0: {}
lodash.isequal@4.5.0: {}
lodash.isplainobject@4.0.6: {}
lodash.union@4.6.0: {}
@@ -6673,13 +6732,13 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
make-fetch-happen@10.2.1(bluebird@3.7.2)(supports-color@8.1.1):
make-fetch-happen@10.2.1:
dependencies:
agentkeepalive: 4.6.0
cacache: 16.1.3(bluebird@3.7.2)
cacache: 16.1.3
http-cache-semantics: 4.2.0
http-proxy-agent: 5.0.0(supports-color@8.1.1)
https-proxy-agent: 5.0.1(supports-color@8.1.1)
http-proxy-agent: 5.0.0
https-proxy-agent: 5.0.1
is-lambda: 1.0.1
lru-cache: 7.18.3
minipass: 3.3.6
@@ -6689,7 +6748,7 @@ snapshots:
minipass-pipeline: 1.2.4
negotiator: 0.6.4
promise-retry: 2.0.1
socks-proxy-agent: 7.0.0(supports-color@8.1.1)
socks-proxy-agent: 7.0.0
ssri: 9.0.1
transitivePeerDependencies:
- bluebird
@@ -6844,13 +6903,13 @@ snapshots:
fetch-blob: 3.2.0
formdata-polyfill: 4.0.10
node-gyp@9.4.1(bluebird@3.7.2)(supports-color@8.1.1):
node-gyp@9.4.1:
dependencies:
env-paths: 2.2.1
exponential-backoff: 3.1.3
glob: 7.2.3
glob: 7.2.0
graceful-fs: 4.2.11
make-fetch-happen: 10.2.1(bluebird@3.7.2)(supports-color@8.1.1)
make-fetch-happen: 10.2.1
nopt: 6.0.0
npmlog: 6.0.2
rimraf: 3.0.2
@@ -7057,9 +7116,7 @@ snapshots:
progress@2.0.3: {}
promise-inflight@1.0.1(bluebird@3.7.2):
optionalDependencies:
bluebird: 3.7.2
promise-inflight@1.0.1: {}
promise-retry@2.0.1:
dependencies:
@@ -7171,7 +7228,7 @@ snapshots:
react@19.2.7: {}
read-binary-file-arch@1.0.6(supports-color@8.1.1):
read-binary-file-arch@1.0.6:
dependencies:
debug: 4.4.3(supports-color@8.1.1)
transitivePeerDependencies:
@@ -7239,7 +7296,7 @@ snapshots:
rimraf@3.0.2:
dependencies:
glob: 7.2.3
glob: 7.2.0
roarr@2.15.4:
dependencies:
@@ -7321,6 +7378,8 @@ snapshots:
semver@6.3.1: {}
semver@7.7.4: {}
semver@7.8.4: {}
send@1.2.1(supports-color@8.1.1):
@@ -7344,7 +7403,7 @@ snapshots:
type-fest: 0.13.1
optional: true
serve-static@2.2.1(supports-color@8.1.1):
serve-static@2.2.1:
dependencies:
encodeurl: 2.0.0
escape-html: 1.0.3
@@ -7359,10 +7418,10 @@ snapshots:
shadcn@4.11.0(supports-color@8.1.1)(typescript@5.8.3):
dependencies:
'@babel/core': 7.29.7(supports-color@8.1.1)
'@babel/core': 7.29.7
'@babel/parser': 7.29.7
'@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)
'@babel/preset-typescript': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)
'@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7)
'@babel/preset-typescript': 7.29.7(@babel/core@7.29.7)
'@dotenvx/dotenvx': 1.74.2
'@modelcontextprotocol/sdk': 1.29.0(supports-color@8.1.1)(zod@3.25.76)
'@types/validate-npm-package-name': 4.0.2
@@ -7376,7 +7435,7 @@ snapshots:
fast-glob: 3.3.3
fs-extra: 11.3.5
fuzzysort: 3.1.0
https-proxy-agent: 7.0.6(supports-color@8.1.1)
https-proxy-agent: 7.0.6
kleur: 4.1.5
node-fetch: 3.3.2
open: 11.0.0
@@ -7453,9 +7512,9 @@ snapshots:
smart-buffer@4.2.0: {}
socks-proxy-agent@7.0.0(supports-color@8.1.1):
socks-proxy-agent@7.0.0:
dependencies:
agent-base: 6.0.2(supports-color@8.1.1)
agent-base: 6.0.2
debug: 4.4.3(supports-color@8.1.1)
socks: 2.8.9
transitivePeerDependencies:
@@ -7466,10 +7525,12 @@ snapshots:
ip-address: 10.2.0
smart-buffer: 4.2.0
sonner@2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
sonner@2.0.8(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
source-map-js@1.2.1: {}
@@ -7605,6 +7666,8 @@ snapshots:
tiny-invariant@1.3.3: {}
tiny-typed-emitter@2.1.0: {}
tinyglobby@0.2.17:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
+157
View File
@@ -0,0 +1,157 @@
/**
* SignPath 远程代码签名模块(electron-builder 自定义签名)
*
* 挂载方式:electron-builder.yml -> win.sign: scripts/signpath-sign.js
*
* electron-builder 在构建 Windows 产物时会对每个待签名文件调用本模块,
* 本模块把文件提交到 SignPath 签名后,下载签名产物并原位覆盖。
*
* 实现与 SignPath 官方 PowerShell 模块(Submit-SigningRequest)对齐:
* POST {base}/v1/{orgId}/SigningRequests multipart/form-data,响应 Location 头 = 请求 URL
* GET {base}/v1/{orgId}/SigningRequests/{id} 轮询 status / isFinalStatus,完成后返回 signedArtifactLink
* GET signedArtifactLink 下载签名产物
* 认证:Authorization: Bearer <API token>
*
* 环境变量(CI 中由 GitHub Secrets 注入;本地构建未设置时自动跳过签名):
* SIGNPATH_API_TOKEN 必填(API Token
* SIGNPATH_ORG_ID 必填(SignPath 组织 ID
* SIGNPATH_PROJECT_SLUG 可选,默认 Koring_Launcher
* SIGNPATH_SIGNING_POLICY_SLUG 签名策略 slug(必填,如 Koring_Launcher_Dev_builder
* SIGNPATH_ARTIFACT_CONFIG_SLUG 产物配置 slug;项目只有一个配置时可省略
* SIGNPATH_API_BASE 可选,默认 https://app.signpath.io/api
*
* 说明:
* - 无 SIGNPATH_API_TOKEN / SIGNPATH_ORG_ID 时直接返回(不签名),
* 因此本地 `pnpm dist:dev/beta/run` 行为与之前完全一致。
* - 签名发生在 electron-builder 生成 blockmap 与 latest.yml 之前,
* 所以清单中的 sha512 自动对应签名后的安装包。
* - 参考文档:https://docs.signpath.io/build-system-integration
*/
'use strict';
const fs = require('fs');
const path = require('path');
const API_BASE = process.env.SIGNPATH_API_BASE || 'https://app.signpath.io/api';
const PROJECT_SLUG = process.env.SIGNPATH_PROJECT_SLUG || 'Koring_Launcher';
const POLL_INTERVAL_MS = 5000;
const POLL_TIMEOUT_MS = 15 * 60 * 1000; // 单文件签名最长等待 15 分钟
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function failWithDetail(res, ctx) {
let detail = '';
try {
detail = await res.text();
} catch {
/* ignore */
}
throw new Error(`[signpath-sign] ${ctx} failed (${res.status}): ${detail}`);
}
async function signWithSignPath(filePath) {
const token = process.env.SIGNPATH_API_TOKEN;
const orgId = process.env.SIGNPATH_ORG_ID;
const policySlug = process.env.SIGNPATH_SIGNING_POLICY_SLUG;
const artifactSlug = process.env.SIGNPATH_ARTIFACT_CONFIG_SLUG;
if (!policySlug) {
throw new Error('[signpath-sign] SIGNPATH_SIGNING_POLICY_SLUG is required when signing is enabled');
}
const headers = { Authorization: `Bearer ${token}` };
// 1. 提交签名请求(multipart/form-data,与官方 PowerShell 模块一致)
const fileBuf = fs.readFileSync(filePath);
const form = new FormData();
form.append('ProjectSlug', PROJECT_SLUG);
form.append('SigningPolicySlug', policySlug);
if (artifactSlug) {
form.append('ArtifactConfigurationSlug', artifactSlug);
}
form.append('Description', `electron-builder auto-sign: ${path.basename(filePath)}`);
form.append('Artifact', new Blob([fileBuf]), path.basename(filePath));
console.log(
`[signpath-sign] submitting signing request: ${path.basename(filePath)} (${(fileBuf.length / 1024 / 1024).toFixed(1)} MB)`
);
const submitRes = await fetch(`${API_BASE}/v1/${orgId}/SigningRequests`, {
method: 'POST',
headers,
body: form,
});
if (!submitRes.ok) {
await failWithDetail(submitRes, 'submit');
}
const requestUrl = submitRes.headers.get('location');
if (!requestUrl) {
throw new Error('[signpath-sign] submit response has no Location header');
}
console.log(`[signpath-sign] request created: ${requestUrl}`);
// 2. 轮询直到最终状态(isFinalStatus),要求 Completed
const deadline = Date.now() + POLL_TIMEOUT_MS;
let data = null;
for (;;) {
await sleep(POLL_INTERVAL_MS);
const pollRes = await fetch(requestUrl, { headers });
if (!pollRes.ok) {
await failWithDetail(pollRes, 'status query');
}
data = await pollRes.json();
console.log(`[signpath-sign] status: ${data.status}${data.workflowStatus ? ` / ${data.workflowStatus}` : ''}`);
if (data.isFinalStatus) break;
if (Date.now() > deadline) {
throw new Error('[signpath-sign] signing timed out');
}
}
if (data.status !== 'Completed') {
throw new Error(`[signpath-sign] request ${data.status}${data.workflowStatus ? ` / ${data.workflowStatus}` : ''}`);
}
// 3. 通过 signedArtifactLink 下载签名产物并原位覆盖(网络错误重试)
const downloadUrl = data.signedArtifactLink;
if (!downloadUrl) {
throw new Error('[signpath-sign] completed request has no signedArtifactLink');
}
const signedBuf = await downloadWithRetry(downloadUrl, headers);
const tmpPath = `${filePath}.signing.tmp`;
fs.writeFileSync(tmpPath, signedBuf);
fs.renameSync(tmpPath, filePath);
console.log(`[signpath-sign] signed OK (${(signedBuf.length / 1024 / 1024).toFixed(1)} MB)`);
}
const DOWNLOAD_MAX_ATTEMPTS = 3;
async function downloadWithRetry(downloadUrl, headers) {
let lastError = null;
for (let attempt = 1; attempt <= DOWNLOAD_MAX_ATTEMPTS; attempt++) {
try {
const dlRes = await fetch(downloadUrl, { headers });
if (!dlRes.ok) {
await failWithDetail(dlRes, 'download signed artifact');
}
return Buffer.from(await dlRes.arrayBuffer());
} catch (err) {
lastError = err;
console.warn(`[signpath-sign] download attempt ${attempt}/${DOWNLOAD_MAX_ATTEMPTS} failed: ${err.message}`);
if (attempt < DOWNLOAD_MAX_ATTEMPTS) {
await sleep(3000 * attempt);
}
}
}
throw lastError;
}
/**
* electron-builder 自定义签名入口。
* configuration.path 为待签名文件绝对路径;无 token 时跳过(本地开发构建)。
*/
module.exports = async function signPathSign(configuration) {
if (!process.env.SIGNPATH_API_TOKEN || !process.env.SIGNPATH_ORG_ID) {
console.log(`[signpath-sign] no SignPath credentials, skip signing: ${configuration.name}`);
return;
}
await signWithSignPath(configuration.path);
};
+17 -2
View File
@@ -70,14 +70,17 @@ function App() {
// Listen for preloaded config from main process
const unsub = window.electronAPI?.onConfigPreload((data) => {
const { config, isFirstLaunch } = data;
useConfigStore.getState().applyPreloaded(config as AppConfig, isFirstLaunch);
const cfg = config as AppConfig;
useConfigStore.getState().applyPreloaded(cfg, isFirstLaunch);
// 语言偏好 → <html lang>
document.documentElement.lang = (cfg as AppConfig).app?.language ?? "zh-CN";
syncThemeFromConfig();
syncA11yFromConfig();
syncBackgroundFromConfig();
useAuthStore.getState().initFromRegistry();
useKoringAuthStore.getState().initFromDisk();
// Navigate to OOBE on first launch or if oobe not completed
if (isFirstLaunch || config.oobe) {
if (isFirstLaunch || cfg.oobe) {
useRouteStore.getState().navigate("oobe");
}
});
@@ -85,6 +88,18 @@ function App() {
return () => { unsub?.(); };
}, []);
// 主进程权威配置广播 → 覆盖本地镜像并同步派生 store
useEffect(() => {
const unsub = window.electronAPI?.onConfigChanged((config) => {
useConfigStore.getState().applyChanged(config as AppConfig);
syncThemeFromConfig();
syncA11yFromConfig();
syncBackgroundFromConfig();
});
return () => { unsub?.(); };
}, []);
return (
<RootLayout>
<Page key={current} />
+36 -7
View File
@@ -1,4 +1,4 @@
import { ipcInvoke } from './ipc';
import { ipcInvoke, onIpcEvent } from './ipc';
export interface ThemeConfig {
darkMode: string;
@@ -36,6 +36,11 @@ export interface JavaConfig {
jvmArgs: string;
}
export interface ServerConfig {
ip: string;
port: number;
}
export interface AdvancedConfig {
afterLaunch: string;
winMode: string;
@@ -44,6 +49,20 @@ export interface AdvancedConfig {
gameArgs: string;
preLaunchCmd: string;
debugMode: boolean;
/** 快速进入服务器(启动后自动加入;ip 为空则不自动加入) */
server: ServerConfig;
}
export interface AppInfoConfig {
/** 界面语言偏好(zh-CN | en-US);语言包开发中,暂仅保存并设置 <html lang> */
language: string;
}
export interface UiConfig {
/** 首页实例标题显示 */
showInstanceTitle: boolean;
/** 标题栏任务队列按钮显示 */
showTaskButton: boolean;
}
export interface DownloadConfig {
@@ -77,6 +96,7 @@ export interface InstanceMeta {
export interface AppConfig {
version: number;
oobe: boolean;
app: AppInfoConfig;
theme: ThemeConfig;
a11y: A11yConfig;
background: BackgroundConfig;
@@ -85,15 +105,10 @@ export interface AppConfig {
advanced: AdvancedConfig;
download: DownloadConfig;
network: NetworkConfig;
ui: UiConfig;
instances: InstanceMeta[];
}
interface CommandResult {
success: boolean;
data: unknown;
error: string | null;
}
export async function getConfig(): Promise<AppConfig> {
const result = await ipcInvoke<AppConfig>('config:get');
return result;
@@ -102,3 +117,17 @@ export async function getConfig(): Promise<AppConfig> {
export async function saveConfig(config: AppConfig): Promise<void> {
await ipcInvoke('config:save', config);
}
/**
* 主进程权威更新配置:提交 { section, patch } 补丁,
* 主进程深度合并、debounce 稀疏写盘并广播 config:changed。
* 返回合并后的完整配置。
*/
export async function updateConfig(section: string, patch: unknown): Promise<AppConfig> {
return ipcInvoke<AppConfig>('config:update', { section, patch });
}
/** 监听主进程广播的配置变更(完整配置) */
export function onConfigChanged(callback: (config: AppConfig) => void): () => void {
return onIpcEvent<AppConfig>('config:changed', callback);
}
+2 -26
View File
@@ -97,20 +97,6 @@ export async function installInstance(
return ipcInvoke<{ requestId: string }>('instance:install', { name, gamePath });
}
export async function launchInstance(
name: string,
gamePath: string,
options: {
username: string;
uuid: string;
accessToken?: string;
javaPath?: string;
server?: { host: string; port?: number };
}
): Promise<{ requestId: string }> {
return ipcInvoke<{ requestId: string }>('instance:launch', { name, gamePath, ...options });
}
export async function diagnoseInstance(
name: string,
gamePath: string
@@ -162,6 +148,8 @@ export async function importExistingInstance(
java?: string;
minMemory?: number;
maxMemory?: number;
/** 版本文件来源目录(默认 = gamePath */
sourceGamePath?: string;
}
): Promise<InstanceInfo> {
return ipcInvoke<InstanceInfo>('instance:import', {
@@ -201,15 +189,3 @@ export function onInstallComplete(callback: (data: { requestId: string; data: In
export function onInstallError(callback: (data: { requestId: string; error: string }) => void) {
return onIpcEvent('instance:install-error', callback);
}
export function onLaunchEvent(callback: (data: { requestId: string; event: string; [key: string]: unknown }) => void) {
return onIpcEvent('instance:launch-event', callback);
}
export function onLaunchComplete(callback: (data: { requestId: string; data: { pid: number; version: string; username: string } }) => void) {
return onIpcEvent('instance:launch-complete', callback);
}
export function onLaunchError(callback: (data: { requestId: string; error: string }) => void) {
return onIpcEvent('instance:launch-error', callback);
}
+19
View File
@@ -0,0 +1,19 @@
import { ipcInvoke } from './ipc';
export interface JavaInfo {
path: string;
version: string;
majorVersion: number;
}
/** 扫描系统已安装的 JavaJAVA_HOME / PATH / 常见安装目录) */
export async function scanJava(): Promise<JavaInfo[]> {
const data = await ipcInvoke<{ javaList: JavaInfo[] }>('java:scan');
return data?.javaList ?? [];
}
/** 校验指定路径是否为可用的 Java 可执行文件 */
export async function resolveJava(path: string): Promise<JavaInfo | null> {
const data = await ipcInvoke<{ java: JavaInfo | null }>('java:resolve', { path });
return data?.java ?? null;
}
+24 -11
View File
@@ -1,17 +1,25 @@
import { ipcInvoke, onIpcEvent } from './ipc';
export interface LaunchOptions {
gamePath: string;
javaPath: string;
version: string;
/** 启动所需的账户档案(来自 authStore) */
export interface LaunchProfile {
username: string;
uuid: string;
accessToken?: string;
memory?: { min?: string; max?: string };
jvmArgs?: string[];
gameArgs?: string[];
server?: { ip: string; port?: number };
detached?: boolean;
}
/** 快速联机目标服务器 */
export interface LaunchServer {
ip: string;
port?: number;
}
/** 统一启动接口契约:指定实例 + 游戏根目录 + 账户档案(可选快速联机) */
export interface LaunchGamePayload {
instanceName: string;
/** 实例父目录(游戏根目录) */
gamePath: string;
profile: LaunchProfile;
server?: LaunchServer;
}
export interface LaunchResult {
@@ -21,8 +29,12 @@ export interface LaunchResult {
requestId: string;
}
export async function launchGame(options: LaunchOptions): Promise<LaunchResult> {
return ipcInvoke<LaunchResult>('launch:launch', options);
/**
* 启动游戏。主进程会读取权威配置(Koring.yml 内存缓存)自动应用
* Java 路径 / 内存 / GC / JVM 参数 / 游戏参数 / 窗口模式 / 启动前命令等设置。
*/
export async function launchGame(payload: LaunchGamePayload): Promise<LaunchResult> {
return ipcInvoke<LaunchResult>('launch:launch', payload);
}
export async function diagnoseVersion(
@@ -32,6 +44,7 @@ export async function diagnoseVersion(
return ipcInvoke('launch:diagnose', { gamePath, version });
}
/** 订阅某个启动请求的游戏事件流(stdout / stderr / window-ready / exit */
export function onGameEvent(
requestId: string,
callback: (event: { event: string; [key: string]: unknown }) => void
+1 -1
View File
@@ -25,7 +25,7 @@ export class ErrorBoundary extends Component<Props, State> {
</p>
<Button
size="sm"
variant="flat"
variant="outline"
onPress={() => this.setState({ hasError: false, errorMsg: "" })}
>
+13 -3
View File
@@ -1,12 +1,22 @@
import { Typography } from "@heroui/react";
export function PageHeader({ title, desc }: { title: string; desc: string }) {
return (
<>
<h2 className="text-xl font-bold text-foreground mb-1">{title}</h2>
<p className="text-sm text-muted-foreground mb-6">{desc}</p>
<Typography.Heading level={2} className="text-xl font-bold text-foreground mb-1">
{title}
</Typography.Heading>
<Typography.Paragraph size="sm" className="text-sm text-muted-foreground mb-6">
{desc}
</Typography.Paragraph>
</>
);
}
export function SectionTitle({ children }: { children: React.ReactNode }) {
return <h3 className="text-lg font-bold text-foreground mb-3">{children}</h3>;
return (
<Typography.Heading level={3} className="text-lg font-bold text-foreground mb-3">
{children}
</Typography.Heading>
);
}
+36
View File
@@ -0,0 +1,36 @@
// 设置页统一徽章:版本类型 / 加载器 / 状态标签共用一个组件与样式组合。
import { cn } from "@/lib/utils";
export type SettingBadgeVariant = "neutral" | "primary" | "success" | "warning" | "info" | "error" | "violet";
const badgeStyles: Record<SettingBadgeVariant, string> = {
neutral: "bg-foreground/[0.05] dark:bg-white/[0.05] text-muted-foreground border-border/30 dark:border-white/[0.05]",
primary: "bg-primary/10 text-primary border-primary/20",
success: "bg-green-500/10 text-green-600 dark:bg-green-500/15 dark:text-green-400 border-green-500/20",
warning: "bg-amber-500/10 text-amber-600 dark:bg-amber-500/15 dark:text-amber-400 border-amber-500/20",
info: "bg-sky-500/10 text-sky-600 dark:bg-sky-500/15 dark:text-sky-400 border-sky-500/20",
error: "bg-red-500/10 text-red-600 dark:bg-red-500/15 dark:text-red-400 border-red-500/20",
violet: "bg-violet-500/10 text-violet-600 dark:bg-violet-500/15 dark:text-violet-400 border-violet-500/20",
};
export function SettingBadge({
variant = "neutral",
className,
children,
}: {
variant?: SettingBadgeVariant;
className?: string;
children: React.ReactNode;
}) {
return (
<span
className={cn(
"inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-md border font-medium whitespace-nowrap",
badgeStyles[variant],
className,
)}
>
{children}
</span>
);
}
+2 -6
View File
@@ -1,9 +1,5 @@
import { Card } from "@heroui/react";
import { SettingSurface } from "./SettingSurface";
export function SettingCard({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<Card variant="transparent" className={`glass-card px-5 py-4 ${className ?? ""}`}>
{children}
</Card>
);
return <SettingSurface className={`px-5 py-4 ${className ?? ""}`}>{children}</SettingSurface>;
}
@@ -0,0 +1,28 @@
// 设置页列表项容器:版本行 / 扫描结果行等可操作列表项的统一外观。
import { cn } from "@/lib/utils";
export function SettingListItem({
className,
children,
selected = false,
}: {
className?: string;
children: React.ReactNode;
/** 选中态(高亮边框) */
selected?: boolean;
}) {
return (
<div
className={cn(
"flex items-center gap-3 px-3 py-2 rounded-xl border",
"bg-foreground/[0.03] dark:bg-white/[0.03]",
selected
? "border-primary/30 bg-primary/[0.04]"
: "border-black/[0.06] dark:border-white/[0.07]",
className,
)}
>
{children}
</div>
);
}
+8 -2
View File
@@ -1,9 +1,15 @@
import { Typography } from "@heroui/react";
export function SettingRow({ label, desc, children }: { label: string; desc?: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">{label}</p>
{desc && <p className="text-[13px] text-muted-foreground mt-0.5">{desc}</p>}
<Typography.Paragraph className="text-sm font-medium text-foreground">{label}</Typography.Paragraph>
{desc && (
<Typography.Paragraph size="xs" className="text-[13px] text-muted-foreground mt-0.5">
{desc}
</Typography.Paragraph>
)}
</div>
<div className="shrink-0">{children}</div>
</div>
+37
View File
@@ -0,0 +1,37 @@
// 设置卡片唯一原语:所有设置子页面卡片统一走这里。
// 基于 HeroUI Surface,用显式类保证确定性的磨砂玻璃外观(与既有设计一致)。
import { Surface } from "@heroui/react";
import { cn } from "@/lib/utils";
export interface SettingSurfaceProps {
className?: string;
children?: React.ReactNode;
/** 是否启用磨砂玻璃(尊重无障碍 reduce-transparency 的全局降级) */
frost?: boolean;
/** 阴影级别:raised 默认、flat 无阴影、bordered 仅边框 */
variant?: "raised" | "flat" | "bordered";
}
const variantCls = {
raised: "shadow-sm",
flat: "shadow-none",
bordered: "shadow-none",
} as const;
export function SettingSurface({ className, frost = true, variant = "raised", children }: SettingSurfaceProps) {
return (
<Surface
variant="transparent"
className={cn(
"rounded-xl",
"bg-white/85 dark:bg-black/45",
"border border-black/[0.06] dark:border-white/[0.07]",
variantCls[variant],
frost && "backdrop-blur-[12px]",
className,
)}
>
{children}
</Surface>
);
}
+15 -25
View File
@@ -1,16 +1,10 @@
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
import { SettingSurface } from "./SettingSurface";
interface SurfaceProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: "raised" | "flat" | "bordered"
frost?: "none" | "sm" | "md" | "lg"
padding?: "none" | "sm" | "md" | "lg"
}
const frostMap = {
none: "",
sm: "backdrop-blur-[6px]",
md: "backdrop-blur-[12px]",
lg: "backdrop-blur-[20px]",
variant?: "raised" | "flat" | "bordered";
frost?: "none" | "sm" | "md" | "lg";
padding?: "none" | "sm" | "md" | "lg";
}
const paddingMap = {
@@ -18,8 +12,9 @@ const paddingMap = {
sm: "px-3 py-2.5",
md: "px-5 py-4",
lg: "px-6 py-5",
}
};
// Surface 是 SettingSurface 的兼容别名(保留原 API,样式与设置卡片统一)
function Surface({
variant = "raised",
frost = "none",
@@ -29,24 +24,19 @@ function Surface({
...props
}: SurfaceProps) {
return (
<div
data-slot="surface"
<SettingSurface
variant={variant}
frost={frost !== "none"}
className={cn(
"rounded-xl transition-colors duration-200",
"bg-white/85 dark:bg-black/45",
"border border-black/[0.06] dark:border-white/[0.07]",
frostMap[frost],
paddingMap[padding],
variant === "raised" && "shadow-sm",
variant === "bordered" && "shadow-none",
variant === "flat" && "shadow-none bg-transparent border-0",
variant === "flat" && "bg-transparent border-0 backdrop-blur-none",
className,
)}
{...props}
>
{children}
</div>
)
</SettingSurface>
);
}
function SurfaceHeader({ className, children, ...props }: React.HTMLAttributes<HTMLDivElement>) {
@@ -61,7 +51,7 @@ function SurfaceHeader({ className, children, ...props }: React.HTMLAttributes<H
>
{children}
</div>
)
);
}
function SurfaceContent({ className, children, ...props }: React.HTMLAttributes<HTMLDivElement>) {
@@ -69,7 +59,7 @@ function SurfaceContent({ className, children, ...props }: React.HTMLAttributes<
<div data-slot="surface-content" className={cn("space-y-3", className)} {...props}>
{children}
</div>
)
);
}
export { Surface, SurfaceHeader, SurfaceContent }
+318
View File
@@ -0,0 +1,318 @@
// 可复用设置控件:HeroUI 3 复合组件封装,统一风格,绑定 configStore setter 自动保存。
// 每个控件都遵循 label / desc / value / onChange 通用接口。
import { useState } from "react";
import {
Button,
Input,
ListBox,
ListBoxItem,
NumberField,
Radio,
RadioGroup,
Select,
Switch,
TextArea,
} from "@heroui/react";
import { Check, ChevronDown, FolderOpen, FolderSearch, Loader2 } from "lucide-react";
import { ipcInvoke } from "@/api/ipc";
import { SettingRow } from "./SettingRow";
export interface SettingOption {
value: string;
label: string;
desc?: string;
}
// HeroUI 默认主题 field 边框宽度为 0--field-border-width: 0px),
// 统一补上与 Select.Trigger 一致的显式边框/背景,保证控件视觉完整。
export const fieldCls =
"rounded-lg border border-border/40 dark:border-white/[0.08] bg-white/60 dark:bg-black/30 text-[13px] text-foreground placeholder:text-muted-foreground/50 focus:border-primary/40 transition-colors";
// ---------- 下拉选择 ----------
export function SettingSelect({
label,
desc,
value,
options,
onChange,
placeholder = "请选择",
className,
}: {
label: string;
desc?: string;
value: string;
options: SettingOption[];
onChange: (v: string) => void;
placeholder?: string;
className?: string;
}) {
const selectedKey = options.some((o) => o.value === value) ? value : "__none__";
return (
<SettingRow label={label} desc={desc}>
<Select.Root
selectedKey={selectedKey}
onSelectionChange={(keys) => {
// RAC 单选时可能传 Key | null,也可能传 Set<Key>;两种形状都兼容
let v: string | undefined;
if (keys === null || keys === undefined) {
v = undefined;
} else if (typeof keys === "string" || typeof keys === "number") {
v = String(keys);
} else if ((keys as unknown) instanceof Set) {
const arr = Array.from(keys);
v = arr.length > 0 ? String(arr[0]) : undefined;
}
if (v && v !== "__none__") onChange(v);
}}
className={className}
>
<Select.Trigger className="h-8 rounded-lg border border-border/40 dark:border-white/[0.08] bg-white/60 dark:bg-black/30 px-3 hover:border-primary/30 transition-colors">
<Select.Value className="text-[13px] text-foreground">
{options.find((o) => o.value === value)?.label ?? placeholder}
</Select.Value>
<Select.Indicator>
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground/60" />
</Select.Indicator>
</Select.Trigger>
<Select.Popover className="z-50 rounded-xl border border-border/50 dark:border-white/[0.08] bg-background shadow-xl p-1.5 min-w-[10rem]">
<ListBox className="max-h-72 overflow-y-auto scroll-area outline-none">
{options.map((opt) => (
<ListBoxItem
key={opt.value}
id={opt.value}
className="text-[13px] py-1.5 px-2.5 rounded-lg data-[selected=true]:bg-primary/10 data-[selected=true]:text-primary outline-none cursor-pointer"
>
{opt.label}
</ListBoxItem>
))}
</ListBox>
</Select.Popover>
</Select.Root>
</SettingRow>
);
}
// ---------- 数字输入 ----------
export function SettingNumberField({
label,
desc,
value,
onChange,
min,
max,
step = 1,
suffix,
className,
}: {
label: string;
desc?: string;
value: number;
onChange: (v: number) => void;
min?: number;
max?: number;
step?: number;
suffix?: string;
className?: string;
}) {
return (
<SettingRow label={label} desc={desc}>
<div className={`flex items-center gap-1.5 ${className ?? ""}`}>
<NumberField.Root
value={value}
onChange={onChange}
minValue={min}
maxValue={max}
step={step}
className="w-28"
>
<NumberField.Group className="flex items-center rounded-lg border border-border/40 dark:border-white/[0.08] bg-white/60 dark:bg-black/30 overflow-hidden focus-within:border-primary/40 transition-colors">
<NumberField.DecrementButton
aria-label="减少"
className="flex items-center justify-center w-7 h-8 shrink-0 text-muted-foreground hover:text-foreground hover:bg-foreground/[0.05] cursor-pointer select-none"
>
</NumberField.DecrementButton>
<NumberField.Input className="w-14 h-8 bg-transparent text-center text-[13px] text-foreground outline-none" />
<NumberField.IncrementButton
aria-label="增加"
className="flex items-center justify-center w-7 h-8 shrink-0 text-muted-foreground hover:text-foreground hover:bg-foreground/[0.05] cursor-pointer select-none"
>
+
</NumberField.IncrementButton>
</NumberField.Group>
</NumberField.Root>
{suffix && <span className="text-[13px] text-muted-foreground shrink-0">{suffix}</span>}
</div>
</SettingRow>
);
}
// ---------- 开关 ----------
export function SettingSwitch({
label,
desc,
checked,
onChange,
}: {
label: string;
desc?: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<SettingRow label={label} desc={desc}>
{/* 注:HeroUI 3 基于 react-ariaSwitch 使用 onChange 而非 onValueChange */}
<Switch isSelected={checked} onChange={onChange}>
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
</Switch>
</SettingRow>
);
}
// ---------- 单选组 ----------
export function SettingRadioGroup({
label,
desc,
value,
options,
onChange,
horizontal = false,
}: {
label?: string;
desc?: string;
value: string;
options: SettingOption[];
onChange: (v: string) => void;
horizontal?: boolean;
}) {
return (
<div>
{label && <p className="text-sm font-medium text-foreground">{label}</p>}
{desc && <p className="text-[13px] text-muted-foreground mt-0.5 mb-2">{desc}</p>}
<RadioGroup
value={value}
onChange={(v) => onChange(String(v))}
className={horizontal ? "flex items-center gap-4" : "space-y-2"}
>
{options.map((opt) => (
<Radio key={opt.value} value={opt.value}>
<Radio.Control className="border border-border/40 dark:border-white/[0.08] bg-white/60 dark:bg-black/30">
<Radio.Indicator />
</Radio.Control>
<Radio.Content>{opt.label}</Radio.Content>
</Radio>
))}
</RadioGroup>
</div>
);
}
// ---------- 多行文本 ----------
export function SettingTextArea({
label,
desc,
value,
onChange,
rows = 3,
placeholder,
}: {
label: string;
desc?: string;
value: string;
onChange: (v: string) => void;
rows?: number;
placeholder?: string;
}) {
return (
<div className="space-y-2">
{label && <p className="text-sm font-medium text-foreground">{label}</p>}
{desc && <p className="text-[13px] text-muted-foreground">{desc}</p>}
<TextArea
value={value}
onChange={(e) => onChange(e.target.value)}
rows={rows}
placeholder={placeholder}
fullWidth
className={fieldCls}
/>
</div>
);
}
// ---------- 文本输入 + 浏览按钮 ----------
export function SettingFilePicker({
label,
desc,
value,
onChange,
placeholder,
mode = "file",
filters,
showCheck,
}: {
label: string;
desc?: string;
value: string;
onChange: (v: string) => void;
placeholder?: string;
/** file:选择文件;folder:选择文件夹 */
mode?: "file" | "folder";
filters?: { name: string; extensions: string[] }[];
/** 显示路径校验通过标记(配合外部 resolveJava 校验) */
showCheck?: boolean;
}) {
const [browsing, setBrowsing] = useState(false);
const handleBrowse = async () => {
setBrowsing(true);
try {
if (mode === "file") {
const result = await ipcInvoke<{ srcPath: string; ext: string } | null>("dialog:openFile", {
filters,
});
if (result) onChange(result.srcPath);
} else {
const result = await ipcInvoke<{ folderPath: string } | null>("dialog:openFolder");
if (result) onChange(result.folderPath);
}
} catch {
// 对话框取消或失败则忽略
} finally {
setBrowsing(false);
}
};
return (
<div>
{label && <p className="text-sm font-medium text-foreground">{label}</p>}
{desc && <p className="text-[13px] text-muted-foreground mt-0.5 mb-2">{desc}</p>}
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0 relative">
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
fullWidth
className={fieldCls}
/>
{showCheck && value && (
<Check className="absolute right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-green-500" />
)}
</div>
<Button size="sm" variant="outline" onPress={handleBrowse} isDisabled={browsing} className="shrink-0">
{browsing ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : mode === "file" ? (
<FolderSearch className="w-3.5 h-3.5" />
) : (
<FolderOpen className="w-3.5 h-3.5" />
)}
</Button>
</div>
</div>
);
}
+13 -1
View File
@@ -2,4 +2,16 @@ export { SettingCard } from "./SettingCard";
export { SettingRow } from "./SettingRow";
export { PageHeader, SectionTitle } from "./SectionTitle";
export { Surface, SurfaceHeader, SurfaceContent } from "./Surface";
export { Skeleton } from "@/components/ui/skeleton";
export { SettingBadge, type SettingBadgeVariant } from "./SettingBadge";
export { SettingListItem } from "./SettingListItem";
export {
SettingSelect,
SettingNumberField,
SettingSwitch,
SettingRadioGroup,
SettingTextArea,
SettingFilePicker,
fieldCls,
type SettingOption,
} from "./controls";
export { Skeleton } from "@/components/ui/skeleton";
+3 -1
View File
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
import { BUILD_MODE } from "@/lib/mode";
import { TaskButton } from "@/components/task/TaskButton";
import { useRouteStore } from "@/stores/routeStore";
import { useConfigStore } from "@/stores/configStore";
import { Info } from "lucide-react";
import clsx from "clsx";
@@ -22,6 +23,7 @@ export function WindowControls({
}: WindowControlsProps) {
const [isMaximized, setIsMaximized] = useState(false);
const navigate = useRouteStore((s) => s.navigate);
const showTaskButton = useConfigStore((s) => s.config.ui?.showTaskButton ?? true);
useEffect(() => {
window.electronAPI?.isMaximized().then(setIsMaximized);
@@ -42,7 +44,7 @@ export function WindowControls({
const showBadge = BUILD_MODE !== "run";
const badgeLabel = BUILD_MODE === "dev" ? "DEV" : "BETA";
const showTask = !isSub && !isOobe;
const showTask = !isSub && !isOobe && showTaskButton;
const showInfo = isOobe;
return (
+12 -29
View File
@@ -15,28 +15,28 @@ import { toast } from "sonner";
import { useInstanceStore } from "@/stores/instanceStore";
import { useConfigStore } from "@/stores/configStore";
import { useAuthStore } from "@/stores/authStore";
import { useLaunchStore } from "@/stores/launchStore";
import { useConfirmDialogStore } from "@/stores/confirmDialogStore";
import { useRouteStore } from "@/stores/routeStore";
import { onLaunchComplete, onLaunchError } from "@/api/instance";
import { openPath } from "@/api/system";
import { InstanceList } from "./InstanceList";
import { InstanceDetail } from "./InstanceDetail";
import { EditDialog } from "./EditDialog";
export function Gallery() {
const { instances, loading, fetchInstances, remove, launch } = useInstanceStore();
const { instances, loading, fetchInstances, remove } = useInstanceStore();
const gameConfig = useConfigStore((s) => s.config.game);
const javaConfig = useConfigStore((s) => s.config.java);
const configInstances = useConfigStore((s) => s.config.instances);
const setInstances = useConfigStore((s) => s.setInstances);
const user = useAuthStore((s) => s.user);
const openConfirm = useConfirmDialogStore((s) => s.openDialog);
const navigate = useRouteStore((s) => s.navigate);
const setStoreSection = useRouteStore((s) => s.setStoreSection);
const launch = useLaunchStore((s) => s.launch);
const launching = useLaunchStore((s) => s.launching);
const [selectedName, setSelectedName] = useState<string | null>(null);
const [editOpen, setEditOpen] = useState(false);
const [launching, setLaunching] = useState(false);
// 跳转到资源中心"原版游戏"分类创建实例
const goCreateInstance = useCallback(() => {
@@ -63,41 +63,24 @@ export function Gallery() {
}
}, [instances, selectedName]);
// 监听启动结果
useEffect(() => {
const offComplete = onLaunchComplete(() => {
setLaunching(false);
toast.success("游戏启动成功");
});
const offError = onLaunchError(({ error }) => {
setLaunching(false);
toast.error(`启动失败: ${error}`);
});
return () => {
offComplete();
offError();
};
}, []);
const selected = instances.find((i) => i.name === selectedName) ?? null;
// 启动游戏
// 启动游戏(统一接口:主进程自动应用权威配置)
const handlePlay = async () => {
if (!selected) return;
if (!user?.username || !user?.uuid) {
toast.warning("请先在设置中登录账号");
return;
}
setLaunching(true);
try {
await launch(selected.name, gameConfig.gameDir, {
username: user.username,
uuid: user.uuid,
accessToken: user.accessToken,
javaPath: javaConfig.javaPath || undefined,
});
await launch(selected.name, gameConfig.gameDir);
const error = useLaunchStore.getState().error;
if (error) {
toast.error(`启动失败: ${error}`);
} else {
toast.success("游戏已启动");
}
} catch (e: any) {
setLaunching(false);
toast.error(`启动失败: ${e.message || e}`);
}
};
+4
View File
@@ -1,10 +1,14 @@
import { useInstanceStore } from "@/stores/instanceStore";
import { useRouteStore } from "@/stores/routeStore";
import { useConfigStore } from "@/stores/configStore";
import clsx from "clsx";
export function InstanceTitle() {
const currentInstance = useInstanceStore((s) => s.currentInstance);
const navigate = useRouteStore((s) => s.navigate);
const showTitle = useConfigStore((s) => s.config.ui?.showInstanceTitle ?? true);
if (!showTitle) return null;
return (
<div
+39 -10
View File
@@ -1,13 +1,34 @@
import { Play, Settings, Package } from "lucide-react";
import { Play, Settings, Package, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { useRouteStore } from "@/stores/routeStore";
import { useInstanceStore } from "@/stores/instanceStore";
import { useConfigStore } from "@/stores/configStore";
import { useLaunchStore } from "@/stores/launchStore";
import clsx from "clsx";
interface StartCardProps {
onSettingsClick?: () => void;
}
export function StartCard({ onSettingsClick }: StartCardProps) {
export function StartCard() {
const navigate = useRouteStore((s) => s.navigate);
const currentInstance = useInstanceStore((s) => s.currentInstance);
const gameDir = useConfigStore((s) => s.config.game.gameDir);
const launching = useLaunchStore((s) => s.launching);
const running = useLaunchStore((s) => s.running);
const launch = useLaunchStore((s) => s.launch);
const clearError = useLaunchStore((s) => s.clearError);
// 启动游戏:自动携带实例 + 主进程权威配置(Java/内存/GC/窗口等)
const handleLaunch = async () => {
clearError();
if (!currentInstance) {
toast.warning("请先选择一个游戏实例");
navigate("gallery");
return;
}
await launch(currentInstance.name, gameDir);
const error = useLaunchStore.getState().error;
if (error) {
toast.error(error);
}
};
return (
<div
@@ -20,7 +41,7 @@ export function StartCard({ onSettingsClick }: StartCardProps) {
>
{/* Settings button */}
<button
onClick={onSettingsClick ?? (() => navigate("setting"))}
onClick={() => navigate("setting")}
className="flex items-center justify-center w-9 h-9 ml-0.5 text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-all duration-150 cursor-pointer shrink-0 rounded-full"
aria-label="设置"
>
@@ -28,10 +49,18 @@ export function StartCard({ onSettingsClick }: StartCardProps) {
</button>
{/* Launch button */}
<button className="flex items-center justify-center h-9 rounded-full cursor-pointer shrink-0 active:scale-[0.97] transition-all duration-150">
<button
onClick={handleLaunch}
disabled={launching}
className="flex items-center justify-center h-9 rounded-full cursor-pointer shrink-0 active:scale-[0.97] transition-all duration-150 disabled:cursor-not-allowed disabled:opacity-70"
>
<span className="flex items-center gap-1.5 px-4 h-7 rounded-full font-medium text-sm text-primary bg-primary/10 hover:bg-primary/20 transition-colors">
<Play className="w-3.5 h-3.5 fill-current" />
{launching ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Play className="w-3.5 h-3.5 fill-current" />
)}
{launching ? "正在启动..." : running ? "游戏中" : "启动游戏"}
</span>
</button>
+105 -71
View File
@@ -1,7 +1,15 @@
import { Input } from "@heroui/react";
import { useConfigStore } from "@/stores/configStore";
import { Switch, RadioGroup, Radio, Input, TextArea } from "@heroui/react";
import { SettingCard, SettingRow, PageHeader, SectionTitle } from "@/components/setting";
import {
SettingCard,
SettingSelect,
SettingSwitch,
SettingNumberField,
SettingFilePicker,
fieldCls,
PageHeader,
SectionTitle,
} from "@/components/setting";
const launcherBehavior = [
{ value: "close", label: "关闭启动器" },
{ value: "minimize", label: "最小化到任务栏" },
@@ -23,69 +31,52 @@ export function AdvancedSetting() {
<PageHeader title="高级设置" desc="游戏高级启动参数、调试选项与实验性功能" />
<div className="space-y-6">
{/* 启动行为 */}
<div>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<SettingCard>
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"></p>
<p className="text-[13px] text-muted-foreground"></p>
<RadioGroup
value={adv.afterLaunch}
onValueChange={(v) => setAdvanced({ afterLaunch: v })}
className="mt-2 space-y-2"
>
{launcherBehavior.map((opt) => (
<Radio key={opt.value} value={opt.value}>
<Radio.Content>{opt.label}</Radio.Content>
</Radio>
))}
</RadioGroup>
</div>
<SettingSelect
label="启动后启动器行为"
desc="游戏窗口就绪后启动器的处理方式"
value={adv.afterLaunch}
options={launcherBehavior}
onChange={(v) => setAdvanced({ afterLaunch: v })}
/>
</SettingCard>
</div>
</div>
{/* 窗口设置 */}
<div>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<SettingCard>
<div className="space-y-3">
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"></p>
<RadioGroup
value={adv.winMode}
onValueChange={(v) => setAdvanced({ winMode: v })}
className="space-y-2"
>
{windowSize.map((opt) => (
<Radio key={opt.value} value={opt.value}>
<Radio.Content>{opt.label}</Radio.Content>
</Radio>
))}
</RadioGroup>
</div>
<div className="space-y-4">
<SettingSelect
label="窗口大小"
value={adv.winMode}
options={windowSize}
onChange={(v) => setAdvanced({ winMode: v })}
/>
{adv.winMode === "custom" && (
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="text-[13px] text-muted-foreground"></span>
<Input
type="number"
value={String(adv.customWidth)}
onChange={(e) => setAdvanced({ customWidth: Number(e.target.value) })}
className="w-20"
/>
</div>
<span className="text-muted-foreground">×</span>
<div className="flex items-center gap-2">
<span className="text-[13px] text-muted-foreground"></span>
<Input
type="number"
value={String(adv.customHeight)}
onChange={(e) => setAdvanced({ customHeight: Number(e.target.value) })}
className="w-20"
/>
</div>
<div className="flex items-center gap-4">
<SettingNumberField
label="宽"
value={adv.customWidth}
onChange={(v) => setAdvanced({ customWidth: v })}
min={320}
max={7680}
suffix="px"
/>
<SettingNumberField
label="高"
value={adv.customHeight}
onChange={(v) => setAdvanced({ customHeight: v })}
min={240}
max={4320}
suffix="px"
/>
</div>
)}
</div>
@@ -93,53 +84,96 @@ export function AdvancedSetting() {
</div>
</div>
{/* 快速进入服务器 */}
<div>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<SettingCard>
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"></p>
<p className="text-[13px] text-muted-foreground">
IP
</p>
<div className="flex items-center gap-2">
<Input
value={adv.server?.ip ?? ""}
onChange={(e) =>
setAdvanced({ server: { ip: e.target.value, port: adv.server?.port ?? 25565 } })
}
placeholder="例如 mc.example.com"
fullWidth
className={fieldCls}
/>
<Input
type="number"
value={String(adv.server?.port ?? 25565)}
onChange={(e) =>
setAdvanced({ server: { ip: adv.server?.ip ?? "", port: Number(e.target.value) || 25565 } })
}
className={`w-24 shrink-0 ${fieldCls}`}
aria-label="服务器端口"
/>
<span className="text-[13px] text-muted-foreground shrink-0"></span>
</div>
</div>
</SettingCard>
</div>
</div>
{/* 游戏参数 */}
<div>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<SettingCard>
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"></p>
<p className="text-[13px] text-muted-foreground"></p>
<p className="text-[13px] text-muted-foreground">
--demo
</p>
<Input
value={adv.gameArgs}
onChange={(e) => setAdvanced({ gameArgs: e.target.value })}
placeholder="可选,例如 --demo"
fullWidth
className={fieldCls}
/>
</div>
</SettingCard>
</div>
</div>
{/* 启动命令 */}
<div>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<SettingCard>
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"></p>
<p className="text-[13px] text-muted-foreground"></p>
<Input
value={adv.preLaunchCmd}
onChange={(e) => setAdvanced({ preLaunchCmd: e.target.value })}
placeholder="可选,例如 D:\scripts\pre-launch.bat"
fullWidth
/>
</div>
<SettingFilePicker
label="启动前执行命令"
desc="游戏启动前自动执行的命令或程序路径(Windows 批处理需以 cmd /c 开头)"
value={adv.preLaunchCmd}
onChange={(v) => setAdvanced({ preLaunchCmd: v })}
placeholder="例如 cmd /c D:\scripts\pre-launch.bat"
mode="file"
filters={[
{ name: "批处理 / 可执行文件", extensions: ["bat", "cmd", "exe"] },
{ name: "所有文件", extensions: ["*"] },
]}
/>
</SettingCard>
</div>
</div>
{/* 调试 */}
<div>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<SettingCard>
<SettingRow label="调试模式" desc="启用后将在控制台输出详细日志,可能影响性能">
<Switch isSelected={adv.debugMode} onValueChange={(v) => setAdvanced({ debugMode: v })}>
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
</Switch>
</SettingRow>
<SettingSwitch
label="调试模式"
desc="启用后附加 -Dkoring.debugMode=true 并在控制台输出详细日志,可能影响性能"
checked={adv.debugMode}
onChange={(v) => setAdvanced({ debugMode: v })}
/>
</SettingCard>
</div>
</div>
+164 -8
View File
@@ -1,15 +1,171 @@
import { EmptyState } from "@heroui/react";
import { Gamepad2 } from "lucide-react";
import { PageHeader } from "@/components/setting";
import { useState } from "react";
import { Button, Avatar, Input } from "@heroui/react";
import { Gamepad2, LogOut, Loader2, UserRound, Wifi, ShieldQuestion, AlertCircle } from "lucide-react";
import { toast } from "sonner";
import { useAuthStore } from "@/stores/authStore";
import {
SettingCard,
SettingBadge,
PageHeader,
SectionTitle,
fieldCls,
} from "@/components/setting";
export function GameAccountSetting() {
const user = useAuthStore((s) => s.user);
const loading = useAuthStore((s) => s.loading);
const error = useAuthStore((s) => s.error);
const logout = useAuthStore((s) => s.logout);
const loginOffline = useAuthStore((s) => s.loginOffline);
const clearError = useAuthStore((s) => s.clearError);
const [username, setUsername] = useState("");
// 离线账号登录
const handleOfflineLogin = async () => {
clearError();
const name = username.trim();
if (!name) {
toast.warning("请输入离线用户名");
return;
}
await loginOffline(name);
if (!useAuthStore.getState().error) {
toast.success(`已登录离线账号:${name}`);
setUsername("");
}
};
// 退出登录
const handleLogout = async () => {
await logout();
toast.success("已退出游戏账号");
};
return (
<div>
<PageHeader title="游戏账户&档案" desc="管理 Minecraft 游戏内账户、正版验证与游戏档案配置" />
<EmptyState className="py-16">
<Gamepad2 className="w-10 h-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground mt-3"></p>
</EmptyState>
<PageHeader title="游戏账户&档案" desc="管理 Minecraft 游戏内账户(离线 / 微软)与登录状态" />
<div className="space-y-6">
{/* 当前账号 */}
<div>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<SettingCard>
{user ? (
<div className="flex items-center gap-4">
<Avatar size="lg" className="shrink-0">
<Avatar.Fallback>
<UserRound className="w-7 h-7 text-foreground/40" />
</Avatar.Fallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-base font-medium text-foreground truncate">{user.username}</p>
{user.accessToken ? (
<SettingBadge variant="info"></SettingBadge>
) : (
<SettingBadge variant="neutral">线</SettingBadge>
)}
</div>
<p className="text-[12px] text-muted-foreground/70 mt-0.5 font-mono truncate">UUID: {user.uuid}</p>
<p className="text-[12px] text-muted-foreground/60 mt-0.5">
使
</p>
</div>
<Button size="sm" variant="danger-soft" className="shrink-0" onPress={handleLogout} isDisabled={loading}>
<LogOut className="w-3.5 h-3.5" />
退
</Button>
</div>
) : (
<div className="flex flex-col items-center gap-3 py-10 text-center">
<div className="w-14 h-14 rounded-full bg-foreground/[0.06] flex items-center justify-center shrink-0">
<Gamepad2 className="w-8 h-8 text-foreground/30" />
</div>
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="text-[12px] text-muted-foreground/60 mt-1">
使线
</p>
</div>
</div>
)}
</SettingCard>
</div>
</div>
{/* 登录方式 */}
<div>
<SectionTitle></SectionTitle>
<div className="space-y-3">
{/* 离线账号 */}
<SettingCard>
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-foreground/[0.05] dark:bg-white/[0.05] flex items-center justify-center shrink-0">
<Wifi className="w-4 h-4 text-muted-foreground/70" />
</div>
<div>
<p className="text-sm font-medium text-foreground">线</p>
<p className="text-[12px] text-muted-foreground/70"></p>
</div>
</div>
<div className="flex items-center gap-2">
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="输入离线用户名(不超过 16 字符)"
maxLength={16}
fullWidth
className={fieldCls}
onKeyDown={(e) => {
if (e.key === "Enter") handleOfflineLogin();
}}
/>
<Button
size="sm"
variant="primary"
className="shrink-0"
onPress={handleOfflineLogin}
isDisabled={loading}
>
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <UserRound className="w-3.5 h-3.5" />}
</Button>
</div>
{error && (
<p className="text-[12px] text-red-500/80 flex items-center gap-1">
<AlertCircle className="w-3.5 h-3.5" />
{error}
</p>
)}
</div>
</SettingCard>
{/* 微软账号(开发中) */}
<SettingCard>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-foreground/[0.05] dark:bg-white/[0.05] flex items-center justify-center shrink-0">
<ShieldQuestion className="w-4 h-4 text-muted-foreground/70" />
</div>
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-foreground"></p>
<SettingBadge variant="warning"></SettingBadge>
</div>
<p className="text-[12px] text-muted-foreground/70"></p>
</div>
</div>
<Button size="sm" variant="outline" className="shrink-0" isDisabled>
</Button>
</div>
</SettingCard>
</div>
</div>
</div>
</div>
);
}
+67 -49
View File
@@ -8,30 +8,30 @@
// 未经允许的情况下删除此版权头可能会受到民事指控
// 请勿在未经Lingke Network (china)允许的范围内修改代码并分发
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useRef, useCallback } from "react";
import { Button, Skeleton } from "@heroui/react";
import { RefreshCw, FolderOpen, Trash2, Search, Plus, CircleCheck, CircleAlert, Home, Check, Download, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { useConfigStore } from "@/stores/configStore";
import { useInstanceStore } from "@/stores/instanceStore";
import { scanGameDir, selectFolder, importExistingInstance, type ScannedVersion } from "@/api/instance";
import { SettingCard, PageHeader, SectionTitle } from "@/components/setting";
import { listInstances, scanGameDir, selectFolder, importExistingInstance, type ScannedVersion } from "@/api/instance";
import { SettingCard, SettingBadge, SettingListItem, PageHeader, SectionTitle, type SettingBadgeVariant } from "@/components/setting";
// 版本类型标签样式
const TYPE_BADGE: Record<string, { label: string; cls: string }> = {
release: { label: "正式版", cls: "bg-green-500/10 text-green-600 dark:text-green-400" },
snapshot: { label: "预览版", cls: "bg-amber-500/10 text-amber-600 dark:text-amber-400" },
"old_alpha": { label: "Alpha", cls: "bg-purple-500/10 text-purple-600 dark:text-purple-400" },
"old_beta": { label: "Beta", cls: "bg-indigo-500/10 text-indigo-600 dark:text-indigo-400" },
unknown: { label: "未知", cls: "bg-foreground/[0.06] dark:bg-white/[0.06] text-muted-foreground" },
// 版本类型标签(统一徽章变体)
const TYPE_BADGE: Record<string, { label: string; variant: SettingBadgeVariant }> = {
release: { label: "正式版", variant: "success" },
snapshot: { label: "预览版", variant: "warning" },
old_alpha: { label: "Alpha", variant: "violet" },
old_beta: { label: "Beta", variant: "info" },
unknown: { label: "未知", variant: "neutral" },
};
// 加载器标签样式
const LOADER_BADGE: Record<string, { label: string; cls: string }> = {
forge: { label: "Forge", cls: "bg-orange-500/10 text-orange-600 dark:text-orange-400" },
fabric: { label: "Fabric", cls: "bg-sky-500/10 text-sky-600 dark:text-sky-400" },
quilt: { label: "Quilt", cls: "bg-pink-500/10 text-pink-600 dark:text-pink-400" },
optifine: { label: "OptiFine", cls: "bg-violet-500/10 text-violet-600 dark:text-violet-400" },
// 加载器标签(统一徽章变体)
const LOADER_BADGE: Record<string, { label: string; variant: SettingBadgeVariant }> = {
forge: { label: "Forge", variant: "warning" },
fabric: { label: "Fabric", variant: "info" },
quilt: { label: "Quilt", variant: "violet" },
optifine: { label: "OptiFine", variant: "violet" },
};
function getBadge(type: string) {
@@ -46,10 +46,10 @@ function formatTime(iso?: string): string {
function FileStatus({ ok, label }: { ok: boolean; label: string }) {
return (
<span className={`inline-flex items-center gap-1 text-[11px] ${ok ? "text-green-600 dark:text-green-400" : "text-red-500/70"}`}>
<SettingBadge variant={ok ? "success" : "error"}>
{ok ? <CircleCheck className="w-3 h-3" /> : <CircleAlert className="w-3 h-3" />}
{label}
</span>
</SettingBadge>
);
}
@@ -90,21 +90,27 @@ export function GameDirSetting() {
// 组件挂载时自动扫描主目录
useEffect(() => {
handleScan(gameDir);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 主目录变化时自动扫描
// 主目录变化时自动重扫新目录(跳过首次挂载)
const firstRunRef = useRef(true);
useEffect(() => {
if (scanTarget && scanTarget !== gameDir) {
// 目录已变化但还没扫描过新目录
if (firstRunRef.current) {
firstRunRef.current = false;
return;
}
}, [gameDir]);
handleScan(gameDir);
}, [gameDir, handleScan]);
// 导入单个版本
// 导入单个版本(来源 = 当前扫描目录,实例建在主库)
const handleImport = async (versionId: string) => {
const source = scanTarget || gameDir;
setImporting(versionId);
try {
await importExistingInstance(versionId, gameDir, versionId, {
description: `Imported from ${gameDir}`,
description: `Imported from ${source}`,
sourceGamePath: source,
});
await fetchInstances(gameDir);
toast.success(`已导入版本 ${versionId}`);
@@ -114,7 +120,7 @@ export function GameDirSetting() {
setImporting(null);
};
// 批量导入所有健康版本
// 批量导入所有健康版本(幂等:已存在实例跳过)
const handleImportAll = async () => {
if (!scanResults || scanResults.length === 0) return;
const healthy = scanResults.filter((v) => v.healthy);
@@ -122,13 +128,26 @@ export function GameDirSetting() {
toast.info("没有可导入的健康版本");
return;
}
const source = scanTarget || gameDir;
setImportingBatch(true);
let success = 0;
let failed = 0;
let skipped = 0;
// 已有实例名集合,避免重复导入全部失败
let existing = new Set<string>();
try {
const list = await listInstances(gameDir);
existing = new Set(list.map((i) => i.name));
} catch {}
for (const v of healthy) {
if (existing.has(v.id)) {
skipped++;
continue;
}
try {
await importExistingInstance(v.id, gameDir, v.id, {
description: `Imported from ${gameDir}`,
description: `Imported from ${source}`,
sourceGamePath: source,
});
success++;
} catch {
@@ -137,10 +156,15 @@ export function GameDirSetting() {
}
await fetchInstances(gameDir);
setImportingBatch(false);
if (failed === 0) {
const parts = [`成功 ${success}`];
if (skipped > 0) parts.push(`跳过 ${skipped}`);
if (failed > 0) parts.push(`失败 ${failed}`);
if (failed === 0 && skipped === 0) {
toast.success(`成功导入 ${success} 个版本`);
} else if (failed === 0) {
toast.success(`导入完成:${parts.join('')}`);
} else {
toast.warning(`导入完成:${success} 成功,${failed} 失败`);
toast.warning(`导入完成:${parts.join('')}`);
}
};
@@ -261,29 +285,24 @@ export function GameDirSetting() {
const badge = getBadge(v.type);
const isImporting = importing === v.id;
return (
<div
key={v.id}
className="flex items-center gap-3 px-3.5 py-2.5 rounded-xl bg-foreground/[0.03] dark:bg-white/[0.03] border border-border/20 dark:border-white/[0.04]"
>
<SettingListItem key={v.id}>
{/* 版本图标 */}
<div className={`w-7 h-7 rounded-lg flex items-center justify-center shrink-0 ${badge.cls}`}>
<Home className="w-3.5 h-3.5" />
<div className="w-7 h-7 rounded-lg bg-foreground/[0.05] dark:bg-white/[0.05] flex items-center justify-center shrink-0">
<Home className="w-3.5 h-3.5 text-muted-foreground/70" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-[13px] font-mono font-semibold text-foreground">{v.id}</p>
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${badge.cls}`}>
{badge.label}
</span>
<SettingBadge variant={badge.variant}>{badge.label}</SettingBadge>
{/* 加载器标签 */}
{v.loaders.map((loader) => (
<span
key={loader}
className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${LOADER_BADGE[loader]?.cls ?? ""}`}
>
{LOADER_BADGE[loader]?.label ?? loader}
</span>
))}
{v.loaders.map((loader) => {
const lb = LOADER_BADGE[loader];
return lb ? (
<SettingBadge key={loader} variant={lb.variant}>
{lb.label}
</SettingBadge>
) : null;
})}
<span className="text-[11px] text-muted-foreground/60">{formatTime(v.releaseTime)}</span>
</div>
<div className="flex items-center gap-3 mt-1">
@@ -311,7 +330,7 @@ export function GameDirSetting() {
)}
</Button>
</div>
</SettingListItem>
);
})}
</div>
@@ -390,10 +409,9 @@ export function GameDirSetting() {
{scanResults.map((v) => {
const badge = getBadge(v.type);
return (
<span key={v.id} className="inline-flex items-center gap-1 text-[11px] px-2 py-1 rounded-md bg-foreground/[0.04] dark:bg-white/[0.04]">
<span className={`w-1.5 h-1.5 rounded-full ${badge.cls.replace(/\/\d+/, "").replace(/\s.*/, "")}`} />
<SettingBadge key={v.id} variant={badge.variant}>
{v.id}
</span>
</SettingBadge>
);
})}
</div>
+171 -79
View File
@@ -1,18 +1,20 @@
import { useCallback, useEffect, useState } from "react";
import { Button, Slider, Skeleton } from "@heroui/react";
import { Cpu, Loader2, RefreshCw, Check, AlertCircle } from "lucide-react";
import { toast } from "sonner";
import { useConfigStore } from "@/stores/configStore";
import { Button, Slider, RadioGroup, Radio, Input, TextArea } from "@heroui/react";
import { Cpu, FolderSearch } from "lucide-react";
import { SettingCard, SettingRow, PageHeader, SectionTitle } from "@/components/setting";
interface JavaInfo {
path: string;
version: string;
vendor: string;
}
const mockJavaList: JavaInfo[] = [
{ path: "C:\\Program Files\\Java\\jdk-21\\bin\\javaw.exe", version: "21.0.3", vendor: "Oracle OpenJDK" },
{ path: "C:\\Program Files\\Eclipse Adoptium\\jdk-17.0.10.7-hotspot\\bin\\javaw.exe", version: "17.0.10", vendor: "Eclipse Temurin" },
];
import { scanJava, resolveJava, type JavaInfo } from "@/api/java";
import {
SettingCard,
SettingRow,
SettingSelect,
SettingRadioGroup,
SettingTextArea,
SettingFilePicker,
SettingListItem,
PageHeader,
SectionTitle,
} from "@/components/setting";
const gcOptions = [
{ value: "auto", label: "不指定(由 Java 自动选择)" },
@@ -20,80 +22,179 @@ const gcOptions = [
{ value: "g1", label: "G1GC(标准,兼容性好)" },
];
// 根据路径推断发行版名称(展示用)
function javaVendorLabel(j: JavaInfo): string {
const p = j.path.toLowerCase();
if (p.includes("temurin") || p.includes("adoptium")) return "Eclipse Temurin";
if (p.includes("zulu")) return "Azul Zulu";
if (p.includes("corretto") || p.includes("amazon")) return "Amazon Corretto";
if (p.includes("microsoft")) return "Microsoft OpenJDK";
if (p.includes("oracle") || p.includes("jdk") || p.includes("java")) return "OpenJDK";
return "Java";
}
export function JavaMemSetting() {
const java = useConfigStore((s) => s.config.java);
const setJava = useConfigStore((s) => s.setJava);
const [scanning, setScanning] = useState(false);
const [javaList, setJavaList] = useState<JavaInfo[]>([]);
const [validated, setValidated] = useState<JavaInfo | null>(null);
const [validating, setValidating] = useState(false);
// 扫描系统 Java
const handleScan = useCallback(async () => {
setScanning(true);
try {
const list = await scanJava();
setJavaList(list);
if (list.length === 0) {
toast.info("未检测到已安装的 Java,请手动指定路径");
} else {
toast.success(`检测到 ${list.length} 个 Java 环境`);
}
} catch (e: any) {
toast.error(`检测失败: ${e?.message || e}`);
}
setScanning(false);
}, []);
// 配置路径变化后自动校验(700ms debounce,避免每次按键都 spawn java
useEffect(() => {
if (!java.javaPath.trim()) {
setValidated(null);
setValidating(false);
return;
}
setValidating(true);
const timer = setTimeout(async () => {
try {
const info = await resolveJava(java.javaPath.trim());
setValidated(info);
} catch {
setValidated(null);
}
setValidating(false);
}, 700);
return () => clearTimeout(timer);
}, [java.javaPath]);
const isCurrent = (path: string) => java.javaPath === path;
return (
<div>
<PageHeader title="Java 虚拟机与内存" desc="配置 Java 运行环境路径、JVM 参数与游戏内存分配" />
<div className="space-y-6">
{/* Java 环境 */}
<div>
<SectionTitle>Java </SectionTitle>
<div className="space-y-3">
<SettingCard>
<SettingRow label="自动检测" desc="扫描系统中已安装的 Java 版本">
<Button size="sm" variant="outline">
<FolderSearch className="w-3.5 h-3.5 mr-1.5" />
<SettingRow label="自动检测" desc="扫描系统中已安装的 JavaJAVA_HOME / PATH / 常见安装目录)">
<Button size="sm" variant="outline" onPress={handleScan} isDisabled={scanning}>
{scanning ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
{scanning ? "检测中..." : "检测"}
</Button>
</SettingRow>
</SettingCard>
{mockJavaList.map((j) => (
<SettingCard key={j.path}>
<div className="flex items-center gap-3">
<Cpu className="w-4 h-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">
{j.vendor} {j.version}
</p>
<p className="text-[11px] text-muted-foreground/60 mt-0.5 font-mono truncate">
{j.path}
</p>
</div>
<Button size="sm" variant="outline" className="shrink-0" onPress={() => setJava({ javaPath: j.path })}>
使
</Button>
{scanning && (
<SettingCard>
<div className="space-y-2">
{Array.from({ length: 2 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full rounded-lg" />
))}
</div>
</SettingCard>
))}
)}
{!scanning && javaList.length > 0 && (
<SettingCard>
<div className="space-y-2">
<p className="text-[12px] text-muted-foreground/70">使</p>
{javaList.map((j) => {
const current = isCurrent(j.path);
return (
<SettingListItem key={j.path} selected={current}>
<Cpu className="w-4 h-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">
{javaVendorLabel(j)} {j.version}
</p>
<p className="text-[11px] text-muted-foreground/60 mt-0.5 font-mono truncate">{j.path}</p>
</div>
<Button
size="sm"
variant={current ? "primary" : "outline"}
className="shrink-0"
isDisabled={current}
onPress={() => setJava({ javaPath: j.path })}
>
{current ? (
<>
<Check className="w-3.5 h-3.5" />
</>
) : (
"使用"
)}
</Button>
</SettingListItem>
);
})}
</div>
</SettingCard>
)}
<SettingCard>
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"></p>
<div className="flex items-center gap-2">
<Input
value={java.javaPath}
onChange={(e) => setJava({ javaPath: e.target.value })}
placeholder="输入 javaw.exe 完整路径"
fullWidth
/>
<Button size="sm" variant="outline"></Button>
</div>
<p className="text-[13px] text-muted-foreground">
javaw.exe / java.exe
{validating && "(校验中..."}
</p>
<SettingFilePicker
label=""
value={java.javaPath}
onChange={(v) => setJava({ javaPath: v })}
placeholder="例如 C:\Program Files\Java\jdk-21\bin\javaw.exe"
mode="file"
filters={[{ name: "Java 可执行文件", extensions: ["exe"] }]}
showCheck={!!validated}
/>
{validated && (
<p className="text-[12px] text-green-600 dark:text-green-400 flex items-center gap-1">
<Check className="w-3.5 h-3.5" />
{javaVendorLabel(validated)} {validated.version}Java {validated.majorVersion}
</p>
)}
{!validating && java.javaPath.trim() && !validated && (
<p className="text-[12px] text-red-500/80 flex items-center gap-1">
<AlertCircle className="w-3.5 h-3.5" />
Java
</p>
)}
</div>
</SettingCard>
</div>
</div>
{/* 内存分配 */}
<div>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<SettingCard>
<div className="space-y-3">
<RadioGroup
<SettingRadioGroup
value={java.memMode}
onValueChange={(v) => setJava({ memMode: v })}
className="flex items-center gap-4"
>
<Radio value="auto">
<Radio.Content></Radio.Content>
</Radio>
<Radio value="custom">
<Radio.Content></Radio.Content>
</Radio>
</RadioGroup>
options={[
{ value: "auto", label: "自动配置" },
{ value: "custom", label: "自定义" },
]}
onChange={(v) => setJava({ memMode: v })}
horizontal
/>
{java.memMode === "custom" && (
<div>
<div className="flex items-center justify-between mb-1.5">
@@ -123,43 +224,34 @@ export function JavaMemSetting() {
</div>
</div>
{/* JVM 参数 */}
<div>
<SectionTitle>JVM </SectionTitle>
<div className="space-y-3">
<SettingCard>
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"> JVM </p>
<p className="text-[13px] text-muted-foreground"> -XX:+UseZGC</p>
<TextArea
value={java.jvmArgs}
onChange={(e) => setJava({ jvmArgs: e.target.value })}
placeholder="可选,留空使用默认参数"
rows={3}
fullWidth
/>
</div>
<SettingTextArea
label="额外 JVM 启动参数"
desc="每行一个参数,例如 -XX:+UseZGC;支持引号包裹含空格的值"
value={java.jvmArgs}
onChange={(v) => setJava({ jvmArgs: v })}
rows={3}
placeholder="可选,留空使用默认参数"
/>
</SettingCard>
</div>
</div>
{/* 垃圾回收 */}
<div>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<SettingCard>
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">GC </p>
<RadioGroup
value={java.gc}
onValueChange={(v) => setJava({ gc: v })}
className="space-y-2"
>
{gcOptions.map((opt) => (
<Radio key={opt.value} value={opt.value}>
<Radio.Content>{opt.label}</Radio.Content>
</Radio>
))}
</RadioGroup>
</div>
<SettingSelect
label="GC 算法"
value={java.gc}
options={gcOptions}
onChange={(v) => setJava({ gc: v })}
/>
</SettingCard>
</div>
</div>
+4 -4
View File
@@ -1,6 +1,6 @@
import { useConfigStore } from "@/stores/configStore";
import { Slider, RadioGroup, Radio, Input } from "@heroui/react";
import { SettingCard, PageHeader, SectionTitle } from "@/components/setting";
import { SettingCard, PageHeader, SectionTitle, fieldCls } from "@/components/setting";
const downloadSources = [
{ value: "mirror", label: "尽量使用镜像源(推荐国内用户)" },
@@ -26,7 +26,7 @@ export function DownloadSetting() {
<p className="text-[13px] text-muted-foreground">jarlib</p>
<RadioGroup
value={dl.fileSource}
onValueChange={(v) => setDownload({ fileSource: v })}
onChange={(v) => setDownload({ fileSource: String(v) })}
className="mt-2 space-y-2"
>
{downloadSources.map((opt) => (
@@ -44,7 +44,7 @@ export function DownloadSetting() {
<p className="text-[13px] text-muted-foreground"></p>
<RadioGroup
value={dl.versionSource}
onValueChange={(v) => setDownload({ versionSource: v })}
onChange={(v) => setDownload({ versionSource: String(v) })}
className="mt-2 space-y-2"
>
{downloadSources.map((opt) => (
@@ -103,7 +103,7 @@ export function DownloadSetting() {
type="number"
value={String(dl.speedLimit)}
onChange={(e) => setDownload({ speedLimit: Number(e.target.value) })}
className="w-28"
className={`w-28 ${fieldCls}`}
/>
<span className="text-[13px] text-muted-foreground">KB/s</span>
</div>
+3 -2
View File
@@ -2,7 +2,7 @@ import { useCallback } from "react";
import { useConfigStore } from "@/stores/configStore";
import { Switch, Input } from "@heroui/react";
import { ShieldCheck } from "lucide-react";
import { SettingCard, SettingRow, PageHeader, SectionTitle } from "@/components/setting";
import { SettingCard, SettingRow, PageHeader, SectionTitle, fieldCls } from "@/components/setting";
export function SecurityIdSetting() {
const enabled = useConfigStore((s) => s.config.network.securityId.enabled);
@@ -30,7 +30,7 @@ export function SecurityIdSetting() {
label="启用第三方认证"
desc="使用自定义认证服务器替代 Microsoft 认证(适用于离线服务器)"
>
<Switch isSelected={enabled} onValueChange={handleToggle}>
<Switch isSelected={enabled} onChange={handleToggle}>
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
@@ -50,6 +50,7 @@ export function SecurityIdSetting() {
onChange={handleUrlChange}
placeholder="https://auth.example.com"
fullWidth
className={fieldCls}
/>
</div>
</div>
+3 -3
View File
@@ -15,7 +15,7 @@ export function A11ySetting() {
<div className="space-y-3">
<SettingCard>
<SettingRow label="减少动画" desc="关闭页面切换动画和背景动效">
<Switch isSelected={reduceMotion} onValueChange={setReduceMotion}>
<Switch isSelected={reduceMotion} onChange={setReduceMotion}>
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
@@ -25,7 +25,7 @@ export function A11ySetting() {
<SettingCard>
<SettingRow label="减少透明度" desc="将磨砂玻璃效果替换为纯色背景,提升可读性">
<Switch isSelected={reduceTransparency} onValueChange={setReduceTransparency}>
<Switch isSelected={reduceTransparency} onChange={setReduceTransparency}>
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
@@ -35,7 +35,7 @@ export function A11ySetting() {
<SettingCard>
<SettingRow label="高对比度" desc="增强文字与背景的对比度,改善可读性">
<Switch isSelected={highContrast} onValueChange={setHighContrast}>
<Switch isSelected={highContrast} onChange={setHighContrast}>
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
+55 -7
View File
@@ -1,15 +1,63 @@
import { EmptyState } from "@heroui/react";
import { Languages } from "lucide-react";
import { PageHeader } from "@/components/setting";
import { useConfigStore } from "@/stores/configStore";
import {
SettingCard,
SettingSelect,
SettingBadge,
PageHeader,
SectionTitle,
} from "@/components/setting";
const languageOptions = [
{ value: "zh-CN", label: "简体中文" },
{ value: "en-US", label: "English" },
];
export function LangSetting() {
const language = useConfigStore((s) => s.config.app?.language ?? "zh-CN");
const setApp = useConfigStore((s) => s.setApp);
const handleChange = (v: string) => {
setApp({ language: v });
document.documentElement.lang = v;
};
return (
<div>
<PageHeader title="语言" desc="选择启动器界面的显示语言与地区偏好" />
<EmptyState className="py-16">
<Languages 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>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<SettingCard>
<SettingSelect
label="界面语言"
desc="切换后保存偏好并更新页面 lang 属性"
value={language}
options={languageOptions}
onChange={handleChange}
/>
</SettingCard>
</div>
</div>
<div>
<SectionTitle></SectionTitle>
<SettingCard>
<div className="flex items-start gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-foreground"></p>
<SettingBadge variant="warning"></SettingBadge>
</div>
<p className="text-[13px] text-muted-foreground mt-0.5">
线
</p>
</div>
</div>
</SettingCard>
</div>
</div>
</div>
);
}
@@ -12,14 +12,15 @@ function ThemePreviewCard({ mode, selected, onClick }: { mode: DarkMode; selecte
const label = isAuto ? "跟随系统" : isDark ? "深色模式" : "浅色模式";
return (
<button
onClick={onClick}
<Button
variant="ghost"
onPress={onClick}
className={clsx(
"relative rounded-md p-1 transition-all duration-200",
"relative rounded-md p-1 transition-all duration-200 h-auto min-w-0",
"border-2",
selected
? "border-primary ring-2 ring-primary/20"
: "border-transparent hover:border-muted-foreground/20",
? "!border-primary ring-2 ring-primary/20"
: "!border-transparent hover:!border-muted-foreground/20",
)}
>
<div className="relative w-[140px] h-[96px] rounded overflow-hidden">
@@ -77,7 +78,7 @@ function ThemePreviewCard({ mode, selected, onClick }: { mode: DarkMode; selecte
)}
</div>
<p className="text-[11px] text-center mt-1.5 text-muted-foreground">{label}</p>
</button>
</Button>
);
}
@@ -170,7 +171,7 @@ export function ThemeBgSetting() {
<SettingCard>
<SettingRow label="背景图片视差" desc="背景图片随窗口滚动产生视差位移">
<Switch isSelected={parallax} onValueChange={setParallax}>
<Switch isSelected={parallax} onChange={setParallax}>
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
+50 -8
View File
@@ -1,15 +1,57 @@
import { EmptyState } from "@heroui/react";
import { Monitor } from "lucide-react";
import { PageHeader } from "@/components/setting";
import { useConfigStore } from "@/stores/configStore";
import {
SettingCard,
SettingSwitch,
PageHeader,
SectionTitle,
} from "@/components/setting";
export function UiSetting() {
const ui = useConfigStore((s) => s.config.ui);
const setUi = useConfigStore((s) => s.setUi);
return (
<div>
<PageHeader title="主界面" desc="自定义启动器主界面的布局、模块显示与交互方式" />
<EmptyState className="py-16">
<Monitor className="w-10 h-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground mt-3"></p>
</EmptyState>
<PageHeader title="主界面" desc="自定义启动器主界面的元素显示与交互方式" />
<div className="space-y-6">
<div>
<SectionTitle></SectionTitle>
<div className="space-y-3">
<SettingCard>
<SettingSwitch
label="首页实例标题"
desc="在首页左下角显示当前实例的大标题(点击可进入实例管理)"
checked={ui?.showInstanceTitle ?? true}
onChange={(v) => setUi({ showInstanceTitle: v })}
/>
</SettingCard>
<SettingCard>
<SettingSwitch
label="任务队列按钮"
desc="在标题栏右侧显示任务队列入口(安装/下载进度)"
checked={ui?.showTaskButton ?? true}
onChange={(v) => setUi({ showTaskButton: v })}
/>
</SettingCard>
</div>
</div>
<div>
<SectionTitle></SectionTitle>
<SettingCard>
<div className="space-y-1.5">
<p className="text-[13px] text-muted-foreground">
/ /
</p>
<p className="text-[13px] text-muted-foreground">
</p>
</div>
</SettingCard>
</div>
</div>
</div>
);
}
+59 -41
View File
@@ -1,8 +1,9 @@
import { create } from "zustand";
import {
getConfig,
saveConfig,
updateConfig,
type AppConfig,
type AppInfoConfig,
type ThemeConfig,
type A11yConfig,
type BackgroundConfig,
@@ -11,19 +12,17 @@ import {
type AdvancedConfig,
type DownloadConfig,
type NetworkConfig,
type UiConfig,
type InstanceMeta,
} from "@/api/config";
import { DEFAULT_BG } from "@/lib/mode";
let saveTimer: ReturnType<typeof setTimeout> | null = null;
function debouncedSave(config: AppConfig) {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
saveConfig(config).catch((e) => {
console.error("[config] save failed:", e);
});
}, 300);
}
/**
* store
* - config:preload / config:get
* - setX { section, patch }config:update
* - 广 config:changed广
*/
interface ConfigState {
config: AppConfig;
@@ -32,6 +31,8 @@ interface ConfigState {
init: () => Promise<void>;
applyPreloaded: (config: AppConfig, isFirstLaunch: boolean) => void;
applyChanged: (config: AppConfig) => void;
setApp: (patch: Partial<AppInfoConfig>) => void;
setTheme: (patch: Partial<ThemeConfig>) => void;
setA11y: (patch: Partial<A11yConfig>) => void;
setBackground: (patch: Partial<BackgroundConfig>) => void;
@@ -40,6 +41,7 @@ interface ConfigState {
setAdvanced: (patch: Partial<AdvancedConfig>) => void;
setDownload: (patch: Partial<DownloadConfig>) => void;
setNetwork: (patch: Partial<NetworkConfig>) => void;
setUi: (patch: Partial<UiConfig>) => void;
setInstances: (instances: InstanceMeta[]) => void;
setOobe: (value: boolean) => void;
}
@@ -47,17 +49,26 @@ interface ConfigState {
const DEFAULT_CONFIG: AppConfig = {
version: 1,
oobe: true,
app: { language: "zh-CN" },
theme: { darkMode: "auto", parallax: true },
a11y: { reduceMotion: false, reduceTransparency: false, highContrast: false, contentBlurOpacity: 50 },
background: { bgType: "image", image: DEFAULT_BG, blur: 0, opacity: 100 },
game: { gameDir: ".minecraft", resourceDir: "", savesDir: "", instancesDir: ".minecraft/instances", gameDirs: [] },
java: { javaPath: "", memMode: "auto", memGB: 4, gc: "auto", jvmArgs: "" },
advanced: { afterLaunch: "close", winMode: "default", customWidth: 854, customHeight: 480, gameArgs: "", preLaunchCmd: "", debugMode: false },
advanced: { afterLaunch: "close", winMode: "default", customWidth: 854, customHeight: 480, gameArgs: "", preLaunchCmd: "", debugMode: false, server: { ip: "", port: 25565 } },
download: { fileSource: "mirror", versionSource: "mirror", threads: 16, speedLimit: 0 },
network: { securityId: { enabled: false, authUrl: "" } },
ui: { showInstanceTitle: true, showTaskButton: true },
instances: [],
};
// 乐观更新本地 + 提交主进程;主进程广播回来时以广播为准(applyChanged 覆盖)
function submit(section: string, patch: unknown) {
updateConfig(section, patch).catch((e) => {
console.error(`[config] update ${section} failed:`, e);
});
}
export const useConfigStore = create<ConfigState>((set, get) => ({
config: DEFAULT_CONFIG,
loaded: false,
@@ -67,6 +78,11 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
set({ config, isFirstLaunch, loaded: true });
},
// 主进程广播的权威配置 → 整体覆盖本地镜像
applyChanged: (config) => {
set({ config, loaded: true });
},
init: async () => {
// If already preloaded, skip IPC call
if (get().loaded) return;
@@ -79,73 +95,75 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
}
},
setApp: (patch) => {
const { config } = get();
set({ config: { ...config, app: { ...config.app, ...patch } } });
submit("app", patch);
},
setTheme: (patch) => {
const { config } = get();
const next = { ...config, theme: { ...config.theme, ...patch } };
set({ config: next });
debouncedSave(next);
set({ config: { ...config, theme: { ...config.theme, ...patch } } });
submit("theme", patch);
},
setA11y: (patch) => {
const { config } = get();
const next = { ...config, a11y: { ...config.a11y, ...patch } };
set({ config: next });
debouncedSave(next);
set({ config: { ...config, a11y: { ...config.a11y, ...patch } } });
submit("a11y", patch);
},
setBackground: (patch) => {
const { config } = get();
const next = { ...config, background: { ...config.background, ...patch } };
set({ config: next });
debouncedSave(next);
set({ config: { ...config, background: { ...config.background, ...patch } } });
submit("background", patch);
},
setGame: (patch) => {
const { config } = get();
const next = { ...config, game: { ...config.game, ...patch } };
set({ config: next });
debouncedSave(next);
set({ config: { ...config, game: { ...config.game, ...patch } } });
submit("game", patch);
},
setJava: (patch) => {
const { config } = get();
const next = { ...config, java: { ...config.java, ...patch } };
set({ config: next });
debouncedSave(next);
set({ config: { ...config, java: { ...config.java, ...patch } } });
submit("java", patch);
},
setAdvanced: (patch) => {
const { config } = get();
const next = { ...config, advanced: { ...config.advanced, ...patch } };
set({ config: next });
debouncedSave(next);
set({ config: { ...config, advanced: { ...config.advanced, ...patch } } });
submit("advanced", patch);
},
setDownload: (patch) => {
const { config } = get();
const next = { ...config, download: { ...config.download, ...patch } };
set({ config: next });
debouncedSave(next);
set({ config: { ...config, download: { ...config.download, ...patch } } });
submit("download", patch);
},
setNetwork: (patch) => {
const { config } = get();
const next = { ...config, network: { ...config.network, ...patch } };
set({ config: next });
debouncedSave(next);
set({ config: { ...config, network: { ...config.network, ...patch } } });
submit("network", patch);
},
setUi: (patch) => {
const { config } = get();
set({ config: { ...config, ui: { ...config.ui, ...patch } } });
submit("ui", patch);
},
setInstances: (instances) => {
const { config } = get();
const next = { ...config, instances };
set({ config: next });
debouncedSave(next);
set({ config: { ...config, instances } });
submit("instances", instances);
},
setOobe: (value) => {
const { config } = get();
const next = { ...config, oobe: value };
set({ config: next });
debouncedSave(next);
set({ config: { ...config, oobe: value } });
submit("oobe", value);
},
}));
-24
View File
@@ -5,7 +5,6 @@ import {
deleteInstance,
getInstanceInfo,
installInstance,
launchInstance,
type InstanceInfo,
type InstanceRuntime,
} from "../api/instance";
@@ -32,17 +31,6 @@ interface InstanceState {
remove: (name: string, gamePath: string) => Promise<void>;
select: (name: string, gamePath: string) => Promise<void>;
install: (name: string, gamePath: string) => Promise<string>;
launch: (
name: string,
gamePath: string,
options: {
username: string;
uuid: string;
accessToken?: string;
javaPath?: string;
server?: { host: string; port?: number };
}
) => Promise<string>;
clearError: () => void;
}
@@ -112,17 +100,5 @@ export const useInstanceStore = create<InstanceState>((set) => ({
}
},
launch: async (name, gamePath, options) => {
set({ loading: true, error: null });
try {
const { requestId } = await launchInstance(name, gamePath, options);
set({ loading: false });
return requestId;
} catch (e: any) {
set({ error: e.message, loading: false });
throw e;
}
},
clearError: () => set({ error: null }),
}));
+36 -17
View File
@@ -1,6 +1,7 @@
import { create } from "zustand";
import { launchGame, onGameEvent, diagnoseVersion } from "../api/launch";
import type { LaunchOptions, LaunchResult } from "../api/launch";
import type { LaunchResult, LaunchServer } from "../api/launch";
import { useAuthStore } from "./authStore";
interface GameEvent {
event: string;
@@ -8,13 +9,19 @@ interface GameEvent {
}
interface LaunchState {
/** 正在发起启动请求 */
launching: boolean;
launched: boolean;
/** 游戏进程正在运行 */
running: boolean;
gameResult: LaunchResult | null;
events: GameEvent[];
error: string | null;
launch: (options: LaunchOptions) => Promise<void>;
/**
* +
* authStore Java//GC/
*/
launch: (instanceName: string, gamePath: string, server?: LaunchServer) => Promise<void>;
diagnose: (gamePath: string, version: string) => Promise<void>;
reset: () => void;
clearError: () => void;
@@ -22,30 +29,42 @@ interface LaunchState {
export const useLaunchStore = create<LaunchState>((set) => ({
launching: false,
launched: false,
running: false,
gameResult: null,
events: [],
error: null,
launch: async (options: LaunchOptions) => {
set({ launching: true, error: null, events: [] });
launch: async (instanceName: string, gamePath: string, server?: LaunchServer) => {
const user = useAuthStore.getState().user;
if (!user?.username || !user?.uuid) {
set({ error: "请先在设置中登录账号", launching: false });
return;
}
set({ launching: true, running: false, error: null, events: [], gameResult: null });
try {
const result = await launchGame(options);
set({ gameResult: result, launching: false, launched: true });
// Listen for game events
const unlisten = await onGameEvent(result.requestId, (event) => {
set((state) => ({
events: [...state.events, event],
}));
const result = await launchGame({
instanceName,
gamePath,
profile: {
username: user.username,
uuid: user.uuid,
accessToken: user.accessToken || undefined,
},
server,
});
set({ gameResult: result, launching: false, running: true });
// 订阅事件流(stdout / stderr / window-ready / exit
const unlisten = onGameEvent(result.requestId, (event) => {
set((state) => ({ events: [...state.events, event] }));
if (event.event === "exit") {
set({ launched: false });
set({ running: false });
unlisten();
}
});
} catch (e: any) {
set({ error: e.message, launching: false });
set({ error: e?.message || String(e), launching: false, running: false });
}
},
@@ -58,7 +77,7 @@ export const useLaunchStore = create<LaunchState>((set) => ({
},
reset: () => {
set({ launching: false, launched: false, gameResult: null, events: [] });
set({ launching: false, running: false, gameResult: null, events: [], error: null });
},
clearError: () => set({ error: null }),
+2
View File
@@ -13,6 +13,8 @@ interface ElectronAPI {
onConfigPreload: (callback: (data: { config: unknown; isFirstLaunch: boolean }) => void) => () => void;
onConfigChanged: (callback: (config: unknown) => void) => () => void;
openExternal: (url: string) => Promise<void>;
// Crash monitoring