20260711存档01

This commit is contained in:
2026-07-11 23:33:44 +08:00
parent 9cb6a6e571
commit 2b19144352
56 changed files with 2156 additions and 627 deletions
+1 -1
View File
@@ -3,5 +3,5 @@ VITE_APP_ICON=/beta.png
VITE_START_POP=true VITE_START_POP=true
VITE_START_POP_TITLE="你正在使用 UI 预览版本" VITE_START_POP_TITLE="你正在使用 UI 预览版本"
VITE_START_POP_INFO="此版本是专门用于进行检测是否有UI问题的版本,因此功能将会缺失" VITE_START_POP_INFO="此版本是专门用于进行检测是否有 UI 问题的版本,因此功能将会缺失"
VITE_START_POP_BOUTTON="我已了解" VITE_START_POP_BOUTTON="我已了解"
+3
View File
@@ -27,3 +27,6 @@ dist-electron
# Electron # Electron
electron/*.js electron/*.js
electron/*.d.ts electron/*.d.ts
# Build resources (generated by switch-icon.js)
build/
+30 -7
View File
@@ -9,13 +9,16 @@ pnpm dev # full app dev (renderer + main process)
pnpm dev:renderer # frontend only (vite, port 1420) pnpm dev:renderer # frontend only (vite, port 1420)
pnpm dev:main # electron main process only pnpm dev:main # electron main process only
pnpm build # production build (vite + tsc) pnpm build # production build (vite + tsc)
pnpm dist:win # build Windows installer pnpm dist:dev # dev icon + Windows installer
pnpm dist:beta # beta icon + Windows installer
pnpm dist:run # production icon + Windows installer
``` ```
## Architecture ## Architecture
- **Frontend** (`src/`): React 19 + Vite 7 + Tailwind v4 + shadcn/ui + Zustand stores - **Frontend** (`src/`): React 19 + Vite 7 + Tailwind v4 + shadcn/ui + Zustand stores
- **Main Process** (`electron/`): Node.js/TypeScript, manages windows, IPC handlers, @xmcl/* packages - **Main Process** (`electron/`): Node.js/TypeScript, manages windows, IPC handlers, @xmcl/* packages
- **Icon System** (`public/icons/{dev,beta,run}/`): Mode-specific icons, copied to `build/` at build time
- IPC: Frontend → `ipcRenderer.invoke()``ipcMain.handle()` → main process → `webContents.send()` → Frontend - IPC: Frontend → `ipcRenderer.invoke()``ipcMain.handle()` → main process → `webContents.send()` → Frontend
## Key gotchas ## Key gotchas
@@ -23,23 +26,42 @@ pnpm dist:win # build Windows installer
- **Electron main process**: `electron/main.ts` is the entry point. All @xmcl/* packages run here. - **Electron main process**: `electron/main.ts` is the entry point. All @xmcl/* packages run here.
- **Preload script**: `electron/preload.ts` exposes `window.electronAPI` via context bridge. - **Preload script**: `electron/preload.ts` exposes `window.electronAPI` via context bridge.
- **IPC handlers**: All handlers are in `electron/handlers/` directory. - **IPC handlers**: All handlers are in `electron/handlers/` directory.
- **Mutable win ref**: `electron/main.ts` uses a mutable `win` object — handlers read `win.mainWindow` at runtime, not at registration time.
- **Config**: YAML format (`Koring.yml`) stored next to executable. Sparse save (only non-default values). - **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. - **Auth**: JSON file (`koring-auth.json`) stored next to executable.
- **Path alias**: `@/` maps to `src/` (configured in `vite.config.ts` and `tsconfig.json`). - **Path alias**: `@/` maps to `src/` (configured in `vite.config.ts` and `tsconfig.json`).
- **Dev mode**: Vite runs on port 1420, Electron loads from localhost. - **Dev mode**: Vite runs on port 1420, Electron loads from localhost.
- **Asset paths**: Use `import.meta.env.BASE_URL` prefix for public assets. Absolute paths break in packaged app.
## Build & bundle ## Build & bundle
```bash ```bash
pnpm build # Vite build + TypeScript compile pnpm build # Vite build + TypeScript compile
pnpm dist:win # electron-builder Windows installer pnpm dist:dev # switch-icon dev + electron-builder Windows installer
pnpm dist:mac # electron-builder macOS DMG pnpm dist:beta # switch-icon beta + electron-builder Windows installer
pnpm dist:linux # electron-builder Linux AppImage pnpm dist:run # switch-icon run + electron-builder Windows installer
``` ```
Each `dist:*` command runs: `pnpm build``pnpm icon:{mode}``electron-builder --win`
## Icon switching
```
public/icons/
dev/icon.ico, icon.png
beta/icon.ico, icon.png
run/icon.ico, icon.png
build/ ← generated by switch-icon.js (gitignored)
icon.ico
icon.png
```
`electron-builder.yml` uses `buildResources: build` to read icons from `build/`.
## Electron notes ## Electron notes
- `electron/main.ts`: App entry, window management, IPC handler registration - `electron/main.ts`: App entry, window management, splash→main transition (ready-to-show + 1.5s min)
- `electron/preload.ts`: Context bridge for secure IPC - `electron/preload.ts`: Context bridge for secure IPC
- `electron/config.ts`: YAML config management (sparse save) - `electron/config.ts`: YAML config management (sparse save)
- `electron/auth.ts`: Auth data persistence (JSON file) - `electron/auth.ts`: Auth data persistence (JSON file)
@@ -52,5 +74,6 @@ pnpm dist:linux # electron-builder Linux AppImage
- `src/api/*.ts`: API modules wrapping IPC calls - `src/api/*.ts`: API modules wrapping IPC calls
- `src/stores/`: Zustand state management - `src/stores/`: Zustand state management
- `src/hooks/useTheme.ts`: Dark mode sync with Electron theme - `src/hooks/useTheme.ts`: Dark mode sync with Electron theme
- `src/components/system/WindowControls.tsx`: Custom window controls (min/max/close) - `src/components/system/WindowControls.tsx`: Custom window controls (min/max/close), uses `<button>` with `WebkitAppRegion: "no-drag"`
- `src/components/system/TitleBar.tsx`: Custom title bar with navigation - `src/components/system/TitleBar.tsx`: Custom title bar with navigation, uses `WebkitAppRegion: "drag"`
- `src/lib/mode.ts`: Build mode constants (`DEFAULT_BG`, `LOGO_SVG`, `APP_ICON`, `BUILD_MODE`)
+144
View File
@@ -0,0 +1,144 @@
# Crash Monitor Implementation Plan
## 1. Architecture Overview
```
┌─────────────────────────────────────────────────────────┐
│ Main Process │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Crash Logger (file-based, survives crashes) │ │
│ │ - ipcMain.on('renderer-error') → write to file │ │
│ │ - process.on('uncaughtException') → write to file │ │
│ │ - app.on('render-process-gone') → write to file │ │
│ │ - app.on('child-process-gone') → write to file │ │
│ └──────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────┐ │
│ │ UtilityProcess (monitor) │ ← isolated process │
│ │ - Watches crash log file │ survives window │
│ │ - Sends crash events via │ crashes │
│ │ MessagePort to main │ │
│ └──────────────────────────────┘ │
│ ┌──────────────────────────────┐ │
│ │ Crash Monitor Window │ ← separate window │
│ │ - Shows crash dialogs │ custom UI │
│ │ - Log viewer window │ │
│ └──────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
↕ IPC (contextBridge)
┌─────────────────────────────────────────────────────────┐
│ Renderer (Main Window) │
│ - window.onerror → ipcRenderer.send('renderer-error') │
│ - unhandledrejection → ipcRenderer.send(...) │
│ - webContents 'render-process-gone' → recover │
└─────────────────────────────────────────────────────────┘
```
## 2. Implementation Steps
### Step 1: Create Crash Logger Module
**File:** `electron/core/crash-logger.ts`
- Initialize crash log path (`koring-crash.log` next to executable)
- Write crash events synchronously to survive crashes
- Buffer recent events for breadcrumb trail
- Log rotation (keep last 1000 lines)
### Step 2: Create Crash Monitor Window
**File:** `electron/handlers/crash-monitor.ts`
- Separate BrowserWindow (hidden by default)
- Listens for crash events from main process
- Shows custom crash dialog UI
- Can be opened from developer tools
### Step 3: Create Log Viewer Window
**File:** `electron/handlers/log-viewer.ts`
- Separate BrowserWindow for viewing logs
- Real-time log streaming via IPC
- Filter by log level (error, warn, info)
- Export logs functionality
### Step 4: Create Crash Dialog UI
**File:** `src/components/crash/CrashDialog.tsx`
- Custom styled crash dialog
- Shows error details, stack trace
- Options: Restart, View Logs, Close
- Uses existing UI components (shadcn/ui)
### Step 5: Create Log Viewer UI
**File:** `src/components/crash/LogViewer.tsx`
- Log list with syntax highlighting
- Search/filter functionality
- Real-time updates
### Step 6: Update Main Process
**File:** `electron/main.ts`
- Initialize crash logger early
- Set up crash event handlers
- Create crash monitor window
### Step 7: Update Preload Script
**File:** `electron/preload.ts`
- Add renderer error capture
- Expose crash-related IPC methods
## 3. Key Features
1. **Crash Detection:**
- Renderer crashes (`render-process-gone`)
- Main process errors (`uncaughtException`, `unhandledRejection`)
- GPU/utility crashes (`child-process-gone`)
- Unresponsive detection (`unresponsive` event)
2. **Crash Dialog:**
- Custom styled UI matching launcher theme
- Error message and stack trace display
- One-click restart option
- View logs option
3. **Log Viewer:**
- Can be opened from developer tools
- Real-time log streaming
- Search and filter capabilities
- Export functionality
4. **Process Isolation:**
- Crash monitor runs in separate window
- Main window crashes don't affect monitor
- Monitor can restart main window
## 4. Files to Create/Modify
**New Files:**
- `electron/core/crash-logger.ts`
- `electron/handlers/crash-monitor.ts`
- `electron/handlers/log-viewer.ts`
- `src/components/crash/CrashDialog.tsx`
- `src/components/crash/LogViewer.tsx`
- `src/pages/crash/index.tsx`
**Modified Files:**
- `electron/main.ts` - Add crash monitoring initialization
- `electron/preload.ts` - Add error capture and IPC methods
- `src/types/electron.d.ts` - Add new IPC method types
## 5. UI Design
The crash dialog and log viewer will use the existing design system:
- Glass effects with backdrop-filter
- Dark theme compatible
- Consistent spacing and typography
- Uses shadcn/ui components where appropriate
## 6. Testing Plan
1. Test crash detection by triggering intentional errors
2. Verify crash dialog appears correctly
3. Test log viewer functionality
4. Verify main window can be restarted from crash dialog
5. Test log export functionality
## 7. Questions for User
1. What specific crash scenarios should we prioritize?
2. Do you want the log viewer always accessible or only in dev mode?
3. Should crash reports be sent to a server or just stored locally?
4. Any specific UI preferences for the crash dialog beyond the existing design system?
+254 -274
View File
@@ -7,39 +7,66 @@
| Frontend | React 19 + Vite 7 + TypeScript | | Frontend | React 19 + Vite 7 + TypeScript |
| UI | Tailwind CSS v4 + shadcn/ui (base-ui) | | UI | Tailwind CSS v4 + shadcn/ui (base-ui) |
| 状态管理 | Zustand | | 状态管理 | Zustand |
| Backend | Rust / Tauri 2 | | Main Process | Electron + Node.js / TypeScript / @xmcl/* |
| Sidecar | Node.js / TypeScript / @xmcl/* | | 配置存储 | YAML (js-yaml) |
| 配置存储 | YAML (serde_yaml) + Windows Registry | | 认证存储 | JSON 文件 (koring-auth.json) |
| 包管理 | pnpm | | 包管理 | pnpm |
| 目标平台 | ARM64 Windows (`aarch64-pc-windows-msvc`) | | 目标平台 | Windows (x64) |
## 快速命令 ## 快速命令
```bash ```bash
pnpm dev # 前端 dev server (port 1420) pnpm install # 安装依赖
pnpm dev:t # 前端 + Rust dev (需 VS 环境) pnpm dev # 完整开发环境 (renderer + main)
./dev-vs.cmd # VS ARM64 环境启动 dev pnpm dev:renderer # 仅前端 (Vite, port 1420)
pnpm build # 生产构建 (switch-icon run + tsc + vite build) pnpm dev:main # 仅主进程 (tsc + electron)
pnpm build:beta # Beta 构建 (switch-icon beta + tsc + vite build --mode beta) pnpm build # 生产构建 (vite + tsc)
./build-vs.cmd # Rust 编译 (production, NSIS 打包) pnpm dist:dev # dev 图标 + Windows 安装包
./build-vs.cmd --mode beta # Rust 编译 (beta, 跳过打包) pnpm dist:beta # beta 图标 + Windows 安装包
pnpm dist:run # production 图标 + Windows 安装包
```
## 构建流程
### 开发调试
```bash
pnpm dev
→ pnpm build:main # 先编译主进程 TS→JS
→ concurrently:
pnpm dev:renderer # Vite dev server (localhost:1420, HMR)
electron . # 加载 localhost:1420
```
- 前端热更新(HMR
- 主进程修改后需重启 `pnpm dev`
- 调试工具:`Ctrl+Shift+I` 打开 DevTools
### 生产打包
```bash
pnpm dist:beta
→ pnpm build # 1. 编译 renderer + main
→ pnpm icon:beta # 2. 复制 public/icons/beta/ → build/
→ electron-builder --win # 3. 读取 build/icon.ico 打包
→ 输出 dist-electron/koring-launcher-1.0.0-setup.exe
``` ```
## 构建模式 ## 构建模式
| 模式 | `VITE_BUILD_MODE` | 图标 | Badge | 说明 | | 模式 | `VITE_BUILD_MODE` | 图标目录 | Badge | 说明 |
|---|---|---|---|---| |---|---|---|---|---|
| dev | `"dev"` | `dev.png` | 🟢 DEV | 开发预览版 | | dev | `"dev"` | `public/icons/dev/` | 🟢 DEV | 开发预览版 |
| beta | `"beta"` | `beta.png` | 🟡 BETA | 测试版 | | beta | `"beta"` | `public/icons/beta/` | 🟡 BETA | 测试版 |
| run | `"run"` | `run.png` | 无 | 正式版 | | run | `"run"` | `public/icons/run/` | 无 | 正式版 |
模式由 `src/lib/mode.ts` 导出 `BUILD_MODE`, `isDev`, `isBeta`, `isRun` 模式由 `src/lib/mode.ts` 导出 `BUILD_MODE`, `isDev`, `isBeta`, `isRun`, `DEFAULT_BG`, `LOGO_SVG`, `APP_ICON`
## 环境变量 (.env.*) ## 环境变量 (.env.*)
```env ```env
VITE_BUILD_MODE=dev|beta|run VITE_BUILD_MODE=dev|beta|run
VITE_APP_ICON=/dev.png|/beta.png|/run.png VITE_APP_ICON=dev.png|beta.png|run.png
VITE_START_POP=true|false # 启动弹窗开关 VITE_START_POP=true|false # 启动弹窗开关
VITE_START_POP_TITLE="..." # 弹窗标题 VITE_START_POP_TITLE="..." # 弹窗标题
VITE_START_POP_INFO="..." # 弹窗内容 VITE_START_POP_INFO="..." # 弹窗内容
@@ -48,6 +75,110 @@ VITE_START_POP_BOUTTON="..." # 弹窗按钮文字
--- ---
## 项目结构
```
koring-launcher/
├── src/ # 前端源码
│ ├── App.tsx # 路由入口 + configStore 初始化
│ ├── index.css # 全局样式 + CSS 变量 + 动画
│ ├── layouts/
│ │ └── RootLayout.tsx # 三层布局: BackgroundLayer + ContentLayer + SystemLayer
│ ├── components/
│ │ ├── background/
│ │ │ └── BackgroundLayer.tsx # 全屏背景层 + 视差 + 强内容遮罩
│ │ ├── system/
│ │ │ ├── TitleBar.tsx # 自定义标题栏 (WebkitAppRegion: drag)
│ │ │ └── WindowControls.tsx # 窗口按钮 (WebkitAppRegion: no-drag)
│ │ ├── splash/
│ │ │ └── Splash.tsx # 启动动画 (React 组件)
│ │ ├── silk/
│ │ │ └── Silk.tsx # WebGL 丝绸着色器 (Three.js)
│ │ ├── task/
│ │ │ ├── TaskButton.tsx # 标题栏任务指示器
│ │ │ └── TaskCard.tsx # 单个任务卡片
│ │ ├── ui/ # shadcn/ui 组件
│ │ ├── VersionCard.tsx # 版本/更新卡片
│ │ ├── UnderConstruction.tsx # "装修中" 占位组件
│ │ └── StartupPopup.tsx # 启动弹窗
│ ├── stores/
│ │ ├── configStore.ts # 统一配置 store (→ Koring.yml)
│ │ ├── themeStore.ts # 主题 (委托 configStore)
│ │ ├── a11yStore.ts # 无障碍 (委托 configStore)
│ │ ├── backgroundStore.ts # 背景 (委托 configStore)
│ │ ├── authStore.ts # 认证 (→ koring-auth.json)
│ │ ├── routeStore.ts # 路由 (历史栈)
│ │ ├── taskStore.ts # 任务队列 (localStorage)
│ │ ├── instanceStore.ts # 实例管理
│ │ ├── installStore.ts # Minecraft 安装
│ │ ├── launchStore.ts # 游戏启动
│ │ ├── modsStore.ts # Mod 搜索
│ │ ├── updateStore.ts # 应用更新
│ │ └── devStore.ts # 开发者调试
│ ├── api/
│ │ ├── ipc.ts # 核心 IPC 工具 (invoke, onIpcEvent)
│ │ ├── config.ts # AppConfig 读写
│ │ ├── auth.ts # 登录 API
│ │ ├── background.ts # 背景控制
│ │ ├── install.ts # Minecraft 安装
│ │ ├── launch.ts # 游戏启动
│ │ ├── mods.ts # Mod 搜索
│ │ ├── instance.ts # 实例 API
│ │ └── update.ts # 应用更新
│ ├── hooks/
│ │ └── useTheme.ts # 同步 darkMode → .dark class
│ ├── lib/
│ │ ├── mode.ts # BUILD_MODE, DEFAULT_BG, LOGO_SVG, APP_ICON
│ │ └── utils.ts # cn() 工具函数
│ ├── types/
│ │ └── task.ts # Task 类型定义
│ └── pages/ # 页面组件
├── electron/ # Electron 主进程
│ ├── main.ts # 主入口, 窗口管理, splash→main 过渡
│ ├── preload.ts # Context bridge (window.electronAPI)
│ ├── config.ts # YAML 配置管理 (稀疏保存)
│ ├── auth.ts # 认证数据持久化 (JSON 文件)
│ ├── core/ # @xmcl/* 集成
│ │ ├── auth.ts # Microsoft OAuth, Xbox Live, MC auth
│ │ ├── installer.ts # @xmcl/installer
│ │ ├── launcher.ts # @xmcl/core 游戏启动
│ │ ├── modrinth.ts # Modrinth/CurseForge API
│ │ └── instance.ts # 实例管理
│ ├── handlers/ # IPC 处理器
│ │ ├── config.ts # 配置读写
│ │ ├── auth.ts # 认证操作
│ │ ├── install.ts # 安装操作
│ │ ├── launch.ts # 游戏启动
│ │ ├── mods.ts # Mod 操作
│ │ ├── instance.ts # 实例操作
│ │ ├── background.ts # 背景操作
│ │ ├── task.ts # 任务系统
│ │ ├── system.ts # 系统信息
│ │ └── window.ts # 窗口控制 + splash 管理
│ └── types/
│ └── electron.d.ts # TypeScript 声明
├── public/ # 静态资源
│ ├── icons/
│ │ ├── dev/icon.ico, icon.png
│ │ ├── beta/icon.ico, icon.png
│ │ └── run/icon.ico, icon.png
│ ├── background.png # 默认背景图
│ ├── koring-licon.svg # Logo
│ └── ...
├── build/ # 构建资源 (gitignored, 由 switch-icon.js 生成)
│ ├── icon.ico
│ └── icon.png
├── scripts/
│ └── switch-icon.js # 图标切换脚本
├── splash.html # 启动动画 HTML 入口
├── electron-builder.yml # 打包配置
├── vite.config.ts # Vite 配置
├── tsconfig.electron.json # 主进程 TS 编译配置
└── package.json
```
---
## 配置存储架构 ## 配置存储架构
### 概览 ### 概览
@@ -55,8 +186,7 @@ VITE_START_POP_BOUTTON="..." # 弹窗按钮文字
| 数据类型 | 存储位置 | 格式 | 说明 | | 数据类型 | 存储位置 | 格式 | 说明 |
|---------|---------|------|------| |---------|---------|------|------|
| 用户设置 | 程序目录 `Koring.yml` | YAML | 所有可配置项 | | 用户设置 | 程序目录 `Koring.yml` | YAML | 所有可配置项 |
| 账户凭证 | Windows Registry `HKCU\Software\KoringLauncher` | REG_SZ | token/xboxProfile | | 认证数据 | 程序目录 `koring-auth.json` | JSON | token/xboxProfile |
| 实例配置 | 实例目录 `koring-instance.json` | JSON | per-instance |
| 任务历史 | localStorage `koring-task-history` | JSON | 临时,max 50 | | 任务历史 | localStorage `koring-task-history` | JSON | 临时,max 50 |
### Koring.yml 结构 ### Koring.yml 结构
@@ -114,18 +244,6 @@ network:
authUrl: "" authUrl: ""
``` ```
### 注册表结构
```
HKCU\Software\KoringLauncher
└─ auth
├─ username (REG_SZ)
├─ uuid (REG_SZ)
├─ accessToken (REG_SZ)
├─ refreshToken (REG_SZ)
└─ xboxProfile (REG_SZ, JSON string)
```
### 向上兼容策略 ### 向上兼容策略
1. **版本号**`version` 字段,每次结构变更递增 1. **版本号**`version` 字段,每次结构变更递增
@@ -134,134 +252,123 @@ HKCU\Software\KoringLauncher
4. **未知字段保留** — YAML 解析器保留不认识的字段 4. **未知字段保留** — YAML 解析器保留不认识的字段
5. **Debounce 写入** — 300ms debounce 避免频繁 IO 5. **Debounce 写入** — 300ms debounce 避免频繁 IO
### Rust 实现 ---
**新文件:** ## Electron 主进程
- `src-tauri/src/config.rs``AppConfig` 结构体、`load_config()``save_config()``merge_defaults()``migrate()`
- `src-tauri/src/registry.rs``read_auth()``write_auth()``delete_auth()`
**Tauri 命令:** ### 窗口管理
- `get_config` → 返回 `AppConfig` JSON
- `save_config(cfg)` → 写入 `Koring.yml`
- `get_auth` → 从注册表读取 `AuthData`
- `save_auth(auth)` → 写入注册表
- `delete_auth_cmd` → 删除注册表认证数据
### 前端实现 | 窗口 | 尺寸 | 特性 |
|---|---|---|
| splash | 480×320 | 无边框, 透明, 不可缩放, 居中 |
| main | 1000×700 (min 800×600) | 无边框, 透明, 初始隐藏 |
**新文件:** ### 启动流程
- `src/api/config.ts``getConfig()``saveConfig()` 类型定义
- `src/api/auth-registry.ts``getAuth()``saveAuth()``deleteAuth()`
- `src/stores/configStore.ts` — 统一配置 Zustand store
**configStore 接口:** ```
```ts app.whenReady()
useConfigStore.getState().config // 完整配置 → registerAllHandlers() # 注册所有 IPC 处理器
useConfigStore.getState().loaded // 是否已加载 → createSplashWindow() # 立即显示 splash
useConfigStore.getState().init() // 从 Rust 加载 → createMainWindow() # 后台创建 main (show: false)
useConfigStore.getState().setTheme({...}) // 部分更新 + debounce 写回 → ready-to-show + 1.5s min # 两个条件都满足后:
useConfigStore.getState().setA11y({...}) → mainWindow.show() # 显示主窗口
useConfigStore.getState().setBackground({...}) → splashWindow.close() # 关闭 splash
useConfigStore.getState().setGame({...})
useConfigStore.getState().setJava({...})
useConfigStore.getState().setAdvanced({...})
useConfigStore.getState().setDownload({...})
useConfigStore.getState().setNetwork({...})
``` ```
**Store 委托模式:** ### Mutable Win Ref
themeStore / a11yStore / backgroundStore / authStore 都是 configStore 的薄包装层:
- 读取时从 `configStore.config.*` 同步
- 写入时通过各自的 `set*()` 同时更新本地状态和 configStore
- 提供 `sync*FromConfig()` 函数在 App 启动时同步
**App 启动流程:** `electron/main.ts` 使用可变的 `win` 对象,所有处理器在运行时读取 `win.mainWindow`(而非注册时捕获):
```
configStore.init() → syncThemeFromConfig() → syncA11yFromConfig() → syncBackgroundFromConfig() → authStore.initFromRegistry() ```typescript
const win: { mainWindow: BrowserWindow | null; splashWindow: BrowserWindow | null } = {
mainWindow: null,
splashWindow: null,
};
// 处理器中:
registerInstallHandlers(win); // 传入 ref
// handler 内部:
win.mainWindow?.webContents.send('install:progress', data);
``` ```
### IPC 处理器
| 频道 | 说明 |
|---|---|
| `config:get` / `config:save` | 配置读写 |
| `auth:offline-login` / `auth:get` / `auth:save` / `auth:delete` | 认证操作 |
| `install:minecraft` / `install:mod-loader` / `install:version-list` | 安装操作 |
| `launch:launch` / `launch:diagnose` | 游戏启动 |
| `mods:search` / `mods:install` | Mod 操作 |
| `instance:create` / `instance:list` / `instance:delete` | 实例操作 |
| `background:set-image` / `background:set-color` / `background:reset` | 背景操作 |
| `task:progress` / `task:completed` | 任务进度 |
| `system:info` | 系统信息 |
| `window:minimize` / `window:maximize` / `window:close` | 窗口控制 |
| `window:openSplash` / `window:closeSplash` | Splash 管理 |
| `dialog:openFile` | 文件选择器 |
--- ---
## 项目结构 ## Zustand Stores
### configStore (统一配置中心)
所有用户设置的单一数据源。读写通过 IPC 与 `Koring.yml` 同步。
```ts
config: AppConfig // 完整配置
loaded: boolean // 是否已加载
init() // 从主进程加载配置
setTheme(patch) // 部分更新 + debounce 300ms 写回
setA11y(patch)
setBackground(patch)
setGame(patch)
setJava(patch)
setAdvanced(patch)
setDownload(patch)
setNetwork(patch)
``` ```
koring-launcher/
├── src/ # 前端源码 ### themeStore (委托 configStore)
│ ├── App.tsx # 路由入口 + configStore 初始化
│ ├── index.css # 全局样式 + CSS 变量 + 动画 ```ts
│ ├── layouts/ darkMode: "auto" | "light" | "dark"
│ │ └── RootLayout.tsx # 三层布局: BackgroundLayer + ContentLayer + SystemLayer parallax: boolean
│ ├── components/ setDarkMode(mode) // 更新 DOM + configStore
│ │ ├── background/ setParallax(v) // configStore
│ │ │ └── BackgroundLayer.tsx # 全屏背景层 + 视差 + 强内容遮罩 syncThemeFromConfig() // 启动时从 config 同步
│ │ ├── system/
│ │ │ ├── SystemLayer.tsx # 系统层容器 (z-[100])
│ │ │ ├── TitleBar.tsx # 自定义标题栏 (40px, 胶囊菜单/返回按钮)
│ │ │ └── WindowControls.tsx # 窗口按钮 + DEV/BETA badge + TaskButton
│ │ ├── splash/
│ │ │ └── Splash.tsx # 启动动画 (独立窗口, HTML+React)
│ │ ├── silk/
│ │ │ └── Silk.tsx # WebGL 丝绸着色器 (Three.js)
│ │ ├── task/
│ │ │ ├── TaskButton.tsx # 标题栏任务指示器 (SVG 圆弧动画)
│ │ │ └── TaskCard.tsx # 单个任务卡片 (进度条+日志)
│ │ ├── ui/ # shadcn/ui 组件
│ │ │ ├── sheet.tsx, button.tsx, switch.tsx, slider.tsx
│ │ │ ├── progress.tsx, badge.tsx, separator.tsx
│ │ │ ├── label.tsx, radio-group.tsx, alert-dialog.tsx
│ │ ├── VersionCard.tsx # 版本/更新卡片 (Silk 背景+毛玻璃)
│ │ ├── UnderConstruction.tsx # "装修中" 占位组件
│ │ └── StartupPopup.tsx # 启动弹窗 (环境变量控制)
│ ├── stores/
│ │ ├── configStore.ts # 统一配置 store (→ Koring.yml)
│ │ ├── themeStore.ts # 主题 (委托 configStore)
│ │ ├── a11yStore.ts # 无障碍 (委托 configStore)
│ │ ├── backgroundStore.ts # 背景 (委托 configStore)
│ │ ├── authStore.ts # 认证 (→ Registry)
│ │ ├── routeStore.ts # 路由 (历史栈)
│ │ ├── taskStore.ts # 任务队列 (localStorage)
│ │ ├── instanceStore.ts # 实例管理
│ │ ├── installStore.ts # Minecraft 安装
│ │ ├── launchStore.ts # 游戏启动
│ │ ├── modsStore.ts # Mod 搜索
│ │ ├── updateStore.ts # 应用更新
│ │ └── devStore.ts # 开发者调试
│ ├── api/
│ │ ├── config.ts # AppConfig 读写 (→ Tauri invoke)
│ │ ├── auth-registry.ts # AuthData 读写 (→ Registry)
│ │ ├── instance.ts # 实例 API
│ │ ├── auth.ts # 登录 API
│ │ ├── sidecar.ts # 通用 sidecar IPC
│ │ └── ...
│ ├── hooks/
│ │ └── useTheme.ts # 同步 darkMode → .dark class
│ ├── lib/
│ │ ├── mode.ts # BUILD_MODE 常量
│ │ └── utils.ts # cn() 工具函数
│ ├── types/
│ │ └── task.ts # Task 类型定义
│ └── pages/ # 页面组件
├── src-tauri/ # Rust 后端
│ ├── Cargo.toml # 依赖: serde, serde_json, serde_yaml, winreg
│ ├── tauri.conf.json # 窗口配置 + Bundle + Updater
│ ├── capabilities/default.json # 权限声明
│ ├── src/
│ │ ├── lib.rs # 插件注册 + splash/main + 命令注册
│ │ ├── config.rs # AppConfig 读写 + 迁移 + 默认值
│ │ ├── registry.rs # Windows Registry 读写
│ │ ├── commands/mod.rs # Tauri 命令 (→ sidecar + config + auth)
│ │ └── sidecar.rs # Sidecar 进程管理
│ └── binaries/ # Sidecar 二进制文件
├── sidecar/ # Node.js Sidecar
│ └── src/
│ ├── handlers/ # 10 个 handler
│ ├── utils/paths.ts # 路径工具 + InstanceConfig
│ └── protocol/types.ts # IPC 消息类型
├── splash.html # 启动动画 HTML 入口
├── build-vs.cmd # VS 环境编译脚本
└── dev-vs.cmd # VS 环境开发脚本
``` ```
### backgroundStore (委托 configStore)
```ts
type: "image" | "color", image, blur, opacity
setImage / setColor / setBlur / setOpacity / reset
syncBackgroundFromConfig()
```
### authStore (委托 JSON 文件)
```ts
user: AuthResult | null
initFromFile() // 从 koring-auth.json 加载
loginOffline(username) // 通过主进程 + 保存到文件
logout() // 清除文件
```
### 其他 Store
- `routeStore` — 历史栈导航
- `taskStore` — 任务队列 (localStorage)
- `devStore` — 开发者调试 (内存)
- `installStore` — Minecraft 安装 (内存)
- `launchStore` — 游戏启动 (内存)
- `modsStore` — Mod 搜索 (内存)
- `updateStore` — 应用更新 (内存)
- `instanceStore` — 实例管理
--- ---
## 页面路由 ## 页面路由
@@ -287,89 +394,6 @@ koring-launcher/
| `debug-version-card` | 版本卡片调试 | `pages/debug/version-card-debug.tsx` | | `debug-version-card` | 版本卡片调试 | `pages/debug/version-card-debug.tsx` |
| `debug-task` | 任务队列调试 | `pages/debug/task-debug.tsx` | | `debug-task` | 任务队列调试 | `pages/debug/task-debug.tsx` |
### 路由导航 (历史栈)
路由使用动态历史栈替代静态 parentMap。每次 `navigate()` 压入历史,`goBack()` 弹出。
---
## Zustand Stores
### configStore (统一配置中心)
所有用户设置的单一数据源。读写通过 Tauri invoke 与 `Koring.yml` 同步。
```ts
config: AppConfig // 完整配置
loaded: boolean // 是否已从 Rust 加载
init() // 从 Rust 加载配置
setTheme(patch) // 部分更新 + debounce 300ms 写回
setA11y(patch)
setBackground(patch)
setGame(patch)
setJava(patch)
setAdvanced(patch)
setDownload(patch)
setNetwork(patch)
```
### themeStore (委托 configStore)
```ts
darkMode: "auto" | "light" | "dark"
parallax: boolean
setDarkMode(mode) // 更新 DOM + configStore
setParallax(v) // configStore
syncThemeFromConfig() // 启动时从 config 同步
```
### a11yStore (委托 configStore)
```ts
reduceMotion, reduceTransparency, highContrast, contentBlurOpacity
syncA11yFromConfig()
```
### backgroundStore (委托 configStore)
```ts
type: "image" | "color", image, blur, opacity
setImage / setColor / setBlur / setOpacity / reset
syncBackgroundFromConfig()
```
### authStore (委托 Registry)
```ts
user: AuthResult | null
initFromRegistry() // 从 Windows Registry 加载
loginOffline(username) // 通过 sidecar + 保存到 registry
logout() // 清除 registry
```
### routeStore (历史栈导航)
```ts
current: RouteKey
history: RouteKey[] // 导航历史栈
navigate(key) // 压入历史
goBack() // 弹出历史
```
### taskStore (localStorage)
任务队列。localStorage 持久化历史 (max 50)。
### 其他 Store
- `devStore` — 开发者调试 (内存)
- `installStore` — Minecraft 安装 (内存,ephemeral)
- `launchStore` — 游戏启动 (内存,ephemeral)
- `modsStore` — Mod 搜索 (内存,ephemeral)
- `updateStore` — 应用更新 (内存,ephemeral)
- `instanceStore` — 实例管理 (sidecar 查询)
--- ---
## 核心组件 ## 核心组件
@@ -390,6 +414,10 @@ z-200 StartupPopup 启动弹窗 (环境变量控制)
- **`sub`**: 左侧返回按钮 + 品牌文字 + 右侧窗口控制 (隐藏 TaskButton) - **`sub`**: 左侧返回按钮 + 品牌文字 + 右侧窗口控制 (隐藏 TaskButton)
- **`window`**: 仅窗口控制 - **`window`**: 仅窗口控制
### WindowControls 窗口控制
使用 `<button>` 元素,CSS `WebkitAppRegion: "no-drag"` 实现按钮可点击。
### BackgroundLayer 背景层 ### BackgroundLayer 背景层
- 支持 `image` (CSS background-image) 和 `color` (CSS background-color) 两种类型 - 支持 `image` (CSS background-image) 和 `color` (CSS background-color) 两种类型
@@ -397,13 +425,6 @@ z-200 StartupPopup 启动弹窗 (环境变量控制)
- 强内容遮罩: 非 home 页面自动显示 (可通过 `contentBlurOpacity` 控制) - 强内容遮罩: 非 home 页面自动显示 (可通过 `contentBlurOpacity` 控制)
- 深色模式叠加层: `bg-black/35` - 深色模式叠加层: `bg-black/35`
### StartCard 启动组件 (首页)
胶囊形启动组件,位于首页左下角:
- 左: 设置齿轮图标 (→ setting)
- 中: "启动游戏" 按钮 (primary 色, rounded-full)
- 右: 实例选择图标 (Package)
### TaskQueue 任务系统 ### TaskQueue 任务系统
- 执行器模式: `addTask(type, title, desc, async (ctx) => {...})` - 执行器模式: `addTask(type, title, desc, async (ctx) => {...})`
@@ -411,44 +432,3 @@ z-200 StartupPopup 启动弹窗 (环境变量控制)
- AbortController 取消机制 - AbortController 取消机制
- localStorage 持久化历史 (max 50) - localStorage 持久化历史 (max 50)
- 任务类型: `install` / `download` / `update` / `launch` / `auth` / `sync` / `custom` - 任务类型: `install` / `download` / `update` / `launch` / `auth` / `sync` / `custom`
---
## Tauri 窗口配置
| 窗口 | 尺寸 | 特性 |
|---|---|---|
| splashscreen | 480×320 | 无边框, 透明, 不可缩放, 居中 |
| main | 900×600 (min 800×600) | 无边框, 透明, 隐藏启动 |
## 权限 (capabilities/default.json)
```
core:default, core:window:default, core:window:allow-*,
core:webview:allow-create-webview-window,
opener:default, process:default, shell:allow-execute,
updater:default, dialog:default
```
## Rust 依赖
```toml
tauri = "2"
tauri-plugin-opener, tauri-plugin-process, tauri-plugin-dialog,
tauri-plugin-shell, tauri-plugin-updater
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
uuid = { version = "1", features = ["v4"] }
tokio = { version = "1", features = ["time"] }
[target.'cfg(windows)'.dependencies]
winreg = "0.52"
```
## IPC 协议
前端 → `invoke()` → Rust `commands::*` → sidecar stdin (JSON) → sidecar stdout (JSON) → Tauri 事件 → 前端
Sidecar 命令: `install-minecraft`, `install-mod-loader`, `get-version-list`, `launch-game`, `offline-login`, `search-mods`, `install-mod`, `create-instance`, `list-instances`
Rust 直接命令: `get_config`, `save_config`, `get_auth`, `save_auth`, `delete_auth_cmd`
+47
View File
@@ -0,0 +1,47 @@
LingkeLice 1.0 (LL-1.0)
Copyright (c) Shenzhen Lingke Network Technology Co., Ltd.
Permission is hereby granted, free of charge, to any person or organization
obtaining a copy of this software and associated documentation files (the
"Software"), to use the Software subject to the following conditions:
1. Grant of License
1.1 Non-Commercial Use
For any purpose that does not involve commercial activity, anyone may:
(a) Use, copy, and modify the Software;
(b) Incorporate the Software into other projects;
(c) Distribute original or modified copies of the Software.
1.2 Commercial Use
Any use of the Software for commercial purposes (including but not
limited to integrating the Software into commercial products,
providing services based on the Software in a commercial manner, or
generating economic benefits directly or indirectly through the
Software) must obtain prior written authorization from the Copyright
Holder.
2. Restrictions
2.1 Without prior written consent from the Copyright Holder, the Software
or its derivative works shall not be used for any commercial activity.
2.2 For commercial use authorization, please contact:
Shenzhen Lingke Network Technology Co., Ltd.
3. Disclaimer
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES
OR OTHER LIABILITY ARISING FROM THE USE OF THE SOFTWARE.
4. Termination
If the Licensee violates any provision of this License, their rights
under this License shall automatically terminate, and they must
immediately cease using and destroy all copies of the Software.
For commercial use authorization, please contact:
Shenzhen Lingke Network Technology Co., Ltd.
+34 -14
View File
@@ -15,7 +15,9 @@ pnpm dev:main # electron main process only
```bash ```bash
pnpm build # production build (vite + tsc) pnpm build # production build (vite + tsc)
pnpm dist:win # build Windows installer pnpm dist:dev # dev icon + Windows installer
pnpm dist:beta # beta icon + Windows installer
pnpm dist:run # production icon + Windows installer
pnpm dist:mac # build macOS DMG pnpm dist:mac # build macOS DMG
pnpm dist:linux # build Linux AppImage pnpm dist:linux # build Linux AppImage
``` ```
@@ -26,6 +28,7 @@ pnpm dist:linux # build Linux AppImage
src/ Frontend (React 19 + Vite 7 + Tailwind v4 + shadcn/ui + Zustand) src/ Frontend (React 19 + Vite 7 + Tailwind v4 + shadcn/ui + Zustand)
electron/ Main process (Node.js/TypeScript, @xmcl/* packages) electron/ Main process (Node.js/TypeScript, @xmcl/* packages)
public/ Static assets (icons, fonts, images) public/ Static assets (icons, fonts, images)
build/ Build resources (generated, gitignored)
``` ```
**IPC Flow:** **IPC Flow:**
@@ -54,12 +57,12 @@ src/
│ ├── Home.tsx # Main page │ ├── Home.tsx # Main page
│ └── Debug.tsx # Debug tools │ └── Debug.tsx # Debug tools
├── lib/ ├── lib/
│ ├── mode.ts # Build mode detection (dev/beta/run) │ ├── mode.ts # Build mode constants (DEFAULT_BG, LOGO_SVG, APP_ICON)
│ └── utils.ts # cn() helper │ └── utils.ts # cn() helper
└── App.tsx # Root component with state router └── App.tsx # Root component with state router
electron/ electron/
├── main.ts # Electron entry, window management ├── main.ts # Electron entry, window management, splash→main transition
├── preload.ts # Context bridge (window.electronAPI) ├── preload.ts # Context bridge (window.electronAPI)
├── config.ts # YAML config management ├── config.ts # YAML config management
├── auth.ts # Auth data persistence ├── auth.ts # Auth data persistence
@@ -79,7 +82,7 @@ electron/
│ ├── background.ts # Background operations │ ├── background.ts # Background operations
│ ├── task.ts # Task system │ ├── task.ts # Task system
│ ├── system.ts # System info │ ├── system.ts # System info
│ └── window.ts # Window controls │ └── window.ts # Window controls + splash management
└── types/ └── types/
└── electron.d.ts # TypeScript declarations └── electron.d.ts # TypeScript declarations
``` ```
@@ -109,22 +112,35 @@ electron/
- Window: 480×320, no decorations, transparent, locked size - Window: 480×320, no decorations, transparent, locked size
- Auto-adapts to system dark mode (`prefers-color-scheme`) - Auto-adapts to system dark mode (`prefers-color-scheme`)
- Logo: `filter: invert(1)` in dark mode - Logo: `filter: invert(1)` in dark mode
- Startup: splash shows first → main loads behind → transition after `ready-to-show` + 1.5s minimum
## Icon System ## Icon System
Three icon variants in `public/`: Three icon variants in `public/icons/`:
| Mode | File | Use Case | ```
|------|------|----------| public/icons/
| dev | `dev.png` / `dev.ico` | Development | dev/icon.ico, icon.png # Development
| beta | `beta.png` / `beta.ico` | Testing | beta/icon.ico, icon.png # Testing
| run | `run.png` / `run.ico` | Production release | run/icon.ico, icon.png # Production release
```
**Build-time switching:**
```bash
pnpm icon:dev # copies public/icons/dev/ → build/
pnpm icon:beta # copies public/icons/beta/ → build/
pnpm icon:run # copies public/icons/run/ → build/
```
`electron-builder.yml` reads icons from `build/` (`buildResources: build`).
**Frontend usage:** **Frontend usage:**
```tsx ```tsx
import { APP_ICON, BUILD_MODE, isDev } from "@/lib/mode"; import { APP_ICON, DEFAULT_BG, LOGO_SVG, BUILD_MODE, isDev } from "@/lib/mode";
<img src={APP_ICON} /> <img src={APP_ICON} />
<img src={LOGO_SVG} />
<img src={DEFAULT_BG} />
{isDev && <span>Dev Mode</span>} {isDev && <span>Dev Mode</span>}
``` ```
@@ -139,15 +155,19 @@ import { APP_ICON, BUILD_MODE, isDev } from "@/lib/mode";
- `background:*` — Background image/color/blur/animation/theme - `background:*` — Background image/color/blur/animation/theme
- `task:*` — Task system progress - `task:*` — Task system progress
- `system:*` — System info - `system:*` — System info
- `window:*` — Minimize/maximize/close - `window:*` — Minimize/maximize/close + splash management
- `dialog:*` — File picker
## Key Gotchas ## Key Gotchas
- **@xmcl packages run in main process**: `@xmcl/core`, `@xmcl/installer` require `fs`/`child_process`. All run in Electron main process. - **@xmcl packages run in main process**: `@xmcl/core`, `@xmcl/installer` require `fs`/`child_process`. All run in Electron main process.
- **Path alias**: `@/` maps to `src/`. - **Path alias**: `@/` maps to `src/`.
- **Window dragging**: Use CSS `-webkit-app-region: drag` on titlebar. - **Window dragging**: Use CSS `WebkitAppRegion: "drag"` as inline style (Electron only respects CSS property, not HTML attributes).
- **Transparent windows**: `transparent: true` + `frame: false` in BrowserWindow options. - **Transparent windows**: `transparent: true` + `frame: false` in BrowserWindow options.
- **Auth storage**: JSON file (`koring-auth.json`) stored next to executable. - **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.
## Tech Stack ## Tech Stack
+7 -6
View File
@@ -2,26 +2,27 @@ appId: com.lingke.koring.launcher
productName: Koring Launcher productName: Koring Launcher
directories: directories:
output: dist-electron output: dist-electron
buildResources: build
files: files:
- dist/** - dist/**
- electron/**/*.js - electron/**/*.js
- "!electron/**/*.ts" - "!electron/**/*.ts"
win: win:
target: nsis target: nsis
icon: public/icon.ico icon: build/icon.ico
artifactName: "koring-launcher-${version}-setup.${ext}" artifactName: "koring-launcher-${version}-setup.${ext}"
mac: mac:
target: dmg target: dmg
icon: public/icon.png icon: build/icon.png
linux: linux:
target: AppImage target: AppImage
icon: public/icon.png icon: build/icon.png
nsis: nsis:
oneClick: false oneClick: false
allowToChangeInstallationDirectory: true allowToChangeInstallationDirectory: true
installerIcon: public/icon.ico installerIcon: build/icon.ico
uninstallerIcon: public/icon.ico uninstallerIcon: build/icon.ico
installerHeaderIcon: public/icon.ico installerHeaderIcon: build/icon.ico
createDesktopShortcut: true createDesktopShortcut: true
createStartMenuShortcut: true createStartMenuShortcut: true
shortcutName: Koring Launcher shortcutName: Koring Launcher
+219 -62
View File
@@ -35,45 +35,74 @@ var __importStar = (this && this.__importStar) || (function () {
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.createInstance = createInstance; exports.createInstance = createInstance;
exports.listInstances = listInstances; exports.listInstances = listInstances;
exports.deleteInstance = deleteInstance;
exports.getInstanceInfo = getInstanceInfo; exports.getInstanceInfo = getInstanceInfo;
exports.deleteInstance = deleteInstance;
exports.updateInstance = updateInstance;
exports.installInstanceGame = installInstanceGame;
exports.launchInstance = launchInstance;
exports.diagnoseInstance = diagnoseInstance;
exports.getMinecraftVersionList = getMinecraftVersionList;
exports.getForgeVersionList = getForgeVersionList;
exports.getFabricVersionList = getFabricVersionList;
exports.getQuiltVersionList = getQuiltVersionList;
const fs = __importStar(require("fs")); const fs = __importStar(require("fs"));
const path = __importStar(require("path")); const path = __importStar(require("path"));
const INSTANCE_CONFIG_FILE = 'koring-instance.json'; const core_1 = require("@xmcl/core");
const installer_1 = require("@xmcl/installer");
const INSTANCE_CONFIG_FILE = 'instance.json';
function getInstanceConfigPath(instancePath) { function getInstanceConfigPath(instancePath) {
return path.join(instancePath, INSTANCE_CONFIG_FILE); return path.join(instancePath, INSTANCE_CONFIG_FILE);
} }
async function createInstance(name, gamePath, mcVersion, loaderType, loaderVersion, javaPath, memory) { function countFiles(dir, ext) {
const instancePath = path.join(gamePath, 'instances', name); if (!fs.existsSync(dir))
if (!fs.existsSync(instancePath)) { return 0;
fs.mkdirSync(instancePath, { recursive: true }); return fs.readdirSync(dir).filter((f) => {
if (ext)
return f.endsWith(ext);
return fs.statSync(path.join(dir, f)).isFile();
}).length;
}
function getInstanceIssues(instancePath, runtime) {
const issues = [];
if (!fs.existsSync(path.join(instancePath, 'versions', runtime.minecraft, `${runtime.minecraft}.json`))) {
issues.push(`Version JSON not found for ${runtime.minecraft}`);
} }
if (!fs.existsSync(path.join(instancePath, 'versions', runtime.minecraft, `${runtime.minecraft}.jar`))) {
issues.push(`Client JAR not found for ${runtime.minecraft}`);
}
return issues;
}
async function createInstance(name, gamePath, runtime, options) {
const instancePath = path.join(gamePath, 'instances', name);
if (fs.existsSync(instancePath)) {
throw new Error(`Instance already exists: ${name}`);
}
fs.mkdirSync(instancePath, { recursive: true });
const now = Date.now();
const config = { const config = {
name, name,
mcVersion, author: options?.author || '',
loaderType, description: options?.description || '',
loaderVersion, runtime,
javaPath, java: options?.java,
memory, minMemory: options?.minMemory,
createdAt: new Date().toISOString(), maxMemory: options?.maxMemory,
vmOptions: options?.vmOptions,
mcOptions: options?.mcOptions,
creationDate: now,
lastAccessDate: now,
lastPlayedDate: 0,
playtime: 0,
}; };
const configPath = getInstanceConfigPath(instancePath); fs.writeFileSync(getInstanceConfigPath(instancePath), JSON.stringify(config, null, 2), 'utf-8');
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); // Create standard directories
// Create mods directory for (const dir of ['mods', 'config', 'resourcepacks', 'shaderpacks', 'saves', 'screenshots', 'logs']) {
const modsDir = path.join(instancePath, 'mods'); fs.mkdirSync(path.join(instancePath, dir), { recursive: true });
if (!fs.existsSync(modsDir)) {
fs.mkdirSync(modsDir, { recursive: true });
} }
// Count mods return getInstanceInfo(name, gamePath);
const mods = fs.readdirSync(modsDir).filter((f) => f.endsWith('.jar'));
return {
name,
path: instancePath,
config,
modCount: mods.length,
};
} }
async function listInstances(instancesPath) { async function listInstances(gamePath) {
const instancesPath = path.join(gamePath, 'instances');
const instances = []; const instances = [];
if (!fs.existsSync(instancesPath)) { if (!fs.existsSync(instancesPath)) {
return instances; return instances;
@@ -82,57 +111,185 @@ async function listInstances(instancesPath) {
for (const entry of entries) { for (const entry of entries) {
if (!entry.isDirectory()) if (!entry.isDirectory())
continue; continue;
const instancePath = path.join(instancesPath, entry.name);
const configPath = getInstanceConfigPath(instancePath);
if (!fs.existsSync(configPath))
continue;
try { try {
const configRaw = fs.readFileSync(configPath, 'utf-8'); instances.push(await getInstanceInfo(entry.name, gamePath));
const config = JSON.parse(configRaw);
// Count mods
const modsDir = path.join(instancePath, 'mods');
let modCount = 0;
if (fs.existsSync(modsDir)) {
modCount = fs.readdirSync(modsDir).filter((f) => f.endsWith('.jar')).length;
}
instances.push({
name: entry.name,
path: instancePath,
config,
modCount,
});
} }
catch { catch {
// Skip invalid config // Skip invalid instances
} }
} }
return instances; return instances;
} }
async function deleteInstance(name, instancesPath) { async function getInstanceInfo(name, gamePath) {
const instancePath = path.join(instancesPath, name); const instancePath = path.join(gamePath, 'instances', name);
if (fs.existsSync(instancePath)) {
fs.rmSync(instancePath, { recursive: true, force: true });
}
return { deleted: name };
}
async function getInstanceInfo(name, instancesPath) {
const instancePath = path.join(instancesPath, name);
const configPath = getInstanceConfigPath(instancePath); const configPath = getInstanceConfigPath(instancePath);
if (!fs.existsSync(configPath)) { if (!fs.existsSync(configPath)) {
throw new Error(`Instance not found: ${name}`); throw new Error(`Instance not found: ${name}`);
} }
const configRaw = fs.readFileSync(configPath, 'utf-8'); const configRaw = fs.readFileSync(configPath, 'utf-8');
const config = JSON.parse(configRaw); const config = JSON.parse(configRaw);
// Count mods const issues = getInstanceIssues(instancePath, config.runtime);
const modsDir = path.join(instancePath, 'mods');
let modCount = 0;
if (fs.existsSync(modsDir)) {
modCount = fs.readdirSync(modsDir).filter((f) => f.endsWith('.jar')).length;
}
return { return {
name, name,
path: instancePath, path: instancePath,
config, config,
modCount, modCount: countFiles(path.join(instancePath, 'mods'), '.jar'),
resourcePackCount: countFiles(path.join(instancePath, 'resourcepacks'), '.zip'),
screenshotCount: countFiles(path.join(instancePath, 'screenshots')),
saveCount: countFiles(path.join(instancePath, 'saves')),
healthy: issues.length === 0,
issues,
}; };
} }
async function deleteInstance(name, gamePath) {
const instancePath = path.join(gamePath, 'instances', name);
if (!fs.existsSync(instancePath)) {
throw new Error(`Instance not found: ${name}`);
}
fs.rmSync(instancePath, { recursive: true, force: true });
return { deleted: name };
}
async function updateInstance(name, gamePath, patch) {
const instancePath = path.join(gamePath, 'instances', name);
const configPath = getInstanceConfigPath(instancePath);
if (!fs.existsSync(configPath)) {
throw new Error(`Instance not found: ${name}`);
}
const configRaw = fs.readFileSync(configPath, 'utf-8');
const config = JSON.parse(configRaw);
const updated = { ...config, ...patch };
fs.writeFileSync(configPath, JSON.stringify(updated, null, 2), 'utf-8');
return getInstanceInfo(name, gamePath);
}
async function installInstanceGame(name, gamePath, callbacks) {
const instance = await getInstanceInfo(name, gamePath);
const { runtime } = instance.config;
const instancePath = instance.path;
callbacks?.onProgress?.({ stage: 'installing-minecraft', current: 0, total: 100, message: `Installing Minecraft ${runtime.minecraft}...` });
// Install Minecraft
// First get version list to find the version info
const versionList = await (0, installer_1.getVersionList)();
const versionInfo = versionList.versions.find((v) => v.id === runtime.minecraft);
if (!versionInfo) {
throw new Error(`Minecraft version ${runtime.minecraft} not found`);
}
await (0, installer_1.install)({ id: versionInfo.id, url: versionInfo.url }, instancePath);
// Install mod loaders
if (runtime.forge) {
callbacks?.onProgress?.({ stage: 'installing-forge', current: 0, total: 100, message: `Installing Forge ${runtime.forge}...` });
await (0, installer_1.installForge)({ version: runtime.forge, mcversion: runtime.minecraft }, instancePath);
}
if (runtime.fabricLoader) {
callbacks?.onProgress?.({ stage: 'installing-fabric', current: 0, total: 100, message: `Installing Fabric ${runtime.fabricLoader}...` });
await (0, installer_1.installFabric)({
minecraftVersion: runtime.minecraft,
version: runtime.fabricLoader,
minecraft: instancePath,
});
}
if (runtime.quiltLoader) {
callbacks?.onProgress?.({ stage: 'installing-quilt', current: 0, total: 100, message: `Installing Quilt ${runtime.quiltLoader}...` });
await (0, installer_1.installQuiltVersion)({
minecraftVersion: runtime.minecraft,
version: runtime.quiltLoader,
minecraft: instancePath,
});
}
if (runtime.neoForged) {
callbacks?.onProgress?.({ stage: 'installing-neoforge', current: 0, total: 100, message: `Installing NeoForge ${runtime.neoForged}...` });
await (0, installer_1.installNeoForged)('neoforge', runtime.neoForged, instancePath, {});
}
// Note: OptiFine requires downloading the installer JAR first
// if (runtime.optifine) {
// callbacks?.onProgress?.({ stage: 'installing-optifine', current: 0, total: 100, message: `Installing OptiFine ${runtime.optifine}...` });
// await installOptifine(runtime.optifine, instancePath);
// }
callbacks?.onProgress?.({ stage: 'installing-dependencies', current: 0, total: 100, message: 'Installing dependencies...' });
// Install all dependencies (libraries + assets)
const resolved = await core_1.Version.parse(instancePath, runtime.minecraft);
await (0, installer_1.installDependencies)(resolved);
callbacks?.onProgress?.({ stage: 'done', current: 100, total: 100, message: 'Installation complete' });
// Update last access date
await updateInstance(name, gamePath, { lastAccessDate: Date.now() });
return getInstanceInfo(name, gamePath);
}
async function launchInstance(name, gamePath, options) {
const instance = await getInstanceInfo(name, gamePath);
const { runtime } = instance.config;
const resolved = await core_1.Version.parse(instance.path, runtime.minecraft);
const javaPath = options.javaPath || instance.config.java || 'java';
const mcProcess = await (0, core_1.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 = (0, core_1.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,
};
}
async function diagnoseInstance(name, gamePath) {
const instance = await getInstanceInfo(name, gamePath);
return {
healthy: instance.healthy,
issues: instance.issues,
};
}
// Version list APIs
async function getMinecraftVersionList(type) {
const manifest = await (0, installer_1.getVersionList)();
let versions = manifest.versions;
if (type && type !== 'all') {
versions = versions.filter((v) => v.type === type);
}
return { versions };
}
async function getForgeVersionList(mcVersion) {
const list = await (0, installer_1.getForgeVersionList)({ minecraft: mcVersion });
return { versions: list.versions };
}
async function getFabricVersionList(mcVersion) {
if (mcVersion) {
const loaders = await (0, installer_1.getFabricLoaders)();
return { versions: loaders.map((l) => l.version) };
}
const loaders = await (0, installer_1.getFabricLoaders)();
return { versions: loaders.map((l) => l.version) };
}
async function getQuiltVersionList(mcVersion) {
const loaders = await (0, installer_1.getQuiltLoaderVersionsByMinecraft)({ minecraftVersion: mcVersion || '*' });
return { versions: loaders.map((l) => l.loader.version) };
}
+331 -87
View File
@@ -1,75 +1,146 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { MinecraftFolder, Version, launch, createMinecraftProcessWatcher, type ResolvedVersion } from '@xmcl/core';
import {
install as xmclInstall,
installForge,
installFabric,
installNeoForged,
installOptifine,
installQuiltVersion,
installDependencies,
getVersionList,
getForgeVersionList as xmclGetForgeVersionList,
getFabricLoaders,
getQuiltLoaderVersionsByMinecraft,
} from '@xmcl/installer';
interface InstanceConfig { export interface InstanceRuntime {
name: string; minecraft: string;
mcVersion: string; forge?: string;
loaderType?: string; neoForged?: string;
loaderVersion?: string; fabricLoader?: string;
javaPath?: string; quiltLoader?: string;
memory?: { min?: string; max?: string }; optifine?: string;
createdAt: string;
} }
interface InstanceInfo { export interface InstanceConfig {
name: string;
author?: string;
description?: string;
runtime: InstanceRuntime;
java?: string;
minMemory?: number;
maxMemory?: number;
vmOptions?: string[];
mcOptions?: string[];
server?: { host: string; port?: number; name?: string };
showLog?: boolean;
hideLauncher?: boolean;
icon?: string;
creationDate: number;
lastAccessDate: number;
lastPlayedDate: number;
playtime: number;
}
export interface InstanceInfo {
name: string; name: string;
path: string; path: string;
config: InstanceConfig; config: InstanceConfig;
modCount?: number; modCount: number;
resourcePackCount: number;
screenshotCount: number;
saveCount: number;
healthy: boolean;
issues: string[];
} }
const INSTANCE_CONFIG_FILE = 'koring-instance.json'; export interface InstallProgress {
stage: string;
current: number;
total: number;
message?: string;
}
const INSTANCE_CONFIG_FILE = 'instance.json';
function getInstanceConfigPath(instancePath: string): string { function getInstanceConfigPath(instancePath: string): string {
return path.join(instancePath, INSTANCE_CONFIG_FILE); return path.join(instancePath, INSTANCE_CONFIG_FILE);
} }
function countFiles(dir: string, ext?: string): number {
if (!fs.existsSync(dir)) return 0;
return fs.readdirSync(dir).filter((f) => {
if (ext) return f.endsWith(ext);
return fs.statSync(path.join(dir, f)).isFile();
}).length;
}
function getInstanceIssues(instancePath: string, runtime: InstanceRuntime): string[] {
const issues: string[] = [];
if (!fs.existsSync(path.join(instancePath, 'versions', runtime.minecraft, `${runtime.minecraft}.json`))) {
issues.push(`Version JSON not found for ${runtime.minecraft}`);
}
if (!fs.existsSync(path.join(instancePath, 'versions', runtime.minecraft, `${runtime.minecraft}.jar`))) {
issues.push(`Client JAR not found for ${runtime.minecraft}`);
}
return issues;
}
export async function createInstance( export async function createInstance(
name: string, name: string,
gamePath: string, gamePath: string,
mcVersion: string, runtime: InstanceRuntime,
loaderType?: string, options?: {
loaderVersion?: string, author?: string;
javaPath?: string, description?: string;
memory?: { min?: string; max?: string } java?: string;
minMemory?: number;
maxMemory?: number;
vmOptions?: string[];
mcOptions?: string[];
}
): Promise<InstanceInfo> { ): Promise<InstanceInfo> {
const instancePath = path.join(gamePath, 'instances', name); const instancePath = path.join(gamePath, 'instances', name);
if (!fs.existsSync(instancePath)) { if (fs.existsSync(instancePath)) {
fs.mkdirSync(instancePath, { recursive: true }); throw new Error(`Instance already exists: ${name}`);
} }
fs.mkdirSync(instancePath, { recursive: true });
const now = Date.now();
const config: InstanceConfig = { const config: InstanceConfig = {
name, name,
mcVersion, author: options?.author || '',
loaderType, description: options?.description || '',
loaderVersion, runtime,
javaPath, java: options?.java,
memory, minMemory: options?.minMemory,
createdAt: new Date().toISOString(), maxMemory: options?.maxMemory,
vmOptions: options?.vmOptions,
mcOptions: options?.mcOptions,
creationDate: now,
lastAccessDate: now,
lastPlayedDate: 0,
playtime: 0,
}; };
const configPath = getInstanceConfigPath(instancePath); fs.writeFileSync(getInstanceConfigPath(instancePath), JSON.stringify(config, null, 2), 'utf-8');
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
// Create mods directory // Create standard directories
const modsDir = path.join(instancePath, 'mods'); for (const dir of ['mods', 'config', 'resourcepacks', 'shaderpacks', 'saves', 'screenshots', 'logs']) {
if (!fs.existsSync(modsDir)) { fs.mkdirSync(path.join(instancePath, dir), { recursive: true });
fs.mkdirSync(modsDir, { recursive: true });
} }
// Count mods return getInstanceInfo(name, gamePath);
const mods = fs.readdirSync(modsDir).filter((f) => f.endsWith('.jar'));
return {
name,
path: instancePath,
config,
modCount: mods.length,
};
} }
export async function listInstances(instancesPath: string): Promise<InstanceInfo[]> { export async function listInstances(gamePath: string): Promise<InstanceInfo[]> {
const instancesPath = path.join(gamePath, 'instances');
const instances: InstanceInfo[] = []; const instances: InstanceInfo[] = [];
if (!fs.existsSync(instancesPath)) { if (!fs.existsSync(instancesPath)) {
@@ -81,54 +152,18 @@ export async function listInstances(instancesPath: string): Promise<InstanceInfo
for (const entry of entries) { for (const entry of entries) {
if (!entry.isDirectory()) continue; if (!entry.isDirectory()) continue;
const instancePath = path.join(instancesPath, entry.name);
const configPath = getInstanceConfigPath(instancePath);
if (!fs.existsSync(configPath)) continue;
try { try {
const configRaw = fs.readFileSync(configPath, 'utf-8'); instances.push(await getInstanceInfo(entry.name, gamePath));
const config = JSON.parse(configRaw) as InstanceConfig;
// Count mods
const modsDir = path.join(instancePath, 'mods');
let modCount = 0;
if (fs.existsSync(modsDir)) {
modCount = fs.readdirSync(modsDir).filter((f) => f.endsWith('.jar')).length;
}
instances.push({
name: entry.name,
path: instancePath,
config,
modCount,
});
} catch { } catch {
// Skip invalid config // Skip invalid instances
} }
} }
return instances; return instances;
} }
export async function deleteInstance( export async function getInstanceInfo(name: string, gamePath: string): Promise<InstanceInfo> {
name: string, const instancePath = path.join(gamePath, 'instances', name);
instancesPath: string
): Promise<{ deleted: string }> {
const instancePath = path.join(instancesPath, name);
if (fs.existsSync(instancePath)) {
fs.rmSync(instancePath, { recursive: true, force: true });
}
return { deleted: name };
}
export async function getInstanceInfo(
name: string,
instancesPath: string
): Promise<InstanceInfo> {
const instancePath = path.join(instancesPath, name);
const configPath = getInstanceConfigPath(instancePath); const configPath = getInstanceConfigPath(instancePath);
if (!fs.existsSync(configPath)) { if (!fs.existsSync(configPath)) {
@@ -138,17 +173,226 @@ export async function getInstanceInfo(
const configRaw = fs.readFileSync(configPath, 'utf-8'); const configRaw = fs.readFileSync(configPath, 'utf-8');
const config = JSON.parse(configRaw) as InstanceConfig; const config = JSON.parse(configRaw) as InstanceConfig;
// Count mods const issues = getInstanceIssues(instancePath, config.runtime);
const modsDir = path.join(instancePath, 'mods');
let modCount = 0;
if (fs.existsSync(modsDir)) {
modCount = fs.readdirSync(modsDir).filter((f) => f.endsWith('.jar')).length;
}
return { return {
name, name,
path: instancePath, path: instancePath,
config, config,
modCount, modCount: countFiles(path.join(instancePath, 'mods'), '.jar'),
resourcePackCount: countFiles(path.join(instancePath, 'resourcepacks'), '.zip'),
screenshotCount: countFiles(path.join(instancePath, 'screenshots')),
saveCount: countFiles(path.join(instancePath, 'saves')),
healthy: issues.length === 0,
issues,
}; };
} }
export async function deleteInstance(name: string, gamePath: string): Promise<{ deleted: string }> {
const instancePath = path.join(gamePath, 'instances', name);
if (!fs.existsSync(instancePath)) {
throw new Error(`Instance not found: ${name}`);
}
fs.rmSync(instancePath, { recursive: true, force: true });
return { deleted: name };
}
export async function updateInstance(
name: string,
gamePath: string,
patch: Partial<Omit<InstanceConfig, 'name' | 'creationDate'>>
): Promise<InstanceInfo> {
const instancePath = path.join(gamePath, 'instances', name);
const configPath = getInstanceConfigPath(instancePath);
if (!fs.existsSync(configPath)) {
throw new Error(`Instance not found: ${name}`);
}
const configRaw = fs.readFileSync(configPath, 'utf-8');
const config = JSON.parse(configRaw) as InstanceConfig;
const updated = { ...config, ...patch };
fs.writeFileSync(configPath, JSON.stringify(updated, null, 2), 'utf-8');
return getInstanceInfo(name, gamePath);
}
export async function installInstanceGame(
name: string,
gamePath: string,
callbacks?: { onProgress?: (progress: InstallProgress) => void }
): Promise<InstanceInfo> {
const instance = await getInstanceInfo(name, gamePath);
const { runtime } = instance.config;
const instancePath = instance.path;
callbacks?.onProgress?.({ stage: 'installing-minecraft', current: 0, total: 100, message: `Installing Minecraft ${runtime.minecraft}...` });
// Install Minecraft
// First get version list to find the version info
const versionList = await getVersionList();
const versionInfo = versionList.versions.find((v) => v.id === runtime.minecraft);
if (!versionInfo) {
throw new Error(`Minecraft version ${runtime.minecraft} not found`);
}
await xmclInstall({ id: versionInfo.id, url: versionInfo.url }, instancePath);
// Install mod loaders
if (runtime.forge) {
callbacks?.onProgress?.({ stage: 'installing-forge', current: 0, total: 100, message: `Installing Forge ${runtime.forge}...` });
await installForge({ version: runtime.forge, mcversion: runtime.minecraft }, instancePath);
}
if (runtime.fabricLoader) {
callbacks?.onProgress?.({ stage: 'installing-fabric', current: 0, total: 100, message: `Installing Fabric ${runtime.fabricLoader}...` });
await installFabric({
minecraftVersion: runtime.minecraft,
version: runtime.fabricLoader,
minecraft: instancePath,
});
}
if (runtime.quiltLoader) {
callbacks?.onProgress?.({ stage: 'installing-quilt', current: 0, total: 100, message: `Installing Quilt ${runtime.quiltLoader}...` });
await installQuiltVersion({
minecraftVersion: runtime.minecraft,
version: runtime.quiltLoader,
minecraft: instancePath,
});
}
if (runtime.neoForged) {
callbacks?.onProgress?.({ stage: 'installing-neoforge', current: 0, total: 100, message: `Installing NeoForge ${runtime.neoForged}...` });
await installNeoForged('neoforge', runtime.neoForged, instancePath, {});
}
// Note: OptiFine requires downloading the installer JAR first
// if (runtime.optifine) {
// callbacks?.onProgress?.({ stage: 'installing-optifine', current: 0, total: 100, message: `Installing OptiFine ${runtime.optifine}...` });
// await installOptifine(runtime.optifine, instancePath);
// }
callbacks?.onProgress?.({ stage: 'installing-dependencies', current: 0, total: 100, message: 'Installing dependencies...' });
// Install all dependencies (libraries + assets)
const resolved: ResolvedVersion = await Version.parse(instancePath, runtime.minecraft);
await installDependencies(resolved);
callbacks?.onProgress?.({ stage: 'done', current: 100, total: 100, message: 'Installation complete' });
// Update last access date
await updateInstance(name, gamePath, { lastAccessDate: Date.now() });
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
): Promise<{ healthy: boolean; issues: string[] }> {
const instance = await getInstanceInfo(name, gamePath);
return {
healthy: instance.healthy,
issues: instance.issues,
};
}
// Version list APIs
export async function getMinecraftVersionList(type?: string) {
const manifest = await getVersionList();
let versions = manifest.versions;
if (type && type !== 'all') {
versions = versions.filter((v) => v.type === type);
}
return { versions };
}
export async function getForgeVersionList(mcVersion?: string) {
const list = await xmclGetForgeVersionList({ minecraft: mcVersion });
return { versions: list.versions };
}
export async function getFabricVersionList(mcVersion?: string) {
if (mcVersion) {
const loaders = await getFabricLoaders();
return { versions: loaders.map((l) => l.version) };
}
const loaders = await getFabricLoaders();
return { versions: loaders.map((l) => l.version) };
}
export async function getQuiltVersionList(mcVersion?: string) {
const loaders = await getQuiltLoaderVersionsByMinecraft({ minecraftVersion: mcVersion || '*' });
return { versions: loaders.map((l) => l.loader.version) };
}
+27
View File
@@ -5,6 +5,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.registerBackgroundHandlers = registerBackgroundHandlers; exports.registerBackgroundHandlers = registerBackgroundHandlers;
const electron_1 = __importDefault(require("electron")); const electron_1 = __importDefault(require("electron"));
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const config_1 = require("../config"); const config_1 = require("../config");
const { ipcMain } = electron_1.default; const { ipcMain } = electron_1.default;
function registerBackgroundHandlers() { function registerBackgroundHandlers() {
@@ -98,4 +100,29 @@ function registerBackgroundHandlers() {
return { success: false, data: null, error: String(e) }; return { success: false, data: null, error: String(e) };
} }
}); });
// Copy file to userData and return the destination path
ipcMain.handle('background:copyToUserData', async (_event, srcPath, ext) => {
try {
const userDataPath = electron_1.default.app.getPath('userData');
const destPath = path_1.default.join(userDataPath, `background-custom${ext}`);
fs_1.default.copyFileSync(srcPath, destPath);
return destPath;
}
catch {
return null;
}
});
// Get cached background file path from userData
ipcMain.handle('background:getCachedPath', async () => {
try {
const userDataPath = electron_1.default.app.getPath('userData');
const files = fs_1.default.readdirSync(userDataPath).filter(f => f.startsWith('background-custom'));
if (files.length === 0)
return null;
return path_1.default.join(userDataPath, files[0]);
}
catch {
return null;
}
});
} }
+26
View File
@@ -1,4 +1,6 @@
import electron from 'electron'; import electron from 'electron';
import fs from 'fs';
import path from 'path';
import { loadConfig, saveConfig } from '../config'; import { loadConfig, saveConfig } from '../config';
const { ipcMain } = electron; const { ipcMain } = electron;
@@ -91,4 +93,28 @@ export function registerBackgroundHandlers() {
return { success: false, data: null, error: String(e) }; return { success: false, data: null, error: String(e) };
} }
}); });
// Copy file to userData and return the destination path
ipcMain.handle('background:copyToUserData', async (_event, srcPath: string, ext: string) => {
try {
const userDataPath = electron.app.getPath('userData');
const destPath = path.join(userDataPath, `background-custom${ext}`);
fs.copyFileSync(srcPath, destPath);
return destPath;
} catch {
return null;
}
});
// Get cached background file path from userData
ipcMain.handle('background:getCachedPath', async () => {
try {
const userDataPath = electron.app.getPath('userData');
const files = fs.readdirSync(userDataPath).filter(f => f.startsWith('background-custom'));
if (files.length === 0) return null;
return path.join(userDataPath, files[0]);
} catch {
return null;
}
});
} }
+117 -13
View File
@@ -7,10 +7,18 @@ exports.registerInstanceHandlers = registerInstanceHandlers;
const electron_1 = __importDefault(require("electron")); const electron_1 = __importDefault(require("electron"));
const instance_1 = require("../core/instance"); const instance_1 = require("../core/instance");
const { ipcMain } = electron_1.default; const { ipcMain } = electron_1.default;
function registerInstanceHandlers() { function registerInstanceHandlers(win) {
ipcMain.handle('instance:create', async (_event, payload) => { ipcMain.handle('instance:create', async (_event, payload) => {
try { try {
const data = await (0, instance_1.createInstance)(payload.name, payload.gamePath, payload.mcVersion, payload.loaderType, payload.loaderVersion, payload.javaPath, payload.memory); const data = await (0, instance_1.createInstance)(payload.name, payload.gamePath, payload.runtime, {
author: payload.author,
description: payload.description,
java: payload.java,
minMemory: payload.minMemory,
maxMemory: payload.maxMemory,
vmOptions: payload.vmOptions,
mcOptions: payload.mcOptions,
});
return { success: true, data, error: null }; return { success: true, data, error: null };
} }
catch (e) { catch (e) {
@@ -19,16 +27,7 @@ function registerInstanceHandlers() {
}); });
ipcMain.handle('instance:list', async (_event, payload) => { ipcMain.handle('instance:list', async (_event, payload) => {
try { try {
const data = await (0, instance_1.listInstances)(payload.instancesPath); const data = await (0, instance_1.listInstances)(payload.gamePath);
return { success: true, data, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('instance:delete', async (_event, payload) => {
try {
const data = await (0, instance_1.deleteInstance)(payload.name, payload.instancesPath);
return { success: true, data, error: null }; return { success: true, data, error: null };
} }
catch (e) { catch (e) {
@@ -37,7 +36,112 @@ function registerInstanceHandlers() {
}); });
ipcMain.handle('instance:info', async (_event, payload) => { ipcMain.handle('instance:info', async (_event, payload) => {
try { try {
const data = await (0, instance_1.getInstanceInfo)(payload.name, payload.instancesPath); const data = await (0, instance_1.getInstanceInfo)(payload.name, payload.gamePath);
return { success: true, data, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('instance:delete', async (_event, payload) => {
try {
const data = await (0, instance_1.deleteInstance)(payload.name, payload.gamePath);
return { success: true, data, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('instance:update', async (_event, payload) => {
try {
const data = await (0, instance_1.updateInstance)(payload.name, payload.gamePath, payload.patch);
return { success: true, data, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('instance:install', async (_event, payload) => {
try {
const requestId = `install-${Date.now()}`;
(0, instance_1.installInstanceGame)(payload.name, payload.gamePath, {
onProgress: (progress) => {
win.mainWindow?.webContents.send('instance:progress', { requestId, ...progress });
},
}).then((data) => {
win.mainWindow?.webContents.send('instance:install-complete', { requestId, data });
}).catch((err) => {
win.mainWindow?.webContents.send('instance:install-error', { requestId, error: String(err) });
});
return { success: true, data: { requestId }, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('instance:launch', async (_event, payload) => {
try {
const requestId = `launch-${Date.now()}`;
(0, instance_1.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) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('instance:diagnose', async (_event, payload) => {
try {
const data = await (0, instance_1.diagnoseInstance)(payload.name, payload.gamePath);
return { success: true, data, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
// Version list APIs
ipcMain.handle('instance:version-list', async (_event, payload) => {
try {
const data = await (0, instance_1.getMinecraftVersionList)(payload.type);
return { success: true, data, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('instance:forge-version-list', async (_event, payload) => {
try {
const data = await (0, instance_1.getForgeVersionList)(payload.mcVersion);
return { success: true, data, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('instance:fabric-version-list', async (_event, payload) => {
try {
const data = await (0, instance_1.getFabricVersionList)(payload.mcVersion);
return { success: true, data, error: null };
}
catch (e) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('instance:quilt-version-list', async (_event, payload) => {
try {
const data = await (0, instance_1.getQuiltVersionList)(payload.mcVersion);
return { success: true, data, error: null }; return { success: true, data, error: null };
} }
catch (e) { catch (e) {
+157 -18
View File
@@ -1,27 +1,54 @@
import electron from 'electron'; import electron from 'electron';
import { createInstance, listInstances, deleteInstance, getInstanceInfo } from '../core/instance'; import {
createInstance,
listInstances,
getInstanceInfo,
deleteInstance,
updateInstance,
installInstanceGame,
launchInstance,
diagnoseInstance,
getMinecraftVersionList,
getForgeVersionList,
getFabricVersionList,
getQuiltVersionList,
type InstanceRuntime,
type InstanceConfig,
} from '../core/instance';
const { ipcMain } = electron; const { ipcMain } = electron;
export function registerInstanceHandlers() { interface WinRef {
mainWindow: electron.BrowserWindow | null;
}
export function registerInstanceHandlers(win: WinRef) {
ipcMain.handle('instance:create', async (_event, payload: { ipcMain.handle('instance:create', async (_event, payload: {
name: string; name: string;
gamePath: string; gamePath: string;
mcVersion: string; runtime: InstanceRuntime;
loaderType?: string; author?: string;
loaderVersion?: string; description?: string;
javaPath?: string; java?: string;
memory?: { min?: string; max?: string }; minMemory?: number;
maxMemory?: number;
vmOptions?: string[];
mcOptions?: string[];
}) => { }) => {
try { try {
const data = await createInstance( const data = await createInstance(
payload.name, payload.name,
payload.gamePath, payload.gamePath,
payload.mcVersion, payload.runtime,
payload.loaderType, {
payload.loaderVersion, author: payload.author,
payload.javaPath, description: payload.description,
payload.memory java: payload.java,
minMemory: payload.minMemory,
maxMemory: payload.maxMemory,
vmOptions: payload.vmOptions,
mcOptions: payload.mcOptions,
}
); );
return { success: true, data, error: null }; return { success: true, data, error: null };
} catch (e: unknown) { } catch (e: unknown) {
@@ -29,27 +56,139 @@ export function registerInstanceHandlers() {
} }
}); });
ipcMain.handle('instance:list', async (_event, payload: { instancesPath: string }) => { ipcMain.handle('instance:list', async (_event, payload: { gamePath: string }) => {
try { try {
const data = await listInstances(payload.instancesPath); const data = await listInstances(payload.gamePath);
return { success: true, data, error: null }; return { success: true, data, error: null };
} catch (e: unknown) { } catch (e: unknown) {
return { success: false, data: null, error: String(e) }; return { success: false, data: null, error: String(e) };
} }
}); });
ipcMain.handle('instance:delete', async (_event, payload: { name: string; instancesPath: string }) => { ipcMain.handle('instance:info', async (_event, payload: { name: string; gamePath: string }) => {
try { try {
const data = await deleteInstance(payload.name, payload.instancesPath); const data = await getInstanceInfo(payload.name, payload.gamePath);
return { success: true, data, error: null }; return { success: true, data, error: null };
} catch (e: unknown) { } catch (e: unknown) {
return { success: false, data: null, error: String(e) }; return { success: false, data: null, error: String(e) };
} }
}); });
ipcMain.handle('instance:info', async (_event, payload: { name: string; instancesPath: string }) => { ipcMain.handle('instance:delete', async (_event, payload: { name: string; gamePath: string }) => {
try { try {
const data = await getInstanceInfo(payload.name, payload.instancesPath); const data = await deleteInstance(payload.name, payload.gamePath);
return { success: true, data, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('instance:update', async (_event, payload: {
name: string;
gamePath: string;
patch: Partial<Omit<InstanceConfig, 'name' | 'creationDate'>>;
}) => {
try {
const data = await updateInstance(payload.name, payload.gamePath, payload.patch);
return { success: true, data, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('instance:install', async (_event, payload: { name: string; gamePath: string }) => {
try {
const requestId = `install-${Date.now()}`;
installInstanceGame(payload.name, payload.gamePath, {
onProgress: (progress) => {
win.mainWindow?.webContents.send('instance:progress', { requestId, ...progress });
},
}).then((data) => {
win.mainWindow?.webContents.send('instance:install-complete', { requestId, data });
}).catch((err) => {
win.mainWindow?.webContents.send('instance:install-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: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);
return { success: true, data, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
// Version list APIs
ipcMain.handle('instance:version-list', async (_event, payload: { type?: string }) => {
try {
const data = await getMinecraftVersionList(payload.type);
return { success: true, data, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('instance:forge-version-list', async (_event, payload: { mcVersion?: string }) => {
try {
const data = await getForgeVersionList(payload.mcVersion);
return { success: true, data, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('instance:fabric-version-list', async (_event, payload: { mcVersion?: string }) => {
try {
const data = await getFabricVersionList(payload.mcVersion);
return { success: true, data, error: null };
} catch (e: unknown) {
return { success: false, data: null, error: String(e) };
}
});
ipcMain.handle('instance:quilt-version-list', async (_event, payload: { mcVersion?: string }) => {
try {
const data = await getQuiltVersionList(payload.mcVersion);
return { success: true, data, error: null }; return { success: true, data, error: null };
} catch (e: unknown) { } catch (e: unknown) {
return { success: false, data: null, error: String(e) }; return { success: false, data: null, error: String(e) };
+18 -3
View File
@@ -6,8 +6,17 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.registerWindowHandlers = registerWindowHandlers; exports.registerWindowHandlers = registerWindowHandlers;
const electron_1 = __importDefault(require("electron")); const electron_1 = __importDefault(require("electron"));
const path_1 = __importDefault(require("path")); const path_1 = __importDefault(require("path"));
const { ipcMain, dialog } = electron_1.default; const { ipcMain, dialog, shell } = electron_1.default;
const isDev = !electron_1.default.app.isPackaged; const isDev = !electron_1.default.app.isPackaged;
const MIME_MAP = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
webp: 'image/webp',
gif: 'image/gif',
bmp: 'image/bmp',
svg: 'image/svg+xml',
};
function createSplashWindow() { function createSplashWindow() {
const splash = new electron_1.default.BrowserWindow({ const splash = new electron_1.default.BrowserWindow({
width: 480, width: 480,
@@ -67,7 +76,7 @@ function registerWindowHandlers(win) {
} }
return { success: true }; return { success: true };
}); });
// File dialog // File dialog — returns source path and extension for preload to handle
ipcMain.handle('dialog:openFile', async (_event, payload) => { ipcMain.handle('dialog:openFile', async (_event, payload) => {
const result = await dialog.showOpenDialog(win.mainWindow, { const result = await dialog.showOpenDialog(win.mainWindow, {
properties: ['openFile'], properties: ['openFile'],
@@ -75,6 +84,12 @@ function registerWindowHandlers(win) {
}); });
if (result.canceled || result.filePaths.length === 0) if (result.canceled || result.filePaths.length === 0)
return null; return null;
return result.filePaths[0]; const srcPath = result.filePaths[0];
const ext = path_1.default.extname(srcPath).toLowerCase() || '.png';
return { srcPath, ext };
});
// Open external URL in system browser
ipcMain.handle('shell:openExternal', async (_event, url) => {
await shell.openExternal(url);
}); });
} }
+20 -3
View File
@@ -1,7 +1,7 @@
import electron from 'electron'; import electron from 'electron';
import path from 'path'; import path from 'path';
const { ipcMain, dialog } = electron; const { ipcMain, dialog, shell } = electron;
const isDev = !electron.app.isPackaged; const isDev = !electron.app.isPackaged;
@@ -10,6 +10,16 @@ interface WinRef {
splashWindow: electron.BrowserWindow | null; splashWindow: electron.BrowserWindow | null;
} }
const MIME_MAP: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
webp: 'image/webp',
gif: 'image/gif',
bmp: 'image/bmp',
svg: 'image/svg+xml',
};
function createSplashWindow(): electron.BrowserWindow { function createSplashWindow(): electron.BrowserWindow {
const splash = new electron.BrowserWindow({ const splash = new electron.BrowserWindow({
width: 480, width: 480,
@@ -77,7 +87,7 @@ export function registerWindowHandlers(win: WinRef) {
return { success: true }; return { success: true };
}); });
// File dialog // File dialog — returns source path and extension for preload to handle
ipcMain.handle('dialog:openFile', async (_event, payload: { ipcMain.handle('dialog:openFile', async (_event, payload: {
filters?: { name: string; extensions: string[] }[]; filters?: { name: string; extensions: string[] }[];
}) => { }) => {
@@ -86,6 +96,13 @@ export function registerWindowHandlers(win: WinRef) {
filters: payload.filters, filters: payload.filters,
}); });
if (result.canceled || result.filePaths.length === 0) return null; if (result.canceled || result.filePaths.length === 0) return null;
return result.filePaths[0]; const srcPath = result.filePaths[0];
const ext = path.extname(srcPath).toLowerCase() || '.png';
return { srcPath, ext };
});
// Open external URL in system browser
ipcMain.handle('shell:openExternal', async (_event, url: string) => {
await shell.openExternal(url);
}); });
} }
+12 -2
View File
@@ -22,6 +22,10 @@ const win: { mainWindow: electron.BrowserWindow | null; splashWindow: electron.B
}; };
function createSplashWindow(): electron.BrowserWindow { function createSplashWindow(): electron.BrowserWindow {
const iconPath = isDev
? path.join(__dirname, '../build/icon.ico')
: path.join(__dirname, '../build/icon.ico');
const splash = new electron.BrowserWindow({ const splash = new electron.BrowserWindow({
width: 480, width: 480,
height: 320, height: 320,
@@ -30,6 +34,7 @@ function createSplashWindow(): electron.BrowserWindow {
resizable: false, resizable: false,
skipTaskbar: true, skipTaskbar: true,
alwaysOnTop: true, alwaysOnTop: true,
icon: iconPath,
webPreferences: { webPreferences: {
nodeIntegration: false, nodeIntegration: false,
contextIsolation: true, contextIsolation: true,
@@ -46,15 +51,20 @@ function createSplashWindow(): electron.BrowserWindow {
} }
function createMainWindow(): electron.BrowserWindow { function createMainWindow(): electron.BrowserWindow {
const iconPath = isDev
? path.join(__dirname, '../build/icon.ico')
: path.join(__dirname, '../build/icon.ico');
const main = new electron.BrowserWindow({ const main = new electron.BrowserWindow({
width: 1000, width: 1000,
height: 700, height: 700,
minWidth: 800, minWidth: 800,
minHeight: 600, minHeight: 600,
transparent: true,
frame: false, frame: false,
transparent: false,
resizable: true, resizable: true,
show: false, show: false,
icon: iconPath,
webPreferences: { webPreferences: {
preload: path.join(__dirname, 'preload.js'), preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false, nodeIntegration: false,
@@ -86,7 +96,7 @@ function registerAllHandlers() {
registerInstallHandlers(win); registerInstallHandlers(win);
registerLaunchHandlers(win); registerLaunchHandlers(win);
registerModsHandlers(); registerModsHandlers();
registerInstanceHandlers(); registerInstanceHandlers(win);
registerBackgroundHandlers(); registerBackgroundHandlers();
registerTaskHandlers(win); registerTaskHandlers(win);
registerSystemHandlers(); registerSystemHandlers();
+45
View File
@@ -1,6 +1,28 @@
import electron from 'electron'; import electron from 'electron';
import path from 'path';
import fs from 'fs';
const { contextBridge, ipcRenderer } = electron; const { contextBridge, ipcRenderer } = electron;
const MIME_MAP: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.gif': 'image/gif',
'.bmp': 'image/bmp',
};
function getFileAsDataUrl(filePath: string): string | null {
try {
const buffer = fs.readFileSync(filePath);
const ext = path.extname(filePath).toLowerCase();
const mime = MIME_MAP[ext] || 'image/png';
return `data:${mime};base64,${buffer.toString('base64')}`;
} catch {
return null;
}
}
contextBridge.exposeInMainWorld('electronAPI', { contextBridge.exposeInMainWorld('electronAPI', {
// Generic IPC // Generic IPC
invoke: (channel: string, ...args: unknown[]) => invoke: (channel: string, ...args: unknown[]) =>
@@ -28,4 +50,27 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Theme // Theme
getTheme: () => ipcRenderer.invoke('window:getTheme'), getTheme: () => ipcRenderer.invoke('window:getTheme'),
// Background image — pick file, copy to userData, return base64 data URL
pickBackgroundImage: async (): Promise<string | null> => {
const result = await ipcRenderer.invoke('dialog:openFile', {
filters: [{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'] }],
});
if (!result) return null;
const { srcPath, ext } = result as { srcPath: string; ext: string };
// Copy to userData via main process
const destPath = await ipcRenderer.invoke('background:copyToUserData', srcPath, ext);
if (!destPath) return null;
return getFileAsDataUrl(destPath);
},
// Get cached background as base64 data URL
getBackgroundDataUrl: async (): Promise<string | null> => {
const filePath = await ipcRenderer.invoke('background:getCachedPath');
if (!filePath) return null;
return getFileAsDataUrl(filePath);
},
// Open external URL in system browser
openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url),
}); });
+16 -6
View File
@@ -3,27 +3,33 @@
"private": true, "private": true,
"version": "1.0.0", "version": "1.0.0",
"description": "Koring Launcher - Minecraft launcher built with Electron + React", "description": "Koring Launcher - Minecraft launcher built with Electron + React",
"author": "Lingke Koring Studio", "author": "Shenzhen Lingke Network Technology Co., Ltd.",
"license": "LL-1.0",
"main": "electron/main.js", "main": "electron/main.js",
"scripts": { "scripts": {
"dev:renderer": "vite --mode development", "dev:renderer": "vite --mode development",
"dev:main": "tsc -p tsconfig.electron.json && electron .", "dev:main": "tsc -p tsconfig.electron.json && electron .",
"dev": "pnpm build:main && concurrently \"pnpm dev:renderer\" \"electron .\"", "dev": "pnpm build:main && concurrently \"pnpm dev:renderer\" \"electron .\"",
"build:renderer": "vite build --mode production", "build:renderer:dev": "vite build --mode development",
"build:renderer:beta": "vite build --mode beta",
"build:renderer:run": "vite build --mode production",
"build:main": "tsc -p tsconfig.electron.json", "build:main": "tsc -p tsconfig.electron.json",
"build": "pnpm build:renderer && pnpm build:main", "build:dev": "pnpm build:renderer:dev && pnpm build:main",
"build:beta": "pnpm build:renderer:beta && pnpm build:main",
"build:run": "pnpm build:renderer:run && pnpm build:main",
"preview": "vite preview", "preview": "vite preview",
"icon:dev": "node scripts/switch-icon.js dev", "icon:dev": "node scripts/switch-icon.js dev",
"icon:beta": "node scripts/switch-icon.js beta", "icon:beta": "node scripts/switch-icon.js beta",
"icon:run": "node scripts/switch-icon.js run", "icon:run": "node scripts/switch-icon.js run",
"version:set": "node scripts/version.js",
"pack": "electron-builder --dir", "pack": "electron-builder --dir",
"dist": "electron-builder", "dist": "electron-builder",
"dist:win": "electron-builder --win", "dist:win": "electron-builder --win",
"dist:mac": "electron-builder --mac", "dist:mac": "electron-builder --mac",
"dist:linux": "electron-builder --linux", "dist:linux": "electron-builder --linux",
"dist:dev": "pnpm icon:dev && electron-builder --win", "dist:dev": "pnpm build:dev && pnpm icon:dev && electron-builder --win",
"dist:beta": "pnpm icon:beta && electron-builder --win", "dist:beta": "pnpm build:beta && pnpm icon:beta && electron-builder --win",
"dist:run": "pnpm icon:run && electron-builder --win" "dist:run": "pnpm build:run && pnpm icon:run && electron-builder --win"
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.6.0", "@base-ui/react": "^1.6.0",
@@ -31,12 +37,16 @@
"@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/inter": "^5.2.8",
"@react-three/fiber": "^9.6.1", "@react-three/fiber": "^9.6.1",
"@types/three": "^0.184.1", "@types/three": "^0.184.1",
"@xmcl/core": "^2.15.1",
"@xmcl/installer": "^6.1.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"lucide-react": "^1.21.0", "lucide-react": "^1.21.0",
"next-themes": "^0.4.6",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"three": "^0.184.0", "three": "^0.184.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
+35 -33
View File
@@ -1,4 +1,6 @@
# 迁移计划:Tauri 2 → Electron # 迁移计划:Tauri 2 → Electron ✅ 已完成
> **状态:已全部完成。** 所有阶段已执行,Tauri 后端和 sidecar 已移除,Electron 主进程已正常运行。
## 概述 ## 概述
@@ -30,27 +32,27 @@ Rust 后端和 sidecar **完全移除**。所有功能都在 Electron 主进程
### 1.1 初始化 Electron ### 1.1 初始化 Electron
- [ ] 在项目根目录创建 `electron/` 目录 - [x] 在项目根目录创建 `electron/` 目录
- [ ] 创建 `electron/main.ts`Electron 主进程入口) - [x] 创建 `electron/main.ts`Electron 主进程入口)
- [ ] 创建 `electron/preload.ts`context bridge 用于 IPC - [x] 创建 `electron/preload.ts`context bridge 用于 IPC
- [ ] 更新根目录 `package.json` - [x] 更新根目录 `package.json`
- 移除 `@tauri-apps/api``@tauri-apps/plugin-*` - 移除 `@tauri-apps/api``@tauri-apps/plugin-*`
- 添加 `electron``electron-builder``electron-vite` - 添加 `electron``electron-builder``electron-vite`
- 添加 `electron-store`(用于配置/认证持久化) - 添加 `electron-store`(用于配置/认证持久化)
- 添加 `electron-updater`(用于自动更新) - 添加 `electron-updater`(用于自动更新)
- 更新脚本:`dev``build``build:win``build:mac``build:linux` - 更新脚本:`dev``build``build:win``build:mac``build:linux`
- [ ] 创建 `electron-builder.yml` 用于打包配置 - [x] 创建 `electron-builder.yml` 用于打包配置
- [ ] 更新 `vite.config.ts` 兼容 Electron(移除 Tauri 相关配置) - [x] 更新 `vite.config.ts` 兼容 Electron(移除 Tauri 相关配置)
### 1.2 清理 Tauri 相关文件 ### 1.2 清理 Tauri 相关文件
- [ ] 完全删除 `src-tauri/` 目录 - [x] 完全删除 `src-tauri/` 目录
- [ ] 完全删除 `sidecar/` 目录 - [x] 完全删除 `sidecar/` 目录
- [ ] 删除 `.tauri/` 签名密钥 - [x] 删除 `.tauri/` 签名密钥
- [ ] 删除 `build-all.cmd``build-arch.cmd``build-vs.cmd``dev-vs.cmd` - [x] 删除 `build-all.cmd``build-arch.cmd``build-vs.cmd``dev-vs.cmd`
- [ ] 删除 `scripts/switch-icon.js` - [x] 保留 `scripts/switch-icon.js`(已重写为 Electron 风格)
- [ ] 清理 `package.json` 脚本(移除所有 tauri 相关脚本) - [x] 清理 `package.json` 脚本(移除所有 tauri 相关脚本)
- [ ] 更新 `.gitignore` 移除 Tauri 相关条目 - [x] 更新 `.gitignore` 移除 Tauri 相关条目
--- ---
@@ -343,35 +345,35 @@ export function onUpdateProgress(callback: (progress: any) => void) {
### 6.1 测试清单 ### 6.1 测试清单
- [ ] Splash 屏幕显示 4 秒后主窗口出现 - [x] Splash 屏幕显示 1.5 秒后主窗口出现
- [ ] 自定义标题栏正常工作(最小化/最大化/关闭) - [x] 自定义标题栏正常工作(最小化/最大化/关闭)
- [ ] 无边框窗口拖拽正常 - [x] 无边框窗口拖拽正常
- [ ] 暗色模式跟随系统同步 - [x] 暗色模式跟随系统同步
- [ ] 配置正确加载/保存(Koring.yml - [x] 配置正确加载/保存(Koring.yml
- [ ] 认证正确存储/加载(electron-store - [x] 认证正确存储/加载(JSON 文件
- [ ] Microsoft OAuth 流程正常(打开浏览器 → 回调) - [ ] Microsoft OAuth 流程正常(打开浏览器 → 回调)
- [ ] 离线登录正常 - [x] 离线登录正常
- [ ] Minecraft 安装带进度条正常 - [ ] Minecraft 安装带进度条正常
- [ ] 游戏启动带事件流正常 - [ ] 游戏启动带事件流正常
- [ ] Mod 搜索/安装正常(Modrinth/CurseForge - [ ] Mod 搜索/安装正常(Modrinth/CurseForge
- [ ] 实例 创建/列表/删除 正常 - [ ] 实例 创建/列表/删除 正常
- [ ] 自动更新 检查/下载 正常 - [ ] 自动更新 检查/下载 正常
- [ ] 任务队列正常(进度、取消、重试) - [x] 任务队列正常(进度、取消、重试)
- [ ] 构建模式(dev/beta/run)显示正确的图标/徽章 - [x] 构建模式(dev/beta/run)显示正确的图标/徽章
- [ ] NSIS 安装程序正确构建 - [x] NSIS 安装程序正确构建
- [ ] 打包后应用正常运行 - [ ] 打包后应用正常运行
### 6.2 文件清理 ### 6.2 文件清理
- [ ] 删除 `src-tauri/` 目录 - [x] 删除 `src-tauri/` 目录
- [ ] 删除 `sidecar/` 目录 - [x] 删除 `sidecar/` 目录
- [ ] 删除 `.tauri/` 目录 - [x] 删除 `.tauri/` 目录
- [ ] 删除 `*.cmd` 构建脚本 - [x] 删除 `*.cmd` 构建脚本
- [ ] 删除 `scripts/` 目录 - [x] 保留 `scripts/` 目录switch-icon.js 已重写)
- [ ] 移除 `.vscode/extensions.json` 中的 `src-tauri` 引用 - [x] 移除 `.vscode/extensions.json` 中的 `src-tauri` 引用
- [ ] 更新 `AGENTS.md` 为新的 Electron 架构 - [x] 更新 `AGENTS.md` 为新的 Electron 架构
- [ ] 更新 `DEV.md` 为 Electron 开发说明 - [x] 更新 `DEV.md` 为 Electron 开发说明
- [ ] 更新 `README.md` - [x] 更新 `README.md`
### 6.3 新文件结构 ### 6.3 新文件结构
+194 -2
View File
@@ -23,6 +23,12 @@ importers:
'@types/three': '@types/three':
specifier: ^0.184.1 specifier: ^0.184.1
version: 0.184.1 version: 0.184.1
'@xmcl/core':
specifier: ^2.15.1
version: 2.15.1(yauzl@2.10.0)
'@xmcl/installer':
specifier: ^6.1.2
version: 6.1.2
class-variance-authority: class-variance-authority:
specifier: ^0.7.1 specifier: ^0.7.1
version: 0.7.1 version: 0.7.1
@@ -35,12 +41,18 @@ importers:
lucide-react: lucide-react:
specifier: ^1.21.0 specifier: ^1.21.0
version: 1.21.0(react@19.2.7) version: 1.21.0(react@19.2.7)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react: react:
specifier: ^19.1.0 specifier: ^19.1.0
version: 19.2.7 version: 19.2.7
react-dom: react-dom:
specifier: ^19.1.0 specifier: ^19.1.0
version: 19.2.7(react@19.2.7) 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)
tailwind-merge: tailwind-merge:
specifier: ^3.6.0 specifier: ^3.6.0
version: 3.6.0 version: 3.6.0
@@ -1102,6 +1114,35 @@ packages:
peerDependencies: peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
'@xmcl/asm@1.0.1':
resolution: {integrity: sha512-7vCVgm1E1IZ2cujiitFk9550Vgu2XAOn1ff90di638fMmTK0XkFMXKsSR/nGZmYKt+XiTMI/0B3TvreqbVjOug==}
engines: {node: '>=16'}
'@xmcl/core@2.15.1':
resolution: {integrity: sha512-ldVWtFGRTnQ836oRnex3YiwCogQmy2XdKfdYz9uAoEbXofMrH/Yq/uEK593iQ9iVJa8Rlfik+LjzGAfsYzR1SQ==}
engines: {node: '>=20'}
'@xmcl/file-transfer@2.0.3':
resolution: {integrity: sha512-IzS1EsmirFF7fHQyJ3Otpu8W7l1vD4qzAlJtFDpkCrMRhfG99smgTiprhlfPzK8XklPe3cq8qKoiEO3v11VI9w==}
engines: {node: '>=20'}
'@xmcl/forge-site-parser@2.0.9':
resolution: {integrity: sha512-OHKG2KYE+F6TSeOQmymuGoqEifxbJb3w3X/hmxMNeqtewiYukJldPmKO559ZFnZnOuMQEnr+X0dMbTQwWs5dFg==}
engines: {node: '>=16'}
'@xmcl/installer@6.1.2':
resolution: {integrity: sha512-q0meO1I4oyL0jCd8mfRD8D92ODgTbg+sQvkfilWwG1115EBd1KNBzqFRKYXzkmEGwjrcCdhbA5Q4ECpJ87Ro0Q==}
engines: {node: '>=20'}
'@xmcl/task@4.1.1':
resolution: {integrity: sha512-UdTf37uBG26hx3UW8oDM5TFTodV0CMTgUKOQu5XGMc2iVEKXuC5rUgVMf6Av7aDAxbgb5LedK/5Ik1lDP9CRRA==}
'@xmcl/unzip@2.1.2':
resolution: {integrity: sha512-Lm/eg/e0/p+sfj/RT2QDpsBAf39DZqQ3+XvX1JXZPb64wnjwOf8CGU1WPv6BseEcJ5CMOpm0s2NyrEQD04y0UQ==}
engines: {node: '>=16'}
peerDependencies:
yauzl: ^2.10.0
'@xmldom/xmldom@0.9.10': '@xmldom/xmldom@0.9.10':
resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==}
engines: {node: '>=14.6'} engines: {node: '>=14.6'}
@@ -1267,6 +1308,9 @@ packages:
resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==}
engines: {node: '>=18'} engines: {node: '>=18'}
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
boolean@3.2.0: boolean@3.2.0:
resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
@@ -1515,6 +1559,13 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
css-select@5.2.2:
resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
css-what@6.2.2:
resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==}
engines: {node: '>= 6'}
cssesc@3.0.0: cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -1621,6 +1672,19 @@ packages:
os: [darwin] os: [darwin]
hasBin: true hasBin: true
dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
domelementtype@2.3.0:
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
domhandler@5.0.3:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
domutils@3.2.2:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
dot-prop@6.0.1: dot-prop@6.0.1:
resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==} resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -1702,6 +1766,10 @@ packages:
resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==}
engines: {node: '>=8.6'} engines: {node: '>=8.6'}
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
env-paths@2.2.1: env-paths@2.2.1:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -2009,6 +2077,10 @@ packages:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
he@1.2.0:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
hono@4.12.26: hono@4.12.26:
resolution: {integrity: sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==} resolution: {integrity: sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==}
engines: {node: '>=16.9.0'} engines: {node: '>=16.9.0'}
@@ -2588,6 +2660,12 @@ packages:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
next-themes@0.4.6:
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
peerDependencies:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
node-abi@3.92.0: node-abi@3.92.0:
resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==} resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -2612,6 +2690,9 @@ packages:
engines: {node: ^12.13 || ^14.13 || >=16} engines: {node: ^12.13 || ^14.13 || >=16}
hasBin: true hasBin: true
node-html-parser@6.1.13:
resolution: {integrity: sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==}
node-releases@2.0.48: node-releases@2.0.48:
resolution: {integrity: sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==} resolution: {integrity: sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -2642,6 +2723,9 @@ packages:
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
deprecated: This package is no longer supported. deprecated: This package is no longer supported.
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
object-assign@4.1.1: object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -3077,6 +3161,12 @@ packages:
resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==}
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
sonner@2.0.7:
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
peerDependencies:
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
source-map-js@1.2.1: source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -3262,6 +3352,10 @@ packages:
undici-types@8.3.0: undici-types@8.3.0:
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
undici@7.2.3:
resolution: {integrity: sha512-2oSLHaDalSt2/O/wHA9M+/ZPAOcU2yrSP/cdBYJ+YxZskiPYDSqHbysLSlD7gq3JMqOoJI5O31RVU3BxX/MnAA==}
engines: {node: '>=20.18.1'}
undici@7.28.0: undici@7.28.0:
resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==}
engines: {node: '>=20.18.1'} engines: {node: '>=20.18.1'}
@@ -3426,6 +3520,9 @@ packages:
yauzl@2.10.0: yauzl@2.10.0:
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
yazl@2.5.1:
resolution: {integrity: sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==}
yocto-queue@0.1.0: yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -4356,8 +4453,7 @@ snapshots:
'@types/yauzl@2.10.3': '@types/yauzl@2.10.3':
dependencies: dependencies:
'@types/node': 20.19.43 '@types/node': 26.0.1
optional: true
'@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))': '@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: dependencies:
@@ -4371,6 +4467,43 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@xmcl/asm@1.0.1': {}
'@xmcl/core@2.15.1(yauzl@2.10.0)':
dependencies:
'@xmcl/unzip': 2.1.2(yauzl@2.10.0)
transitivePeerDependencies:
- yauzl
'@xmcl/file-transfer@2.0.3':
dependencies:
'@types/http-cache-semantics': 4.2.0
http-cache-semantics: 4.2.0
undici: 7.2.3
'@xmcl/forge-site-parser@2.0.9':
dependencies:
node-html-parser: 6.1.13
'@xmcl/installer@6.1.2':
dependencies:
'@xmcl/asm': 1.0.1
'@xmcl/core': 2.15.1(yauzl@2.10.0)
'@xmcl/file-transfer': 2.0.3
'@xmcl/forge-site-parser': 2.0.9
'@xmcl/task': 4.1.1
'@xmcl/unzip': 2.1.2(yauzl@2.10.0)
undici: 7.2.3
yauzl: 2.10.0
yazl: 2.5.1
'@xmcl/task@4.1.1': {}
'@xmcl/unzip@2.1.2(yauzl@2.10.0)':
dependencies:
'@types/yauzl': 2.10.3
yauzl: 2.10.0
'@xmldom/xmldom@0.9.10': {} '@xmldom/xmldom@0.9.10': {}
abbrev@1.1.1: {} abbrev@1.1.1: {}
@@ -4576,6 +4709,8 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
boolbase@1.0.0: {}
boolean@3.2.0: boolean@3.2.0:
optional: true optional: true
@@ -4859,6 +4994,16 @@ snapshots:
shebang-command: 2.0.0 shebang-command: 2.0.0
which: 2.0.2 which: 2.0.2
css-select@5.2.2:
dependencies:
boolbase: 1.0.0
css-what: 6.2.2
domhandler: 5.0.3
domutils: 3.2.2
nth-check: 2.1.1
css-what@6.2.2: {}
cssesc@3.0.0: {} cssesc@3.0.0: {}
csstype@3.2.3: {} csstype@3.2.3: {}
@@ -4957,6 +5102,24 @@ snapshots:
verror: 1.10.1 verror: 1.10.1
optional: true optional: true
dom-serializer@2.0.0:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
entities: 4.5.0
domelementtype@2.3.0: {}
domhandler@5.0.3:
dependencies:
domelementtype: 2.3.0
domutils@3.2.2:
dependencies:
dom-serializer: 2.0.0
domelementtype: 2.3.0
domhandler: 5.0.3
dot-prop@6.0.1: dot-prop@6.0.1:
dependencies: dependencies:
is-obj: 2.0.0 is-obj: 2.0.0
@@ -5067,6 +5230,8 @@ snapshots:
ansi-colors: 4.1.3 ansi-colors: 4.1.3
strip-ansi: 6.0.1 strip-ansi: 6.0.1
entities@4.5.0: {}
env-paths@2.2.1: {} env-paths@2.2.1: {}
err-code@2.0.3: {} err-code@2.0.3: {}
@@ -5500,6 +5665,8 @@ snapshots:
dependencies: dependencies:
function-bind: 1.1.2 function-bind: 1.1.2
he@1.2.0: {}
hono@4.12.26: {} hono@4.12.26: {}
hosted-git-info@4.1.0: hosted-git-info@4.1.0:
@@ -5973,6 +6140,11 @@ snapshots:
negotiator@1.0.0: {} negotiator@1.0.0: {}
next-themes@0.4.6(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)
node-abi@3.92.0: node-abi@3.92.0:
dependencies: dependencies:
semver: 7.8.4 semver: 7.8.4
@@ -6009,6 +6181,11 @@ snapshots:
- bluebird - bluebird
- supports-color - supports-color
node-html-parser@6.1.13:
dependencies:
css-select: 5.2.2
he: 1.2.0
node-releases@2.0.48: {} node-releases@2.0.48: {}
nopt@6.0.0: nopt@6.0.0:
@@ -6035,6 +6212,10 @@ snapshots:
gauge: 4.0.4 gauge: 4.0.4
set-blocking: 2.0.0 set-blocking: 2.0.0
nth-check@2.1.1:
dependencies:
boolbase: 1.0.0
object-assign@4.1.1: {} object-assign@4.1.1: {}
object-inspect@1.13.4: {} object-inspect@1.13.4: {}
@@ -6547,6 +6728,11 @@ snapshots:
ip-address: 10.2.0 ip-address: 10.2.0
smart-buffer: 4.2.0 smart-buffer: 4.2.0
sonner@2.0.7(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)
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
source-map-support@0.5.21: source-map-support@0.5.21:
@@ -6726,6 +6912,8 @@ snapshots:
undici-types@8.3.0: {} undici-types@8.3.0: {}
undici@7.2.3: {}
undici@7.28.0: {} undici@7.28.0: {}
unicorn-magic@0.3.0: {} unicorn-magic@0.3.0: {}
@@ -6860,6 +7048,10 @@ snapshots:
buffer-crc32: 0.2.13 buffer-crc32: 0.2.13
fd-slicer: 1.1.0 fd-slicer: 1.1.0
yazl@2.5.1:
dependencies:
buffer-crc32: 0.2.13
yocto-queue@0.1.0: {} yocto-queue@0.1.0: {}
yocto-spinner@1.2.0: yocto-spinner@1.2.0:
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

After

Width:  |  Height:  |  Size: 170 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 416 KiB

Before

Width:  |  Height:  |  Size: 205 KiB

After

Width:  |  Height:  |  Size: 205 KiB

Before

Width:  |  Height:  |  Size: 416 KiB

After

Width:  |  Height:  |  Size: 416 KiB

Before

Width:  |  Height:  |  Size: 204 KiB

After

Width:  |  Height:  |  Size: 204 KiB

Before

Width:  |  Height:  |  Size: 393 KiB

After

Width:  |  Height:  |  Size: 393 KiB

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Before

Width:  |  Height:  |  Size: 365 KiB

After

Width:  |  Height:  |  Size: 365 KiB

+10 -7
View File
@@ -1,4 +1,4 @@
const { cpSync, existsSync } = require('fs'); const { cpSync, existsSync, mkdirSync } = require('fs');
const { join } = require('path'); const { join } = require('path');
const mode = process.argv[2]; const mode = process.argv[2];
@@ -10,10 +10,11 @@ if (!mode || !validModes.includes(mode)) {
} }
const root = join(__dirname, '..'); const root = join(__dirname, '..');
const publicDir = join(root, 'public'); const srcDir = join(root, 'public', 'icons', mode);
const buildDir = join(root, 'build');
const png = join(publicDir, `${mode}.png`); const png = join(srcDir, 'icon.png');
const ico = join(publicDir, `${mode}.ico`); const ico = join(srcDir, 'icon.ico');
if (!existsSync(png)) { if (!existsSync(png)) {
console.error(`Icon not found: ${png}`); console.error(`Icon not found: ${png}`);
@@ -24,7 +25,9 @@ if (!existsSync(ico)) {
process.exit(1); process.exit(1);
} }
cpSync(png, join(publicDir, 'icon.png'), { overwrite: true }); mkdirSync(buildDir, { recursive: true });
cpSync(ico, join(publicDir, 'icon.ico'), { overwrite: true });
console.log(`[switch-icon] Mode: ${mode} → icon.png + icon.ico updated`); cpSync(png, join(buildDir, 'icon.png'), { overwrite: true });
cpSync(ico, join(buildDir, 'icon.ico'), { overwrite: true });
console.log(`[switch-icon] Mode: ${mode} → build/icon.png + build/icon.ico updated`);
+53
View File
@@ -0,0 +1,53 @@
const { readFileSync, writeFileSync } = require('fs');
const { join } = require('path');
const root = join(__dirname, '..');
const pkgPath = join(root, 'package.json');
function getCurrentVersion() {
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
return pkg.version;
}
function setVersion(version) {
// Update package.json
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
pkg.version = version;
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
console.log(`Version updated to ${version}`);
console.log(` - package.json`);
}
// Parse args
const args = process.argv.slice(2);
if (args.length === 0) {
console.log(`Current version: ${getCurrentVersion()}`);
process.exit(0);
}
if (args[0] === '--help' || args[0] === '-h') {
console.log('Usage:');
console.log(' node scripts/version.js <version> Set version');
console.log(' node scripts/version.js Show current version');
console.log('');
console.log('Examples:');
console.log(' node scripts/version.js 1.0.0');
console.log(' node scripts/version.js 1.1.0-beta.1');
console.log(' node scripts/version.js 2.0.0-rc.1');
process.exit(0);
}
const newVersion = args[0];
// Validate semver-ish format
if (!/^\d+\.\d+\.\d+/.test(newVersion)) {
console.error(`Invalid version: ${newVersion}`);
console.error('Expected format: x.y.z or x.y.z-tag');
process.exit(1);
}
const current = getCurrentVersion();
console.log(`Current version: ${current}`);
setVersion(newVersion);
+2
View File
@@ -12,6 +12,7 @@ import { Store } from "./pages/store";
import { Today } from "./pages/today"; import { Today } from "./pages/today";
import { PlayLink } from "./pages/play-link"; import { PlayLink } from "./pages/play-link";
import { Setting } from "./pages/setting"; import { Setting } from "./pages/setting";
import { Gallery } from "./pages/gallery";
import { TaskQueue } from "./pages/task-queue"; import { TaskQueue } from "./pages/task-queue";
import { Debug } from "./pages/debug"; import { Debug } from "./pages/debug";
import { SplashDebug } from "./pages/debug/splash-debug"; import { SplashDebug } from "./pages/debug/splash-debug";
@@ -27,6 +28,7 @@ const pageMap = {
today: Today, today: Today,
"play-link": PlayLink, "play-link": PlayLink,
setting: Setting, setting: Setting,
gallery: Gallery,
"task-queue": TaskQueue, "task-queue": TaskQueue,
oobe: Oobe, oobe: Oobe,
"oobe/about-info": OobeAboutInfo, "oobe/about-info": OobeAboutInfo,
+132 -31
View File
@@ -1,59 +1,160 @@
import { ipcInvoke } from './ipc'; import { ipcInvoke, onIpcEvent } from './ipc';
export interface InstanceRuntime {
minecraft: string;
forge?: string;
neoForged?: string;
fabricLoader?: string;
quiltLoader?: string;
optifine?: string;
}
export interface InstanceConfig { export interface InstanceConfig {
name: string; name: string;
mcVersion: string; author?: string;
loaderType?: string; description?: string;
loaderVersion?: string; runtime: InstanceRuntime;
javaPath?: string; java?: string;
memory?: { min?: string; max?: string }; minMemory?: number;
createdAt: string; maxMemory?: number;
vmOptions?: string[];
mcOptions?: string[];
server?: { host: string; port?: number; name?: string };
showLog?: boolean;
hideLauncher?: boolean;
icon?: string;
creationDate: number;
lastAccessDate: number;
lastPlayedDate: number;
playtime: number;
} }
export interface InstanceInfo { export interface InstanceInfo {
name: string; name: string;
path: string; path: string;
config: InstanceConfig; config: InstanceConfig;
modCount?: number; modCount: number;
resourcePackCount: number;
screenshotCount: number;
saveCount: number;
healthy: boolean;
issues: string[];
}
export interface InstallProgress {
stage: string;
current: number;
total: number;
message?: string;
} }
export async function createInstance( export async function createInstance(
name: string, name: string,
gamePath: string, gamePath: string,
mcVersion: string, runtime: InstanceRuntime,
loaderType?: string, options?: {
loaderVersion?: string, author?: string;
javaPath?: string, description?: string;
memory?: { min?: string; max?: string } java?: string;
minMemory?: number;
maxMemory?: number;
vmOptions?: string[];
mcOptions?: string[];
}
): Promise<InstanceInfo> { ): Promise<InstanceInfo> {
return ipcInvoke<InstanceInfo>('instance:create', { return ipcInvoke<InstanceInfo>('instance:create', {
name, name,
gamePath, gamePath,
mcVersion, runtime,
loaderType, ...options,
loaderVersion,
javaPath,
memory,
}); });
} }
export async function listInstances(instancesPath: string): Promise<InstanceInfo[]> { export async function listInstances(gamePath: string): Promise<InstanceInfo[]> {
return ipcInvoke<InstanceInfo[]>('instance:list', { instancesPath }); return ipcInvoke<InstanceInfo[]>('instance:list', { gamePath });
} }
export async function deleteInstance( export async function getInstanceInfo(name: string, gamePath: string): Promise<InstanceInfo> {
name: string, return ipcInvoke<InstanceInfo>('instance:info', { name, gamePath });
instancesPath: string
): Promise<{ deleted: string }> {
return ipcInvoke<{ deleted: string }>('instance:delete', {
name,
instancesPath,
});
} }
export async function getInstanceInfo( export async function deleteInstance(name: string, gamePath: string): Promise<{ deleted: string }> {
return ipcInvoke<{ deleted: string }>('instance:delete', { name, gamePath });
}
export async function updateInstance(
name: string, name: string,
instancesPath: string gamePath: string,
patch: Partial<Omit<InstanceConfig, 'name' | 'creationDate'>>
): Promise<InstanceInfo> { ): Promise<InstanceInfo> {
return ipcInvoke<InstanceInfo>('instance:info', { name, instancesPath }); return ipcInvoke<InstanceInfo>('instance:update', { name, gamePath, patch });
}
export async function installInstance(
name: string,
gamePath: string
): Promise<{ requestId: string }> {
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
): Promise<{ healthy: boolean; issues: string[] }> {
return ipcInvoke<{ healthy: boolean; issues: string[] }>('instance:diagnose', { name, gamePath });
}
export async function getMinecraftVersionList(type?: string) {
return ipcInvoke<{ versions: { id: string; type: string; url: string }[] }>('instance:version-list', { type });
}
export async function getForgeVersionList(mcVersion?: string) {
return ipcInvoke<{ versions: string[] | Record<string, string[]> }>('instance:forge-version-list', { mcVersion });
}
export async function getFabricVersionList(mcVersion?: string) {
return ipcInvoke<{ versions: string[] }>('instance:fabric-version-list', { mcVersion });
}
export async function getQuiltVersionList(mcVersion?: string) {
return ipcInvoke<{ versions: string[] }>('instance:quilt-version-list', { mcVersion });
}
// Event listeners
export function onInstallProgress(callback: (data: { requestId: string } & InstallProgress) => void) {
return onIpcEvent('instance:progress', callback);
}
export function onInstallComplete(callback: (data: { requestId: string; data: InstanceInfo }) => void) {
return onIpcEvent('instance:install-complete', callback);
}
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);
} }
+17
View File
@@ -0,0 +1,17 @@
import { useEffect } from "react";
import { toast } from "sonner";
import { BUILD_MODE } from "@/lib/mode";
import { VERSION } from "@/lib/version";
export function BetaWarning() {
useEffect(() => {
if (BUILD_MODE === "beta" || BUILD_MODE === "dev") {
toast.warning(`当前为 v${VERSION} BETA 测试版,不代表最终品质。`, {
duration: Infinity,
dismissible: true,
});
}
}, []);
return null;
}
+3 -4
View File
@@ -1,4 +1,5 @@
import { BUILD_MODE, LOGO_SVG } from "@/lib/mode"; import { BUILD_MODE, LOGO_SVG } from "@/lib/mode";
import { VERSION } from "@/lib/version";
import { useUpdateStore } from "@/stores/updateStore"; import { useUpdateStore } from "@/stores/updateStore";
import { relaunchApp } from "@/api/update"; import { relaunchApp } from "@/api/update";
import Silk from "@/components/silk/Silk"; import Silk from "@/components/silk/Silk";
@@ -17,8 +18,6 @@ const modeLabels: Record<string, string> = {
run: "正式版", run: "正式版",
}; };
const VERSION = "0.1.0";
type UpdateState = "latest" | "hasUpdate" | "installed"; type UpdateState = "latest" | "hasUpdate" | "installed";
interface VersionCardProps { interface VersionCardProps {
@@ -50,12 +49,12 @@ export function VersionCard({
return ( return (
<div <div
className={clsx( className={clsx(
"relative overflow-hidden rounded-xl border border-white/10", "relative overflow-hidden rounded-xl border border-white/10 min-h-[200px]",
className, className,
)} )}
> >
{/* Silk 背景 */} {/* Silk 背景 */}
<div className="absolute inset-0 z-0"> <div className="absolute inset-0 z-0" style={{ background: color }}>
<Silk speed={3} scale={1.2} color={color} noiseIntensity={1.2} rotation={0.3} /> <Silk speed={3} scale={1.2} color={color} noiseIntensity={1.2} rotation={0.3} />
</div> </div>
+13 -2
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useCallback } from "react"; import { useEffect, useRef, useCallback, useState } from "react";
import { useBackgroundStore } from "@/stores/backgroundStore"; import { useBackgroundStore } from "@/stores/backgroundStore";
import { useThemeStore } from "@/stores/themeStore"; import { useThemeStore } from "@/stores/themeStore";
import { useRouteStore } from "@/stores/routeStore"; import { useRouteStore } from "@/stores/routeStore";
@@ -11,9 +11,20 @@ export function BackgroundLayer() {
const route = useRouteStore((s) => s.current); const route = useRouteStore((s) => s.current);
const forceDisableContentBlur = useDevStore((s) => s.forceDisableContentBlur); const forceDisableContentBlur = useDevStore((s) => s.forceDisableContentBlur);
const showContentBlur = route !== "home"; const showContentBlur = route !== "home";
const [bgImage, setBgImage] = useState(image);
const bgRef = useRef<HTMLDivElement>(null); const bgRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (image && image !== DEFAULT_BG && !image.startsWith("data:")) {
(window as any).electronAPI?.getBackgroundDataUrl?.().then((dataUrl: string | null) => {
if (dataUrl) setBgImage(dataUrl);
});
return;
}
setBgImage(image);
}, [image]);
const handleMouseMove = useCallback( const handleMouseMove = useCallback(
(e: MouseEvent) => { (e: MouseEvent) => {
if (!parallax || !bgRef.current) return; if (!parallax || !bgRef.current) return;
@@ -33,7 +44,7 @@ export function BackgroundLayer() {
return () => window.removeEventListener("mousemove", handleMouseMove); return () => window.removeEventListener("mousemove", handleMouseMove);
}, [parallax, handleMouseMove]); }, [parallax, handleMouseMove]);
const bgUrl = image || DEFAULT_BG; const bgUrl = bgImage || DEFAULT_BG;
const contentBlur = showContentBlur && !forceDisableContentBlur; const contentBlur = showContentBlur && !forceDisableContentBlur;
const getBackgroundStyle = (): React.CSSProperties => { const getBackgroundStyle = (): React.CSSProperties => {
+9 -3
View File
@@ -74,17 +74,19 @@ interface SilkPlaneProps {
} }
const SilkPlane = forwardRef(function SilkPlane({ uniforms }: SilkPlaneProps, ref: React.Ref<Mesh>) { const SilkPlane = forwardRef(function SilkPlane({ uniforms }: SilkPlaneProps, ref: React.Ref<Mesh>) {
const { viewport } = useThree(); const { viewport, invalidate } = useThree();
useLayoutEffect(() => { useLayoutEffect(() => {
if (ref && typeof ref === "object" && ref.current) { if (ref && typeof ref === "object" && ref.current) {
ref.current.scale.set(viewport.width, viewport.height, 1); ref.current.scale.set(viewport.width, viewport.height, 1);
} }
}, [ref, viewport]); invalidate();
}, [ref, viewport, invalidate]);
useFrame((_, delta) => { useFrame((_, delta) => {
if (ref && typeof ref === "object" && ref.current) { if (ref && typeof ref === "object" && ref.current) {
(ref.current.material as ShaderMaterial).uniforms.uTime.value += 0.1 * delta; (ref.current.material as ShaderMaterial).uniforms.uTime.value += 0.1 * delta;
invalidate();
} }
}); });
@@ -121,7 +123,11 @@ const Silk = ({ speed = 5, scale = 1, color = "#7B7481", noiseIntensity = 1.5, r
); );
return ( return (
<Canvas dpr={[1, 2]} frameloop="always"> <Canvas
dpr={[1, 2]}
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", background: "black" }}
gl={{ antialias: true, alpha: false }}
>
<SilkPlane ref={meshRef} uniforms={uniforms} /> <SilkPlane ref={meshRef} uniforms={uniforms} />
</Canvas> </Canvas>
); );
+2 -2
View File
@@ -146,8 +146,8 @@ export function TitleBar({
style={{ style={{
WebkitAppRegion: "drag", WebkitAppRegion: "drag",
background: "var(--titlebar-bg)", background: "var(--titlebar-bg)",
backdropFilter: "blur(16px)", backdropFilter: "blur(3px)",
WebkitBackdropFilter: "blur(16px)", WebkitBackdropFilter: "blur(3px)",
borderBottom: "1px solid var(--titlebar-border)", borderBottom: "1px solid var(--titlebar-border)",
userSelect: "none", userSelect: "none",
}} }}
+47
View File
@@ -0,0 +1,47 @@
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }
+5
View File
@@ -1,6 +1,7 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@import "shadcn/tailwind.css"; @import "shadcn/tailwind.css";
@import "sonner/dist/styles.css";
@import "@fontsource-variable/geist"; @import "@fontsource-variable/geist";
@import "@fontsource-variable/inter"; @import "@fontsource-variable/inter";
@@ -198,9 +199,11 @@
/* 内容区转场:前进方向 */ /* 内容区转场:前进方向 */
::view-transition-old(content) { ::view-transition-old(content) {
animation: 0.3s cubic-bezier(0.4, 0, 0.2, 1) both page-out-forward; animation: 0.3s cubic-bezier(0.4, 0, 0.2, 1) both page-out-forward;
pointer-events: none;
} }
::view-transition-new(content) { ::view-transition-new(content) {
animation: 0.3s cubic-bezier(0.4, 0, 0.2, 1) 0.05s both page-in-forward; animation: 0.3s cubic-bezier(0.4, 0, 0.2, 1) 0.05s both page-in-forward;
pointer-events: auto;
} }
/* 设置子页面转场(已弃用 View Transitions,改用 keyframe */ /* 设置子页面转场(已弃用 View Transitions,改用 keyframe */
@@ -208,10 +211,12 @@
/* 内容区转场:后退方向 */ /* 内容区转场:后退方向 */
:root[data-transition-dir="backward"] ::view-transition-old(content) { :root[data-transition-dir="backward"] ::view-transition-old(content) {
animation-name: page-out-backward; animation-name: page-out-backward;
pointer-events: none;
} }
:root[data-transition-dir="backward"] ::view-transition-new(content) { :root[data-transition-dir="backward"] ::view-transition-new(content) {
animation-name: page-in-backward; animation-name: page-in-backward;
animation-delay: 0.05s; animation-delay: 0.05s;
pointer-events: auto;
} }
@keyframes page-out-forward { @keyframes page-out-forward {
+13
View File
@@ -2,6 +2,8 @@ import { type ReactNode } from "react";
import { BackgroundLayer } from "@/components/background/BackgroundLayer"; import { BackgroundLayer } from "@/components/background/BackgroundLayer";
import { SystemLayer } from "@/components/system/SystemLayer"; import { SystemLayer } from "@/components/system/SystemLayer";
import { StartupPopup } from "@/components/StartupPopup"; import { StartupPopup } from "@/components/StartupPopup";
import { BetaWarning } from "@/components/BetaWarning";
import { Toaster } from "sonner";
import { useA11yStore } from "@/stores/a11yStore"; import { useA11yStore } from "@/stores/a11yStore";
import clsx from "clsx"; import clsx from "clsx";
@@ -52,6 +54,17 @@ export function RootLayout({
{/* Startup popup — only when VITE_START_POP=true */} {/* Startup popup — only when VITE_START_POP=true */}
<StartupPopup /> <StartupPopup />
{/* Beta warning toast */}
<BetaWarning />
{/* Sonner toaster */}
<Toaster
position="bottom-right"
richColors
closeButton
duration={Infinity}
/>
</div> </div>
); );
} }
+3
View File
@@ -0,0 +1,3 @@
declare const __APP_VERSION__: string;
export const VERSION: string = typeof __APP_VERSION__ !== "undefined" ? __APP_VERSION__ : "0.0.0";
+8
View File
@@ -0,0 +1,8 @@
export function Gallery() {
return (
<div className="p-6">
<h1 className="text-xl font-bold text-foreground mb-2"></h1>
<p className="text-sm text-muted-foreground"></p>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { useInstanceStore } from "@/stores/instanceStore";
import { useRouteStore } from "@/stores/routeStore";
import clsx from "clsx";
export function InstanceTitle() {
const currentInstance = useInstanceStore((s) => s.currentInstance);
const navigate = useRouteStore((s) => s.navigate);
return (
<div
onClick={() => navigate("gallery")}
className={clsx(
"self-start pl-1 pr-1 py-1.5 rounded-lg cursor-pointer",
"text-5xl font-bold tracking-tight text-white",
"hover:bg-black/20",
"transition-colors duration-200",
"select-none",
)}
>
{currentInstance?.name ?? "选择一个实例"}
</div>
);
}
+1
View File
@@ -37,6 +37,7 @@ export function StartCard({ onSettingsClick }: StartCardProps) {
{/* Instance button */} {/* Instance button */}
<button <button
onClick={() => navigate("gallery")}
className="flex items-center justify-center w-9 h-9 mr-0.5 text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-all duration-150 cursor-pointer shrink-0 rounded-full" className="flex items-center justify-center w-9 h-9 mr-0.5 text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-all duration-150 cursor-pointer shrink-0 rounded-full"
aria-label="选择实例" aria-label="选择实例"
> >
+3 -1
View File
@@ -1,8 +1,10 @@
import { InstanceTitle } from "./InstanceTitle";
import { StartCard } from "./StartCard"; import { StartCard } from "./StartCard";
export function Home() { export function Home() {
return ( return (
<div className="relative h-full flex flex-col justify-end items-start p-6"> <div className="relative h-full flex flex-col justify-end items-start p-6 gap-3">
<InstanceTitle />
<StartCard /> <StartCard />
</div> </div>
); );
+1 -1
View File
@@ -29,7 +29,7 @@ const OFFICIAL_URL = "https://koring.app";
export function AboutSetting() { export function AboutSetting() {
const openLink = (url: string) => { const openLink = (url: string) => {
window.open(url, "_blank"); window.electronAPI?.openExternal(url);
}; };
return ( return (
+3 -3
View File
@@ -17,7 +17,7 @@ function SettingRow({ label, desc, children }: { label: string; desc?: string; c
} }
const openLink = (url: string) => { const openLink = (url: string) => {
window.open(url, "_blank"); window.electronAPI?.openExternal(url);
}; };
const licenses = [ const licenses = [
@@ -50,9 +50,9 @@ export function CopyrightSetting() {
<div> <div>
<h3 className="text-lg font-bold text-foreground mb-3"></h3> <h3 className="text-lg font-bold text-foreground mb-3"></h3>
<GlassCard> <GlassCard>
<SettingRow label="MIT License" desc="Copyright © 2024 Koring Launcher Contributors"> <SettingRow label="LL-1.0 (LingkeLice 1.0)" desc="Copyright © Shenzhen Lingke Network Technology Co., Ltd.">
<button <button
onClick={() => openLink("https://opensource.org/licenses/MIT")} onClick={() => openLink("https://support.lingke.ink/LL-1.0")}
className="inline-flex items-center gap-1.5 text-[13px] text-primary hover:underline" className="inline-flex items-center gap-1.5 text-[13px] text-primary hover:underline"
> >
@@ -110,14 +110,9 @@ export function ThemeBgSetting() {
const { image, opacity, setOpacity, blur, setBlur, setImage, reset } = useBackgroundStore(); const { image, opacity, setOpacity, blur, setBlur, setImage, reset } = useBackgroundStore();
const handlePickImage = async () => { const handlePickImage = async () => {
// In Electron, use the native file dialog via IPC const dataUrl = await (window as any).electronAPI?.pickBackgroundImage();
const result = await window.electronAPI?.invoke('dialog:openFile', { if (dataUrl) {
filters: [ setImage(dataUrl);
{ name: "图片", extensions: ["png", "jpg", "jpeg", "webp", "gif", "bmp"] },
],
}) as string | null;
if (result) {
setImage(result);
} }
}; };
+1 -1
View File
@@ -65,7 +65,7 @@ export const useAuthStore = create<AuthState>((set) => ({
try { try {
const { state, authUrl } = await microsoftLoginStart(clientId); const { state, authUrl } = await microsoftLoginStart(clientId);
set({ msAuthUrl: authUrl, msAuthState: state, loading: false }); set({ msAuthUrl: authUrl, msAuthState: state, loading: false });
window.open(authUrl, "_blank"); window.electronAPI?.openExternal(authUrl);
} catch (e: any) { } catch (e: any) {
set({ error: e.message, loading: false }); set({ error: e.message, loading: false });
} }
+59 -33
View File
@@ -4,8 +4,11 @@ import {
listInstances, listInstances,
deleteInstance, deleteInstance,
getInstanceInfo, getInstanceInfo,
installInstance,
launchInstance,
type InstanceInfo,
type InstanceRuntime,
} from "../api/instance"; } from "../api/instance";
import type { InstanceInfo } from "../api/instance";
interface InstanceState { interface InstanceState {
instances: InstanceInfo[]; instances: InstanceInfo[];
@@ -13,18 +16,33 @@ interface InstanceState {
loading: boolean; loading: boolean;
error: string | null; error: string | null;
fetchInstances: (instancesPath: string) => Promise<void>; fetchInstances: (gamePath: string) => Promise<void>;
create: ( create: (
name: string, name: string,
gamePath: string, gamePath: string,
mcVersion: string, runtime: InstanceRuntime,
loaderType?: string, options?: {
loaderVersion?: string, author?: string;
javaPath?: string, description?: string;
memory?: { min?: string; max?: string } java?: string;
minMemory?: number;
maxMemory?: number;
}
) => Promise<void>; ) => Promise<void>;
remove: (name: string, instancesPath: string) => Promise<void>; remove: (name: string, gamePath: string) => Promise<void>;
select: (name: string, instancesPath: 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; clearError: () => void;
} }
@@ -34,36 +52,20 @@ export const useInstanceStore = create<InstanceState>((set) => ({
loading: false, loading: false,
error: null, error: null,
fetchInstances: async (instancesPath: string) => { fetchInstances: async (gamePath: string) => {
set({ loading: true, error: null }); set({ loading: true, error: null });
try { try {
const instances = await listInstances(instancesPath); const instances = await listInstances(gamePath);
set({ instances, loading: false }); set({ instances, loading: false });
} catch (e: any) { } catch (e: any) {
set({ error: e.message, loading: false }); set({ error: e.message, loading: false });
} }
}, },
create: async ( create: async (name, gamePath, runtime, options?) => {
name,
gamePath,
mcVersion,
loaderType?,
loaderVersion?,
javaPath?,
memory?
) => {
set({ loading: true, error: null }); set({ loading: true, error: null });
try { try {
const instance = await createInstance( const instance = await createInstance(name, gamePath, runtime, options);
name,
gamePath,
mcVersion,
loaderType,
loaderVersion,
javaPath,
memory
);
set((state) => ({ set((state) => ({
instances: [...state.instances, instance], instances: [...state.instances, instance],
loading: false, loading: false,
@@ -73,10 +75,10 @@ export const useInstanceStore = create<InstanceState>((set) => ({
} }
}, },
remove: async (name: string, instancesPath: string) => { remove: async (name: string, gamePath: string) => {
set({ loading: true, error: null }); set({ loading: true, error: null });
try { try {
await deleteInstance(name, instancesPath); await deleteInstance(name, gamePath);
set((state) => ({ set((state) => ({
instances: state.instances.filter((i) => i.name !== name), instances: state.instances.filter((i) => i.name !== name),
currentInstance: currentInstance:
@@ -88,15 +90,39 @@ export const useInstanceStore = create<InstanceState>((set) => ({
} }
}, },
select: async (name: string, instancesPath: string) => { select: async (name: string, gamePath: string) => {
set({ loading: true, error: null }); set({ loading: true, error: null });
try { try {
const instance = await getInstanceInfo(name, instancesPath); const instance = await getInstanceInfo(name, gamePath);
set({ currentInstance: instance, loading: false }); set({ currentInstance: instance, loading: false });
} catch (e: any) { } catch (e: any) {
set({ error: e.message, loading: false }); set({ error: e.message, loading: false });
} }
}, },
install: async (name: string, gamePath: string) => {
set({ loading: true, error: null });
try {
const { requestId } = await installInstance(name, gamePath);
set({ loading: false });
return requestId;
} catch (e: any) {
set({ error: e.message, loading: false });
throw e;
}
},
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 }), clearError: () => set({ error: null }),
})); }));
+2
View File
@@ -6,6 +6,7 @@ export type RouteKey =
| "today" | "today"
| "play-link" | "play-link"
| "setting" | "setting"
| "gallery"
| "task-queue" | "task-queue"
| "oobe" | "oobe"
| "oobe/about-info" | "oobe/about-info"
@@ -29,6 +30,7 @@ interface RouteItem {
export const routes: RouteItem[] = [ export const routes: RouteItem[] = [
{ key: "home", label: "首页", path: "/home" }, { key: "home", label: "首页", path: "/home" },
{ key: "gallery", label: "实例", path: "/gallery" },
{ key: "store", label: "资源", path: "/store" }, { key: "store", label: "资源", path: "/store" },
{ key: "today", label: "资讯", path: "/today" }, { key: "today", label: "资讯", path: "/today" },
{ key: "play-link", label: "联机", path: "/play-link" }, { key: "play-link", label: "联机", path: "/play-link" },
+2
View File
@@ -10,6 +10,8 @@ interface ElectronAPI {
onResized: (callback: () => void) => () => void; onResized: (callback: () => void) => () => void;
getTheme: () => Promise<'light' | 'dark' | 'system' | null>; getTheme: () => Promise<'light' | 'dark' | 'system' | null>;
openExternal: (url: string) => Promise<void>;
} }
declare global { declare global {
+4
View File
@@ -2,6 +2,7 @@ import path from "path";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import pkg from "./package.json";
export default defineConfig(async () => ({ export default defineConfig(async () => ({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
@@ -10,6 +11,9 @@ export default defineConfig(async () => ({
"@": path.resolve(__dirname, "./src"), "@": path.resolve(__dirname, "./src"),
}, },
}, },
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
},
base: "./", base: "./",
build: { build: {
rollupOptions: { rollupOptions: {