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
+3
View File
@@ -27,3 +27,6 @@ dist-electron
# Electron
electron/*.js
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:main # electron main process only
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
- **Frontend** (`src/`): React 19 + Vite 7 + Tailwind v4 + shadcn/ui + Zustand stores
- **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
## 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.
- **Preload script**: `electron/preload.ts` exposes `window.electronAPI` via context bridge.
- **IPC handlers**: All handlers are in `electron/handlers/` directory.
- **Mutable win ref**: `electron/main.ts` uses a mutable `win` object — handlers read `win.mainWindow` at runtime, not at registration time.
- **Config**: YAML format (`Koring.yml`) stored next to executable. Sparse save (only non-default values).
- **Auth**: JSON file (`koring-auth.json`) stored next to executable.
- **Path alias**: `@/` maps to `src/` (configured in `vite.config.ts` and `tsconfig.json`).
- **Dev mode**: Vite runs on port 1420, Electron loads from localhost.
- **Asset paths**: Use `import.meta.env.BASE_URL` prefix for public assets. Absolute paths break in packaged app.
## Build & bundle
```bash
pnpm build # Vite build + TypeScript compile
pnpm dist:win # electron-builder Windows installer
pnpm dist:mac # electron-builder macOS DMG
pnpm dist:linux # electron-builder Linux AppImage
pnpm dist:dev # switch-icon dev + electron-builder Windows installer
pnpm dist:beta # switch-icon beta + electron-builder Windows installer
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/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/config.ts`: YAML config management (sparse save)
- `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/stores/`: Zustand state management
- `src/hooks/useTheme.ts`: Dark mode sync with Electron theme
- `src/components/system/WindowControls.tsx`: Custom window controls (min/max/close)
- `src/components/system/TitleBar.tsx`: Custom title bar with navigation
- `src/components/system/WindowControls.tsx`: Custom window controls (min/max/close), uses `<button>` with `WebkitAppRegion: "no-drag"`
- `src/components/system/TitleBar.tsx`: Custom title bar with navigation, uses `WebkitAppRegion: "drag"`
- `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 |
| UI | Tailwind CSS v4 + shadcn/ui (base-ui) |
| 状态管理 | Zustand |
| Backend | Rust / Tauri 2 |
| Sidecar | Node.js / TypeScript / @xmcl/* |
| 配置存储 | YAML (serde_yaml) + Windows Registry |
| Main Process | Electron + Node.js / TypeScript / @xmcl/* |
| 配置存储 | YAML (js-yaml) |
| 认证存储 | JSON 文件 (koring-auth.json) |
| 包管理 | pnpm |
| 目标平台 | ARM64 Windows (`aarch64-pc-windows-msvc`) |
| 目标平台 | Windows (x64) |
## 快速命令
```bash
pnpm dev # 前端 dev server (port 1420)
pnpm dev:t # 前端 + Rust dev (需 VS 环境)
./dev-vs.cmd # VS ARM64 环境启动 dev
pnpm build # 生产构建 (switch-icon run + tsc + vite build)
pnpm build:beta # Beta 构建 (switch-icon beta + tsc + vite build --mode beta)
./build-vs.cmd # Rust 编译 (production, NSIS 打包)
./build-vs.cmd --mode beta # Rust 编译 (beta, 跳过打包)
pnpm install # 安装依赖
pnpm dev # 完整开发环境 (renderer + main)
pnpm dev:renderer # 仅前端 (Vite, port 1420)
pnpm dev:main # 仅主进程 (tsc + electron)
pnpm build # 生产构建 (vite + tsc)
pnpm dist:dev # dev 图标 + Windows 安装包
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 | 开发预览版 |
| beta | `"beta"` | `beta.png` | 🟡 BETA | 测试版 |
| run | `"run"` | `run.png` | 无 | 正式版 |
| dev | `"dev"` | `public/icons/dev/` | 🟢 DEV | 开发预览版 |
| beta | `"beta"` | `public/icons/beta/` | 🟡 BETA | 测试版 |
| 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
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_TITLE="..." # 弹窗标题
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 | 所有可配置项 |
| 账户凭证 | Windows Registry `HKCU\Software\KoringLauncher` | REG_SZ | token/xboxProfile |
| 实例配置 | 实例目录 `koring-instance.json` | JSON | per-instance |
| 认证数据 | 程序目录 `koring-auth.json` | JSON | token/xboxProfile |
| 任务历史 | localStorage `koring-task-history` | JSON | 临时,max 50 |
### Koring.yml 结构
@@ -114,18 +244,6 @@ network:
authUrl: ""
```
### 注册表结构
```
HKCU\Software\KoringLauncher
└─ auth
├─ username (REG_SZ)
├─ uuid (REG_SZ)
├─ accessToken (REG_SZ)
├─ refreshToken (REG_SZ)
└─ xboxProfile (REG_SZ, JSON string)
```
### 向上兼容策略
1. **版本号**`version` 字段,每次结构变更递增
@@ -134,134 +252,123 @@ HKCU\Software\KoringLauncher
4. **未知字段保留** — YAML 解析器保留不认识的字段
5. **Debounce 写入** — 300ms debounce 避免频繁 IO
### Rust 实现
---
**新文件:**
- `src-tauri/src/config.rs``AppConfig` 结构体、`load_config()``save_config()``merge_defaults()``migrate()`
- `src-tauri/src/registry.rs``read_auth()``write_auth()``delete_auth()`
## Electron 主进程
**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
useConfigStore.getState().config // 完整配置
useConfigStore.getState().loaded // 是否已加载
useConfigStore.getState().init() // 从 Rust 加载
useConfigStore.getState().setTheme({...}) // 部分更新 + debounce 写回
useConfigStore.getState().setA11y({...})
useConfigStore.getState().setBackground({...})
useConfigStore.getState().setGame({...})
useConfigStore.getState().setJava({...})
useConfigStore.getState().setAdvanced({...})
useConfigStore.getState().setDownload({...})
useConfigStore.getState().setNetwork({...})
```
app.whenReady()
→ registerAllHandlers() # 注册所有 IPC 处理器
→ createSplashWindow() # 立即显示 splash
→ createMainWindow() # 后台创建 main (show: false)
→ ready-to-show + 1.5s min # 两个条件都满足后:
→ mainWindow.show() # 显示主窗口
→ splashWindow.close() # 关闭 splash
```
**Store 委托模式:**
themeStore / a11yStore / backgroundStore / authStore 都是 configStore 的薄包装层:
- 读取时从 `configStore.config.*` 同步
- 写入时通过各自的 `set*()` 同时更新本地状态和 configStore
- 提供 `sync*FromConfig()` 函数在 App 启动时同步
### Mutable Win Ref
**App 启动流程:**
```
configStore.init() → syncThemeFromConfig() → syncA11yFromConfig() → syncBackgroundFromConfig() → authStore.initFromRegistry()
`electron/main.ts` 使用可变的 `win` 对象,所有处理器在运行时读取 `win.mainWindow`(而非注册时捕获):
```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/ # 前端源码
│ ├── App.tsx # 路由入口 + configStore 初始化
│ ├── index.css # 全局样式 + CSS 变量 + 动画
│ ├── layouts/
│ │ └── RootLayout.tsx # 三层布局: BackgroundLayer + ContentLayer + SystemLayer
│ ├── components/
│ │ ├── background/
│ │ │ └── BackgroundLayer.tsx # 全屏背景层 + 视差 + 强内容遮罩
│ │ ├── 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 环境开发脚本
### themeStore (委托 configStore)
```ts
darkMode: "auto" | "light" | "dark"
parallax: boolean
setDarkMode(mode) // 更新 DOM + configStore
setParallax(v) // configStore
syncThemeFromConfig() // 启动时从 config 同步
```
### 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-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)
- **`window`**: 仅窗口控制
### WindowControls 窗口控制
使用 `<button>` 元素,CSS `WebkitAppRegion: "no-drag"` 实现按钮可点击。
### BackgroundLayer 背景层
- 支持 `image` (CSS background-image) 和 `color` (CSS background-color) 两种类型
@@ -397,13 +425,6 @@ z-200 StartupPopup 启动弹窗 (环境变量控制)
- 强内容遮罩: 非 home 页面自动显示 (可通过 `contentBlurOpacity` 控制)
- 深色模式叠加层: `bg-black/35`
### StartCard 启动组件 (首页)
胶囊形启动组件,位于首页左下角:
- 左: 设置齿轮图标 (→ setting)
- 中: "启动游戏" 按钮 (primary 色, rounded-full)
- 右: 实例选择图标 (Package)
### TaskQueue 任务系统
- 执行器模式: `addTask(type, title, desc, async (ctx) => {...})`
@@ -411,44 +432,3 @@ z-200 StartupPopup 启动弹窗 (环境变量控制)
- AbortController 取消机制
- localStorage 持久化历史 (max 50)
- 任务类型: `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
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: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)
electron/ Main process (Node.js/TypeScript, @xmcl/* packages)
public/ Static assets (icons, fonts, images)
build/ Build resources (generated, gitignored)
```
**IPC Flow:**
@@ -54,12 +57,12 @@ src/
│ ├── Home.tsx # Main page
│ └── Debug.tsx # Debug tools
├── lib/
│ ├── mode.ts # Build mode detection (dev/beta/run)
│ ├── mode.ts # Build mode constants (DEFAULT_BG, LOGO_SVG, APP_ICON)
│ └── utils.ts # cn() helper
└── App.tsx # Root component with state router
electron/
├── main.ts # Electron entry, window management
├── main.ts # Electron entry, window management, splash→main transition
├── preload.ts # Context bridge (window.electronAPI)
├── config.ts # YAML config management
├── auth.ts # Auth data persistence
@@ -79,7 +82,7 @@ electron/
│ ├── background.ts # Background operations
│ ├── task.ts # Task system
│ ├── system.ts # System info
│ └── window.ts # Window controls
│ └── window.ts # Window controls + splash management
└── types/
└── electron.d.ts # TypeScript declarations
```
@@ -109,22 +112,35 @@ electron/
- Window: 480×320, no decorations, transparent, locked size
- Auto-adapts to system dark mode (`prefers-color-scheme`)
- Logo: `filter: invert(1)` in dark mode
- Startup: splash shows first → main loads behind → transition after `ready-to-show` + 1.5s minimum
## Icon System
Three icon variants in `public/`:
Three icon variants in `public/icons/`:
| Mode | File | Use Case |
|------|------|----------|
| dev | `dev.png` / `dev.ico` | Development |
| beta | `beta.png` / `beta.ico` | Testing |
| run | `run.png` / `run.ico` | Production release |
```
public/icons/
dev/icon.ico, icon.png # Development
beta/icon.ico, icon.png # Testing
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:**
```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={LOGO_SVG} />
<img src={DEFAULT_BG} />
{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
- `task:*` — Task system progress
- `system:*` — System info
- `window:*` — Minimize/maximize/close
- `window:*` — Minimize/maximize/close + splash management
- `dialog:*` — File picker
## Key Gotchas
- **@xmcl packages run in main process**: `@xmcl/core`, `@xmcl/installer` require `fs`/`child_process`. All run in Electron main process.
- **Path alias**: `@/` maps to `src/`.
- **Window dragging**: Use CSS `-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.
- **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
+7 -6
View File
@@ -2,26 +2,27 @@ appId: com.lingke.koring.launcher
productName: Koring Launcher
directories:
output: dist-electron
buildResources: build
files:
- dist/**
- electron/**/*.js
- "!electron/**/*.ts"
win:
target: nsis
icon: public/icon.ico
icon: build/icon.ico
artifactName: "koring-launcher-${version}-setup.${ext}"
mac:
target: dmg
icon: public/icon.png
icon: build/icon.png
linux:
target: AppImage
icon: public/icon.png
icon: build/icon.png
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
installerIcon: public/icon.ico
uninstallerIcon: public/icon.ico
installerHeaderIcon: public/icon.ico
installerIcon: build/icon.ico
uninstallerIcon: build/icon.ico
installerHeaderIcon: build/icon.ico
createDesktopShortcut: true
createStartMenuShortcut: true
shortcutName: Koring Launcher
+219 -62
View File
@@ -35,45 +35,74 @@ var __importStar = (this && this.__importStar) || (function () {
Object.defineProperty(exports, "__esModule", { value: true });
exports.createInstance = createInstance;
exports.listInstances = listInstances;
exports.deleteInstance = deleteInstance;
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 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) {
return path.join(instancePath, INSTANCE_CONFIG_FILE);
}
async function createInstance(name, gamePath, mcVersion, loaderType, loaderVersion, javaPath, memory) {
const instancePath = path.join(gamePath, 'instances', name);
if (!fs.existsSync(instancePath)) {
fs.mkdirSync(instancePath, { recursive: true });
function countFiles(dir, ext) {
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, 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 = {
name,
mcVersion,
loaderType,
loaderVersion,
javaPath,
memory,
createdAt: new Date().toISOString(),
author: options?.author || '',
description: options?.description || '',
runtime,
java: options?.java,
minMemory: options?.minMemory,
maxMemory: options?.maxMemory,
vmOptions: options?.vmOptions,
mcOptions: options?.mcOptions,
creationDate: now,
lastAccessDate: now,
lastPlayedDate: 0,
playtime: 0,
};
const configPath = getInstanceConfigPath(instancePath);
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
// Create mods directory
const modsDir = path.join(instancePath, 'mods');
if (!fs.existsSync(modsDir)) {
fs.mkdirSync(modsDir, { recursive: true });
fs.writeFileSync(getInstanceConfigPath(instancePath), JSON.stringify(config, null, 2), 'utf-8');
// Create standard directories
for (const dir of ['mods', 'config', 'resourcepacks', 'shaderpacks', 'saves', 'screenshots', 'logs']) {
fs.mkdirSync(path.join(instancePath, dir), { recursive: true });
}
// Count mods
const mods = fs.readdirSync(modsDir).filter((f) => f.endsWith('.jar'));
return {
name,
path: instancePath,
config,
modCount: mods.length,
};
return getInstanceInfo(name, gamePath);
}
async function listInstances(instancesPath) {
async function listInstances(gamePath) {
const instancesPath = path.join(gamePath, 'instances');
const instances = [];
if (!fs.existsSync(instancesPath)) {
return instances;
@@ -82,57 +111,185 @@ async function listInstances(instancesPath) {
for (const entry of entries) {
if (!entry.isDirectory())
continue;
const instancePath = path.join(instancesPath, entry.name);
const configPath = getInstanceConfigPath(instancePath);
if (!fs.existsSync(configPath))
continue;
try {
const configRaw = fs.readFileSync(configPath, 'utf-8');
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,
});
instances.push(await getInstanceInfo(entry.name, gamePath));
}
catch {
// Skip invalid config
// Skip invalid instances
}
}
return instances;
}
async function deleteInstance(name, instancesPath) {
const instancePath = path.join(instancesPath, 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);
async function getInstanceInfo(name, gamePath) {
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);
// 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;
}
const issues = getInstanceIssues(instancePath, config.runtime);
return {
name,
path: instancePath,
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 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 {
name: string;
mcVersion: string;
loaderType?: string;
loaderVersion?: string;
javaPath?: string;
memory?: { min?: string; max?: string };
createdAt: string;
export interface InstanceRuntime {
minecraft: string;
forge?: string;
neoForged?: string;
fabricLoader?: string;
quiltLoader?: string;
optifine?: 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;
path: string;
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 {
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(
name: string,
gamePath: string,
mcVersion: string,
loaderType?: string,
loaderVersion?: string,
javaPath?: string,
memory?: { min?: string; max?: string }
runtime: InstanceRuntime,
options?: {
author?: string;
description?: string;
java?: string;
minMemory?: number;
maxMemory?: number;
vmOptions?: string[];
mcOptions?: string[];
}
): Promise<InstanceInfo> {
const instancePath = path.join(gamePath, 'instances', name);
if (!fs.existsSync(instancePath)) {
fs.mkdirSync(instancePath, { recursive: true });
if (fs.existsSync(instancePath)) {
throw new Error(`Instance already exists: ${name}`);
}
fs.mkdirSync(instancePath, { recursive: true });
const now = Date.now();
const config: InstanceConfig = {
name,
mcVersion,
loaderType,
loaderVersion,
javaPath,
memory,
createdAt: new Date().toISOString(),
author: options?.author || '',
description: options?.description || '',
runtime,
java: options?.java,
minMemory: options?.minMemory,
maxMemory: options?.maxMemory,
vmOptions: options?.vmOptions,
mcOptions: options?.mcOptions,
creationDate: now,
lastAccessDate: now,
lastPlayedDate: 0,
playtime: 0,
};
const configPath = getInstanceConfigPath(instancePath);
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
fs.writeFileSync(getInstanceConfigPath(instancePath), JSON.stringify(config, null, 2), 'utf-8');
// Create mods directory
const modsDir = path.join(instancePath, 'mods');
if (!fs.existsSync(modsDir)) {
fs.mkdirSync(modsDir, { recursive: true });
// Create standard directories
for (const dir of ['mods', 'config', 'resourcepacks', 'shaderpacks', 'saves', 'screenshots', 'logs']) {
fs.mkdirSync(path.join(instancePath, dir), { recursive: true });
}
// Count mods
const mods = fs.readdirSync(modsDir).filter((f) => f.endsWith('.jar'));
return {
name,
path: instancePath,
config,
modCount: mods.length,
};
return getInstanceInfo(name, gamePath);
}
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[] = [];
if (!fs.existsSync(instancesPath)) {
@@ -81,54 +152,18 @@ export async function listInstances(instancesPath: string): Promise<InstanceInfo
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const instancePath = path.join(instancesPath, entry.name);
const configPath = getInstanceConfigPath(instancePath);
if (!fs.existsSync(configPath)) continue;
try {
const configRaw = fs.readFileSync(configPath, 'utf-8');
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,
});
instances.push(await getInstanceInfo(entry.name, gamePath));
} catch {
// Skip invalid config
// Skip invalid instances
}
}
return instances;
}
export async function deleteInstance(
name: string,
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);
export async function getInstanceInfo(name: string, gamePath: string): Promise<InstanceInfo> {
const instancePath = path.join(gamePath, 'instances', name);
const configPath = getInstanceConfigPath(instancePath);
if (!fs.existsSync(configPath)) {
@@ -138,17 +173,226 @@ export async function getInstanceInfo(
const configRaw = fs.readFileSync(configPath, 'utf-8');
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;
}
const issues = getInstanceIssues(instancePath, config.runtime);
return {
name,
path: instancePath,
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 });
exports.registerBackgroundHandlers = registerBackgroundHandlers;
const electron_1 = __importDefault(require("electron"));
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const config_1 = require("../config");
const { ipcMain } = electron_1.default;
function registerBackgroundHandlers() {
@@ -98,4 +100,29 @@ function registerBackgroundHandlers() {
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 fs from 'fs';
import path from 'path';
import { loadConfig, saveConfig } from '../config';
const { ipcMain } = electron;
@@ -91,4 +93,28 @@ export function registerBackgroundHandlers() {
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 instance_1 = require("../core/instance");
const { ipcMain } = electron_1.default;
function registerInstanceHandlers() {
function registerInstanceHandlers(win) {
ipcMain.handle('instance:create', async (_event, payload) => {
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 };
}
catch (e) {
@@ -19,16 +27,7 @@ function registerInstanceHandlers() {
});
ipcMain.handle('instance:list', async (_event, payload) => {
try {
const data = await (0, instance_1.listInstances)(payload.instancesPath);
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);
const data = await (0, instance_1.listInstances)(payload.gamePath);
return { success: true, data, error: null };
}
catch (e) {
@@ -37,7 +36,112 @@ function registerInstanceHandlers() {
});
ipcMain.handle('instance:info', async (_event, payload) => {
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 };
}
catch (e) {
+157 -18
View File
@@ -1,27 +1,54 @@
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;
export function registerInstanceHandlers() {
interface WinRef {
mainWindow: electron.BrowserWindow | null;
}
export function registerInstanceHandlers(win: WinRef) {
ipcMain.handle('instance:create', async (_event, payload: {
name: string;
gamePath: string;
mcVersion: string;
loaderType?: string;
loaderVersion?: string;
javaPath?: string;
memory?: { min?: string; max?: string };
runtime: InstanceRuntime;
author?: string;
description?: string;
java?: string;
minMemory?: number;
maxMemory?: number;
vmOptions?: string[];
mcOptions?: string[];
}) => {
try {
const data = await createInstance(
payload.name,
payload.gamePath,
payload.mcVersion,
payload.loaderType,
payload.loaderVersion,
payload.javaPath,
payload.memory
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 };
} 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 {
const data = await listInstances(payload.instancesPath);
const data = await listInstances(payload.gamePath);
return { success: true, data, error: null };
} catch (e: unknown) {
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 {
const data = await deleteInstance(payload.name, payload.instancesPath);
const data = await getInstanceInfo(payload.name, payload.gamePath);
return { success: true, data, error: null };
} catch (e: unknown) {
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 {
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 };
} catch (e: unknown) {
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;
const electron_1 = __importDefault(require("electron"));
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 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() {
const splash = new electron_1.default.BrowserWindow({
width: 480,
@@ -67,7 +76,7 @@ function registerWindowHandlers(win) {
}
return { success: true };
});
// File dialog
// File dialog — returns source path and extension for preload to handle
ipcMain.handle('dialog:openFile', async (_event, payload) => {
const result = await dialog.showOpenDialog(win.mainWindow, {
properties: ['openFile'],
@@ -75,6 +84,12 @@ function registerWindowHandlers(win) {
});
if (result.canceled || result.filePaths.length === 0)
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 path from 'path';
const { ipcMain, dialog } = electron;
const { ipcMain, dialog, shell } = electron;
const isDev = !electron.app.isPackaged;
@@ -10,6 +10,16 @@ interface WinRef {
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 {
const splash = new electron.BrowserWindow({
width: 480,
@@ -77,7 +87,7 @@ export function registerWindowHandlers(win: WinRef) {
return { success: true };
});
// File dialog
// File dialog — returns source path and extension for preload to handle
ipcMain.handle('dialog:openFile', async (_event, payload: {
filters?: { name: string; extensions: string[] }[];
}) => {
@@ -86,6 +96,13 @@ export function registerWindowHandlers(win: WinRef) {
filters: payload.filters,
});
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 {
const iconPath = isDev
? path.join(__dirname, '../build/icon.ico')
: path.join(__dirname, '../build/icon.ico');
const splash = new electron.BrowserWindow({
width: 480,
height: 320,
@@ -30,6 +34,7 @@ function createSplashWindow(): electron.BrowserWindow {
resizable: false,
skipTaskbar: true,
alwaysOnTop: true,
icon: iconPath,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
@@ -46,15 +51,20 @@ function createSplashWindow(): 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({
width: 1000,
height: 700,
minWidth: 800,
minHeight: 600,
transparent: true,
frame: false,
transparent: false,
resizable: true,
show: false,
icon: iconPath,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false,
@@ -86,7 +96,7 @@ function registerAllHandlers() {
registerInstallHandlers(win);
registerLaunchHandlers(win);
registerModsHandlers();
registerInstanceHandlers();
registerInstanceHandlers(win);
registerBackgroundHandlers();
registerTaskHandlers(win);
registerSystemHandlers();
+45
View File
@@ -1,6 +1,28 @@
import electron from 'electron';
import path from 'path';
import fs from 'fs';
const { contextBridge, ipcRenderer } = electron;
const MIME_MAP: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.gif': 'image/gif',
'.bmp': 'image/bmp',
};
function getFileAsDataUrl(filePath: string): string | null {
try {
const buffer = fs.readFileSync(filePath);
const ext = path.extname(filePath).toLowerCase();
const mime = MIME_MAP[ext] || 'image/png';
return `data:${mime};base64,${buffer.toString('base64')}`;
} catch {
return null;
}
}
contextBridge.exposeInMainWorld('electronAPI', {
// Generic IPC
invoke: (channel: string, ...args: unknown[]) =>
@@ -28,4 +50,27 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Theme
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,
"version": "1.0.0",
"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",
"scripts": {
"dev:renderer": "vite --mode development",
"dev:main": "tsc -p tsconfig.electron.json && 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": "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",
"icon:dev": "node scripts/switch-icon.js dev",
"icon:beta": "node scripts/switch-icon.js beta",
"icon:run": "node scripts/switch-icon.js run",
"version:set": "node scripts/version.js",
"pack": "electron-builder --dir",
"dist": "electron-builder",
"dist:win": "electron-builder --win",
"dist:mac": "electron-builder --mac",
"dist:linux": "electron-builder --linux",
"dist:dev": "pnpm icon:dev && electron-builder --win",
"dist:beta": "pnpm icon:beta && electron-builder --win",
"dist:run": "pnpm icon:run && electron-builder --win"
"dist:dev": "pnpm build:dev && pnpm icon:dev && electron-builder --win",
"dist:beta": "pnpm build:beta && pnpm icon:beta && electron-builder --win",
"dist:run": "pnpm build:run && pnpm icon:run && electron-builder --win"
},
"dependencies": {
"@base-ui/react": "^1.6.0",
@@ -31,12 +37,16 @@
"@fontsource-variable/inter": "^5.2.8",
"@react-three/fiber": "^9.6.1",
"@types/three": "^0.184.1",
"@xmcl/core": "^2.15.1",
"@xmcl/installer": "^6.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"js-yaml": "^4.1.0",
"lucide-react": "^1.21.0",
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"three": "^0.184.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
- [ ] 在项目根目录创建 `electron/` 目录
- [ ] 创建 `electron/main.ts`Electron 主进程入口)
- [ ] 创建 `electron/preload.ts`context bridge 用于 IPC
- [ ] 更新根目录 `package.json`
- [x] 在项目根目录创建 `electron/` 目录
- [x] 创建 `electron/main.ts`Electron 主进程入口)
- [x] 创建 `electron/preload.ts`context bridge 用于 IPC
- [x] 更新根目录 `package.json`
- 移除 `@tauri-apps/api``@tauri-apps/plugin-*`
- 添加 `electron``electron-builder``electron-vite`
- 添加 `electron-store`(用于配置/认证持久化)
- 添加 `electron-updater`(用于自动更新)
- 更新脚本:`dev``build``build:win``build:mac``build:linux`
- [ ] 创建 `electron-builder.yml` 用于打包配置
- [ ] 更新 `vite.config.ts` 兼容 Electron(移除 Tauri 相关配置)
- [x] 创建 `electron-builder.yml` 用于打包配置
- [x] 更新 `vite.config.ts` 兼容 Electron(移除 Tauri 相关配置)
### 1.2 清理 Tauri 相关文件
- [ ] 完全删除 `src-tauri/` 目录
- [ ] 完全删除 `sidecar/` 目录
- [ ] 删除 `.tauri/` 签名密钥
- [ ] 删除 `build-all.cmd``build-arch.cmd``build-vs.cmd``dev-vs.cmd`
- [ ] 删除 `scripts/switch-icon.js`
- [ ] 清理 `package.json` 脚本(移除所有 tauri 相关脚本)
- [ ] 更新 `.gitignore` 移除 Tauri 相关条目
- [x] 完全删除 `src-tauri/` 目录
- [x] 完全删除 `sidecar/` 目录
- [x] 删除 `.tauri/` 签名密钥
- [x] 删除 `build-all.cmd``build-arch.cmd``build-vs.cmd``dev-vs.cmd`
- [x] 保留 `scripts/switch-icon.js`(已重写为 Electron 风格)
- [x] 清理 `package.json` 脚本(移除所有 tauri 相关脚本)
- [x] 更新 `.gitignore` 移除 Tauri 相关条目
---
@@ -343,35 +345,35 @@ export function onUpdateProgress(callback: (progress: any) => void) {
### 6.1 测试清单
- [ ] Splash 屏幕显示 4 秒后主窗口出现
- [ ] 自定义标题栏正常工作(最小化/最大化/关闭)
- [ ] 无边框窗口拖拽正常
- [ ] 暗色模式跟随系统同步
- [ ] 配置正确加载/保存(Koring.yml
- [ ] 认证正确存储/加载(electron-store
- [x] Splash 屏幕显示 1.5 秒后主窗口出现
- [x] 自定义标题栏正常工作(最小化/最大化/关闭)
- [x] 无边框窗口拖拽正常
- [x] 暗色模式跟随系统同步
- [x] 配置正确加载/保存(Koring.yml
- [x] 认证正确存储/加载(JSON 文件
- [ ] Microsoft OAuth 流程正常(打开浏览器 → 回调)
- [ ] 离线登录正常
- [x] 离线登录正常
- [ ] Minecraft 安装带进度条正常
- [ ] 游戏启动带事件流正常
- [ ] Mod 搜索/安装正常(Modrinth/CurseForge
- [ ] 实例 创建/列表/删除 正常
- [ ] 自动更新 检查/下载 正常
- [ ] 任务队列正常(进度、取消、重试)
- [ ] 构建模式(dev/beta/run)显示正确的图标/徽章
- [ ] NSIS 安装程序正确构建
- [x] 任务队列正常(进度、取消、重试)
- [x] 构建模式(dev/beta/run)显示正确的图标/徽章
- [x] NSIS 安装程序正确构建
- [ ] 打包后应用正常运行
### 6.2 文件清理
- [ ] 删除 `src-tauri/` 目录
- [ ] 删除 `sidecar/` 目录
- [ ] 删除 `.tauri/` 目录
- [ ] 删除 `*.cmd` 构建脚本
- [ ] 删除 `scripts/` 目录
- [ ] 移除 `.vscode/extensions.json` 中的 `src-tauri` 引用
- [ ] 更新 `AGENTS.md` 为新的 Electron 架构
- [ ] 更新 `DEV.md` 为 Electron 开发说明
- [ ] 更新 `README.md`
- [x] 删除 `src-tauri/` 目录
- [x] 删除 `sidecar/` 目录
- [x] 删除 `.tauri/` 目录
- [x] 删除 `*.cmd` 构建脚本
- [x] 保留 `scripts/` 目录switch-icon.js 已重写)
- [x] 移除 `.vscode/extensions.json` 中的 `src-tauri` 引用
- [x] 更新 `AGENTS.md` 为新的 Electron 架构
- [x] 更新 `DEV.md` 为 Electron 开发说明
- [x] 更新 `README.md`
### 6.3 新文件结构
+194 -2
View File
@@ -23,6 +23,12 @@ importers:
'@types/three':
specifier: ^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:
specifier: ^0.7.1
version: 0.7.1
@@ -35,12 +41,18 @@ importers:
lucide-react:
specifier: ^1.21.0
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:
specifier: ^19.1.0
version: 19.2.7
react-dom:
specifier: ^19.1.0
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:
specifier: ^3.6.0
version: 3.6.0
@@ -1102,6 +1114,35 @@ packages:
peerDependencies:
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':
resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==}
engines: {node: '>=14.6'}
@@ -1267,6 +1308,9 @@ packages:
resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==}
engines: {node: '>=18'}
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
boolean@3.2.0:
resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==}
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==}
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:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
@@ -1621,6 +1672,19 @@ packages:
os: [darwin]
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:
resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==}
engines: {node: '>=10'}
@@ -1702,6 +1766,10 @@ packages:
resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==}
engines: {node: '>=8.6'}
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
env-paths@2.2.1:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
engines: {node: '>=6'}
@@ -2009,6 +2077,10 @@ packages:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
he@1.2.0:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
hono@4.12.26:
resolution: {integrity: sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==}
engines: {node: '>=16.9.0'}
@@ -2588,6 +2660,12 @@ packages:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
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:
resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==}
engines: {node: '>=10'}
@@ -2612,6 +2690,9 @@ packages:
engines: {node: ^12.13 || ^14.13 || >=16}
hasBin: true
node-html-parser@6.1.13:
resolution: {integrity: sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==}
node-releases@2.0.48:
resolution: {integrity: sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==}
engines: {node: '>=18'}
@@ -2642,6 +2723,9 @@ packages:
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
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:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -3077,6 +3161,12 @@ packages:
resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==}
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
sonner@2.0.7:
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
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:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -3262,6 +3352,10 @@ packages:
undici-types@8.3.0:
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:
resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==}
engines: {node: '>=20.18.1'}
@@ -3426,6 +3520,9 @@ packages:
yauzl@2.10.0:
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
yazl@2.5.1:
resolution: {integrity: sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==}
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@@ -4356,8 +4453,7 @@ snapshots:
'@types/yauzl@2.10.3':
dependencies:
'@types/node': 20.19.43
optional: true
'@types/node': 26.0.1
'@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:
@@ -4371,6 +4467,43 @@ snapshots:
transitivePeerDependencies:
- 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': {}
abbrev@1.1.1: {}
@@ -4576,6 +4709,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
boolbase@1.0.0: {}
boolean@3.2.0:
optional: true
@@ -4859,6 +4994,16 @@ snapshots:
shebang-command: 2.0.0
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: {}
csstype@3.2.3: {}
@@ -4957,6 +5102,24 @@ snapshots:
verror: 1.10.1
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:
dependencies:
is-obj: 2.0.0
@@ -5067,6 +5230,8 @@ snapshots:
ansi-colors: 4.1.3
strip-ansi: 6.0.1
entities@4.5.0: {}
env-paths@2.2.1: {}
err-code@2.0.3: {}
@@ -5500,6 +5665,8 @@ snapshots:
dependencies:
function-bind: 1.1.2
he@1.2.0: {}
hono@4.12.26: {}
hosted-git-info@4.1.0:
@@ -5973,6 +6140,11 @@ snapshots:
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:
dependencies:
semver: 7.8.4
@@ -6009,6 +6181,11 @@ snapshots:
- bluebird
- supports-color
node-html-parser@6.1.13:
dependencies:
css-select: 5.2.2
he: 1.2.0
node-releases@2.0.48: {}
nopt@6.0.0:
@@ -6035,6 +6212,10 @@ snapshots:
gauge: 4.0.4
set-blocking: 2.0.0
nth-check@2.1.1:
dependencies:
boolbase: 1.0.0
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
@@ -6547,6 +6728,11 @@ snapshots:
ip-address: 10.2.0
smart-buffer: 4.2.0
sonner@2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
source-map-js@1.2.1: {}
source-map-support@0.5.21:
@@ -6726,6 +6912,8 @@ snapshots:
undici-types@8.3.0: {}
undici@7.2.3: {}
undici@7.28.0: {}
unicorn-magic@0.3.0: {}
@@ -6860,6 +7048,10 @@ snapshots:
buffer-crc32: 0.2.13
fd-slicer: 1.1.0
yazl@2.5.1:
dependencies:
buffer-crc32: 0.2.13
yocto-queue@0.1.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 mode = process.argv[2];
@@ -10,10 +10,11 @@ if (!mode || !validModes.includes(mode)) {
}
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 ico = join(publicDir, `${mode}.ico`);
const png = join(srcDir, 'icon.png');
const ico = join(srcDir, 'icon.ico');
if (!existsSync(png)) {
console.error(`Icon not found: ${png}`);
@@ -24,7 +25,9 @@ if (!existsSync(ico)) {
process.exit(1);
}
cpSync(png, join(publicDir, 'icon.png'), { overwrite: true });
cpSync(ico, join(publicDir, 'icon.ico'), { overwrite: true });
mkdirSync(buildDir, { recursive: 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 { PlayLink } from "./pages/play-link";
import { Setting } from "./pages/setting";
import { Gallery } from "./pages/gallery";
import { TaskQueue } from "./pages/task-queue";
import { Debug } from "./pages/debug";
import { SplashDebug } from "./pages/debug/splash-debug";
@@ -27,6 +28,7 @@ const pageMap = {
today: Today,
"play-link": PlayLink,
setting: Setting,
gallery: Gallery,
"task-queue": TaskQueue,
oobe: Oobe,
"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 {
name: string;
mcVersion: string;
loaderType?: string;
loaderVersion?: string;
javaPath?: string;
memory?: { min?: string; max?: string };
createdAt: 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;
path: string;
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(
name: string,
gamePath: string,
mcVersion: string,
loaderType?: string,
loaderVersion?: string,
javaPath?: string,
memory?: { min?: string; max?: string }
runtime: InstanceRuntime,
options?: {
author?: string;
description?: string;
java?: string;
minMemory?: number;
maxMemory?: number;
vmOptions?: string[];
mcOptions?: string[];
}
): Promise<InstanceInfo> {
return ipcInvoke<InstanceInfo>('instance:create', {
name,
gamePath,
mcVersion,
loaderType,
loaderVersion,
javaPath,
memory,
runtime,
...options,
});
}
export async function listInstances(instancesPath: string): Promise<InstanceInfo[]> {
return ipcInvoke<InstanceInfo[]>('instance:list', { instancesPath });
export async function listInstances(gamePath: string): Promise<InstanceInfo[]> {
return ipcInvoke<InstanceInfo[]>('instance:list', { gamePath });
}
export async function deleteInstance(
name: string,
instancesPath: string
): Promise<{ deleted: string }> {
return ipcInvoke<{ deleted: string }>('instance:delete', {
name,
instancesPath,
});
export async function getInstanceInfo(name: string, gamePath: string): Promise<InstanceInfo> {
return ipcInvoke<InstanceInfo>('instance:info', { name, gamePath });
}
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,
instancesPath: string
gamePath: string,
patch: Partial<Omit<InstanceConfig, 'name' | 'creationDate'>>
): 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 { VERSION } from "@/lib/version";
import { useUpdateStore } from "@/stores/updateStore";
import { relaunchApp } from "@/api/update";
import Silk from "@/components/silk/Silk";
@@ -17,8 +18,6 @@ const modeLabels: Record<string, string> = {
run: "正式版",
};
const VERSION = "0.1.0";
type UpdateState = "latest" | "hasUpdate" | "installed";
interface VersionCardProps {
@@ -50,12 +49,12 @@ export function VersionCard({
return (
<div
className={clsx(
"relative overflow-hidden rounded-xl border border-white/10",
"relative overflow-hidden rounded-xl border border-white/10 min-h-[200px]",
className,
)}
>
{/* 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} />
</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 { useThemeStore } from "@/stores/themeStore";
import { useRouteStore } from "@/stores/routeStore";
@@ -11,9 +11,20 @@ export function BackgroundLayer() {
const route = useRouteStore((s) => s.current);
const forceDisableContentBlur = useDevStore((s) => s.forceDisableContentBlur);
const showContentBlur = route !== "home";
const [bgImage, setBgImage] = useState(image);
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(
(e: MouseEvent) => {
if (!parallax || !bgRef.current) return;
@@ -33,7 +44,7 @@ export function BackgroundLayer() {
return () => window.removeEventListener("mousemove", handleMouseMove);
}, [parallax, handleMouseMove]);
const bgUrl = image || DEFAULT_BG;
const bgUrl = bgImage || DEFAULT_BG;
const contentBlur = showContentBlur && !forceDisableContentBlur;
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 { viewport } = useThree();
const { viewport, invalidate } = useThree();
useLayoutEffect(() => {
if (ref && typeof ref === "object" && ref.current) {
ref.current.scale.set(viewport.width, viewport.height, 1);
}
}, [ref, viewport]);
invalidate();
}, [ref, viewport, invalidate]);
useFrame((_, delta) => {
if (ref && typeof ref === "object" && ref.current) {
(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 (
<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} />
</Canvas>
);
+2 -2
View File
@@ -146,8 +146,8 @@ export function TitleBar({
style={{
WebkitAppRegion: "drag",
background: "var(--titlebar-bg)",
backdropFilter: "blur(16px)",
WebkitBackdropFilter: "blur(16px)",
backdropFilter: "blur(3px)",
WebkitBackdropFilter: "blur(3px)",
borderBottom: "1px solid var(--titlebar-border)",
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 "tw-animate-css";
@import "shadcn/tailwind.css";
@import "sonner/dist/styles.css";
@import "@fontsource-variable/geist";
@import "@fontsource-variable/inter";
@@ -198,9 +199,11 @@
/* 内容区转场:前进方向 */
::view-transition-old(content) {
animation: 0.3s cubic-bezier(0.4, 0, 0.2, 1) both page-out-forward;
pointer-events: none;
}
::view-transition-new(content) {
animation: 0.3s cubic-bezier(0.4, 0, 0.2, 1) 0.05s both page-in-forward;
pointer-events: auto;
}
/* 设置子页面转场(已弃用 View Transitions,改用 keyframe */
@@ -208,10 +211,12 @@
/* 内容区转场:后退方向 */
:root[data-transition-dir="backward"] ::view-transition-old(content) {
animation-name: page-out-backward;
pointer-events: none;
}
:root[data-transition-dir="backward"] ::view-transition-new(content) {
animation-name: page-in-backward;
animation-delay: 0.05s;
pointer-events: auto;
}
@keyframes page-out-forward {
+13
View File
@@ -2,6 +2,8 @@ import { type ReactNode } from "react";
import { BackgroundLayer } from "@/components/background/BackgroundLayer";
import { SystemLayer } from "@/components/system/SystemLayer";
import { StartupPopup } from "@/components/StartupPopup";
import { BetaWarning } from "@/components/BetaWarning";
import { Toaster } from "sonner";
import { useA11yStore } from "@/stores/a11yStore";
import clsx from "clsx";
@@ -52,6 +54,17 @@ export function RootLayout({
{/* Startup popup — only when VITE_START_POP=true */}
<StartupPopup />
{/* Beta warning toast */}
<BetaWarning />
{/* Sonner toaster */}
<Toaster
position="bottom-right"
richColors
closeButton
duration={Infinity}
/>
</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 */}
<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"
aria-label="选择实例"
>
+3 -1
View File
@@ -1,8 +1,10 @@
import { InstanceTitle } from "./InstanceTitle";
import { StartCard } from "./StartCard";
export function Home() {
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 />
</div>
);
+1 -1
View File
@@ -29,7 +29,7 @@ const OFFICIAL_URL = "https://koring.app";
export function AboutSetting() {
const openLink = (url: string) => {
window.open(url, "_blank");
window.electronAPI?.openExternal(url);
};
return (
+3 -3
View File
@@ -17,7 +17,7 @@ function SettingRow({ label, desc, children }: { label: string; desc?: string; c
}
const openLink = (url: string) => {
window.open(url, "_blank");
window.electronAPI?.openExternal(url);
};
const licenses = [
@@ -50,9 +50,9 @@ export function CopyrightSetting() {
<div>
<h3 className="text-lg font-bold text-foreground mb-3"></h3>
<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
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"
>
@@ -110,14 +110,9 @@ export function ThemeBgSetting() {
const { image, opacity, setOpacity, blur, setBlur, setImage, reset } = useBackgroundStore();
const handlePickImage = async () => {
// In Electron, use the native file dialog via IPC
const result = await window.electronAPI?.invoke('dialog:openFile', {
filters: [
{ name: "图片", extensions: ["png", "jpg", "jpeg", "webp", "gif", "bmp"] },
],
}) as string | null;
if (result) {
setImage(result);
const dataUrl = await (window as any).electronAPI?.pickBackgroundImage();
if (dataUrl) {
setImage(dataUrl);
}
};
+1 -1
View File
@@ -65,7 +65,7 @@ export const useAuthStore = create<AuthState>((set) => ({
try {
const { state, authUrl } = await microsoftLoginStart(clientId);
set({ msAuthUrl: authUrl, msAuthState: state, loading: false });
window.open(authUrl, "_blank");
window.electronAPI?.openExternal(authUrl);
} catch (e: any) {
set({ error: e.message, loading: false });
}
+59 -33
View File
@@ -4,8 +4,11 @@ import {
listInstances,
deleteInstance,
getInstanceInfo,
installInstance,
launchInstance,
type InstanceInfo,
type InstanceRuntime,
} from "../api/instance";
import type { InstanceInfo } from "../api/instance";
interface InstanceState {
instances: InstanceInfo[];
@@ -13,18 +16,33 @@ interface InstanceState {
loading: boolean;
error: string | null;
fetchInstances: (instancesPath: string) => Promise<void>;
fetchInstances: (gamePath: string) => Promise<void>;
create: (
name: string,
gamePath: string,
mcVersion: string,
loaderType?: string,
loaderVersion?: string,
javaPath?: string,
memory?: { min?: string; max?: string }
runtime: InstanceRuntime,
options?: {
author?: string;
description?: string;
java?: string;
minMemory?: number;
maxMemory?: number;
}
) => Promise<void>;
remove: (name: string, instancesPath: string) => Promise<void>;
select: (name: string, instancesPath: string) => Promise<void>;
remove: (name: string, gamePath: string) => Promise<void>;
select: (name: string, gamePath: string) => Promise<void>;
install: (name: string, gamePath: string) => Promise<string>;
launch: (
name: string,
gamePath: string,
options: {
username: string;
uuid: string;
accessToken?: string;
javaPath?: string;
server?: { host: string; port?: number };
}
) => Promise<string>;
clearError: () => void;
}
@@ -34,36 +52,20 @@ export const useInstanceStore = create<InstanceState>((set) => ({
loading: false,
error: null,
fetchInstances: async (instancesPath: string) => {
fetchInstances: async (gamePath: string) => {
set({ loading: true, error: null });
try {
const instances = await listInstances(instancesPath);
const instances = await listInstances(gamePath);
set({ instances, loading: false });
} catch (e: any) {
set({ error: e.message, loading: false });
}
},
create: async (
name,
gamePath,
mcVersion,
loaderType?,
loaderVersion?,
javaPath?,
memory?
) => {
create: async (name, gamePath, runtime, options?) => {
set({ loading: true, error: null });
try {
const instance = await createInstance(
name,
gamePath,
mcVersion,
loaderType,
loaderVersion,
javaPath,
memory
);
const instance = await createInstance(name, gamePath, runtime, options);
set((state) => ({
instances: [...state.instances, instance],
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 });
try {
await deleteInstance(name, instancesPath);
await deleteInstance(name, gamePath);
set((state) => ({
instances: state.instances.filter((i) => i.name !== name),
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 });
try {
const instance = await getInstanceInfo(name, instancesPath);
const instance = await getInstanceInfo(name, gamePath);
set({ currentInstance: instance, loading: false });
} catch (e: any) {
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 }),
}));
+2
View File
@@ -6,6 +6,7 @@ export type RouteKey =
| "today"
| "play-link"
| "setting"
| "gallery"
| "task-queue"
| "oobe"
| "oobe/about-info"
@@ -29,6 +30,7 @@ interface RouteItem {
export const routes: RouteItem[] = [
{ key: "home", label: "首页", path: "/home" },
{ key: "gallery", label: "实例", path: "/gallery" },
{ key: "store", label: "资源", path: "/store" },
{ key: "today", label: "资讯", path: "/today" },
{ key: "play-link", label: "联机", path: "/play-link" },
+2
View File
@@ -10,6 +10,8 @@ interface ElectronAPI {
onResized: (callback: () => void) => () => void;
getTheme: () => Promise<'light' | 'dark' | 'system' | null>;
openExternal: (url: string) => Promise<void>;
}
declare global {
+4
View File
@@ -2,6 +2,7 @@ import path from "path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import pkg from "./package.json";
export default defineConfig(async () => ({
plugins: [react(), tailwindcss()],
@@ -10,6 +11,9 @@ export default defineConfig(async () => ({
"@": path.resolve(__dirname, "./src"),
},
},
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
},
base: "./",
build: {
rollupOptions: {