20260711存档01
@@ -3,5 +3,5 @@ VITE_APP_ICON=/beta.png
|
|||||||
|
|
||||||
VITE_START_POP=true
|
VITE_START_POP=true
|
||||||
VITE_START_POP_TITLE="你正在使用 UI 预览版本"
|
VITE_START_POP_TITLE="你正在使用 UI 预览版本"
|
||||||
VITE_START_POP_INFO="此版本是专门用于进行检测是否有UI问题的版本,因此功能将会缺失"
|
VITE_START_POP_INFO="此版本是专门用于进行检测是否有 UI 问题的版本,因此功能将会缺失"
|
||||||
VITE_START_POP_BOUTTON="我已了解"
|
VITE_START_POP_BOUTTON="我已了解"
|
||||||
@@ -27,3 +27,6 @@ dist-electron
|
|||||||
# Electron
|
# Electron
|
||||||
electron/*.js
|
electron/*.js
|
||||||
electron/*.d.ts
|
electron/*.d.ts
|
||||||
|
|
||||||
|
# Build resources (generated by switch-icon.js)
|
||||||
|
build/
|
||||||
|
|||||||
@@ -9,13 +9,16 @@ pnpm dev # full app dev (renderer + main process)
|
|||||||
pnpm dev:renderer # frontend only (vite, port 1420)
|
pnpm dev:renderer # frontend only (vite, port 1420)
|
||||||
pnpm dev:main # electron main process only
|
pnpm dev:main # electron main process only
|
||||||
pnpm build # production build (vite + tsc)
|
pnpm build # production build (vite + tsc)
|
||||||
pnpm dist:win # build Windows installer
|
pnpm dist:dev # dev icon + Windows installer
|
||||||
|
pnpm dist:beta # beta icon + Windows installer
|
||||||
|
pnpm dist:run # production icon + Windows installer
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- **Frontend** (`src/`): React 19 + Vite 7 + Tailwind v4 + shadcn/ui + Zustand stores
|
- **Frontend** (`src/`): React 19 + Vite 7 + Tailwind v4 + shadcn/ui + Zustand stores
|
||||||
- **Main Process** (`electron/`): Node.js/TypeScript, manages windows, IPC handlers, @xmcl/* packages
|
- **Main Process** (`electron/`): Node.js/TypeScript, manages windows, IPC handlers, @xmcl/* packages
|
||||||
|
- **Icon System** (`public/icons/{dev,beta,run}/`): Mode-specific icons, copied to `build/` at build time
|
||||||
- IPC: Frontend → `ipcRenderer.invoke()` → `ipcMain.handle()` → main process → `webContents.send()` → Frontend
|
- IPC: Frontend → `ipcRenderer.invoke()` → `ipcMain.handle()` → main process → `webContents.send()` → Frontend
|
||||||
|
|
||||||
## Key gotchas
|
## Key gotchas
|
||||||
@@ -23,23 +26,42 @@ pnpm dist:win # build Windows installer
|
|||||||
- **Electron main process**: `electron/main.ts` is the entry point. All @xmcl/* packages run here.
|
- **Electron main process**: `electron/main.ts` is the entry point. All @xmcl/* packages run here.
|
||||||
- **Preload script**: `electron/preload.ts` exposes `window.electronAPI` via context bridge.
|
- **Preload script**: `electron/preload.ts` exposes `window.electronAPI` via context bridge.
|
||||||
- **IPC handlers**: All handlers are in `electron/handlers/` directory.
|
- **IPC handlers**: All handlers are in `electron/handlers/` directory.
|
||||||
|
- **Mutable win ref**: `electron/main.ts` uses a mutable `win` object — handlers read `win.mainWindow` at runtime, not at registration time.
|
||||||
- **Config**: YAML format (`Koring.yml`) stored next to executable. Sparse save (only non-default values).
|
- **Config**: YAML format (`Koring.yml`) stored next to executable. Sparse save (only non-default values).
|
||||||
- **Auth**: JSON file (`koring-auth.json`) stored next to executable.
|
- **Auth**: JSON file (`koring-auth.json`) stored next to executable.
|
||||||
- **Path alias**: `@/` maps to `src/` (configured in `vite.config.ts` and `tsconfig.json`).
|
- **Path alias**: `@/` maps to `src/` (configured in `vite.config.ts` and `tsconfig.json`).
|
||||||
- **Dev mode**: Vite runs on port 1420, Electron loads from localhost.
|
- **Dev mode**: Vite runs on port 1420, Electron loads from localhost.
|
||||||
|
- **Asset paths**: Use `import.meta.env.BASE_URL` prefix for public assets. Absolute paths break in packaged app.
|
||||||
|
|
||||||
## Build & bundle
|
## Build & bundle
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm build # Vite build + TypeScript compile
|
pnpm build # Vite build + TypeScript compile
|
||||||
pnpm dist:win # electron-builder Windows installer
|
pnpm dist:dev # switch-icon dev + electron-builder Windows installer
|
||||||
pnpm dist:mac # electron-builder macOS DMG
|
pnpm dist:beta # switch-icon beta + electron-builder Windows installer
|
||||||
pnpm dist:linux # electron-builder Linux AppImage
|
pnpm dist:run # switch-icon run + electron-builder Windows installer
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Each `dist:*` command runs: `pnpm build` → `pnpm icon:{mode}` → `electron-builder --win`
|
||||||
|
|
||||||
|
## Icon switching
|
||||||
|
|
||||||
|
```
|
||||||
|
public/icons/
|
||||||
|
dev/icon.ico, icon.png
|
||||||
|
beta/icon.ico, icon.png
|
||||||
|
run/icon.ico, icon.png
|
||||||
|
|
||||||
|
build/ ← generated by switch-icon.js (gitignored)
|
||||||
|
icon.ico
|
||||||
|
icon.png
|
||||||
|
```
|
||||||
|
|
||||||
|
`electron-builder.yml` uses `buildResources: build` to read icons from `build/`.
|
||||||
|
|
||||||
## Electron notes
|
## Electron notes
|
||||||
|
|
||||||
- `electron/main.ts`: App entry, window management, IPC handler registration
|
- `electron/main.ts`: App entry, window management, splash→main transition (ready-to-show + 1.5s min)
|
||||||
- `electron/preload.ts`: Context bridge for secure IPC
|
- `electron/preload.ts`: Context bridge for secure IPC
|
||||||
- `electron/config.ts`: YAML config management (sparse save)
|
- `electron/config.ts`: YAML config management (sparse save)
|
||||||
- `electron/auth.ts`: Auth data persistence (JSON file)
|
- `electron/auth.ts`: Auth data persistence (JSON file)
|
||||||
@@ -52,5 +74,6 @@ pnpm dist:linux # electron-builder Linux AppImage
|
|||||||
- `src/api/*.ts`: API modules wrapping IPC calls
|
- `src/api/*.ts`: API modules wrapping IPC calls
|
||||||
- `src/stores/`: Zustand state management
|
- `src/stores/`: Zustand state management
|
||||||
- `src/hooks/useTheme.ts`: Dark mode sync with Electron theme
|
- `src/hooks/useTheme.ts`: Dark mode sync with Electron theme
|
||||||
- `src/components/system/WindowControls.tsx`: Custom window controls (min/max/close)
|
- `src/components/system/WindowControls.tsx`: Custom window controls (min/max/close), uses `<button>` with `WebkitAppRegion: "no-drag"`
|
||||||
- `src/components/system/TitleBar.tsx`: Custom title bar with navigation
|
- `src/components/system/TitleBar.tsx`: Custom title bar with navigation, uses `WebkitAppRegion: "drag"`
|
||||||
|
- `src/lib/mode.ts`: Build mode constants (`DEFAULT_BG`, `LOGO_SVG`, `APP_ICON`, `BUILD_MODE`)
|
||||||
|
|||||||
@@ -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?
|
||||||
@@ -7,39 +7,66 @@
|
|||||||
| Frontend | React 19 + Vite 7 + TypeScript |
|
| Frontend | React 19 + Vite 7 + TypeScript |
|
||||||
| UI | Tailwind CSS v4 + shadcn/ui (base-ui) |
|
| UI | Tailwind CSS v4 + shadcn/ui (base-ui) |
|
||||||
| 状态管理 | Zustand |
|
| 状态管理 | Zustand |
|
||||||
| Backend | Rust / Tauri 2 |
|
| Main Process | Electron + Node.js / TypeScript / @xmcl/* |
|
||||||
| Sidecar | Node.js / TypeScript / @xmcl/* |
|
| 配置存储 | YAML (js-yaml) |
|
||||||
| 配置存储 | YAML (serde_yaml) + Windows Registry |
|
| 认证存储 | JSON 文件 (koring-auth.json) |
|
||||||
| 包管理 | pnpm |
|
| 包管理 | pnpm |
|
||||||
| 目标平台 | ARM64 Windows (`aarch64-pc-windows-msvc`) |
|
| 目标平台 | Windows (x64) |
|
||||||
|
|
||||||
## 快速命令
|
## 快速命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm dev # 前端 dev server (port 1420)
|
pnpm install # 安装依赖
|
||||||
pnpm dev:t # 前端 + Rust dev (需 VS 环境)
|
pnpm dev # 完整开发环境 (renderer + main)
|
||||||
./dev-vs.cmd # VS ARM64 环境启动 dev
|
pnpm dev:renderer # 仅前端 (Vite, port 1420)
|
||||||
pnpm build # 生产构建 (switch-icon run + tsc + vite build)
|
pnpm dev:main # 仅主进程 (tsc + electron)
|
||||||
pnpm build:beta # Beta 构建 (switch-icon beta + tsc + vite build --mode beta)
|
pnpm build # 生产构建 (vite + tsc)
|
||||||
./build-vs.cmd # Rust 编译 (production, NSIS 打包)
|
pnpm dist:dev # dev 图标 + Windows 安装包
|
||||||
./build-vs.cmd --mode beta # Rust 编译 (beta, 跳过打包)
|
pnpm dist:beta # beta 图标 + Windows 安装包
|
||||||
|
pnpm dist:run # production 图标 + Windows 安装包
|
||||||
|
```
|
||||||
|
|
||||||
|
## 构建流程
|
||||||
|
|
||||||
|
### 开发调试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev
|
||||||
|
→ pnpm build:main # 先编译主进程 TS→JS
|
||||||
|
→ concurrently:
|
||||||
|
pnpm dev:renderer # Vite dev server (localhost:1420, HMR)
|
||||||
|
electron . # 加载 localhost:1420
|
||||||
|
```
|
||||||
|
|
||||||
|
- 前端热更新(HMR)
|
||||||
|
- 主进程修改后需重启 `pnpm dev`
|
||||||
|
- 调试工具:`Ctrl+Shift+I` 打开 DevTools
|
||||||
|
|
||||||
|
### 生产打包
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dist:beta
|
||||||
|
→ pnpm build # 1. 编译 renderer + main
|
||||||
|
→ pnpm icon:beta # 2. 复制 public/icons/beta/ → build/
|
||||||
|
→ electron-builder --win # 3. 读取 build/icon.ico 打包
|
||||||
|
→ 输出 dist-electron/koring-launcher-1.0.0-setup.exe
|
||||||
```
|
```
|
||||||
|
|
||||||
## 构建模式
|
## 构建模式
|
||||||
|
|
||||||
| 模式 | `VITE_BUILD_MODE` | 图标 | Badge | 说明 |
|
| 模式 | `VITE_BUILD_MODE` | 图标目录 | Badge | 说明 |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| dev | `"dev"` | `dev.png` | 🟢 DEV | 开发预览版 |
|
| dev | `"dev"` | `public/icons/dev/` | 🟢 DEV | 开发预览版 |
|
||||||
| beta | `"beta"` | `beta.png` | 🟡 BETA | 测试版 |
|
| beta | `"beta"` | `public/icons/beta/` | 🟡 BETA | 测试版 |
|
||||||
| run | `"run"` | `run.png` | 无 | 正式版 |
|
| run | `"run"` | `public/icons/run/` | 无 | 正式版 |
|
||||||
|
|
||||||
模式由 `src/lib/mode.ts` 导出 `BUILD_MODE`, `isDev`, `isBeta`, `isRun`。
|
模式由 `src/lib/mode.ts` 导出 `BUILD_MODE`, `isDev`, `isBeta`, `isRun`, `DEFAULT_BG`, `LOGO_SVG`, `APP_ICON`。
|
||||||
|
|
||||||
## 环境变量 (.env.*)
|
## 环境变量 (.env.*)
|
||||||
|
|
||||||
```env
|
```env
|
||||||
VITE_BUILD_MODE=dev|beta|run
|
VITE_BUILD_MODE=dev|beta|run
|
||||||
VITE_APP_ICON=/dev.png|/beta.png|/run.png
|
VITE_APP_ICON=dev.png|beta.png|run.png
|
||||||
VITE_START_POP=true|false # 启动弹窗开关
|
VITE_START_POP=true|false # 启动弹窗开关
|
||||||
VITE_START_POP_TITLE="..." # 弹窗标题
|
VITE_START_POP_TITLE="..." # 弹窗标题
|
||||||
VITE_START_POP_INFO="..." # 弹窗内容
|
VITE_START_POP_INFO="..." # 弹窗内容
|
||||||
@@ -48,6 +75,110 @@ VITE_START_POP_BOUTTON="..." # 弹窗按钮文字
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
koring-launcher/
|
||||||
|
├── src/ # 前端源码
|
||||||
|
│ ├── App.tsx # 路由入口 + configStore 初始化
|
||||||
|
│ ├── index.css # 全局样式 + CSS 变量 + 动画
|
||||||
|
│ ├── layouts/
|
||||||
|
│ │ └── RootLayout.tsx # 三层布局: BackgroundLayer + ContentLayer + SystemLayer
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── background/
|
||||||
|
│ │ │ └── BackgroundLayer.tsx # 全屏背景层 + 视差 + 强内容遮罩
|
||||||
|
│ │ ├── system/
|
||||||
|
│ │ │ ├── TitleBar.tsx # 自定义标题栏 (WebkitAppRegion: drag)
|
||||||
|
│ │ │ └── WindowControls.tsx # 窗口按钮 (WebkitAppRegion: no-drag)
|
||||||
|
│ │ ├── splash/
|
||||||
|
│ │ │ └── Splash.tsx # 启动动画 (React 组件)
|
||||||
|
│ │ ├── silk/
|
||||||
|
│ │ │ └── Silk.tsx # WebGL 丝绸着色器 (Three.js)
|
||||||
|
│ │ ├── task/
|
||||||
|
│ │ │ ├── TaskButton.tsx # 标题栏任务指示器
|
||||||
|
│ │ │ └── TaskCard.tsx # 单个任务卡片
|
||||||
|
│ │ ├── ui/ # shadcn/ui 组件
|
||||||
|
│ │ ├── VersionCard.tsx # 版本/更新卡片
|
||||||
|
│ │ ├── UnderConstruction.tsx # "装修中" 占位组件
|
||||||
|
│ │ └── StartupPopup.tsx # 启动弹窗
|
||||||
|
│ ├── stores/
|
||||||
|
│ │ ├── configStore.ts # 统一配置 store (→ Koring.yml)
|
||||||
|
│ │ ├── themeStore.ts # 主题 (委托 configStore)
|
||||||
|
│ │ ├── a11yStore.ts # 无障碍 (委托 configStore)
|
||||||
|
│ │ ├── backgroundStore.ts # 背景 (委托 configStore)
|
||||||
|
│ │ ├── authStore.ts # 认证 (→ koring-auth.json)
|
||||||
|
│ │ ├── routeStore.ts # 路由 (历史栈)
|
||||||
|
│ │ ├── taskStore.ts # 任务队列 (localStorage)
|
||||||
|
│ │ ├── instanceStore.ts # 实例管理
|
||||||
|
│ │ ├── installStore.ts # Minecraft 安装
|
||||||
|
│ │ ├── launchStore.ts # 游戏启动
|
||||||
|
│ │ ├── modsStore.ts # Mod 搜索
|
||||||
|
│ │ ├── updateStore.ts # 应用更新
|
||||||
|
│ │ └── devStore.ts # 开发者调试
|
||||||
|
│ ├── api/
|
||||||
|
│ │ ├── ipc.ts # 核心 IPC 工具 (invoke, onIpcEvent)
|
||||||
|
│ │ ├── config.ts # AppConfig 读写
|
||||||
|
│ │ ├── auth.ts # 登录 API
|
||||||
|
│ │ ├── background.ts # 背景控制
|
||||||
|
│ │ ├── install.ts # Minecraft 安装
|
||||||
|
│ │ ├── launch.ts # 游戏启动
|
||||||
|
│ │ ├── mods.ts # Mod 搜索
|
||||||
|
│ │ ├── instance.ts # 实例 API
|
||||||
|
│ │ └── update.ts # 应用更新
|
||||||
|
│ ├── hooks/
|
||||||
|
│ │ └── useTheme.ts # 同步 darkMode → .dark class
|
||||||
|
│ ├── lib/
|
||||||
|
│ │ ├── mode.ts # BUILD_MODE, DEFAULT_BG, LOGO_SVG, APP_ICON
|
||||||
|
│ │ └── utils.ts # cn() 工具函数
|
||||||
|
│ ├── types/
|
||||||
|
│ │ └── task.ts # Task 类型定义
|
||||||
|
│ └── pages/ # 页面组件
|
||||||
|
├── electron/ # Electron 主进程
|
||||||
|
│ ├── main.ts # 主入口, 窗口管理, splash→main 过渡
|
||||||
|
│ ├── preload.ts # Context bridge (window.electronAPI)
|
||||||
|
│ ├── config.ts # YAML 配置管理 (稀疏保存)
|
||||||
|
│ ├── auth.ts # 认证数据持久化 (JSON 文件)
|
||||||
|
│ ├── core/ # @xmcl/* 集成
|
||||||
|
│ │ ├── auth.ts # Microsoft OAuth, Xbox Live, MC auth
|
||||||
|
│ │ ├── installer.ts # @xmcl/installer
|
||||||
|
│ │ ├── launcher.ts # @xmcl/core 游戏启动
|
||||||
|
│ │ ├── modrinth.ts # Modrinth/CurseForge API
|
||||||
|
│ │ └── instance.ts # 实例管理
|
||||||
|
│ ├── handlers/ # IPC 处理器
|
||||||
|
│ │ ├── config.ts # 配置读写
|
||||||
|
│ │ ├── auth.ts # 认证操作
|
||||||
|
│ │ ├── install.ts # 安装操作
|
||||||
|
│ │ ├── launch.ts # 游戏启动
|
||||||
|
│ │ ├── mods.ts # Mod 操作
|
||||||
|
│ │ ├── instance.ts # 实例操作
|
||||||
|
│ │ ├── background.ts # 背景操作
|
||||||
|
│ │ ├── task.ts # 任务系统
|
||||||
|
│ │ ├── system.ts # 系统信息
|
||||||
|
│ │ └── window.ts # 窗口控制 + splash 管理
|
||||||
|
│ └── types/
|
||||||
|
│ └── electron.d.ts # TypeScript 声明
|
||||||
|
├── public/ # 静态资源
|
||||||
|
│ ├── icons/
|
||||||
|
│ │ ├── dev/icon.ico, icon.png
|
||||||
|
│ │ ├── beta/icon.ico, icon.png
|
||||||
|
│ │ └── run/icon.ico, icon.png
|
||||||
|
│ ├── background.png # 默认背景图
|
||||||
|
│ ├── koring-licon.svg # Logo
|
||||||
|
│ └── ...
|
||||||
|
├── build/ # 构建资源 (gitignored, 由 switch-icon.js 生成)
|
||||||
|
│ ├── icon.ico
|
||||||
|
│ └── icon.png
|
||||||
|
├── scripts/
|
||||||
|
│ └── switch-icon.js # 图标切换脚本
|
||||||
|
├── splash.html # 启动动画 HTML 入口
|
||||||
|
├── electron-builder.yml # 打包配置
|
||||||
|
├── vite.config.ts # Vite 配置
|
||||||
|
├── tsconfig.electron.json # 主进程 TS 编译配置
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 配置存储架构
|
## 配置存储架构
|
||||||
|
|
||||||
### 概览
|
### 概览
|
||||||
@@ -55,8 +186,7 @@ VITE_START_POP_BOUTTON="..." # 弹窗按钮文字
|
|||||||
| 数据类型 | 存储位置 | 格式 | 说明 |
|
| 数据类型 | 存储位置 | 格式 | 说明 |
|
||||||
|---------|---------|------|------|
|
|---------|---------|------|------|
|
||||||
| 用户设置 | 程序目录 `Koring.yml` | YAML | 所有可配置项 |
|
| 用户设置 | 程序目录 `Koring.yml` | YAML | 所有可配置项 |
|
||||||
| 账户凭证 | Windows Registry `HKCU\Software\KoringLauncher` | REG_SZ | token/xboxProfile |
|
| 认证数据 | 程序目录 `koring-auth.json` | JSON | token/xboxProfile |
|
||||||
| 实例配置 | 实例目录 `koring-instance.json` | JSON | per-instance |
|
|
||||||
| 任务历史 | localStorage `koring-task-history` | JSON | 临时,max 50 |
|
| 任务历史 | localStorage `koring-task-history` | JSON | 临时,max 50 |
|
||||||
|
|
||||||
### Koring.yml 结构
|
### Koring.yml 结构
|
||||||
@@ -114,18 +244,6 @@ network:
|
|||||||
authUrl: ""
|
authUrl: ""
|
||||||
```
|
```
|
||||||
|
|
||||||
### 注册表结构
|
|
||||||
|
|
||||||
```
|
|
||||||
HKCU\Software\KoringLauncher
|
|
||||||
└─ auth
|
|
||||||
├─ username (REG_SZ)
|
|
||||||
├─ uuid (REG_SZ)
|
|
||||||
├─ accessToken (REG_SZ)
|
|
||||||
├─ refreshToken (REG_SZ)
|
|
||||||
└─ xboxProfile (REG_SZ, JSON string)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 向上兼容策略
|
### 向上兼容策略
|
||||||
|
|
||||||
1. **版本号** — `version` 字段,每次结构变更递增
|
1. **版本号** — `version` 字段,每次结构变更递增
|
||||||
@@ -134,134 +252,123 @@ HKCU\Software\KoringLauncher
|
|||||||
4. **未知字段保留** — YAML 解析器保留不认识的字段
|
4. **未知字段保留** — YAML 解析器保留不认识的字段
|
||||||
5. **Debounce 写入** — 300ms debounce 避免频繁 IO
|
5. **Debounce 写入** — 300ms debounce 避免频繁 IO
|
||||||
|
|
||||||
### Rust 实现
|
---
|
||||||
|
|
||||||
**新文件:**
|
## Electron 主进程
|
||||||
- `src-tauri/src/config.rs` — `AppConfig` 结构体、`load_config()`、`save_config()`、`merge_defaults()`、`migrate()`
|
|
||||||
- `src-tauri/src/registry.rs` — `read_auth()`、`write_auth()`、`delete_auth()`
|
|
||||||
|
|
||||||
**Tauri 命令:**
|
### 窗口管理
|
||||||
- `get_config` → 返回 `AppConfig` JSON
|
|
||||||
- `save_config(cfg)` → 写入 `Koring.yml`
|
|
||||||
- `get_auth` → 从注册表读取 `AuthData`
|
|
||||||
- `save_auth(auth)` → 写入注册表
|
|
||||||
- `delete_auth_cmd` → 删除注册表认证数据
|
|
||||||
|
|
||||||
### 前端实现
|
| 窗口 | 尺寸 | 特性 |
|
||||||
|
|---|---|---|
|
||||||
|
| splash | 480×320 | 无边框, 透明, 不可缩放, 居中 |
|
||||||
|
| main | 1000×700 (min 800×600) | 无边框, 透明, 初始隐藏 |
|
||||||
|
|
||||||
**新文件:**
|
### 启动流程
|
||||||
- `src/api/config.ts` — `getConfig()`、`saveConfig()` 类型定义
|
|
||||||
- `src/api/auth-registry.ts` — `getAuth()`、`saveAuth()`、`deleteAuth()`
|
|
||||||
- `src/stores/configStore.ts` — 统一配置 Zustand store
|
|
||||||
|
|
||||||
**configStore 接口:**
|
```
|
||||||
```ts
|
app.whenReady()
|
||||||
useConfigStore.getState().config // 完整配置
|
→ registerAllHandlers() # 注册所有 IPC 处理器
|
||||||
useConfigStore.getState().loaded // 是否已加载
|
→ createSplashWindow() # 立即显示 splash
|
||||||
useConfigStore.getState().init() // 从 Rust 加载
|
→ createMainWindow() # 后台创建 main (show: false)
|
||||||
useConfigStore.getState().setTheme({...}) // 部分更新 + debounce 写回
|
→ ready-to-show + 1.5s min # 两个条件都满足后:
|
||||||
useConfigStore.getState().setA11y({...})
|
→ mainWindow.show() # 显示主窗口
|
||||||
useConfigStore.getState().setBackground({...})
|
→ splashWindow.close() # 关闭 splash
|
||||||
useConfigStore.getState().setGame({...})
|
|
||||||
useConfigStore.getState().setJava({...})
|
|
||||||
useConfigStore.getState().setAdvanced({...})
|
|
||||||
useConfigStore.getState().setDownload({...})
|
|
||||||
useConfigStore.getState().setNetwork({...})
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Store 委托模式:**
|
### Mutable Win Ref
|
||||||
themeStore / a11yStore / backgroundStore / authStore 都是 configStore 的薄包装层:
|
|
||||||
- 读取时从 `configStore.config.*` 同步
|
|
||||||
- 写入时通过各自的 `set*()` 同时更新本地状态和 configStore
|
|
||||||
- 提供 `sync*FromConfig()` 函数在 App 启动时同步
|
|
||||||
|
|
||||||
**App 启动流程:**
|
`electron/main.ts` 使用可变的 `win` 对象,所有处理器在运行时读取 `win.mainWindow`(而非注册时捕获):
|
||||||
```
|
|
||||||
configStore.init() → syncThemeFromConfig() → syncA11yFromConfig() → syncBackgroundFromConfig() → authStore.initFromRegistry()
|
```typescript
|
||||||
|
const win: { mainWindow: BrowserWindow | null; splashWindow: BrowserWindow | null } = {
|
||||||
|
mainWindow: null,
|
||||||
|
splashWindow: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理器中:
|
||||||
|
registerInstallHandlers(win); // 传入 ref
|
||||||
|
// handler 内部:
|
||||||
|
win.mainWindow?.webContents.send('install:progress', data);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### IPC 处理器
|
||||||
|
|
||||||
|
| 频道 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `config:get` / `config:save` | 配置读写 |
|
||||||
|
| `auth:offline-login` / `auth:get` / `auth:save` / `auth:delete` | 认证操作 |
|
||||||
|
| `install:minecraft` / `install:mod-loader` / `install:version-list` | 安装操作 |
|
||||||
|
| `launch:launch` / `launch:diagnose` | 游戏启动 |
|
||||||
|
| `mods:search` / `mods:install` | Mod 操作 |
|
||||||
|
| `instance:create` / `instance:list` / `instance:delete` | 实例操作 |
|
||||||
|
| `background:set-image` / `background:set-color` / `background:reset` | 背景操作 |
|
||||||
|
| `task:progress` / `task:completed` | 任务进度 |
|
||||||
|
| `system:info` | 系统信息 |
|
||||||
|
| `window:minimize` / `window:maximize` / `window:close` | 窗口控制 |
|
||||||
|
| `window:openSplash` / `window:closeSplash` | Splash 管理 |
|
||||||
|
| `dialog:openFile` | 文件选择器 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 项目结构
|
## Zustand Stores
|
||||||
|
|
||||||
|
### configStore (统一配置中心)
|
||||||
|
|
||||||
|
所有用户设置的单一数据源。读写通过 IPC 与 `Koring.yml` 同步。
|
||||||
|
|
||||||
|
```ts
|
||||||
|
config: AppConfig // 完整配置
|
||||||
|
loaded: boolean // 是否已加载
|
||||||
|
|
||||||
|
init() // 从主进程加载配置
|
||||||
|
setTheme(patch) // 部分更新 + debounce 300ms 写回
|
||||||
|
setA11y(patch)
|
||||||
|
setBackground(patch)
|
||||||
|
setGame(patch)
|
||||||
|
setJava(patch)
|
||||||
|
setAdvanced(patch)
|
||||||
|
setDownload(patch)
|
||||||
|
setNetwork(patch)
|
||||||
```
|
```
|
||||||
koring-launcher/
|
|
||||||
├── src/ # 前端源码
|
### themeStore (委托 configStore)
|
||||||
│ ├── App.tsx # 路由入口 + configStore 初始化
|
|
||||||
│ ├── index.css # 全局样式 + CSS 变量 + 动画
|
```ts
|
||||||
│ ├── layouts/
|
darkMode: "auto" | "light" | "dark"
|
||||||
│ │ └── RootLayout.tsx # 三层布局: BackgroundLayer + ContentLayer + SystemLayer
|
parallax: boolean
|
||||||
│ ├── components/
|
setDarkMode(mode) // 更新 DOM + configStore
|
||||||
│ │ ├── background/
|
setParallax(v) // configStore
|
||||||
│ │ │ └── BackgroundLayer.tsx # 全屏背景层 + 视差 + 强内容遮罩
|
syncThemeFromConfig() // 启动时从 config 同步
|
||||||
│ │ ├── system/
|
|
||||||
│ │ │ ├── SystemLayer.tsx # 系统层容器 (z-[100])
|
|
||||||
│ │ │ ├── TitleBar.tsx # 自定义标题栏 (40px, 胶囊菜单/返回按钮)
|
|
||||||
│ │ │ └── WindowControls.tsx # 窗口按钮 + DEV/BETA badge + TaskButton
|
|
||||||
│ │ ├── splash/
|
|
||||||
│ │ │ └── Splash.tsx # 启动动画 (独立窗口, HTML+React)
|
|
||||||
│ │ ├── silk/
|
|
||||||
│ │ │ └── Silk.tsx # WebGL 丝绸着色器 (Three.js)
|
|
||||||
│ │ ├── task/
|
|
||||||
│ │ │ ├── TaskButton.tsx # 标题栏任务指示器 (SVG 圆弧动画)
|
|
||||||
│ │ │ └── TaskCard.tsx # 单个任务卡片 (进度条+日志)
|
|
||||||
│ │ ├── ui/ # shadcn/ui 组件
|
|
||||||
│ │ │ ├── sheet.tsx, button.tsx, switch.tsx, slider.tsx
|
|
||||||
│ │ │ ├── progress.tsx, badge.tsx, separator.tsx
|
|
||||||
│ │ │ ├── label.tsx, radio-group.tsx, alert-dialog.tsx
|
|
||||||
│ │ ├── VersionCard.tsx # 版本/更新卡片 (Silk 背景+毛玻璃)
|
|
||||||
│ │ ├── UnderConstruction.tsx # "装修中" 占位组件
|
|
||||||
│ │ └── StartupPopup.tsx # 启动弹窗 (环境变量控制)
|
|
||||||
│ ├── stores/
|
|
||||||
│ │ ├── configStore.ts # 统一配置 store (→ Koring.yml)
|
|
||||||
│ │ ├── themeStore.ts # 主题 (委托 configStore)
|
|
||||||
│ │ ├── a11yStore.ts # 无障碍 (委托 configStore)
|
|
||||||
│ │ ├── backgroundStore.ts # 背景 (委托 configStore)
|
|
||||||
│ │ ├── authStore.ts # 认证 (→ Registry)
|
|
||||||
│ │ ├── routeStore.ts # 路由 (历史栈)
|
|
||||||
│ │ ├── taskStore.ts # 任务队列 (localStorage)
|
|
||||||
│ │ ├── instanceStore.ts # 实例管理
|
|
||||||
│ │ ├── installStore.ts # Minecraft 安装
|
|
||||||
│ │ ├── launchStore.ts # 游戏启动
|
|
||||||
│ │ ├── modsStore.ts # Mod 搜索
|
|
||||||
│ │ ├── updateStore.ts # 应用更新
|
|
||||||
│ │ └── devStore.ts # 开发者调试
|
|
||||||
│ ├── api/
|
|
||||||
│ │ ├── config.ts # AppConfig 读写 (→ Tauri invoke)
|
|
||||||
│ │ ├── auth-registry.ts # AuthData 读写 (→ Registry)
|
|
||||||
│ │ ├── instance.ts # 实例 API
|
|
||||||
│ │ ├── auth.ts # 登录 API
|
|
||||||
│ │ ├── sidecar.ts # 通用 sidecar IPC
|
|
||||||
│ │ └── ...
|
|
||||||
│ ├── hooks/
|
|
||||||
│ │ └── useTheme.ts # 同步 darkMode → .dark class
|
|
||||||
│ ├── lib/
|
|
||||||
│ │ ├── mode.ts # BUILD_MODE 常量
|
|
||||||
│ │ └── utils.ts # cn() 工具函数
|
|
||||||
│ ├── types/
|
|
||||||
│ │ └── task.ts # Task 类型定义
|
|
||||||
│ └── pages/ # 页面组件
|
|
||||||
├── src-tauri/ # Rust 后端
|
|
||||||
│ ├── Cargo.toml # 依赖: serde, serde_json, serde_yaml, winreg
|
|
||||||
│ ├── tauri.conf.json # 窗口配置 + Bundle + Updater
|
|
||||||
│ ├── capabilities/default.json # 权限声明
|
|
||||||
│ ├── src/
|
|
||||||
│ │ ├── lib.rs # 插件注册 + splash/main + 命令注册
|
|
||||||
│ │ ├── config.rs # AppConfig 读写 + 迁移 + 默认值
|
|
||||||
│ │ ├── registry.rs # Windows Registry 读写
|
|
||||||
│ │ ├── commands/mod.rs # Tauri 命令 (→ sidecar + config + auth)
|
|
||||||
│ │ └── sidecar.rs # Sidecar 进程管理
|
|
||||||
│ └── binaries/ # Sidecar 二进制文件
|
|
||||||
├── sidecar/ # Node.js Sidecar
|
|
||||||
│ └── src/
|
|
||||||
│ ├── handlers/ # 10 个 handler
|
|
||||||
│ ├── utils/paths.ts # 路径工具 + InstanceConfig
|
|
||||||
│ └── protocol/types.ts # IPC 消息类型
|
|
||||||
├── splash.html # 启动动画 HTML 入口
|
|
||||||
├── build-vs.cmd # VS 环境编译脚本
|
|
||||||
└── dev-vs.cmd # VS 环境开发脚本
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### backgroundStore (委托 configStore)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type: "image" | "color", image, blur, opacity
|
||||||
|
setImage / setColor / setBlur / setOpacity / reset
|
||||||
|
syncBackgroundFromConfig()
|
||||||
|
```
|
||||||
|
|
||||||
|
### authStore (委托 JSON 文件)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
user: AuthResult | null
|
||||||
|
initFromFile() // 从 koring-auth.json 加载
|
||||||
|
loginOffline(username) // 通过主进程 + 保存到文件
|
||||||
|
logout() // 清除文件
|
||||||
|
```
|
||||||
|
|
||||||
|
### 其他 Store
|
||||||
|
|
||||||
|
- `routeStore` — 历史栈导航
|
||||||
|
- `taskStore` — 任务队列 (localStorage)
|
||||||
|
- `devStore` — 开发者调试 (内存)
|
||||||
|
- `installStore` — Minecraft 安装 (内存)
|
||||||
|
- `launchStore` — 游戏启动 (内存)
|
||||||
|
- `modsStore` — Mod 搜索 (内存)
|
||||||
|
- `updateStore` — 应用更新 (内存)
|
||||||
|
- `instanceStore` — 实例管理
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 页面路由
|
## 页面路由
|
||||||
@@ -287,89 +394,6 @@ koring-launcher/
|
|||||||
| `debug-version-card` | 版本卡片调试 | `pages/debug/version-card-debug.tsx` |
|
| `debug-version-card` | 版本卡片调试 | `pages/debug/version-card-debug.tsx` |
|
||||||
| `debug-task` | 任务队列调试 | `pages/debug/task-debug.tsx` |
|
| `debug-task` | 任务队列调试 | `pages/debug/task-debug.tsx` |
|
||||||
|
|
||||||
### 路由导航 (历史栈)
|
|
||||||
|
|
||||||
路由使用动态历史栈替代静态 parentMap。每次 `navigate()` 压入历史,`goBack()` 弹出。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Zustand Stores
|
|
||||||
|
|
||||||
### configStore (统一配置中心)
|
|
||||||
|
|
||||||
所有用户设置的单一数据源。读写通过 Tauri invoke 与 `Koring.yml` 同步。
|
|
||||||
|
|
||||||
```ts
|
|
||||||
config: AppConfig // 完整配置
|
|
||||||
loaded: boolean // 是否已从 Rust 加载
|
|
||||||
|
|
||||||
init() // 从 Rust 加载配置
|
|
||||||
setTheme(patch) // 部分更新 + debounce 300ms 写回
|
|
||||||
setA11y(patch)
|
|
||||||
setBackground(patch)
|
|
||||||
setGame(patch)
|
|
||||||
setJava(patch)
|
|
||||||
setAdvanced(patch)
|
|
||||||
setDownload(patch)
|
|
||||||
setNetwork(patch)
|
|
||||||
```
|
|
||||||
|
|
||||||
### themeStore (委托 configStore)
|
|
||||||
|
|
||||||
```ts
|
|
||||||
darkMode: "auto" | "light" | "dark"
|
|
||||||
parallax: boolean
|
|
||||||
setDarkMode(mode) // 更新 DOM + configStore
|
|
||||||
setParallax(v) // configStore
|
|
||||||
syncThemeFromConfig() // 启动时从 config 同步
|
|
||||||
```
|
|
||||||
|
|
||||||
### a11yStore (委托 configStore)
|
|
||||||
|
|
||||||
```ts
|
|
||||||
reduceMotion, reduceTransparency, highContrast, contentBlurOpacity
|
|
||||||
syncA11yFromConfig()
|
|
||||||
```
|
|
||||||
|
|
||||||
### backgroundStore (委托 configStore)
|
|
||||||
|
|
||||||
```ts
|
|
||||||
type: "image" | "color", image, blur, opacity
|
|
||||||
setImage / setColor / setBlur / setOpacity / reset
|
|
||||||
syncBackgroundFromConfig()
|
|
||||||
```
|
|
||||||
|
|
||||||
### authStore (委托 Registry)
|
|
||||||
|
|
||||||
```ts
|
|
||||||
user: AuthResult | null
|
|
||||||
initFromRegistry() // 从 Windows Registry 加载
|
|
||||||
loginOffline(username) // 通过 sidecar + 保存到 registry
|
|
||||||
logout() // 清除 registry
|
|
||||||
```
|
|
||||||
|
|
||||||
### routeStore (历史栈导航)
|
|
||||||
|
|
||||||
```ts
|
|
||||||
current: RouteKey
|
|
||||||
history: RouteKey[] // 导航历史栈
|
|
||||||
navigate(key) // 压入历史
|
|
||||||
goBack() // 弹出历史
|
|
||||||
```
|
|
||||||
|
|
||||||
### taskStore (localStorage)
|
|
||||||
|
|
||||||
任务队列。localStorage 持久化历史 (max 50)。
|
|
||||||
|
|
||||||
### 其他 Store
|
|
||||||
|
|
||||||
- `devStore` — 开发者调试 (内存)
|
|
||||||
- `installStore` — Minecraft 安装 (内存,ephemeral)
|
|
||||||
- `launchStore` — 游戏启动 (内存,ephemeral)
|
|
||||||
- `modsStore` — Mod 搜索 (内存,ephemeral)
|
|
||||||
- `updateStore` — 应用更新 (内存,ephemeral)
|
|
||||||
- `instanceStore` — 实例管理 (sidecar 查询)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 核心组件
|
## 核心组件
|
||||||
@@ -390,6 +414,10 @@ z-200 StartupPopup 启动弹窗 (环境变量控制)
|
|||||||
- **`sub`**: 左侧返回按钮 + 品牌文字 + 右侧窗口控制 (隐藏 TaskButton)
|
- **`sub`**: 左侧返回按钮 + 品牌文字 + 右侧窗口控制 (隐藏 TaskButton)
|
||||||
- **`window`**: 仅窗口控制
|
- **`window`**: 仅窗口控制
|
||||||
|
|
||||||
|
### WindowControls 窗口控制
|
||||||
|
|
||||||
|
使用 `<button>` 元素,CSS `WebkitAppRegion: "no-drag"` 实现按钮可点击。
|
||||||
|
|
||||||
### BackgroundLayer 背景层
|
### BackgroundLayer 背景层
|
||||||
|
|
||||||
- 支持 `image` (CSS background-image) 和 `color` (CSS background-color) 两种类型
|
- 支持 `image` (CSS background-image) 和 `color` (CSS background-color) 两种类型
|
||||||
@@ -397,13 +425,6 @@ z-200 StartupPopup 启动弹窗 (环境变量控制)
|
|||||||
- 强内容遮罩: 非 home 页面自动显示 (可通过 `contentBlurOpacity` 控制)
|
- 强内容遮罩: 非 home 页面自动显示 (可通过 `contentBlurOpacity` 控制)
|
||||||
- 深色模式叠加层: `bg-black/35`
|
- 深色模式叠加层: `bg-black/35`
|
||||||
|
|
||||||
### StartCard 启动组件 (首页)
|
|
||||||
|
|
||||||
胶囊形启动组件,位于首页左下角:
|
|
||||||
- 左: 设置齿轮图标 (→ setting)
|
|
||||||
- 中: "启动游戏" 按钮 (primary 色, rounded-full)
|
|
||||||
- 右: 实例选择图标 (Package)
|
|
||||||
|
|
||||||
### TaskQueue 任务系统
|
### TaskQueue 任务系统
|
||||||
|
|
||||||
- 执行器模式: `addTask(type, title, desc, async (ctx) => {...})`
|
- 执行器模式: `addTask(type, title, desc, async (ctx) => {...})`
|
||||||
@@ -411,44 +432,3 @@ z-200 StartupPopup 启动弹窗 (环境变量控制)
|
|||||||
- AbortController 取消机制
|
- AbortController 取消机制
|
||||||
- localStorage 持久化历史 (max 50)
|
- localStorage 持久化历史 (max 50)
|
||||||
- 任务类型: `install` / `download` / `update` / `launch` / `auth` / `sync` / `custom`
|
- 任务类型: `install` / `download` / `update` / `launch` / `auth` / `sync` / `custom`
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tauri 窗口配置
|
|
||||||
|
|
||||||
| 窗口 | 尺寸 | 特性 |
|
|
||||||
|---|---|---|
|
|
||||||
| splashscreen | 480×320 | 无边框, 透明, 不可缩放, 居中 |
|
|
||||||
| main | 900×600 (min 800×600) | 无边框, 透明, 隐藏启动 |
|
|
||||||
|
|
||||||
## 权限 (capabilities/default.json)
|
|
||||||
|
|
||||||
```
|
|
||||||
core:default, core:window:default, core:window:allow-*,
|
|
||||||
core:webview:allow-create-webview-window,
|
|
||||||
opener:default, process:default, shell:allow-execute,
|
|
||||||
updater:default, dialog:default
|
|
||||||
```
|
|
||||||
|
|
||||||
## Rust 依赖
|
|
||||||
|
|
||||||
```toml
|
|
||||||
tauri = "2"
|
|
||||||
tauri-plugin-opener, tauri-plugin-process, tauri-plugin-dialog,
|
|
||||||
tauri-plugin-shell, tauri-plugin-updater
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
|
||||||
serde_json = "1"
|
|
||||||
serde_yaml = "0.9"
|
|
||||||
uuid = { version = "1", features = ["v4"] }
|
|
||||||
tokio = { version = "1", features = ["time"] }
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
|
||||||
winreg = "0.52"
|
|
||||||
```
|
|
||||||
|
|
||||||
## IPC 协议
|
|
||||||
|
|
||||||
前端 → `invoke()` → Rust `commands::*` → sidecar stdin (JSON) → sidecar stdout (JSON) → Tauri 事件 → 前端
|
|
||||||
|
|
||||||
Sidecar 命令: `install-minecraft`, `install-mod-loader`, `get-version-list`, `launch-game`, `offline-login`, `search-mods`, `install-mod`, `create-instance`, `list-instances`
|
|
||||||
|
|
||||||
Rust 直接命令: `get_config`, `save_config`, `get_auth`, `save_auth`, `delete_auth_cmd`
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -15,7 +15,9 @@ pnpm dev:main # electron main process only
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm build # production build (vite + tsc)
|
pnpm build # production build (vite + tsc)
|
||||||
pnpm dist:win # build Windows installer
|
pnpm dist:dev # dev icon + Windows installer
|
||||||
|
pnpm dist:beta # beta icon + Windows installer
|
||||||
|
pnpm dist:run # production icon + Windows installer
|
||||||
pnpm dist:mac # build macOS DMG
|
pnpm dist:mac # build macOS DMG
|
||||||
pnpm dist:linux # build Linux AppImage
|
pnpm dist:linux # build Linux AppImage
|
||||||
```
|
```
|
||||||
@@ -26,6 +28,7 @@ pnpm dist:linux # build Linux AppImage
|
|||||||
src/ Frontend (React 19 + Vite 7 + Tailwind v4 + shadcn/ui + Zustand)
|
src/ Frontend (React 19 + Vite 7 + Tailwind v4 + shadcn/ui + Zustand)
|
||||||
electron/ Main process (Node.js/TypeScript, @xmcl/* packages)
|
electron/ Main process (Node.js/TypeScript, @xmcl/* packages)
|
||||||
public/ Static assets (icons, fonts, images)
|
public/ Static assets (icons, fonts, images)
|
||||||
|
build/ Build resources (generated, gitignored)
|
||||||
```
|
```
|
||||||
|
|
||||||
**IPC Flow:**
|
**IPC Flow:**
|
||||||
@@ -54,12 +57,12 @@ src/
|
|||||||
│ ├── Home.tsx # Main page
|
│ ├── Home.tsx # Main page
|
||||||
│ └── Debug.tsx # Debug tools
|
│ └── Debug.tsx # Debug tools
|
||||||
├── lib/
|
├── lib/
|
||||||
│ ├── mode.ts # Build mode detection (dev/beta/run)
|
│ ├── mode.ts # Build mode constants (DEFAULT_BG, LOGO_SVG, APP_ICON)
|
||||||
│ └── utils.ts # cn() helper
|
│ └── utils.ts # cn() helper
|
||||||
└── App.tsx # Root component with state router
|
└── App.tsx # Root component with state router
|
||||||
|
|
||||||
electron/
|
electron/
|
||||||
├── main.ts # Electron entry, window management
|
├── main.ts # Electron entry, window management, splash→main transition
|
||||||
├── preload.ts # Context bridge (window.electronAPI)
|
├── preload.ts # Context bridge (window.electronAPI)
|
||||||
├── config.ts # YAML config management
|
├── config.ts # YAML config management
|
||||||
├── auth.ts # Auth data persistence
|
├── auth.ts # Auth data persistence
|
||||||
@@ -79,7 +82,7 @@ electron/
|
|||||||
│ ├── background.ts # Background operations
|
│ ├── background.ts # Background operations
|
||||||
│ ├── task.ts # Task system
|
│ ├── task.ts # Task system
|
||||||
│ ├── system.ts # System info
|
│ ├── system.ts # System info
|
||||||
│ └── window.ts # Window controls
|
│ └── window.ts # Window controls + splash management
|
||||||
└── types/
|
└── types/
|
||||||
└── electron.d.ts # TypeScript declarations
|
└── electron.d.ts # TypeScript declarations
|
||||||
```
|
```
|
||||||
@@ -109,22 +112,35 @@ electron/
|
|||||||
- Window: 480×320, no decorations, transparent, locked size
|
- Window: 480×320, no decorations, transparent, locked size
|
||||||
- Auto-adapts to system dark mode (`prefers-color-scheme`)
|
- Auto-adapts to system dark mode (`prefers-color-scheme`)
|
||||||
- Logo: `filter: invert(1)` in dark mode
|
- Logo: `filter: invert(1)` in dark mode
|
||||||
|
- Startup: splash shows first → main loads behind → transition after `ready-to-show` + 1.5s minimum
|
||||||
|
|
||||||
## Icon System
|
## Icon System
|
||||||
|
|
||||||
Three icon variants in `public/`:
|
Three icon variants in `public/icons/`:
|
||||||
|
|
||||||
| Mode | File | Use Case |
|
```
|
||||||
|------|------|----------|
|
public/icons/
|
||||||
| dev | `dev.png` / `dev.ico` | Development |
|
dev/icon.ico, icon.png # Development
|
||||||
| beta | `beta.png` / `beta.ico` | Testing |
|
beta/icon.ico, icon.png # Testing
|
||||||
| run | `run.png` / `run.ico` | Production release |
|
run/icon.ico, icon.png # Production release
|
||||||
|
```
|
||||||
|
|
||||||
|
**Build-time switching:**
|
||||||
|
```bash
|
||||||
|
pnpm icon:dev # copies public/icons/dev/ → build/
|
||||||
|
pnpm icon:beta # copies public/icons/beta/ → build/
|
||||||
|
pnpm icon:run # copies public/icons/run/ → build/
|
||||||
|
```
|
||||||
|
|
||||||
|
`electron-builder.yml` reads icons from `build/` (`buildResources: build`).
|
||||||
|
|
||||||
**Frontend usage:**
|
**Frontend usage:**
|
||||||
```tsx
|
```tsx
|
||||||
import { APP_ICON, BUILD_MODE, isDev } from "@/lib/mode";
|
import { APP_ICON, DEFAULT_BG, LOGO_SVG, BUILD_MODE, isDev } from "@/lib/mode";
|
||||||
|
|
||||||
<img src={APP_ICON} />
|
<img src={APP_ICON} />
|
||||||
|
<img src={LOGO_SVG} />
|
||||||
|
<img src={DEFAULT_BG} />
|
||||||
{isDev && <span>Dev Mode</span>}
|
{isDev && <span>Dev Mode</span>}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -139,15 +155,19 @@ import { APP_ICON, BUILD_MODE, isDev } from "@/lib/mode";
|
|||||||
- `background:*` — Background image/color/blur/animation/theme
|
- `background:*` — Background image/color/blur/animation/theme
|
||||||
- `task:*` — Task system progress
|
- `task:*` — Task system progress
|
||||||
- `system:*` — System info
|
- `system:*` — System info
|
||||||
- `window:*` — Minimize/maximize/close
|
- `window:*` — Minimize/maximize/close + splash management
|
||||||
|
- `dialog:*` — File picker
|
||||||
|
|
||||||
## Key Gotchas
|
## Key Gotchas
|
||||||
|
|
||||||
- **@xmcl packages run in main process**: `@xmcl/core`, `@xmcl/installer` require `fs`/`child_process`. All run in Electron main process.
|
- **@xmcl packages run in main process**: `@xmcl/core`, `@xmcl/installer` require `fs`/`child_process`. All run in Electron main process.
|
||||||
- **Path alias**: `@/` maps to `src/`.
|
- **Path alias**: `@/` maps to `src/`.
|
||||||
- **Window dragging**: Use CSS `-webkit-app-region: drag` on titlebar.
|
- **Window dragging**: Use CSS `WebkitAppRegion: "drag"` as inline style (Electron only respects CSS property, not HTML attributes).
|
||||||
- **Transparent windows**: `transparent: true` + `frame: false` in BrowserWindow options.
|
- **Transparent windows**: `transparent: true` + `frame: false` in BrowserWindow options.
|
||||||
- **Auth storage**: JSON file (`koring-auth.json`) stored next to executable.
|
- **Mutable win ref**: `electron/main.ts` uses a mutable `win` object — all handlers read `win.mainWindow` at runtime (not captured at registration time).
|
||||||
|
- **Asset paths**: Use `import.meta.env.BASE_URL` prefix for public assets (e.g., `${import.meta.env.BASE_URL}background.png`). Absolute paths like `/background.png` break in packaged app.
|
||||||
|
- **Config**: YAML format (`Koring.yml`) stored next to executable. Sparse save (only non-default values).
|
||||||
|
- **Auth**: JSON file (`koring-auth.json`) stored next to executable.
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
|
|||||||
@@ -2,26 +2,27 @@ appId: com.lingke.koring.launcher
|
|||||||
productName: Koring Launcher
|
productName: Koring Launcher
|
||||||
directories:
|
directories:
|
||||||
output: dist-electron
|
output: dist-electron
|
||||||
|
buildResources: build
|
||||||
files:
|
files:
|
||||||
- dist/**
|
- dist/**
|
||||||
- electron/**/*.js
|
- electron/**/*.js
|
||||||
- "!electron/**/*.ts"
|
- "!electron/**/*.ts"
|
||||||
win:
|
win:
|
||||||
target: nsis
|
target: nsis
|
||||||
icon: public/icon.ico
|
icon: build/icon.ico
|
||||||
artifactName: "koring-launcher-${version}-setup.${ext}"
|
artifactName: "koring-launcher-${version}-setup.${ext}"
|
||||||
mac:
|
mac:
|
||||||
target: dmg
|
target: dmg
|
||||||
icon: public/icon.png
|
icon: build/icon.png
|
||||||
linux:
|
linux:
|
||||||
target: AppImage
|
target: AppImage
|
||||||
icon: public/icon.png
|
icon: build/icon.png
|
||||||
nsis:
|
nsis:
|
||||||
oneClick: false
|
oneClick: false
|
||||||
allowToChangeInstallationDirectory: true
|
allowToChangeInstallationDirectory: true
|
||||||
installerIcon: public/icon.ico
|
installerIcon: build/icon.ico
|
||||||
uninstallerIcon: public/icon.ico
|
uninstallerIcon: build/icon.ico
|
||||||
installerHeaderIcon: public/icon.ico
|
installerHeaderIcon: build/icon.ico
|
||||||
createDesktopShortcut: true
|
createDesktopShortcut: true
|
||||||
createStartMenuShortcut: true
|
createStartMenuShortcut: true
|
||||||
shortcutName: Koring Launcher
|
shortcutName: Koring Launcher
|
||||||
|
|||||||
@@ -35,45 +35,74 @@ var __importStar = (this && this.__importStar) || (function () {
|
|||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
exports.createInstance = createInstance;
|
exports.createInstance = createInstance;
|
||||||
exports.listInstances = listInstances;
|
exports.listInstances = listInstances;
|
||||||
exports.deleteInstance = deleteInstance;
|
|
||||||
exports.getInstanceInfo = getInstanceInfo;
|
exports.getInstanceInfo = getInstanceInfo;
|
||||||
|
exports.deleteInstance = deleteInstance;
|
||||||
|
exports.updateInstance = updateInstance;
|
||||||
|
exports.installInstanceGame = installInstanceGame;
|
||||||
|
exports.launchInstance = launchInstance;
|
||||||
|
exports.diagnoseInstance = diagnoseInstance;
|
||||||
|
exports.getMinecraftVersionList = getMinecraftVersionList;
|
||||||
|
exports.getForgeVersionList = getForgeVersionList;
|
||||||
|
exports.getFabricVersionList = getFabricVersionList;
|
||||||
|
exports.getQuiltVersionList = getQuiltVersionList;
|
||||||
const fs = __importStar(require("fs"));
|
const fs = __importStar(require("fs"));
|
||||||
const path = __importStar(require("path"));
|
const path = __importStar(require("path"));
|
||||||
const INSTANCE_CONFIG_FILE = 'koring-instance.json';
|
const core_1 = require("@xmcl/core");
|
||||||
|
const installer_1 = require("@xmcl/installer");
|
||||||
|
const INSTANCE_CONFIG_FILE = 'instance.json';
|
||||||
function getInstanceConfigPath(instancePath) {
|
function getInstanceConfigPath(instancePath) {
|
||||||
return path.join(instancePath, INSTANCE_CONFIG_FILE);
|
return path.join(instancePath, INSTANCE_CONFIG_FILE);
|
||||||
}
|
}
|
||||||
async function createInstance(name, gamePath, mcVersion, loaderType, loaderVersion, javaPath, memory) {
|
function countFiles(dir, ext) {
|
||||||
const instancePath = path.join(gamePath, 'instances', name);
|
if (!fs.existsSync(dir))
|
||||||
if (!fs.existsSync(instancePath)) {
|
return 0;
|
||||||
fs.mkdirSync(instancePath, { recursive: true });
|
return fs.readdirSync(dir).filter((f) => {
|
||||||
|
if (ext)
|
||||||
|
return f.endsWith(ext);
|
||||||
|
return fs.statSync(path.join(dir, f)).isFile();
|
||||||
|
}).length;
|
||||||
|
}
|
||||||
|
function getInstanceIssues(instancePath, runtime) {
|
||||||
|
const issues = [];
|
||||||
|
if (!fs.existsSync(path.join(instancePath, 'versions', runtime.minecraft, `${runtime.minecraft}.json`))) {
|
||||||
|
issues.push(`Version JSON not found for ${runtime.minecraft}`);
|
||||||
}
|
}
|
||||||
|
if (!fs.existsSync(path.join(instancePath, 'versions', runtime.minecraft, `${runtime.minecraft}.jar`))) {
|
||||||
|
issues.push(`Client JAR not found for ${runtime.minecraft}`);
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
async function createInstance(name, gamePath, runtime, options) {
|
||||||
|
const instancePath = path.join(gamePath, 'instances', name);
|
||||||
|
if (fs.existsSync(instancePath)) {
|
||||||
|
throw new Error(`Instance already exists: ${name}`);
|
||||||
|
}
|
||||||
|
fs.mkdirSync(instancePath, { recursive: true });
|
||||||
|
const now = Date.now();
|
||||||
const config = {
|
const config = {
|
||||||
name,
|
name,
|
||||||
mcVersion,
|
author: options?.author || '',
|
||||||
loaderType,
|
description: options?.description || '',
|
||||||
loaderVersion,
|
runtime,
|
||||||
javaPath,
|
java: options?.java,
|
||||||
memory,
|
minMemory: options?.minMemory,
|
||||||
createdAt: new Date().toISOString(),
|
maxMemory: options?.maxMemory,
|
||||||
|
vmOptions: options?.vmOptions,
|
||||||
|
mcOptions: options?.mcOptions,
|
||||||
|
creationDate: now,
|
||||||
|
lastAccessDate: now,
|
||||||
|
lastPlayedDate: 0,
|
||||||
|
playtime: 0,
|
||||||
};
|
};
|
||||||
const configPath = getInstanceConfigPath(instancePath);
|
fs.writeFileSync(getInstanceConfigPath(instancePath), JSON.stringify(config, null, 2), 'utf-8');
|
||||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
// Create standard directories
|
||||||
// Create mods directory
|
for (const dir of ['mods', 'config', 'resourcepacks', 'shaderpacks', 'saves', 'screenshots', 'logs']) {
|
||||||
const modsDir = path.join(instancePath, 'mods');
|
fs.mkdirSync(path.join(instancePath, dir), { recursive: true });
|
||||||
if (!fs.existsSync(modsDir)) {
|
|
||||||
fs.mkdirSync(modsDir, { recursive: true });
|
|
||||||
}
|
}
|
||||||
// Count mods
|
return getInstanceInfo(name, gamePath);
|
||||||
const mods = fs.readdirSync(modsDir).filter((f) => f.endsWith('.jar'));
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
path: instancePath,
|
|
||||||
config,
|
|
||||||
modCount: mods.length,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
async function listInstances(instancesPath) {
|
async function listInstances(gamePath) {
|
||||||
|
const instancesPath = path.join(gamePath, 'instances');
|
||||||
const instances = [];
|
const instances = [];
|
||||||
if (!fs.existsSync(instancesPath)) {
|
if (!fs.existsSync(instancesPath)) {
|
||||||
return instances;
|
return instances;
|
||||||
@@ -82,57 +111,185 @@ async function listInstances(instancesPath) {
|
|||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!entry.isDirectory())
|
if (!entry.isDirectory())
|
||||||
continue;
|
continue;
|
||||||
const instancePath = path.join(instancesPath, entry.name);
|
|
||||||
const configPath = getInstanceConfigPath(instancePath);
|
|
||||||
if (!fs.existsSync(configPath))
|
|
||||||
continue;
|
|
||||||
try {
|
try {
|
||||||
const configRaw = fs.readFileSync(configPath, 'utf-8');
|
instances.push(await getInstanceInfo(entry.name, gamePath));
|
||||||
const config = JSON.parse(configRaw);
|
|
||||||
// Count mods
|
|
||||||
const modsDir = path.join(instancePath, 'mods');
|
|
||||||
let modCount = 0;
|
|
||||||
if (fs.existsSync(modsDir)) {
|
|
||||||
modCount = fs.readdirSync(modsDir).filter((f) => f.endsWith('.jar')).length;
|
|
||||||
}
|
|
||||||
instances.push({
|
|
||||||
name: entry.name,
|
|
||||||
path: instancePath,
|
|
||||||
config,
|
|
||||||
modCount,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
// Skip invalid config
|
// Skip invalid instances
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return instances;
|
return instances;
|
||||||
}
|
}
|
||||||
async function deleteInstance(name, instancesPath) {
|
async function getInstanceInfo(name, gamePath) {
|
||||||
const instancePath = path.join(instancesPath, name);
|
const instancePath = path.join(gamePath, 'instances', name);
|
||||||
if (fs.existsSync(instancePath)) {
|
|
||||||
fs.rmSync(instancePath, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
return { deleted: name };
|
|
||||||
}
|
|
||||||
async function getInstanceInfo(name, instancesPath) {
|
|
||||||
const instancePath = path.join(instancesPath, name);
|
|
||||||
const configPath = getInstanceConfigPath(instancePath);
|
const configPath = getInstanceConfigPath(instancePath);
|
||||||
if (!fs.existsSync(configPath)) {
|
if (!fs.existsSync(configPath)) {
|
||||||
throw new Error(`Instance not found: ${name}`);
|
throw new Error(`Instance not found: ${name}`);
|
||||||
}
|
}
|
||||||
const configRaw = fs.readFileSync(configPath, 'utf-8');
|
const configRaw = fs.readFileSync(configPath, 'utf-8');
|
||||||
const config = JSON.parse(configRaw);
|
const config = JSON.parse(configRaw);
|
||||||
// Count mods
|
const issues = getInstanceIssues(instancePath, config.runtime);
|
||||||
const modsDir = path.join(instancePath, 'mods');
|
|
||||||
let modCount = 0;
|
|
||||||
if (fs.existsSync(modsDir)) {
|
|
||||||
modCount = fs.readdirSync(modsDir).filter((f) => f.endsWith('.jar')).length;
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
path: instancePath,
|
path: instancePath,
|
||||||
config,
|
config,
|
||||||
modCount,
|
modCount: countFiles(path.join(instancePath, 'mods'), '.jar'),
|
||||||
|
resourcePackCount: countFiles(path.join(instancePath, 'resourcepacks'), '.zip'),
|
||||||
|
screenshotCount: countFiles(path.join(instancePath, 'screenshots')),
|
||||||
|
saveCount: countFiles(path.join(instancePath, 'saves')),
|
||||||
|
healthy: issues.length === 0,
|
||||||
|
issues,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
async function deleteInstance(name, gamePath) {
|
||||||
|
const instancePath = path.join(gamePath, 'instances', name);
|
||||||
|
if (!fs.existsSync(instancePath)) {
|
||||||
|
throw new Error(`Instance not found: ${name}`);
|
||||||
|
}
|
||||||
|
fs.rmSync(instancePath, { recursive: true, force: true });
|
||||||
|
return { deleted: name };
|
||||||
|
}
|
||||||
|
async function updateInstance(name, gamePath, patch) {
|
||||||
|
const instancePath = path.join(gamePath, 'instances', name);
|
||||||
|
const configPath = getInstanceConfigPath(instancePath);
|
||||||
|
if (!fs.existsSync(configPath)) {
|
||||||
|
throw new Error(`Instance not found: ${name}`);
|
||||||
|
}
|
||||||
|
const configRaw = fs.readFileSync(configPath, 'utf-8');
|
||||||
|
const config = JSON.parse(configRaw);
|
||||||
|
const updated = { ...config, ...patch };
|
||||||
|
fs.writeFileSync(configPath, JSON.stringify(updated, null, 2), 'utf-8');
|
||||||
|
return getInstanceInfo(name, gamePath);
|
||||||
|
}
|
||||||
|
async function installInstanceGame(name, gamePath, callbacks) {
|
||||||
|
const instance = await getInstanceInfo(name, gamePath);
|
||||||
|
const { runtime } = instance.config;
|
||||||
|
const instancePath = instance.path;
|
||||||
|
callbacks?.onProgress?.({ stage: 'installing-minecraft', current: 0, total: 100, message: `Installing Minecraft ${runtime.minecraft}...` });
|
||||||
|
// Install Minecraft
|
||||||
|
// First get version list to find the version info
|
||||||
|
const versionList = await (0, installer_1.getVersionList)();
|
||||||
|
const versionInfo = versionList.versions.find((v) => v.id === runtime.minecraft);
|
||||||
|
if (!versionInfo) {
|
||||||
|
throw new Error(`Minecraft version ${runtime.minecraft} not found`);
|
||||||
|
}
|
||||||
|
await (0, installer_1.install)({ id: versionInfo.id, url: versionInfo.url }, instancePath);
|
||||||
|
// Install mod loaders
|
||||||
|
if (runtime.forge) {
|
||||||
|
callbacks?.onProgress?.({ stage: 'installing-forge', current: 0, total: 100, message: `Installing Forge ${runtime.forge}...` });
|
||||||
|
await (0, installer_1.installForge)({ version: runtime.forge, mcversion: runtime.minecraft }, instancePath);
|
||||||
|
}
|
||||||
|
if (runtime.fabricLoader) {
|
||||||
|
callbacks?.onProgress?.({ stage: 'installing-fabric', current: 0, total: 100, message: `Installing Fabric ${runtime.fabricLoader}...` });
|
||||||
|
await (0, installer_1.installFabric)({
|
||||||
|
minecraftVersion: runtime.minecraft,
|
||||||
|
version: runtime.fabricLoader,
|
||||||
|
minecraft: instancePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (runtime.quiltLoader) {
|
||||||
|
callbacks?.onProgress?.({ stage: 'installing-quilt', current: 0, total: 100, message: `Installing Quilt ${runtime.quiltLoader}...` });
|
||||||
|
await (0, installer_1.installQuiltVersion)({
|
||||||
|
minecraftVersion: runtime.minecraft,
|
||||||
|
version: runtime.quiltLoader,
|
||||||
|
minecraft: instancePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (runtime.neoForged) {
|
||||||
|
callbacks?.onProgress?.({ stage: 'installing-neoforge', current: 0, total: 100, message: `Installing NeoForge ${runtime.neoForged}...` });
|
||||||
|
await (0, installer_1.installNeoForged)('neoforge', runtime.neoForged, instancePath, {});
|
||||||
|
}
|
||||||
|
// Note: OptiFine requires downloading the installer JAR first
|
||||||
|
// if (runtime.optifine) {
|
||||||
|
// callbacks?.onProgress?.({ stage: 'installing-optifine', current: 0, total: 100, message: `Installing OptiFine ${runtime.optifine}...` });
|
||||||
|
// await installOptifine(runtime.optifine, instancePath);
|
||||||
|
// }
|
||||||
|
callbacks?.onProgress?.({ stage: 'installing-dependencies', current: 0, total: 100, message: 'Installing dependencies...' });
|
||||||
|
// Install all dependencies (libraries + assets)
|
||||||
|
const resolved = await core_1.Version.parse(instancePath, runtime.minecraft);
|
||||||
|
await (0, installer_1.installDependencies)(resolved);
|
||||||
|
callbacks?.onProgress?.({ stage: 'done', current: 100, total: 100, message: 'Installation complete' });
|
||||||
|
// Update last access date
|
||||||
|
await updateInstance(name, gamePath, { lastAccessDate: Date.now() });
|
||||||
|
return getInstanceInfo(name, gamePath);
|
||||||
|
}
|
||||||
|
async function launchInstance(name, gamePath, options) {
|
||||||
|
const instance = await getInstanceInfo(name, gamePath);
|
||||||
|
const { runtime } = instance.config;
|
||||||
|
const resolved = await core_1.Version.parse(instance.path, runtime.minecraft);
|
||||||
|
const javaPath = options.javaPath || instance.config.java || 'java';
|
||||||
|
const mcProcess = await (0, core_1.launch)({
|
||||||
|
gameProfile: {
|
||||||
|
id: options.uuid,
|
||||||
|
name: options.username,
|
||||||
|
},
|
||||||
|
javaPath,
|
||||||
|
version: resolved,
|
||||||
|
gamePath: instance.path,
|
||||||
|
minMemory: instance.config.minMemory || 1024,
|
||||||
|
maxMemory: instance.config.maxMemory || 4096,
|
||||||
|
extraExecOption: { detached: true, stdio: 'ignore' },
|
||||||
|
server: options.server ? { ip: options.server.host, port: options.server.port } : undefined,
|
||||||
|
});
|
||||||
|
const watcher = (0, core_1.createMinecraftProcessWatcher)(mcProcess);
|
||||||
|
watcher.on('minecraft-window-ready', () => {
|
||||||
|
options.onEvent?.({ event: 'window-ready' });
|
||||||
|
});
|
||||||
|
watcher.on('minecraft-exit', ({ code }) => {
|
||||||
|
options.onEvent?.({ event: 'exit', code });
|
||||||
|
});
|
||||||
|
// Update playtime tracking
|
||||||
|
const startTime = Date.now();
|
||||||
|
mcProcess.on('exit', async () => {
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
try {
|
||||||
|
const info = await getInstanceInfo(name, gamePath);
|
||||||
|
await updateInstance(name, gamePath, {
|
||||||
|
lastPlayedDate: Date.now(),
|
||||||
|
playtime: (info.config.playtime || 0) + elapsed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// Ignore errors during playtime update
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Update last access date
|
||||||
|
await updateInstance(name, gamePath, { lastAccessDate: Date.now() });
|
||||||
|
return {
|
||||||
|
pid: mcProcess.pid || 0,
|
||||||
|
version: runtime.minecraft,
|
||||||
|
username: options.username,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function diagnoseInstance(name, gamePath) {
|
||||||
|
const instance = await getInstanceInfo(name, gamePath);
|
||||||
|
return {
|
||||||
|
healthy: instance.healthy,
|
||||||
|
issues: instance.issues,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Version list APIs
|
||||||
|
async function getMinecraftVersionList(type) {
|
||||||
|
const manifest = await (0, installer_1.getVersionList)();
|
||||||
|
let versions = manifest.versions;
|
||||||
|
if (type && type !== 'all') {
|
||||||
|
versions = versions.filter((v) => v.type === type);
|
||||||
|
}
|
||||||
|
return { versions };
|
||||||
|
}
|
||||||
|
async function getForgeVersionList(mcVersion) {
|
||||||
|
const list = await (0, installer_1.getForgeVersionList)({ minecraft: mcVersion });
|
||||||
|
return { versions: list.versions };
|
||||||
|
}
|
||||||
|
async function getFabricVersionList(mcVersion) {
|
||||||
|
if (mcVersion) {
|
||||||
|
const loaders = await (0, installer_1.getFabricLoaders)();
|
||||||
|
return { versions: loaders.map((l) => l.version) };
|
||||||
|
}
|
||||||
|
const loaders = await (0, installer_1.getFabricLoaders)();
|
||||||
|
return { versions: loaders.map((l) => l.version) };
|
||||||
|
}
|
||||||
|
async function getQuiltVersionList(mcVersion) {
|
||||||
|
const loaders = await (0, installer_1.getQuiltLoaderVersionsByMinecraft)({ minecraftVersion: mcVersion || '*' });
|
||||||
|
return { versions: loaders.map((l) => l.loader.version) };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,75 +1,146 @@
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import { MinecraftFolder, Version, launch, createMinecraftProcessWatcher, type ResolvedVersion } from '@xmcl/core';
|
||||||
|
import {
|
||||||
|
install as xmclInstall,
|
||||||
|
installForge,
|
||||||
|
installFabric,
|
||||||
|
installNeoForged,
|
||||||
|
installOptifine,
|
||||||
|
installQuiltVersion,
|
||||||
|
installDependencies,
|
||||||
|
getVersionList,
|
||||||
|
getForgeVersionList as xmclGetForgeVersionList,
|
||||||
|
getFabricLoaders,
|
||||||
|
getQuiltLoaderVersionsByMinecraft,
|
||||||
|
} from '@xmcl/installer';
|
||||||
|
|
||||||
interface InstanceConfig {
|
export interface InstanceRuntime {
|
||||||
name: string;
|
minecraft: string;
|
||||||
mcVersion: string;
|
forge?: string;
|
||||||
loaderType?: string;
|
neoForged?: string;
|
||||||
loaderVersion?: string;
|
fabricLoader?: string;
|
||||||
javaPath?: string;
|
quiltLoader?: string;
|
||||||
memory?: { min?: string; max?: string };
|
optifine?: string;
|
||||||
createdAt: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InstanceInfo {
|
export interface InstanceConfig {
|
||||||
|
name: string;
|
||||||
|
author?: string;
|
||||||
|
description?: string;
|
||||||
|
runtime: InstanceRuntime;
|
||||||
|
java?: string;
|
||||||
|
minMemory?: number;
|
||||||
|
maxMemory?: number;
|
||||||
|
vmOptions?: string[];
|
||||||
|
mcOptions?: string[];
|
||||||
|
server?: { host: string; port?: number; name?: string };
|
||||||
|
showLog?: boolean;
|
||||||
|
hideLauncher?: boolean;
|
||||||
|
icon?: string;
|
||||||
|
creationDate: number;
|
||||||
|
lastAccessDate: number;
|
||||||
|
lastPlayedDate: number;
|
||||||
|
playtime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstanceInfo {
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
config: InstanceConfig;
|
config: InstanceConfig;
|
||||||
modCount?: number;
|
modCount: number;
|
||||||
|
resourcePackCount: number;
|
||||||
|
screenshotCount: number;
|
||||||
|
saveCount: number;
|
||||||
|
healthy: boolean;
|
||||||
|
issues: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const INSTANCE_CONFIG_FILE = 'koring-instance.json';
|
export interface InstallProgress {
|
||||||
|
stage: string;
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INSTANCE_CONFIG_FILE = 'instance.json';
|
||||||
|
|
||||||
function getInstanceConfigPath(instancePath: string): string {
|
function getInstanceConfigPath(instancePath: string): string {
|
||||||
return path.join(instancePath, INSTANCE_CONFIG_FILE);
|
return path.join(instancePath, INSTANCE_CONFIG_FILE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function countFiles(dir: string, ext?: string): number {
|
||||||
|
if (!fs.existsSync(dir)) return 0;
|
||||||
|
return fs.readdirSync(dir).filter((f) => {
|
||||||
|
if (ext) return f.endsWith(ext);
|
||||||
|
return fs.statSync(path.join(dir, f)).isFile();
|
||||||
|
}).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInstanceIssues(instancePath: string, runtime: InstanceRuntime): string[] {
|
||||||
|
const issues: string[] = [];
|
||||||
|
|
||||||
|
if (!fs.existsSync(path.join(instancePath, 'versions', runtime.minecraft, `${runtime.minecraft}.json`))) {
|
||||||
|
issues.push(`Version JSON not found for ${runtime.minecraft}`);
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(path.join(instancePath, 'versions', runtime.minecraft, `${runtime.minecraft}.jar`))) {
|
||||||
|
issues.push(`Client JAR not found for ${runtime.minecraft}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
export async function createInstance(
|
export async function createInstance(
|
||||||
name: string,
|
name: string,
|
||||||
gamePath: string,
|
gamePath: string,
|
||||||
mcVersion: string,
|
runtime: InstanceRuntime,
|
||||||
loaderType?: string,
|
options?: {
|
||||||
loaderVersion?: string,
|
author?: string;
|
||||||
javaPath?: string,
|
description?: string;
|
||||||
memory?: { min?: string; max?: string }
|
java?: string;
|
||||||
|
minMemory?: number;
|
||||||
|
maxMemory?: number;
|
||||||
|
vmOptions?: string[];
|
||||||
|
mcOptions?: string[];
|
||||||
|
}
|
||||||
): Promise<InstanceInfo> {
|
): Promise<InstanceInfo> {
|
||||||
const instancePath = path.join(gamePath, 'instances', name);
|
const instancePath = path.join(gamePath, 'instances', name);
|
||||||
|
|
||||||
if (!fs.existsSync(instancePath)) {
|
if (fs.existsSync(instancePath)) {
|
||||||
fs.mkdirSync(instancePath, { recursive: true });
|
throw new Error(`Instance already exists: ${name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(instancePath, { recursive: true });
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
const config: InstanceConfig = {
|
const config: InstanceConfig = {
|
||||||
name,
|
name,
|
||||||
mcVersion,
|
author: options?.author || '',
|
||||||
loaderType,
|
description: options?.description || '',
|
||||||
loaderVersion,
|
runtime,
|
||||||
javaPath,
|
java: options?.java,
|
||||||
memory,
|
minMemory: options?.minMemory,
|
||||||
createdAt: new Date().toISOString(),
|
maxMemory: options?.maxMemory,
|
||||||
|
vmOptions: options?.vmOptions,
|
||||||
|
mcOptions: options?.mcOptions,
|
||||||
|
creationDate: now,
|
||||||
|
lastAccessDate: now,
|
||||||
|
lastPlayedDate: 0,
|
||||||
|
playtime: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const configPath = getInstanceConfigPath(instancePath);
|
fs.writeFileSync(getInstanceConfigPath(instancePath), JSON.stringify(config, null, 2), 'utf-8');
|
||||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
|
||||||
|
|
||||||
// Create mods directory
|
// Create standard directories
|
||||||
const modsDir = path.join(instancePath, 'mods');
|
for (const dir of ['mods', 'config', 'resourcepacks', 'shaderpacks', 'saves', 'screenshots', 'logs']) {
|
||||||
if (!fs.existsSync(modsDir)) {
|
fs.mkdirSync(path.join(instancePath, dir), { recursive: true });
|
||||||
fs.mkdirSync(modsDir, { recursive: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count mods
|
return getInstanceInfo(name, gamePath);
|
||||||
const mods = fs.readdirSync(modsDir).filter((f) => f.endsWith('.jar'));
|
|
||||||
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
path: instancePath,
|
|
||||||
config,
|
|
||||||
modCount: mods.length,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listInstances(instancesPath: string): Promise<InstanceInfo[]> {
|
export async function listInstances(gamePath: string): Promise<InstanceInfo[]> {
|
||||||
|
const instancesPath = path.join(gamePath, 'instances');
|
||||||
const instances: InstanceInfo[] = [];
|
const instances: InstanceInfo[] = [];
|
||||||
|
|
||||||
if (!fs.existsSync(instancesPath)) {
|
if (!fs.existsSync(instancesPath)) {
|
||||||
@@ -81,54 +152,18 @@ export async function listInstances(instancesPath: string): Promise<InstanceInfo
|
|||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!entry.isDirectory()) continue;
|
if (!entry.isDirectory()) continue;
|
||||||
|
|
||||||
const instancePath = path.join(instancesPath, entry.name);
|
|
||||||
const configPath = getInstanceConfigPath(instancePath);
|
|
||||||
|
|
||||||
if (!fs.existsSync(configPath)) continue;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const configRaw = fs.readFileSync(configPath, 'utf-8');
|
instances.push(await getInstanceInfo(entry.name, gamePath));
|
||||||
const config = JSON.parse(configRaw) as InstanceConfig;
|
|
||||||
|
|
||||||
// Count mods
|
|
||||||
const modsDir = path.join(instancePath, 'mods');
|
|
||||||
let modCount = 0;
|
|
||||||
if (fs.existsSync(modsDir)) {
|
|
||||||
modCount = fs.readdirSync(modsDir).filter((f) => f.endsWith('.jar')).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
instances.push({
|
|
||||||
name: entry.name,
|
|
||||||
path: instancePath,
|
|
||||||
config,
|
|
||||||
modCount,
|
|
||||||
});
|
|
||||||
} catch {
|
} catch {
|
||||||
// Skip invalid config
|
// Skip invalid instances
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return instances;
|
return instances;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteInstance(
|
export async function getInstanceInfo(name: string, gamePath: string): Promise<InstanceInfo> {
|
||||||
name: string,
|
const instancePath = path.join(gamePath, 'instances', name);
|
||||||
instancesPath: string
|
|
||||||
): Promise<{ deleted: string }> {
|
|
||||||
const instancePath = path.join(instancesPath, name);
|
|
||||||
|
|
||||||
if (fs.existsSync(instancePath)) {
|
|
||||||
fs.rmSync(instancePath, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
return { deleted: name };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getInstanceInfo(
|
|
||||||
name: string,
|
|
||||||
instancesPath: string
|
|
||||||
): Promise<InstanceInfo> {
|
|
||||||
const instancePath = path.join(instancesPath, name);
|
|
||||||
const configPath = getInstanceConfigPath(instancePath);
|
const configPath = getInstanceConfigPath(instancePath);
|
||||||
|
|
||||||
if (!fs.existsSync(configPath)) {
|
if (!fs.existsSync(configPath)) {
|
||||||
@@ -138,17 +173,226 @@ export async function getInstanceInfo(
|
|||||||
const configRaw = fs.readFileSync(configPath, 'utf-8');
|
const configRaw = fs.readFileSync(configPath, 'utf-8');
|
||||||
const config = JSON.parse(configRaw) as InstanceConfig;
|
const config = JSON.parse(configRaw) as InstanceConfig;
|
||||||
|
|
||||||
// Count mods
|
const issues = getInstanceIssues(instancePath, config.runtime);
|
||||||
const modsDir = path.join(instancePath, 'mods');
|
|
||||||
let modCount = 0;
|
|
||||||
if (fs.existsSync(modsDir)) {
|
|
||||||
modCount = fs.readdirSync(modsDir).filter((f) => f.endsWith('.jar')).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
path: instancePath,
|
path: instancePath,
|
||||||
config,
|
config,
|
||||||
modCount,
|
modCount: countFiles(path.join(instancePath, 'mods'), '.jar'),
|
||||||
|
resourcePackCount: countFiles(path.join(instancePath, 'resourcepacks'), '.zip'),
|
||||||
|
screenshotCount: countFiles(path.join(instancePath, 'screenshots')),
|
||||||
|
saveCount: countFiles(path.join(instancePath, 'saves')),
|
||||||
|
healthy: issues.length === 0,
|
||||||
|
issues,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteInstance(name: string, gamePath: string): Promise<{ deleted: string }> {
|
||||||
|
const instancePath = path.join(gamePath, 'instances', name);
|
||||||
|
|
||||||
|
if (!fs.existsSync(instancePath)) {
|
||||||
|
throw new Error(`Instance not found: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.rmSync(instancePath, { recursive: true, force: true });
|
||||||
|
return { deleted: name };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateInstance(
|
||||||
|
name: string,
|
||||||
|
gamePath: string,
|
||||||
|
patch: Partial<Omit<InstanceConfig, 'name' | 'creationDate'>>
|
||||||
|
): Promise<InstanceInfo> {
|
||||||
|
const instancePath = path.join(gamePath, 'instances', name);
|
||||||
|
const configPath = getInstanceConfigPath(instancePath);
|
||||||
|
|
||||||
|
if (!fs.existsSync(configPath)) {
|
||||||
|
throw new Error(`Instance not found: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const configRaw = fs.readFileSync(configPath, 'utf-8');
|
||||||
|
const config = JSON.parse(configRaw) as InstanceConfig;
|
||||||
|
|
||||||
|
const updated = { ...config, ...patch };
|
||||||
|
fs.writeFileSync(configPath, JSON.stringify(updated, null, 2), 'utf-8');
|
||||||
|
|
||||||
|
return getInstanceInfo(name, gamePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installInstanceGame(
|
||||||
|
name: string,
|
||||||
|
gamePath: string,
|
||||||
|
callbacks?: { onProgress?: (progress: InstallProgress) => void }
|
||||||
|
): Promise<InstanceInfo> {
|
||||||
|
const instance = await getInstanceInfo(name, gamePath);
|
||||||
|
const { runtime } = instance.config;
|
||||||
|
const instancePath = instance.path;
|
||||||
|
|
||||||
|
callbacks?.onProgress?.({ stage: 'installing-minecraft', current: 0, total: 100, message: `Installing Minecraft ${runtime.minecraft}...` });
|
||||||
|
|
||||||
|
// Install Minecraft
|
||||||
|
// First get version list to find the version info
|
||||||
|
const versionList = await getVersionList();
|
||||||
|
const versionInfo = versionList.versions.find((v) => v.id === runtime.minecraft);
|
||||||
|
if (!versionInfo) {
|
||||||
|
throw new Error(`Minecraft version ${runtime.minecraft} not found`);
|
||||||
|
}
|
||||||
|
await xmclInstall({ id: versionInfo.id, url: versionInfo.url }, instancePath);
|
||||||
|
|
||||||
|
// Install mod loaders
|
||||||
|
if (runtime.forge) {
|
||||||
|
callbacks?.onProgress?.({ stage: 'installing-forge', current: 0, total: 100, message: `Installing Forge ${runtime.forge}...` });
|
||||||
|
await installForge({ version: runtime.forge, mcversion: runtime.minecraft }, instancePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runtime.fabricLoader) {
|
||||||
|
callbacks?.onProgress?.({ stage: 'installing-fabric', current: 0, total: 100, message: `Installing Fabric ${runtime.fabricLoader}...` });
|
||||||
|
await installFabric({
|
||||||
|
minecraftVersion: runtime.minecraft,
|
||||||
|
version: runtime.fabricLoader,
|
||||||
|
minecraft: instancePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runtime.quiltLoader) {
|
||||||
|
callbacks?.onProgress?.({ stage: 'installing-quilt', current: 0, total: 100, message: `Installing Quilt ${runtime.quiltLoader}...` });
|
||||||
|
await installQuiltVersion({
|
||||||
|
minecraftVersion: runtime.minecraft,
|
||||||
|
version: runtime.quiltLoader,
|
||||||
|
minecraft: instancePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runtime.neoForged) {
|
||||||
|
callbacks?.onProgress?.({ stage: 'installing-neoforge', current: 0, total: 100, message: `Installing NeoForge ${runtime.neoForged}...` });
|
||||||
|
await installNeoForged('neoforge', runtime.neoForged, instancePath, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: OptiFine requires downloading the installer JAR first
|
||||||
|
// if (runtime.optifine) {
|
||||||
|
// callbacks?.onProgress?.({ stage: 'installing-optifine', current: 0, total: 100, message: `Installing OptiFine ${runtime.optifine}...` });
|
||||||
|
// await installOptifine(runtime.optifine, instancePath);
|
||||||
|
// }
|
||||||
|
|
||||||
|
callbacks?.onProgress?.({ stage: 'installing-dependencies', current: 0, total: 100, message: 'Installing dependencies...' });
|
||||||
|
|
||||||
|
// Install all dependencies (libraries + assets)
|
||||||
|
const resolved: ResolvedVersion = await Version.parse(instancePath, runtime.minecraft);
|
||||||
|
await installDependencies(resolved);
|
||||||
|
|
||||||
|
callbacks?.onProgress?.({ stage: 'done', current: 100, total: 100, message: 'Installation complete' });
|
||||||
|
|
||||||
|
// Update last access date
|
||||||
|
await updateInstance(name, gamePath, { lastAccessDate: Date.now() });
|
||||||
|
|
||||||
|
return getInstanceInfo(name, gamePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function launchInstance(
|
||||||
|
name: string,
|
||||||
|
gamePath: string,
|
||||||
|
options: {
|
||||||
|
username: string;
|
||||||
|
uuid: string;
|
||||||
|
accessToken?: string;
|
||||||
|
javaPath?: string;
|
||||||
|
server?: { host: string; port?: number };
|
||||||
|
onEvent?: (event: { event: string; [key: string]: unknown }) => void;
|
||||||
|
}
|
||||||
|
): Promise<{ pid: number; version: string; username: string }> {
|
||||||
|
const instance = await getInstanceInfo(name, gamePath);
|
||||||
|
const { runtime } = instance.config;
|
||||||
|
|
||||||
|
const resolved: ResolvedVersion = await Version.parse(instance.path, runtime.minecraft);
|
||||||
|
|
||||||
|
const javaPath = options.javaPath || instance.config.java || 'java';
|
||||||
|
|
||||||
|
const mcProcess = await launch({
|
||||||
|
gameProfile: {
|
||||||
|
id: options.uuid,
|
||||||
|
name: options.username,
|
||||||
|
},
|
||||||
|
javaPath,
|
||||||
|
version: resolved,
|
||||||
|
gamePath: instance.path,
|
||||||
|
minMemory: instance.config.minMemory || 1024,
|
||||||
|
maxMemory: instance.config.maxMemory || 4096,
|
||||||
|
extraExecOption: { detached: true, stdio: 'ignore' },
|
||||||
|
server: options.server ? { ip: options.server.host, port: options.server.port } : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const watcher = createMinecraftProcessWatcher(mcProcess);
|
||||||
|
|
||||||
|
watcher.on('minecraft-window-ready', () => {
|
||||||
|
options.onEvent?.({ event: 'window-ready' });
|
||||||
|
});
|
||||||
|
|
||||||
|
watcher.on('minecraft-exit', ({ code }) => {
|
||||||
|
options.onEvent?.({ event: 'exit', code });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update playtime tracking
|
||||||
|
const startTime = Date.now();
|
||||||
|
mcProcess.on('exit', async () => {
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
try {
|
||||||
|
const info = await getInstanceInfo(name, gamePath);
|
||||||
|
await updateInstance(name, gamePath, {
|
||||||
|
lastPlayedDate: Date.now(),
|
||||||
|
playtime: (info.config.playtime || 0) + elapsed,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Ignore errors during playtime update
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update last access date
|
||||||
|
await updateInstance(name, gamePath, { lastAccessDate: Date.now() });
|
||||||
|
|
||||||
|
return {
|
||||||
|
pid: mcProcess.pid || 0,
|
||||||
|
version: runtime.minecraft,
|
||||||
|
username: options.username,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function diagnoseInstance(
|
||||||
|
name: string,
|
||||||
|
gamePath: string
|
||||||
|
): Promise<{ healthy: boolean; issues: string[] }> {
|
||||||
|
const instance = await getInstanceInfo(name, gamePath);
|
||||||
|
return {
|
||||||
|
healthy: instance.healthy,
|
||||||
|
issues: instance.issues,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version list APIs
|
||||||
|
export async function getMinecraftVersionList(type?: string) {
|
||||||
|
const manifest = await getVersionList();
|
||||||
|
let versions = manifest.versions;
|
||||||
|
if (type && type !== 'all') {
|
||||||
|
versions = versions.filter((v) => v.type === type);
|
||||||
|
}
|
||||||
|
return { versions };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getForgeVersionList(mcVersion?: string) {
|
||||||
|
const list = await xmclGetForgeVersionList({ minecraft: mcVersion });
|
||||||
|
return { versions: list.versions };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFabricVersionList(mcVersion?: string) {
|
||||||
|
if (mcVersion) {
|
||||||
|
const loaders = await getFabricLoaders();
|
||||||
|
return { versions: loaders.map((l) => l.version) };
|
||||||
|
}
|
||||||
|
const loaders = await getFabricLoaders();
|
||||||
|
return { versions: loaders.map((l) => l.version) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getQuiltVersionList(mcVersion?: string) {
|
||||||
|
const loaders = await getQuiltLoaderVersionsByMinecraft({ minecraftVersion: mcVersion || '*' });
|
||||||
|
return { versions: loaders.map((l) => l.loader.version) };
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
exports.registerBackgroundHandlers = registerBackgroundHandlers;
|
exports.registerBackgroundHandlers = registerBackgroundHandlers;
|
||||||
const electron_1 = __importDefault(require("electron"));
|
const electron_1 = __importDefault(require("electron"));
|
||||||
|
const fs_1 = __importDefault(require("fs"));
|
||||||
|
const path_1 = __importDefault(require("path"));
|
||||||
const config_1 = require("../config");
|
const config_1 = require("../config");
|
||||||
const { ipcMain } = electron_1.default;
|
const { ipcMain } = electron_1.default;
|
||||||
function registerBackgroundHandlers() {
|
function registerBackgroundHandlers() {
|
||||||
@@ -98,4 +100,29 @@ function registerBackgroundHandlers() {
|
|||||||
return { success: false, data: null, error: String(e) };
|
return { success: false, data: null, error: String(e) };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// Copy file to userData and return the destination path
|
||||||
|
ipcMain.handle('background:copyToUserData', async (_event, srcPath, ext) => {
|
||||||
|
try {
|
||||||
|
const userDataPath = electron_1.default.app.getPath('userData');
|
||||||
|
const destPath = path_1.default.join(userDataPath, `background-custom${ext}`);
|
||||||
|
fs_1.default.copyFileSync(srcPath, destPath);
|
||||||
|
return destPath;
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Get cached background file path from userData
|
||||||
|
ipcMain.handle('background:getCachedPath', async () => {
|
||||||
|
try {
|
||||||
|
const userDataPath = electron_1.default.app.getPath('userData');
|
||||||
|
const files = fs_1.default.readdirSync(userDataPath).filter(f => f.startsWith('background-custom'));
|
||||||
|
if (files.length === 0)
|
||||||
|
return null;
|
||||||
|
return path_1.default.join(userDataPath, files[0]);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import electron from 'electron';
|
import electron from 'electron';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
import { loadConfig, saveConfig } from '../config';
|
import { loadConfig, saveConfig } from '../config';
|
||||||
|
|
||||||
const { ipcMain } = electron;
|
const { ipcMain } = electron;
|
||||||
@@ -91,4 +93,28 @@ export function registerBackgroundHandlers() {
|
|||||||
return { success: false, data: null, error: String(e) };
|
return { success: false, data: null, error: String(e) };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Copy file to userData and return the destination path
|
||||||
|
ipcMain.handle('background:copyToUserData', async (_event, srcPath: string, ext: string) => {
|
||||||
|
try {
|
||||||
|
const userDataPath = electron.app.getPath('userData');
|
||||||
|
const destPath = path.join(userDataPath, `background-custom${ext}`);
|
||||||
|
fs.copyFileSync(srcPath, destPath);
|
||||||
|
return destPath;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get cached background file path from userData
|
||||||
|
ipcMain.handle('background:getCachedPath', async () => {
|
||||||
|
try {
|
||||||
|
const userDataPath = electron.app.getPath('userData');
|
||||||
|
const files = fs.readdirSync(userDataPath).filter(f => f.startsWith('background-custom'));
|
||||||
|
if (files.length === 0) return null;
|
||||||
|
return path.join(userDataPath, files[0]);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,18 @@ exports.registerInstanceHandlers = registerInstanceHandlers;
|
|||||||
const electron_1 = __importDefault(require("electron"));
|
const electron_1 = __importDefault(require("electron"));
|
||||||
const instance_1 = require("../core/instance");
|
const instance_1 = require("../core/instance");
|
||||||
const { ipcMain } = electron_1.default;
|
const { ipcMain } = electron_1.default;
|
||||||
function registerInstanceHandlers() {
|
function registerInstanceHandlers(win) {
|
||||||
ipcMain.handle('instance:create', async (_event, payload) => {
|
ipcMain.handle('instance:create', async (_event, payload) => {
|
||||||
try {
|
try {
|
||||||
const data = await (0, instance_1.createInstance)(payload.name, payload.gamePath, payload.mcVersion, payload.loaderType, payload.loaderVersion, payload.javaPath, payload.memory);
|
const data = await (0, instance_1.createInstance)(payload.name, payload.gamePath, payload.runtime, {
|
||||||
|
author: payload.author,
|
||||||
|
description: payload.description,
|
||||||
|
java: payload.java,
|
||||||
|
minMemory: payload.minMemory,
|
||||||
|
maxMemory: payload.maxMemory,
|
||||||
|
vmOptions: payload.vmOptions,
|
||||||
|
mcOptions: payload.mcOptions,
|
||||||
|
});
|
||||||
return { success: true, data, error: null };
|
return { success: true, data, error: null };
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
@@ -19,16 +27,7 @@ function registerInstanceHandlers() {
|
|||||||
});
|
});
|
||||||
ipcMain.handle('instance:list', async (_event, payload) => {
|
ipcMain.handle('instance:list', async (_event, payload) => {
|
||||||
try {
|
try {
|
||||||
const data = await (0, instance_1.listInstances)(payload.instancesPath);
|
const data = await (0, instance_1.listInstances)(payload.gamePath);
|
||||||
return { success: true, data, error: null };
|
|
||||||
}
|
|
||||||
catch (e) {
|
|
||||||
return { success: false, data: null, error: String(e) };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
ipcMain.handle('instance:delete', async (_event, payload) => {
|
|
||||||
try {
|
|
||||||
const data = await (0, instance_1.deleteInstance)(payload.name, payload.instancesPath);
|
|
||||||
return { success: true, data, error: null };
|
return { success: true, data, error: null };
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
@@ -37,7 +36,112 @@ function registerInstanceHandlers() {
|
|||||||
});
|
});
|
||||||
ipcMain.handle('instance:info', async (_event, payload) => {
|
ipcMain.handle('instance:info', async (_event, payload) => {
|
||||||
try {
|
try {
|
||||||
const data = await (0, instance_1.getInstanceInfo)(payload.name, payload.instancesPath);
|
const data = await (0, instance_1.getInstanceInfo)(payload.name, payload.gamePath);
|
||||||
|
return { success: true, data, error: null };
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
return { success: false, data: null, error: String(e) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ipcMain.handle('instance:delete', async (_event, payload) => {
|
||||||
|
try {
|
||||||
|
const data = await (0, instance_1.deleteInstance)(payload.name, payload.gamePath);
|
||||||
|
return { success: true, data, error: null };
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
return { success: false, data: null, error: String(e) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ipcMain.handle('instance:update', async (_event, payload) => {
|
||||||
|
try {
|
||||||
|
const data = await (0, instance_1.updateInstance)(payload.name, payload.gamePath, payload.patch);
|
||||||
|
return { success: true, data, error: null };
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
return { success: false, data: null, error: String(e) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ipcMain.handle('instance:install', async (_event, payload) => {
|
||||||
|
try {
|
||||||
|
const requestId = `install-${Date.now()}`;
|
||||||
|
(0, instance_1.installInstanceGame)(payload.name, payload.gamePath, {
|
||||||
|
onProgress: (progress) => {
|
||||||
|
win.mainWindow?.webContents.send('instance:progress', { requestId, ...progress });
|
||||||
|
},
|
||||||
|
}).then((data) => {
|
||||||
|
win.mainWindow?.webContents.send('instance:install-complete', { requestId, data });
|
||||||
|
}).catch((err) => {
|
||||||
|
win.mainWindow?.webContents.send('instance:install-error', { requestId, error: String(err) });
|
||||||
|
});
|
||||||
|
return { success: true, data: { requestId }, error: null };
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
return { success: false, data: null, error: String(e) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ipcMain.handle('instance:launch', async (_event, payload) => {
|
||||||
|
try {
|
||||||
|
const requestId = `launch-${Date.now()}`;
|
||||||
|
(0, instance_1.launchInstance)(payload.name, payload.gamePath, {
|
||||||
|
username: payload.username,
|
||||||
|
uuid: payload.uuid,
|
||||||
|
accessToken: payload.accessToken,
|
||||||
|
javaPath: payload.javaPath,
|
||||||
|
server: payload.server,
|
||||||
|
onEvent: (event) => {
|
||||||
|
win.mainWindow?.webContents.send('instance:launch-event', { requestId, ...event });
|
||||||
|
},
|
||||||
|
}).then((data) => {
|
||||||
|
win.mainWindow?.webContents.send('instance:launch-complete', { requestId, data });
|
||||||
|
}).catch((err) => {
|
||||||
|
win.mainWindow?.webContents.send('instance:launch-error', { requestId, error: String(err) });
|
||||||
|
});
|
||||||
|
return { success: true, data: { requestId }, error: null };
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
return { success: false, data: null, error: String(e) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ipcMain.handle('instance:diagnose', async (_event, payload) => {
|
||||||
|
try {
|
||||||
|
const data = await (0, instance_1.diagnoseInstance)(payload.name, payload.gamePath);
|
||||||
|
return { success: true, data, error: null };
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
return { success: false, data: null, error: String(e) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Version list APIs
|
||||||
|
ipcMain.handle('instance:version-list', async (_event, payload) => {
|
||||||
|
try {
|
||||||
|
const data = await (0, instance_1.getMinecraftVersionList)(payload.type);
|
||||||
|
return { success: true, data, error: null };
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
return { success: false, data: null, error: String(e) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ipcMain.handle('instance:forge-version-list', async (_event, payload) => {
|
||||||
|
try {
|
||||||
|
const data = await (0, instance_1.getForgeVersionList)(payload.mcVersion);
|
||||||
|
return { success: true, data, error: null };
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
return { success: false, data: null, error: String(e) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ipcMain.handle('instance:fabric-version-list', async (_event, payload) => {
|
||||||
|
try {
|
||||||
|
const data = await (0, instance_1.getFabricVersionList)(payload.mcVersion);
|
||||||
|
return { success: true, data, error: null };
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
return { success: false, data: null, error: String(e) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ipcMain.handle('instance:quilt-version-list', async (_event, payload) => {
|
||||||
|
try {
|
||||||
|
const data = await (0, instance_1.getQuiltVersionList)(payload.mcVersion);
|
||||||
return { success: true, data, error: null };
|
return { success: true, data, error: null };
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
|
|||||||
@@ -1,27 +1,54 @@
|
|||||||
import electron from 'electron';
|
import electron from 'electron';
|
||||||
import { createInstance, listInstances, deleteInstance, getInstanceInfo } from '../core/instance';
|
import {
|
||||||
|
createInstance,
|
||||||
|
listInstances,
|
||||||
|
getInstanceInfo,
|
||||||
|
deleteInstance,
|
||||||
|
updateInstance,
|
||||||
|
installInstanceGame,
|
||||||
|
launchInstance,
|
||||||
|
diagnoseInstance,
|
||||||
|
getMinecraftVersionList,
|
||||||
|
getForgeVersionList,
|
||||||
|
getFabricVersionList,
|
||||||
|
getQuiltVersionList,
|
||||||
|
type InstanceRuntime,
|
||||||
|
type InstanceConfig,
|
||||||
|
} from '../core/instance';
|
||||||
|
|
||||||
const { ipcMain } = electron;
|
const { ipcMain } = electron;
|
||||||
|
|
||||||
export function registerInstanceHandlers() {
|
interface WinRef {
|
||||||
|
mainWindow: electron.BrowserWindow | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerInstanceHandlers(win: WinRef) {
|
||||||
ipcMain.handle('instance:create', async (_event, payload: {
|
ipcMain.handle('instance:create', async (_event, payload: {
|
||||||
name: string;
|
name: string;
|
||||||
gamePath: string;
|
gamePath: string;
|
||||||
mcVersion: string;
|
runtime: InstanceRuntime;
|
||||||
loaderType?: string;
|
author?: string;
|
||||||
loaderVersion?: string;
|
description?: string;
|
||||||
javaPath?: string;
|
java?: string;
|
||||||
memory?: { min?: string; max?: string };
|
minMemory?: number;
|
||||||
|
maxMemory?: number;
|
||||||
|
vmOptions?: string[];
|
||||||
|
mcOptions?: string[];
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
const data = await createInstance(
|
const data = await createInstance(
|
||||||
payload.name,
|
payload.name,
|
||||||
payload.gamePath,
|
payload.gamePath,
|
||||||
payload.mcVersion,
|
payload.runtime,
|
||||||
payload.loaderType,
|
{
|
||||||
payload.loaderVersion,
|
author: payload.author,
|
||||||
payload.javaPath,
|
description: payload.description,
|
||||||
payload.memory
|
java: payload.java,
|
||||||
|
minMemory: payload.minMemory,
|
||||||
|
maxMemory: payload.maxMemory,
|
||||||
|
vmOptions: payload.vmOptions,
|
||||||
|
mcOptions: payload.mcOptions,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
return { success: true, data, error: null };
|
return { success: true, data, error: null };
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
@@ -29,27 +56,139 @@ export function registerInstanceHandlers() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('instance:list', async (_event, payload: { instancesPath: string }) => {
|
ipcMain.handle('instance:list', async (_event, payload: { gamePath: string }) => {
|
||||||
try {
|
try {
|
||||||
const data = await listInstances(payload.instancesPath);
|
const data = await listInstances(payload.gamePath);
|
||||||
return { success: true, data, error: null };
|
return { success: true, data, error: null };
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
return { success: false, data: null, error: String(e) };
|
return { success: false, data: null, error: String(e) };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('instance:delete', async (_event, payload: { name: string; instancesPath: string }) => {
|
ipcMain.handle('instance:info', async (_event, payload: { name: string; gamePath: string }) => {
|
||||||
try {
|
try {
|
||||||
const data = await deleteInstance(payload.name, payload.instancesPath);
|
const data = await getInstanceInfo(payload.name, payload.gamePath);
|
||||||
return { success: true, data, error: null };
|
return { success: true, data, error: null };
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
return { success: false, data: null, error: String(e) };
|
return { success: false, data: null, error: String(e) };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('instance:info', async (_event, payload: { name: string; instancesPath: string }) => {
|
ipcMain.handle('instance:delete', async (_event, payload: { name: string; gamePath: string }) => {
|
||||||
try {
|
try {
|
||||||
const data = await getInstanceInfo(payload.name, payload.instancesPath);
|
const data = await deleteInstance(payload.name, payload.gamePath);
|
||||||
|
return { success: true, data, error: null };
|
||||||
|
} catch (e: unknown) {
|
||||||
|
return { success: false, data: null, error: String(e) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('instance:update', async (_event, payload: {
|
||||||
|
name: string;
|
||||||
|
gamePath: string;
|
||||||
|
patch: Partial<Omit<InstanceConfig, 'name' | 'creationDate'>>;
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
const data = await updateInstance(payload.name, payload.gamePath, payload.patch);
|
||||||
|
return { success: true, data, error: null };
|
||||||
|
} catch (e: unknown) {
|
||||||
|
return { success: false, data: null, error: String(e) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('instance:install', async (_event, payload: { name: string; gamePath: string }) => {
|
||||||
|
try {
|
||||||
|
const requestId = `install-${Date.now()}`;
|
||||||
|
|
||||||
|
installInstanceGame(payload.name, payload.gamePath, {
|
||||||
|
onProgress: (progress) => {
|
||||||
|
win.mainWindow?.webContents.send('instance:progress', { requestId, ...progress });
|
||||||
|
},
|
||||||
|
}).then((data) => {
|
||||||
|
win.mainWindow?.webContents.send('instance:install-complete', { requestId, data });
|
||||||
|
}).catch((err) => {
|
||||||
|
win.mainWindow?.webContents.send('instance:install-error', { requestId, error: String(err) });
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, data: { requestId }, error: null };
|
||||||
|
} catch (e: unknown) {
|
||||||
|
return { success: false, data: null, error: String(e) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('instance:launch', async (_event, payload: {
|
||||||
|
name: string;
|
||||||
|
gamePath: string;
|
||||||
|
username: string;
|
||||||
|
uuid: string;
|
||||||
|
accessToken?: string;
|
||||||
|
javaPath?: string;
|
||||||
|
server?: { host: string; port?: number };
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
const requestId = `launch-${Date.now()}`;
|
||||||
|
|
||||||
|
launchInstance(payload.name, payload.gamePath, {
|
||||||
|
username: payload.username,
|
||||||
|
uuid: payload.uuid,
|
||||||
|
accessToken: payload.accessToken,
|
||||||
|
javaPath: payload.javaPath,
|
||||||
|
server: payload.server,
|
||||||
|
onEvent: (event) => {
|
||||||
|
win.mainWindow?.webContents.send('instance:launch-event', { requestId, ...event });
|
||||||
|
},
|
||||||
|
}).then((data) => {
|
||||||
|
win.mainWindow?.webContents.send('instance:launch-complete', { requestId, data });
|
||||||
|
}).catch((err) => {
|
||||||
|
win.mainWindow?.webContents.send('instance:launch-error', { requestId, error: String(err) });
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, data: { requestId }, error: null };
|
||||||
|
} catch (e: unknown) {
|
||||||
|
return { success: false, data: null, error: String(e) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('instance:diagnose', async (_event, payload: { name: string; gamePath: string }) => {
|
||||||
|
try {
|
||||||
|
const data = await diagnoseInstance(payload.name, payload.gamePath);
|
||||||
|
return { success: true, data, error: null };
|
||||||
|
} catch (e: unknown) {
|
||||||
|
return { success: false, data: null, error: String(e) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Version list APIs
|
||||||
|
ipcMain.handle('instance:version-list', async (_event, payload: { type?: string }) => {
|
||||||
|
try {
|
||||||
|
const data = await getMinecraftVersionList(payload.type);
|
||||||
|
return { success: true, data, error: null };
|
||||||
|
} catch (e: unknown) {
|
||||||
|
return { success: false, data: null, error: String(e) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('instance:forge-version-list', async (_event, payload: { mcVersion?: string }) => {
|
||||||
|
try {
|
||||||
|
const data = await getForgeVersionList(payload.mcVersion);
|
||||||
|
return { success: true, data, error: null };
|
||||||
|
} catch (e: unknown) {
|
||||||
|
return { success: false, data: null, error: String(e) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('instance:fabric-version-list', async (_event, payload: { mcVersion?: string }) => {
|
||||||
|
try {
|
||||||
|
const data = await getFabricVersionList(payload.mcVersion);
|
||||||
|
return { success: true, data, error: null };
|
||||||
|
} catch (e: unknown) {
|
||||||
|
return { success: false, data: null, error: String(e) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('instance:quilt-version-list', async (_event, payload: { mcVersion?: string }) => {
|
||||||
|
try {
|
||||||
|
const data = await getQuiltVersionList(payload.mcVersion);
|
||||||
return { success: true, data, error: null };
|
return { success: true, data, error: null };
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
return { success: false, data: null, error: String(e) };
|
return { success: false, data: null, error: String(e) };
|
||||||
|
|||||||
@@ -6,8 +6,17 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|||||||
exports.registerWindowHandlers = registerWindowHandlers;
|
exports.registerWindowHandlers = registerWindowHandlers;
|
||||||
const electron_1 = __importDefault(require("electron"));
|
const electron_1 = __importDefault(require("electron"));
|
||||||
const path_1 = __importDefault(require("path"));
|
const path_1 = __importDefault(require("path"));
|
||||||
const { ipcMain, dialog } = electron_1.default;
|
const { ipcMain, dialog, shell } = electron_1.default;
|
||||||
const isDev = !electron_1.default.app.isPackaged;
|
const isDev = !electron_1.default.app.isPackaged;
|
||||||
|
const MIME_MAP = {
|
||||||
|
png: 'image/png',
|
||||||
|
jpg: 'image/jpeg',
|
||||||
|
jpeg: 'image/jpeg',
|
||||||
|
webp: 'image/webp',
|
||||||
|
gif: 'image/gif',
|
||||||
|
bmp: 'image/bmp',
|
||||||
|
svg: 'image/svg+xml',
|
||||||
|
};
|
||||||
function createSplashWindow() {
|
function createSplashWindow() {
|
||||||
const splash = new electron_1.default.BrowserWindow({
|
const splash = new electron_1.default.BrowserWindow({
|
||||||
width: 480,
|
width: 480,
|
||||||
@@ -67,7 +76,7 @@ function registerWindowHandlers(win) {
|
|||||||
}
|
}
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
// File dialog
|
// File dialog — returns source path and extension for preload to handle
|
||||||
ipcMain.handle('dialog:openFile', async (_event, payload) => {
|
ipcMain.handle('dialog:openFile', async (_event, payload) => {
|
||||||
const result = await dialog.showOpenDialog(win.mainWindow, {
|
const result = await dialog.showOpenDialog(win.mainWindow, {
|
||||||
properties: ['openFile'],
|
properties: ['openFile'],
|
||||||
@@ -75,6 +84,12 @@ function registerWindowHandlers(win) {
|
|||||||
});
|
});
|
||||||
if (result.canceled || result.filePaths.length === 0)
|
if (result.canceled || result.filePaths.length === 0)
|
||||||
return null;
|
return null;
|
||||||
return result.filePaths[0];
|
const srcPath = result.filePaths[0];
|
||||||
|
const ext = path_1.default.extname(srcPath).toLowerCase() || '.png';
|
||||||
|
return { srcPath, ext };
|
||||||
|
});
|
||||||
|
// Open external URL in system browser
|
||||||
|
ipcMain.handle('shell:openExternal', async (_event, url) => {
|
||||||
|
await shell.openExternal(url);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import electron from 'electron';
|
import electron from 'electron';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
const { ipcMain, dialog } = electron;
|
const { ipcMain, dialog, shell } = electron;
|
||||||
|
|
||||||
const isDev = !electron.app.isPackaged;
|
const isDev = !electron.app.isPackaged;
|
||||||
|
|
||||||
@@ -10,6 +10,16 @@ interface WinRef {
|
|||||||
splashWindow: electron.BrowserWindow | null;
|
splashWindow: electron.BrowserWindow | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MIME_MAP: Record<string, string> = {
|
||||||
|
png: 'image/png',
|
||||||
|
jpg: 'image/jpeg',
|
||||||
|
jpeg: 'image/jpeg',
|
||||||
|
webp: 'image/webp',
|
||||||
|
gif: 'image/gif',
|
||||||
|
bmp: 'image/bmp',
|
||||||
|
svg: 'image/svg+xml',
|
||||||
|
};
|
||||||
|
|
||||||
function createSplashWindow(): electron.BrowserWindow {
|
function createSplashWindow(): electron.BrowserWindow {
|
||||||
const splash = new electron.BrowserWindow({
|
const splash = new electron.BrowserWindow({
|
||||||
width: 480,
|
width: 480,
|
||||||
@@ -77,7 +87,7 @@ export function registerWindowHandlers(win: WinRef) {
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
// File dialog
|
// File dialog — returns source path and extension for preload to handle
|
||||||
ipcMain.handle('dialog:openFile', async (_event, payload: {
|
ipcMain.handle('dialog:openFile', async (_event, payload: {
|
||||||
filters?: { name: string; extensions: string[] }[];
|
filters?: { name: string; extensions: string[] }[];
|
||||||
}) => {
|
}) => {
|
||||||
@@ -86,6 +96,13 @@ export function registerWindowHandlers(win: WinRef) {
|
|||||||
filters: payload.filters,
|
filters: payload.filters,
|
||||||
});
|
});
|
||||||
if (result.canceled || result.filePaths.length === 0) return null;
|
if (result.canceled || result.filePaths.length === 0) return null;
|
||||||
return result.filePaths[0];
|
const srcPath = result.filePaths[0];
|
||||||
|
const ext = path.extname(srcPath).toLowerCase() || '.png';
|
||||||
|
return { srcPath, ext };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open external URL in system browser
|
||||||
|
ipcMain.handle('shell:openExternal', async (_event, url: string) => {
|
||||||
|
await shell.openExternal(url);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ const win: { mainWindow: electron.BrowserWindow | null; splashWindow: electron.B
|
|||||||
};
|
};
|
||||||
|
|
||||||
function createSplashWindow(): electron.BrowserWindow {
|
function createSplashWindow(): electron.BrowserWindow {
|
||||||
|
const iconPath = isDev
|
||||||
|
? path.join(__dirname, '../build/icon.ico')
|
||||||
|
: path.join(__dirname, '../build/icon.ico');
|
||||||
|
|
||||||
const splash = new electron.BrowserWindow({
|
const splash = new electron.BrowserWindow({
|
||||||
width: 480,
|
width: 480,
|
||||||
height: 320,
|
height: 320,
|
||||||
@@ -30,6 +34,7 @@ function createSplashWindow(): electron.BrowserWindow {
|
|||||||
resizable: false,
|
resizable: false,
|
||||||
skipTaskbar: true,
|
skipTaskbar: true,
|
||||||
alwaysOnTop: true,
|
alwaysOnTop: true,
|
||||||
|
icon: iconPath,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
@@ -46,15 +51,20 @@ function createSplashWindow(): electron.BrowserWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createMainWindow(): electron.BrowserWindow {
|
function createMainWindow(): electron.BrowserWindow {
|
||||||
|
const iconPath = isDev
|
||||||
|
? path.join(__dirname, '../build/icon.ico')
|
||||||
|
: path.join(__dirname, '../build/icon.ico');
|
||||||
|
|
||||||
const main = new electron.BrowserWindow({
|
const main = new electron.BrowserWindow({
|
||||||
width: 1000,
|
width: 1000,
|
||||||
height: 700,
|
height: 700,
|
||||||
minWidth: 800,
|
minWidth: 800,
|
||||||
minHeight: 600,
|
minHeight: 600,
|
||||||
transparent: true,
|
|
||||||
frame: false,
|
frame: false,
|
||||||
|
transparent: false,
|
||||||
resizable: true,
|
resizable: true,
|
||||||
show: false,
|
show: false,
|
||||||
|
icon: iconPath,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: path.join(__dirname, 'preload.js'),
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
@@ -86,7 +96,7 @@ function registerAllHandlers() {
|
|||||||
registerInstallHandlers(win);
|
registerInstallHandlers(win);
|
||||||
registerLaunchHandlers(win);
|
registerLaunchHandlers(win);
|
||||||
registerModsHandlers();
|
registerModsHandlers();
|
||||||
registerInstanceHandlers();
|
registerInstanceHandlers(win);
|
||||||
registerBackgroundHandlers();
|
registerBackgroundHandlers();
|
||||||
registerTaskHandlers(win);
|
registerTaskHandlers(win);
|
||||||
registerSystemHandlers();
|
registerSystemHandlers();
|
||||||
|
|||||||
@@ -1,6 +1,28 @@
|
|||||||
import electron from 'electron';
|
import electron from 'electron';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
const { contextBridge, ipcRenderer } = electron;
|
const { contextBridge, ipcRenderer } = electron;
|
||||||
|
|
||||||
|
const MIME_MAP: Record<string, string> = {
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.bmp': 'image/bmp',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getFileAsDataUrl(filePath: string): string | null {
|
||||||
|
try {
|
||||||
|
const buffer = fs.readFileSync(filePath);
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
const mime = MIME_MAP[ext] || 'image/png';
|
||||||
|
return `data:${mime};base64,${buffer.toString('base64')}`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('electronAPI', {
|
contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
// Generic IPC
|
// Generic IPC
|
||||||
invoke: (channel: string, ...args: unknown[]) =>
|
invoke: (channel: string, ...args: unknown[]) =>
|
||||||
@@ -28,4 +50,27 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
|
|
||||||
// Theme
|
// Theme
|
||||||
getTheme: () => ipcRenderer.invoke('window:getTheme'),
|
getTheme: () => ipcRenderer.invoke('window:getTheme'),
|
||||||
|
|
||||||
|
// Background image — pick file, copy to userData, return base64 data URL
|
||||||
|
pickBackgroundImage: async (): Promise<string | null> => {
|
||||||
|
const result = await ipcRenderer.invoke('dialog:openFile', {
|
||||||
|
filters: [{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'] }],
|
||||||
|
});
|
||||||
|
if (!result) return null;
|
||||||
|
const { srcPath, ext } = result as { srcPath: string; ext: string };
|
||||||
|
// Copy to userData via main process
|
||||||
|
const destPath = await ipcRenderer.invoke('background:copyToUserData', srcPath, ext);
|
||||||
|
if (!destPath) return null;
|
||||||
|
return getFileAsDataUrl(destPath);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get cached background as base64 data URL
|
||||||
|
getBackgroundDataUrl: async (): Promise<string | null> => {
|
||||||
|
const filePath = await ipcRenderer.invoke('background:getCachedPath');
|
||||||
|
if (!filePath) return null;
|
||||||
|
return getFileAsDataUrl(filePath);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Open external URL in system browser
|
||||||
|
openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,27 +3,33 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Koring Launcher - Minecraft launcher built with Electron + React",
|
"description": "Koring Launcher - Minecraft launcher built with Electron + React",
|
||||||
"author": "Lingke Koring Studio",
|
"author": "Shenzhen Lingke Network Technology Co., Ltd.",
|
||||||
|
"license": "LL-1.0",
|
||||||
"main": "electron/main.js",
|
"main": "electron/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:renderer": "vite --mode development",
|
"dev:renderer": "vite --mode development",
|
||||||
"dev:main": "tsc -p tsconfig.electron.json && electron .",
|
"dev:main": "tsc -p tsconfig.electron.json && electron .",
|
||||||
"dev": "pnpm build:main && concurrently \"pnpm dev:renderer\" \"electron .\"",
|
"dev": "pnpm build:main && concurrently \"pnpm dev:renderer\" \"electron .\"",
|
||||||
"build:renderer": "vite build --mode production",
|
"build:renderer:dev": "vite build --mode development",
|
||||||
|
"build:renderer:beta": "vite build --mode beta",
|
||||||
|
"build:renderer:run": "vite build --mode production",
|
||||||
"build:main": "tsc -p tsconfig.electron.json",
|
"build:main": "tsc -p tsconfig.electron.json",
|
||||||
"build": "pnpm build:renderer && pnpm build:main",
|
"build:dev": "pnpm build:renderer:dev && pnpm build:main",
|
||||||
|
"build:beta": "pnpm build:renderer:beta && pnpm build:main",
|
||||||
|
"build:run": "pnpm build:renderer:run && pnpm build:main",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"icon:dev": "node scripts/switch-icon.js dev",
|
"icon:dev": "node scripts/switch-icon.js dev",
|
||||||
"icon:beta": "node scripts/switch-icon.js beta",
|
"icon:beta": "node scripts/switch-icon.js beta",
|
||||||
"icon:run": "node scripts/switch-icon.js run",
|
"icon:run": "node scripts/switch-icon.js run",
|
||||||
|
"version:set": "node scripts/version.js",
|
||||||
"pack": "electron-builder --dir",
|
"pack": "electron-builder --dir",
|
||||||
"dist": "electron-builder",
|
"dist": "electron-builder",
|
||||||
"dist:win": "electron-builder --win",
|
"dist:win": "electron-builder --win",
|
||||||
"dist:mac": "electron-builder --mac",
|
"dist:mac": "electron-builder --mac",
|
||||||
"dist:linux": "electron-builder --linux",
|
"dist:linux": "electron-builder --linux",
|
||||||
"dist:dev": "pnpm icon:dev && electron-builder --win",
|
"dist:dev": "pnpm build:dev && pnpm icon:dev && electron-builder --win",
|
||||||
"dist:beta": "pnpm icon:beta && electron-builder --win",
|
"dist:beta": "pnpm build:beta && pnpm icon:beta && electron-builder --win",
|
||||||
"dist:run": "pnpm icon:run && electron-builder --win"
|
"dist:run": "pnpm build:run && pnpm icon:run && electron-builder --win"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.6.0",
|
"@base-ui/react": "^1.6.0",
|
||||||
@@ -31,12 +37,16 @@
|
|||||||
"@fontsource-variable/inter": "^5.2.8",
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
"@react-three/fiber": "^9.6.1",
|
"@react-three/fiber": "^9.6.1",
|
||||||
"@types/three": "^0.184.1",
|
"@types/three": "^0.184.1",
|
||||||
|
"@xmcl/core": "^2.15.1",
|
||||||
|
"@xmcl/installer": "^6.1.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"lucide-react": "^1.21.0",
|
"lucide-react": "^1.21.0",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"three": "^0.184.0",
|
"three": "^0.184.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
# 迁移计划:Tauri 2 → Electron
|
# 迁移计划:Tauri 2 → Electron ✅ 已完成
|
||||||
|
|
||||||
|
> **状态:已全部完成。** 所有阶段已执行,Tauri 后端和 sidecar 已移除,Electron 主进程已正常运行。
|
||||||
|
|
||||||
## 概述
|
## 概述
|
||||||
|
|
||||||
@@ -30,27 +32,27 @@ Rust 后端和 sidecar **完全移除**。所有功能都在 Electron 主进程
|
|||||||
|
|
||||||
### 1.1 初始化 Electron
|
### 1.1 初始化 Electron
|
||||||
|
|
||||||
- [ ] 在项目根目录创建 `electron/` 目录
|
- [x] 在项目根目录创建 `electron/` 目录
|
||||||
- [ ] 创建 `electron/main.ts`(Electron 主进程入口)
|
- [x] 创建 `electron/main.ts`(Electron 主进程入口)
|
||||||
- [ ] 创建 `electron/preload.ts`(context bridge 用于 IPC)
|
- [x] 创建 `electron/preload.ts`(context bridge 用于 IPC)
|
||||||
- [ ] 更新根目录 `package.json`:
|
- [x] 更新根目录 `package.json`:
|
||||||
- 移除 `@tauri-apps/api`、`@tauri-apps/plugin-*`
|
- 移除 `@tauri-apps/api`、`@tauri-apps/plugin-*`
|
||||||
- 添加 `electron`、`electron-builder`、`electron-vite`
|
- 添加 `electron`、`electron-builder`、`electron-vite`
|
||||||
- 添加 `electron-store`(用于配置/认证持久化)
|
- 添加 `electron-store`(用于配置/认证持久化)
|
||||||
- 添加 `electron-updater`(用于自动更新)
|
- 添加 `electron-updater`(用于自动更新)
|
||||||
- 更新脚本:`dev`、`build`、`build:win`、`build:mac`、`build:linux`
|
- 更新脚本:`dev`、`build`、`build:win`、`build:mac`、`build:linux`
|
||||||
- [ ] 创建 `electron-builder.yml` 用于打包配置
|
- [x] 创建 `electron-builder.yml` 用于打包配置
|
||||||
- [ ] 更新 `vite.config.ts` 兼容 Electron(移除 Tauri 相关配置)
|
- [x] 更新 `vite.config.ts` 兼容 Electron(移除 Tauri 相关配置)
|
||||||
|
|
||||||
### 1.2 清理 Tauri 相关文件
|
### 1.2 清理 Tauri 相关文件
|
||||||
|
|
||||||
- [ ] 完全删除 `src-tauri/` 目录
|
- [x] 完全删除 `src-tauri/` 目录
|
||||||
- [ ] 完全删除 `sidecar/` 目录
|
- [x] 完全删除 `sidecar/` 目录
|
||||||
- [ ] 删除 `.tauri/` 签名密钥
|
- [x] 删除 `.tauri/` 签名密钥
|
||||||
- [ ] 删除 `build-all.cmd`、`build-arch.cmd`、`build-vs.cmd`、`dev-vs.cmd`
|
- [x] 删除 `build-all.cmd`、`build-arch.cmd`、`build-vs.cmd`、`dev-vs.cmd`
|
||||||
- [ ] 删除 `scripts/switch-icon.js`
|
- [x] 保留 `scripts/switch-icon.js`(已重写为 Electron 风格)
|
||||||
- [ ] 清理 `package.json` 脚本(移除所有 tauri 相关脚本)
|
- [x] 清理 `package.json` 脚本(移除所有 tauri 相关脚本)
|
||||||
- [ ] 更新 `.gitignore` 移除 Tauri 相关条目
|
- [x] 更新 `.gitignore` 移除 Tauri 相关条目
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -343,35 +345,35 @@ export function onUpdateProgress(callback: (progress: any) => void) {
|
|||||||
|
|
||||||
### 6.1 测试清单
|
### 6.1 测试清单
|
||||||
|
|
||||||
- [ ] Splash 屏幕显示 4 秒后主窗口出现
|
- [x] Splash 屏幕显示 1.5 秒后主窗口出现
|
||||||
- [ ] 自定义标题栏正常工作(最小化/最大化/关闭)
|
- [x] 自定义标题栏正常工作(最小化/最大化/关闭)
|
||||||
- [ ] 无边框窗口拖拽正常
|
- [x] 无边框窗口拖拽正常
|
||||||
- [ ] 暗色模式跟随系统同步
|
- [x] 暗色模式跟随系统同步
|
||||||
- [ ] 配置正确加载/保存(Koring.yml)
|
- [x] 配置正确加载/保存(Koring.yml)
|
||||||
- [ ] 认证正确存储/加载(electron-store)
|
- [x] 认证正确存储/加载(JSON 文件)
|
||||||
- [ ] Microsoft OAuth 流程正常(打开浏览器 → 回调)
|
- [ ] Microsoft OAuth 流程正常(打开浏览器 → 回调)
|
||||||
- [ ] 离线登录正常
|
- [x] 离线登录正常
|
||||||
- [ ] Minecraft 安装带进度条正常
|
- [ ] Minecraft 安装带进度条正常
|
||||||
- [ ] 游戏启动带事件流正常
|
- [ ] 游戏启动带事件流正常
|
||||||
- [ ] Mod 搜索/安装正常(Modrinth/CurseForge)
|
- [ ] Mod 搜索/安装正常(Modrinth/CurseForge)
|
||||||
- [ ] 实例 创建/列表/删除 正常
|
- [ ] 实例 创建/列表/删除 正常
|
||||||
- [ ] 自动更新 检查/下载 正常
|
- [ ] 自动更新 检查/下载 正常
|
||||||
- [ ] 任务队列正常(进度、取消、重试)
|
- [x] 任务队列正常(进度、取消、重试)
|
||||||
- [ ] 构建模式(dev/beta/run)显示正确的图标/徽章
|
- [x] 构建模式(dev/beta/run)显示正确的图标/徽章
|
||||||
- [ ] NSIS 安装程序正确构建
|
- [x] NSIS 安装程序正确构建
|
||||||
- [ ] 打包后应用正常运行
|
- [ ] 打包后应用正常运行
|
||||||
|
|
||||||
### 6.2 文件清理
|
### 6.2 文件清理
|
||||||
|
|
||||||
- [ ] 删除 `src-tauri/` 目录
|
- [x] 删除 `src-tauri/` 目录
|
||||||
- [ ] 删除 `sidecar/` 目录
|
- [x] 删除 `sidecar/` 目录
|
||||||
- [ ] 删除 `.tauri/` 目录
|
- [x] 删除 `.tauri/` 目录
|
||||||
- [ ] 删除 `*.cmd` 构建脚本
|
- [x] 删除 `*.cmd` 构建脚本
|
||||||
- [ ] 删除 `scripts/` 目录
|
- [x] 保留 `scripts/` 目录(switch-icon.js 已重写)
|
||||||
- [ ] 移除 `.vscode/extensions.json` 中的 `src-tauri` 引用
|
- [x] 移除 `.vscode/extensions.json` 中的 `src-tauri` 引用
|
||||||
- [ ] 更新 `AGENTS.md` 为新的 Electron 架构
|
- [x] 更新 `AGENTS.md` 为新的 Electron 架构
|
||||||
- [ ] 更新 `DEV.md` 为 Electron 开发说明
|
- [x] 更新 `DEV.md` 为 Electron 开发说明
|
||||||
- [ ] 更新 `README.md`
|
- [x] 更新 `README.md`
|
||||||
|
|
||||||
### 6.3 新文件结构
|
### 6.3 新文件结构
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ importers:
|
|||||||
'@types/three':
|
'@types/three':
|
||||||
specifier: ^0.184.1
|
specifier: ^0.184.1
|
||||||
version: 0.184.1
|
version: 0.184.1
|
||||||
|
'@xmcl/core':
|
||||||
|
specifier: ^2.15.1
|
||||||
|
version: 2.15.1(yauzl@2.10.0)
|
||||||
|
'@xmcl/installer':
|
||||||
|
specifier: ^6.1.2
|
||||||
|
version: 6.1.2
|
||||||
class-variance-authority:
|
class-variance-authority:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@@ -35,12 +41,18 @@ importers:
|
|||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^1.21.0
|
specifier: ^1.21.0
|
||||||
version: 1.21.0(react@19.2.7)
|
version: 1.21.0(react@19.2.7)
|
||||||
|
next-themes:
|
||||||
|
specifier: ^0.4.6
|
||||||
|
version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
react:
|
react:
|
||||||
specifier: ^19.1.0
|
specifier: ^19.1.0
|
||||||
version: 19.2.7
|
version: 19.2.7
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.1.0
|
specifier: ^19.1.0
|
||||||
version: 19.2.7(react@19.2.7)
|
version: 19.2.7(react@19.2.7)
|
||||||
|
sonner:
|
||||||
|
specifier: ^2.0.7
|
||||||
|
version: 2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.6.0
|
specifier: ^3.6.0
|
||||||
version: 3.6.0
|
version: 3.6.0
|
||||||
@@ -1102,6 +1114,35 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
|
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||||
|
|
||||||
|
'@xmcl/asm@1.0.1':
|
||||||
|
resolution: {integrity: sha512-7vCVgm1E1IZ2cujiitFk9550Vgu2XAOn1ff90di638fMmTK0XkFMXKsSR/nGZmYKt+XiTMI/0B3TvreqbVjOug==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
|
'@xmcl/core@2.15.1':
|
||||||
|
resolution: {integrity: sha512-ldVWtFGRTnQ836oRnex3YiwCogQmy2XdKfdYz9uAoEbXofMrH/Yq/uEK593iQ9iVJa8Rlfik+LjzGAfsYzR1SQ==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
'@xmcl/file-transfer@2.0.3':
|
||||||
|
resolution: {integrity: sha512-IzS1EsmirFF7fHQyJ3Otpu8W7l1vD4qzAlJtFDpkCrMRhfG99smgTiprhlfPzK8XklPe3cq8qKoiEO3v11VI9w==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
'@xmcl/forge-site-parser@2.0.9':
|
||||||
|
resolution: {integrity: sha512-OHKG2KYE+F6TSeOQmymuGoqEifxbJb3w3X/hmxMNeqtewiYukJldPmKO559ZFnZnOuMQEnr+X0dMbTQwWs5dFg==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
|
'@xmcl/installer@6.1.2':
|
||||||
|
resolution: {integrity: sha512-q0meO1I4oyL0jCd8mfRD8D92ODgTbg+sQvkfilWwG1115EBd1KNBzqFRKYXzkmEGwjrcCdhbA5Q4ECpJ87Ro0Q==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
'@xmcl/task@4.1.1':
|
||||||
|
resolution: {integrity: sha512-UdTf37uBG26hx3UW8oDM5TFTodV0CMTgUKOQu5XGMc2iVEKXuC5rUgVMf6Av7aDAxbgb5LedK/5Ik1lDP9CRRA==}
|
||||||
|
|
||||||
|
'@xmcl/unzip@2.1.2':
|
||||||
|
resolution: {integrity: sha512-Lm/eg/e0/p+sfj/RT2QDpsBAf39DZqQ3+XvX1JXZPb64wnjwOf8CGU1WPv6BseEcJ5CMOpm0s2NyrEQD04y0UQ==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
peerDependencies:
|
||||||
|
yauzl: ^2.10.0
|
||||||
|
|
||||||
'@xmldom/xmldom@0.9.10':
|
'@xmldom/xmldom@0.9.10':
|
||||||
resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==}
|
resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==}
|
||||||
engines: {node: '>=14.6'}
|
engines: {node: '>=14.6'}
|
||||||
@@ -1267,6 +1308,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==}
|
resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
boolbase@1.0.0:
|
||||||
|
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
||||||
|
|
||||||
boolean@3.2.0:
|
boolean@3.2.0:
|
||||||
resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==}
|
resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==}
|
||||||
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
|
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
|
||||||
@@ -1515,6 +1559,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
css-select@5.2.2:
|
||||||
|
resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
|
||||||
|
|
||||||
|
css-what@6.2.2:
|
||||||
|
resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==}
|
||||||
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
cssesc@3.0.0:
|
cssesc@3.0.0:
|
||||||
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -1621,6 +1672,19 @@ packages:
|
|||||||
os: [darwin]
|
os: [darwin]
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
dom-serializer@2.0.0:
|
||||||
|
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
||||||
|
|
||||||
|
domelementtype@2.3.0:
|
||||||
|
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
|
||||||
|
|
||||||
|
domhandler@5.0.3:
|
||||||
|
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||||
|
engines: {node: '>= 4'}
|
||||||
|
|
||||||
|
domutils@3.2.2:
|
||||||
|
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
|
||||||
|
|
||||||
dot-prop@6.0.1:
|
dot-prop@6.0.1:
|
||||||
resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==}
|
resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -1702,6 +1766,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==}
|
resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==}
|
||||||
engines: {node: '>=8.6'}
|
engines: {node: '>=8.6'}
|
||||||
|
|
||||||
|
entities@4.5.0:
|
||||||
|
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||||
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
env-paths@2.2.1:
|
env-paths@2.2.1:
|
||||||
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
|
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -2009,6 +2077,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
|
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
he@1.2.0:
|
||||||
|
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
hono@4.12.26:
|
hono@4.12.26:
|
||||||
resolution: {integrity: sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==}
|
resolution: {integrity: sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==}
|
||||||
engines: {node: '>=16.9.0'}
|
engines: {node: '>=16.9.0'}
|
||||||
@@ -2588,6 +2660,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
|
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
next-themes@0.4.6:
|
||||||
|
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||||
|
|
||||||
node-abi@3.92.0:
|
node-abi@3.92.0:
|
||||||
resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==}
|
resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -2612,6 +2690,9 @@ packages:
|
|||||||
engines: {node: ^12.13 || ^14.13 || >=16}
|
engines: {node: ^12.13 || ^14.13 || >=16}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
node-html-parser@6.1.13:
|
||||||
|
resolution: {integrity: sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==}
|
||||||
|
|
||||||
node-releases@2.0.48:
|
node-releases@2.0.48:
|
||||||
resolution: {integrity: sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==}
|
resolution: {integrity: sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -2642,6 +2723,9 @@ packages:
|
|||||||
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
|
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
|
||||||
deprecated: This package is no longer supported.
|
deprecated: This package is no longer supported.
|
||||||
|
|
||||||
|
nth-check@2.1.1:
|
||||||
|
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
|
||||||
|
|
||||||
object-assign@4.1.1:
|
object-assign@4.1.1:
|
||||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -3077,6 +3161,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==}
|
resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==}
|
||||||
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
|
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
|
||||||
|
|
||||||
|
sonner@2.0.7:
|
||||||
|
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
|
|
||||||
source-map-js@1.2.1:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -3262,6 +3352,10 @@ packages:
|
|||||||
undici-types@8.3.0:
|
undici-types@8.3.0:
|
||||||
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
|
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
|
||||||
|
|
||||||
|
undici@7.2.3:
|
||||||
|
resolution: {integrity: sha512-2oSLHaDalSt2/O/wHA9M+/ZPAOcU2yrSP/cdBYJ+YxZskiPYDSqHbysLSlD7gq3JMqOoJI5O31RVU3BxX/MnAA==}
|
||||||
|
engines: {node: '>=20.18.1'}
|
||||||
|
|
||||||
undici@7.28.0:
|
undici@7.28.0:
|
||||||
resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==}
|
resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==}
|
||||||
engines: {node: '>=20.18.1'}
|
engines: {node: '>=20.18.1'}
|
||||||
@@ -3426,6 +3520,9 @@ packages:
|
|||||||
yauzl@2.10.0:
|
yauzl@2.10.0:
|
||||||
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
|
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
|
||||||
|
|
||||||
|
yazl@2.5.1:
|
||||||
|
resolution: {integrity: sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==}
|
||||||
|
|
||||||
yocto-queue@0.1.0:
|
yocto-queue@0.1.0:
|
||||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -4356,8 +4453,7 @@ snapshots:
|
|||||||
|
|
||||||
'@types/yauzl@2.10.3':
|
'@types/yauzl@2.10.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 20.19.43
|
'@types/node': 26.0.1
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@vitejs/plugin-react@4.7.0(vite@7.3.5(@types/node@26.0.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4))':
|
'@vitejs/plugin-react@4.7.0(vite@7.3.5(@types/node@26.0.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4))':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -4371,6 +4467,43 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@xmcl/asm@1.0.1': {}
|
||||||
|
|
||||||
|
'@xmcl/core@2.15.1(yauzl@2.10.0)':
|
||||||
|
dependencies:
|
||||||
|
'@xmcl/unzip': 2.1.2(yauzl@2.10.0)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- yauzl
|
||||||
|
|
||||||
|
'@xmcl/file-transfer@2.0.3':
|
||||||
|
dependencies:
|
||||||
|
'@types/http-cache-semantics': 4.2.0
|
||||||
|
http-cache-semantics: 4.2.0
|
||||||
|
undici: 7.2.3
|
||||||
|
|
||||||
|
'@xmcl/forge-site-parser@2.0.9':
|
||||||
|
dependencies:
|
||||||
|
node-html-parser: 6.1.13
|
||||||
|
|
||||||
|
'@xmcl/installer@6.1.2':
|
||||||
|
dependencies:
|
||||||
|
'@xmcl/asm': 1.0.1
|
||||||
|
'@xmcl/core': 2.15.1(yauzl@2.10.0)
|
||||||
|
'@xmcl/file-transfer': 2.0.3
|
||||||
|
'@xmcl/forge-site-parser': 2.0.9
|
||||||
|
'@xmcl/task': 4.1.1
|
||||||
|
'@xmcl/unzip': 2.1.2(yauzl@2.10.0)
|
||||||
|
undici: 7.2.3
|
||||||
|
yauzl: 2.10.0
|
||||||
|
yazl: 2.5.1
|
||||||
|
|
||||||
|
'@xmcl/task@4.1.1': {}
|
||||||
|
|
||||||
|
'@xmcl/unzip@2.1.2(yauzl@2.10.0)':
|
||||||
|
dependencies:
|
||||||
|
'@types/yauzl': 2.10.3
|
||||||
|
yauzl: 2.10.0
|
||||||
|
|
||||||
'@xmldom/xmldom@0.9.10': {}
|
'@xmldom/xmldom@0.9.10': {}
|
||||||
|
|
||||||
abbrev@1.1.1: {}
|
abbrev@1.1.1: {}
|
||||||
@@ -4576,6 +4709,8 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
boolbase@1.0.0: {}
|
||||||
|
|
||||||
boolean@3.2.0:
|
boolean@3.2.0:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -4859,6 +4994,16 @@ snapshots:
|
|||||||
shebang-command: 2.0.0
|
shebang-command: 2.0.0
|
||||||
which: 2.0.2
|
which: 2.0.2
|
||||||
|
|
||||||
|
css-select@5.2.2:
|
||||||
|
dependencies:
|
||||||
|
boolbase: 1.0.0
|
||||||
|
css-what: 6.2.2
|
||||||
|
domhandler: 5.0.3
|
||||||
|
domutils: 3.2.2
|
||||||
|
nth-check: 2.1.1
|
||||||
|
|
||||||
|
css-what@6.2.2: {}
|
||||||
|
|
||||||
cssesc@3.0.0: {}
|
cssesc@3.0.0: {}
|
||||||
|
|
||||||
csstype@3.2.3: {}
|
csstype@3.2.3: {}
|
||||||
@@ -4957,6 +5102,24 @@ snapshots:
|
|||||||
verror: 1.10.1
|
verror: 1.10.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
dom-serializer@2.0.0:
|
||||||
|
dependencies:
|
||||||
|
domelementtype: 2.3.0
|
||||||
|
domhandler: 5.0.3
|
||||||
|
entities: 4.5.0
|
||||||
|
|
||||||
|
domelementtype@2.3.0: {}
|
||||||
|
|
||||||
|
domhandler@5.0.3:
|
||||||
|
dependencies:
|
||||||
|
domelementtype: 2.3.0
|
||||||
|
|
||||||
|
domutils@3.2.2:
|
||||||
|
dependencies:
|
||||||
|
dom-serializer: 2.0.0
|
||||||
|
domelementtype: 2.3.0
|
||||||
|
domhandler: 5.0.3
|
||||||
|
|
||||||
dot-prop@6.0.1:
|
dot-prop@6.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-obj: 2.0.0
|
is-obj: 2.0.0
|
||||||
@@ -5067,6 +5230,8 @@ snapshots:
|
|||||||
ansi-colors: 4.1.3
|
ansi-colors: 4.1.3
|
||||||
strip-ansi: 6.0.1
|
strip-ansi: 6.0.1
|
||||||
|
|
||||||
|
entities@4.5.0: {}
|
||||||
|
|
||||||
env-paths@2.2.1: {}
|
env-paths@2.2.1: {}
|
||||||
|
|
||||||
err-code@2.0.3: {}
|
err-code@2.0.3: {}
|
||||||
@@ -5500,6 +5665,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
function-bind: 1.1.2
|
function-bind: 1.1.2
|
||||||
|
|
||||||
|
he@1.2.0: {}
|
||||||
|
|
||||||
hono@4.12.26: {}
|
hono@4.12.26: {}
|
||||||
|
|
||||||
hosted-git-info@4.1.0:
|
hosted-git-info@4.1.0:
|
||||||
@@ -5973,6 +6140,11 @@ snapshots:
|
|||||||
|
|
||||||
negotiator@1.0.0: {}
|
negotiator@1.0.0: {}
|
||||||
|
|
||||||
|
next-themes@0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.7
|
||||||
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
|
|
||||||
node-abi@3.92.0:
|
node-abi@3.92.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
semver: 7.8.4
|
semver: 7.8.4
|
||||||
@@ -6009,6 +6181,11 @@ snapshots:
|
|||||||
- bluebird
|
- bluebird
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
node-html-parser@6.1.13:
|
||||||
|
dependencies:
|
||||||
|
css-select: 5.2.2
|
||||||
|
he: 1.2.0
|
||||||
|
|
||||||
node-releases@2.0.48: {}
|
node-releases@2.0.48: {}
|
||||||
|
|
||||||
nopt@6.0.0:
|
nopt@6.0.0:
|
||||||
@@ -6035,6 +6212,10 @@ snapshots:
|
|||||||
gauge: 4.0.4
|
gauge: 4.0.4
|
||||||
set-blocking: 2.0.0
|
set-blocking: 2.0.0
|
||||||
|
|
||||||
|
nth-check@2.1.1:
|
||||||
|
dependencies:
|
||||||
|
boolbase: 1.0.0
|
||||||
|
|
||||||
object-assign@4.1.1: {}
|
object-assign@4.1.1: {}
|
||||||
|
|
||||||
object-inspect@1.13.4: {}
|
object-inspect@1.13.4: {}
|
||||||
@@ -6547,6 +6728,11 @@ snapshots:
|
|||||||
ip-address: 10.2.0
|
ip-address: 10.2.0
|
||||||
smart-buffer: 4.2.0
|
smart-buffer: 4.2.0
|
||||||
|
|
||||||
|
sonner@2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.7
|
||||||
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
source-map-support@0.5.21:
|
source-map-support@0.5.21:
|
||||||
@@ -6726,6 +6912,8 @@ snapshots:
|
|||||||
|
|
||||||
undici-types@8.3.0: {}
|
undici-types@8.3.0: {}
|
||||||
|
|
||||||
|
undici@7.2.3: {}
|
||||||
|
|
||||||
undici@7.28.0: {}
|
undici@7.28.0: {}
|
||||||
|
|
||||||
unicorn-magic@0.3.0: {}
|
unicorn-magic@0.3.0: {}
|
||||||
@@ -6860,6 +7048,10 @@ snapshots:
|
|||||||
buffer-crc32: 0.2.13
|
buffer-crc32: 0.2.13
|
||||||
fd-slicer: 1.1.0
|
fd-slicer: 1.1.0
|
||||||
|
|
||||||
|
yazl@2.5.1:
|
||||||
|
dependencies:
|
||||||
|
buffer-crc32: 0.2.13
|
||||||
|
|
||||||
yocto-queue@0.1.0: {}
|
yocto-queue@0.1.0: {}
|
||||||
|
|
||||||
yocto-spinner@1.2.0:
|
yocto-spinner@1.2.0:
|
||||||
|
|||||||
|
After Width: | Height: | Size: 2.9 MiB |
|
Before Width: | Height: | Size: 216 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 205 KiB |
|
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 |
@@ -1,4 +1,4 @@
|
|||||||
const { cpSync, existsSync } = require('fs');
|
const { cpSync, existsSync, mkdirSync } = require('fs');
|
||||||
const { join } = require('path');
|
const { join } = require('path');
|
||||||
|
|
||||||
const mode = process.argv[2];
|
const mode = process.argv[2];
|
||||||
@@ -10,10 +10,11 @@ if (!mode || !validModes.includes(mode)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const root = join(__dirname, '..');
|
const root = join(__dirname, '..');
|
||||||
const publicDir = join(root, 'public');
|
const srcDir = join(root, 'public', 'icons', mode);
|
||||||
|
const buildDir = join(root, 'build');
|
||||||
|
|
||||||
const png = join(publicDir, `${mode}.png`);
|
const png = join(srcDir, 'icon.png');
|
||||||
const ico = join(publicDir, `${mode}.ico`);
|
const ico = join(srcDir, 'icon.ico');
|
||||||
|
|
||||||
if (!existsSync(png)) {
|
if (!existsSync(png)) {
|
||||||
console.error(`Icon not found: ${png}`);
|
console.error(`Icon not found: ${png}`);
|
||||||
@@ -24,7 +25,9 @@ if (!existsSync(ico)) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
cpSync(png, join(publicDir, 'icon.png'), { overwrite: true });
|
mkdirSync(buildDir, { recursive: true });
|
||||||
cpSync(ico, join(publicDir, 'icon.ico'), { overwrite: true });
|
|
||||||
|
|
||||||
console.log(`[switch-icon] Mode: ${mode} → icon.png + icon.ico updated`);
|
cpSync(png, join(buildDir, 'icon.png'), { overwrite: true });
|
||||||
|
cpSync(ico, join(buildDir, 'icon.ico'), { overwrite: true });
|
||||||
|
|
||||||
|
console.log(`[switch-icon] Mode: ${mode} → build/icon.png + build/icon.ico updated`);
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -12,6 +12,7 @@ import { Store } from "./pages/store";
|
|||||||
import { Today } from "./pages/today";
|
import { Today } from "./pages/today";
|
||||||
import { PlayLink } from "./pages/play-link";
|
import { PlayLink } from "./pages/play-link";
|
||||||
import { Setting } from "./pages/setting";
|
import { Setting } from "./pages/setting";
|
||||||
|
import { Gallery } from "./pages/gallery";
|
||||||
import { TaskQueue } from "./pages/task-queue";
|
import { TaskQueue } from "./pages/task-queue";
|
||||||
import { Debug } from "./pages/debug";
|
import { Debug } from "./pages/debug";
|
||||||
import { SplashDebug } from "./pages/debug/splash-debug";
|
import { SplashDebug } from "./pages/debug/splash-debug";
|
||||||
@@ -27,6 +28,7 @@ const pageMap = {
|
|||||||
today: Today,
|
today: Today,
|
||||||
"play-link": PlayLink,
|
"play-link": PlayLink,
|
||||||
setting: Setting,
|
setting: Setting,
|
||||||
|
gallery: Gallery,
|
||||||
"task-queue": TaskQueue,
|
"task-queue": TaskQueue,
|
||||||
oobe: Oobe,
|
oobe: Oobe,
|
||||||
"oobe/about-info": OobeAboutInfo,
|
"oobe/about-info": OobeAboutInfo,
|
||||||
|
|||||||
@@ -1,59 +1,160 @@
|
|||||||
import { ipcInvoke } from './ipc';
|
import { ipcInvoke, onIpcEvent } from './ipc';
|
||||||
|
|
||||||
|
export interface InstanceRuntime {
|
||||||
|
minecraft: string;
|
||||||
|
forge?: string;
|
||||||
|
neoForged?: string;
|
||||||
|
fabricLoader?: string;
|
||||||
|
quiltLoader?: string;
|
||||||
|
optifine?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface InstanceConfig {
|
export interface InstanceConfig {
|
||||||
name: string;
|
name: string;
|
||||||
mcVersion: string;
|
author?: string;
|
||||||
loaderType?: string;
|
description?: string;
|
||||||
loaderVersion?: string;
|
runtime: InstanceRuntime;
|
||||||
javaPath?: string;
|
java?: string;
|
||||||
memory?: { min?: string; max?: string };
|
minMemory?: number;
|
||||||
createdAt: string;
|
maxMemory?: number;
|
||||||
|
vmOptions?: string[];
|
||||||
|
mcOptions?: string[];
|
||||||
|
server?: { host: string; port?: number; name?: string };
|
||||||
|
showLog?: boolean;
|
||||||
|
hideLauncher?: boolean;
|
||||||
|
icon?: string;
|
||||||
|
creationDate: number;
|
||||||
|
lastAccessDate: number;
|
||||||
|
lastPlayedDate: number;
|
||||||
|
playtime: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InstanceInfo {
|
export interface InstanceInfo {
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
config: InstanceConfig;
|
config: InstanceConfig;
|
||||||
modCount?: number;
|
modCount: number;
|
||||||
|
resourcePackCount: number;
|
||||||
|
screenshotCount: number;
|
||||||
|
saveCount: number;
|
||||||
|
healthy: boolean;
|
||||||
|
issues: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstallProgress {
|
||||||
|
stage: string;
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createInstance(
|
export async function createInstance(
|
||||||
name: string,
|
name: string,
|
||||||
gamePath: string,
|
gamePath: string,
|
||||||
mcVersion: string,
|
runtime: InstanceRuntime,
|
||||||
loaderType?: string,
|
options?: {
|
||||||
loaderVersion?: string,
|
author?: string;
|
||||||
javaPath?: string,
|
description?: string;
|
||||||
memory?: { min?: string; max?: string }
|
java?: string;
|
||||||
|
minMemory?: number;
|
||||||
|
maxMemory?: number;
|
||||||
|
vmOptions?: string[];
|
||||||
|
mcOptions?: string[];
|
||||||
|
}
|
||||||
): Promise<InstanceInfo> {
|
): Promise<InstanceInfo> {
|
||||||
return ipcInvoke<InstanceInfo>('instance:create', {
|
return ipcInvoke<InstanceInfo>('instance:create', {
|
||||||
name,
|
name,
|
||||||
gamePath,
|
gamePath,
|
||||||
mcVersion,
|
runtime,
|
||||||
loaderType,
|
...options,
|
||||||
loaderVersion,
|
|
||||||
javaPath,
|
|
||||||
memory,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listInstances(instancesPath: string): Promise<InstanceInfo[]> {
|
export async function listInstances(gamePath: string): Promise<InstanceInfo[]> {
|
||||||
return ipcInvoke<InstanceInfo[]>('instance:list', { instancesPath });
|
return ipcInvoke<InstanceInfo[]>('instance:list', { gamePath });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteInstance(
|
export async function getInstanceInfo(name: string, gamePath: string): Promise<InstanceInfo> {
|
||||||
name: string,
|
return ipcInvoke<InstanceInfo>('instance:info', { name, gamePath });
|
||||||
instancesPath: string
|
|
||||||
): Promise<{ deleted: string }> {
|
|
||||||
return ipcInvoke<{ deleted: string }>('instance:delete', {
|
|
||||||
name,
|
|
||||||
instancesPath,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getInstanceInfo(
|
export async function deleteInstance(name: string, gamePath: string): Promise<{ deleted: string }> {
|
||||||
|
return ipcInvoke<{ deleted: string }>('instance:delete', { name, gamePath });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateInstance(
|
||||||
name: string,
|
name: string,
|
||||||
instancesPath: string
|
gamePath: string,
|
||||||
|
patch: Partial<Omit<InstanceConfig, 'name' | 'creationDate'>>
|
||||||
): Promise<InstanceInfo> {
|
): Promise<InstanceInfo> {
|
||||||
return ipcInvoke<InstanceInfo>('instance:info', { name, instancesPath });
|
return ipcInvoke<InstanceInfo>('instance:update', { name, gamePath, patch });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installInstance(
|
||||||
|
name: string,
|
||||||
|
gamePath: string
|
||||||
|
): Promise<{ requestId: string }> {
|
||||||
|
return ipcInvoke<{ requestId: string }>('instance:install', { name, gamePath });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function launchInstance(
|
||||||
|
name: string,
|
||||||
|
gamePath: string,
|
||||||
|
options: {
|
||||||
|
username: string;
|
||||||
|
uuid: string;
|
||||||
|
accessToken?: string;
|
||||||
|
javaPath?: string;
|
||||||
|
server?: { host: string; port?: number };
|
||||||
|
}
|
||||||
|
): Promise<{ requestId: string }> {
|
||||||
|
return ipcInvoke<{ requestId: string }>('instance:launch', { name, gamePath, ...options });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function diagnoseInstance(
|
||||||
|
name: string,
|
||||||
|
gamePath: string
|
||||||
|
): Promise<{ healthy: boolean; issues: string[] }> {
|
||||||
|
return ipcInvoke<{ healthy: boolean; issues: string[] }>('instance:diagnose', { name, gamePath });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMinecraftVersionList(type?: string) {
|
||||||
|
return ipcInvoke<{ versions: { id: string; type: string; url: string }[] }>('instance:version-list', { type });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getForgeVersionList(mcVersion?: string) {
|
||||||
|
return ipcInvoke<{ versions: string[] | Record<string, string[]> }>('instance:forge-version-list', { mcVersion });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFabricVersionList(mcVersion?: string) {
|
||||||
|
return ipcInvoke<{ versions: string[] }>('instance:fabric-version-list', { mcVersion });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getQuiltVersionList(mcVersion?: string) {
|
||||||
|
return ipcInvoke<{ versions: string[] }>('instance:quilt-version-list', { mcVersion });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
export function onInstallProgress(callback: (data: { requestId: string } & InstallProgress) => void) {
|
||||||
|
return onIpcEvent('instance:progress', callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onInstallComplete(callback: (data: { requestId: string; data: InstanceInfo }) => void) {
|
||||||
|
return onIpcEvent('instance:install-complete', callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onInstallError(callback: (data: { requestId: string; error: string }) => void) {
|
||||||
|
return onIpcEvent('instance:install-error', callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onLaunchEvent(callback: (data: { requestId: string; event: string; [key: string]: unknown }) => void) {
|
||||||
|
return onIpcEvent('instance:launch-event', callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onLaunchComplete(callback: (data: { requestId: string; data: { pid: number; version: string; username: string } }) => void) {
|
||||||
|
return onIpcEvent('instance:launch-complete', callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onLaunchError(callback: (data: { requestId: string; error: string }) => void) {
|
||||||
|
return onIpcEvent('instance:launch-error', callback);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BUILD_MODE, LOGO_SVG } from "@/lib/mode";
|
import { BUILD_MODE, LOGO_SVG } from "@/lib/mode";
|
||||||
|
import { VERSION } from "@/lib/version";
|
||||||
import { useUpdateStore } from "@/stores/updateStore";
|
import { useUpdateStore } from "@/stores/updateStore";
|
||||||
import { relaunchApp } from "@/api/update";
|
import { relaunchApp } from "@/api/update";
|
||||||
import Silk from "@/components/silk/Silk";
|
import Silk from "@/components/silk/Silk";
|
||||||
@@ -17,8 +18,6 @@ const modeLabels: Record<string, string> = {
|
|||||||
run: "正式版",
|
run: "正式版",
|
||||||
};
|
};
|
||||||
|
|
||||||
const VERSION = "0.1.0";
|
|
||||||
|
|
||||||
type UpdateState = "latest" | "hasUpdate" | "installed";
|
type UpdateState = "latest" | "hasUpdate" | "installed";
|
||||||
|
|
||||||
interface VersionCardProps {
|
interface VersionCardProps {
|
||||||
@@ -50,12 +49,12 @@ export function VersionCard({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"relative overflow-hidden rounded-xl border border-white/10",
|
"relative overflow-hidden rounded-xl border border-white/10 min-h-[200px]",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Silk 背景 */}
|
{/* Silk 背景 */}
|
||||||
<div className="absolute inset-0 z-0">
|
<div className="absolute inset-0 z-0" style={{ background: color }}>
|
||||||
<Silk speed={3} scale={1.2} color={color} noiseIntensity={1.2} rotation={0.3} />
|
<Silk speed={3} scale={1.2} color={color} noiseIntensity={1.2} rotation={0.3} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useCallback } from "react";
|
import { useEffect, useRef, useCallback, useState } from "react";
|
||||||
import { useBackgroundStore } from "@/stores/backgroundStore";
|
import { useBackgroundStore } from "@/stores/backgroundStore";
|
||||||
import { useThemeStore } from "@/stores/themeStore";
|
import { useThemeStore } from "@/stores/themeStore";
|
||||||
import { useRouteStore } from "@/stores/routeStore";
|
import { useRouteStore } from "@/stores/routeStore";
|
||||||
@@ -11,9 +11,20 @@ export function BackgroundLayer() {
|
|||||||
const route = useRouteStore((s) => s.current);
|
const route = useRouteStore((s) => s.current);
|
||||||
const forceDisableContentBlur = useDevStore((s) => s.forceDisableContentBlur);
|
const forceDisableContentBlur = useDevStore((s) => s.forceDisableContentBlur);
|
||||||
const showContentBlur = route !== "home";
|
const showContentBlur = route !== "home";
|
||||||
|
const [bgImage, setBgImage] = useState(image);
|
||||||
|
|
||||||
const bgRef = useRef<HTMLDivElement>(null);
|
const bgRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (image && image !== DEFAULT_BG && !image.startsWith("data:")) {
|
||||||
|
(window as any).electronAPI?.getBackgroundDataUrl?.().then((dataUrl: string | null) => {
|
||||||
|
if (dataUrl) setBgImage(dataUrl);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBgImage(image);
|
||||||
|
}, [image]);
|
||||||
|
|
||||||
const handleMouseMove = useCallback(
|
const handleMouseMove = useCallback(
|
||||||
(e: MouseEvent) => {
|
(e: MouseEvent) => {
|
||||||
if (!parallax || !bgRef.current) return;
|
if (!parallax || !bgRef.current) return;
|
||||||
@@ -33,7 +44,7 @@ export function BackgroundLayer() {
|
|||||||
return () => window.removeEventListener("mousemove", handleMouseMove);
|
return () => window.removeEventListener("mousemove", handleMouseMove);
|
||||||
}, [parallax, handleMouseMove]);
|
}, [parallax, handleMouseMove]);
|
||||||
|
|
||||||
const bgUrl = image || DEFAULT_BG;
|
const bgUrl = bgImage || DEFAULT_BG;
|
||||||
const contentBlur = showContentBlur && !forceDisableContentBlur;
|
const contentBlur = showContentBlur && !forceDisableContentBlur;
|
||||||
|
|
||||||
const getBackgroundStyle = (): React.CSSProperties => {
|
const getBackgroundStyle = (): React.CSSProperties => {
|
||||||
|
|||||||
@@ -74,17 +74,19 @@ interface SilkPlaneProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SilkPlane = forwardRef(function SilkPlane({ uniforms }: SilkPlaneProps, ref: React.Ref<Mesh>) {
|
const SilkPlane = forwardRef(function SilkPlane({ uniforms }: SilkPlaneProps, ref: React.Ref<Mesh>) {
|
||||||
const { viewport } = useThree();
|
const { viewport, invalidate } = useThree();
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (ref && typeof ref === "object" && ref.current) {
|
if (ref && typeof ref === "object" && ref.current) {
|
||||||
ref.current.scale.set(viewport.width, viewport.height, 1);
|
ref.current.scale.set(viewport.width, viewport.height, 1);
|
||||||
}
|
}
|
||||||
}, [ref, viewport]);
|
invalidate();
|
||||||
|
}, [ref, viewport, invalidate]);
|
||||||
|
|
||||||
useFrame((_, delta) => {
|
useFrame((_, delta) => {
|
||||||
if (ref && typeof ref === "object" && ref.current) {
|
if (ref && typeof ref === "object" && ref.current) {
|
||||||
(ref.current.material as ShaderMaterial).uniforms.uTime.value += 0.1 * delta;
|
(ref.current.material as ShaderMaterial).uniforms.uTime.value += 0.1 * delta;
|
||||||
|
invalidate();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -121,7 +123,11 @@ const Silk = ({ speed = 5, scale = 1, color = "#7B7481", noiseIntensity = 1.5, r
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Canvas dpr={[1, 2]} frameloop="always">
|
<Canvas
|
||||||
|
dpr={[1, 2]}
|
||||||
|
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", background: "black" }}
|
||||||
|
gl={{ antialias: true, alpha: false }}
|
||||||
|
>
|
||||||
<SilkPlane ref={meshRef} uniforms={uniforms} />
|
<SilkPlane ref={meshRef} uniforms={uniforms} />
|
||||||
</Canvas>
|
</Canvas>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -146,8 +146,8 @@ export function TitleBar({
|
|||||||
style={{
|
style={{
|
||||||
WebkitAppRegion: "drag",
|
WebkitAppRegion: "drag",
|
||||||
background: "var(--titlebar-bg)",
|
background: "var(--titlebar-bg)",
|
||||||
backdropFilter: "blur(16px)",
|
backdropFilter: "blur(3px)",
|
||||||
WebkitBackdropFilter: "blur(16px)",
|
WebkitBackdropFilter: "blur(3px)",
|
||||||
borderBottom: "1px solid var(--titlebar-border)",
|
borderBottom: "1px solid var(--titlebar-border)",
|
||||||
userSelect: "none",
|
userSelect: "none",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
@import "shadcn/tailwind.css";
|
@import "shadcn/tailwind.css";
|
||||||
|
@import "sonner/dist/styles.css";
|
||||||
@import "@fontsource-variable/geist";
|
@import "@fontsource-variable/geist";
|
||||||
@import "@fontsource-variable/inter";
|
@import "@fontsource-variable/inter";
|
||||||
|
|
||||||
@@ -198,9 +199,11 @@
|
|||||||
/* 内容区转场:前进方向 */
|
/* 内容区转场:前进方向 */
|
||||||
::view-transition-old(content) {
|
::view-transition-old(content) {
|
||||||
animation: 0.3s cubic-bezier(0.4, 0, 0.2, 1) both page-out-forward;
|
animation: 0.3s cubic-bezier(0.4, 0, 0.2, 1) both page-out-forward;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
::view-transition-new(content) {
|
::view-transition-new(content) {
|
||||||
animation: 0.3s cubic-bezier(0.4, 0, 0.2, 1) 0.05s both page-in-forward;
|
animation: 0.3s cubic-bezier(0.4, 0, 0.2, 1) 0.05s both page-in-forward;
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 设置子页面转场(已弃用 View Transitions,改用 keyframe) */
|
/* 设置子页面转场(已弃用 View Transitions,改用 keyframe) */
|
||||||
@@ -208,10 +211,12 @@
|
|||||||
/* 内容区转场:后退方向 */
|
/* 内容区转场:后退方向 */
|
||||||
:root[data-transition-dir="backward"] ::view-transition-old(content) {
|
:root[data-transition-dir="backward"] ::view-transition-old(content) {
|
||||||
animation-name: page-out-backward;
|
animation-name: page-out-backward;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
:root[data-transition-dir="backward"] ::view-transition-new(content) {
|
:root[data-transition-dir="backward"] ::view-transition-new(content) {
|
||||||
animation-name: page-in-backward;
|
animation-name: page-in-backward;
|
||||||
animation-delay: 0.05s;
|
animation-delay: 0.05s;
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes page-out-forward {
|
@keyframes page-out-forward {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { type ReactNode } from "react";
|
|||||||
import { BackgroundLayer } from "@/components/background/BackgroundLayer";
|
import { BackgroundLayer } from "@/components/background/BackgroundLayer";
|
||||||
import { SystemLayer } from "@/components/system/SystemLayer";
|
import { SystemLayer } from "@/components/system/SystemLayer";
|
||||||
import { StartupPopup } from "@/components/StartupPopup";
|
import { StartupPopup } from "@/components/StartupPopup";
|
||||||
|
import { BetaWarning } from "@/components/BetaWarning";
|
||||||
|
import { Toaster } from "sonner";
|
||||||
import { useA11yStore } from "@/stores/a11yStore";
|
import { useA11yStore } from "@/stores/a11yStore";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
@@ -52,6 +54,17 @@ export function RootLayout({
|
|||||||
|
|
||||||
{/* Startup popup — only when VITE_START_POP=true */}
|
{/* Startup popup — only when VITE_START_POP=true */}
|
||||||
<StartupPopup />
|
<StartupPopup />
|
||||||
|
|
||||||
|
{/* Beta warning toast */}
|
||||||
|
<BetaWarning />
|
||||||
|
|
||||||
|
{/* Sonner toaster */}
|
||||||
|
<Toaster
|
||||||
|
position="bottom-right"
|
||||||
|
richColors
|
||||||
|
closeButton
|
||||||
|
duration={Infinity}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
declare const __APP_VERSION__: string;
|
||||||
|
|
||||||
|
export const VERSION: string = typeof __APP_VERSION__ !== "undefined" ? __APP_VERSION__ : "0.0.0";
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ export function StartCard({ onSettingsClick }: StartCardProps) {
|
|||||||
|
|
||||||
{/* Instance button */}
|
{/* Instance button */}
|
||||||
<button
|
<button
|
||||||
|
onClick={() => navigate("gallery")}
|
||||||
className="flex items-center justify-center w-9 h-9 mr-0.5 text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-all duration-150 cursor-pointer shrink-0 rounded-full"
|
className="flex items-center justify-center w-9 h-9 mr-0.5 text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-all duration-150 cursor-pointer shrink-0 rounded-full"
|
||||||
aria-label="选择实例"
|
aria-label="选择实例"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { InstanceTitle } from "./InstanceTitle";
|
||||||
import { StartCard } from "./StartCard";
|
import { StartCard } from "./StartCard";
|
||||||
|
|
||||||
export function Home() {
|
export function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full flex flex-col justify-end items-start p-6">
|
<div className="relative h-full flex flex-col justify-end items-start p-6 gap-3">
|
||||||
|
<InstanceTitle />
|
||||||
<StartCard />
|
<StartCard />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const OFFICIAL_URL = "https://koring.app";
|
|||||||
|
|
||||||
export function AboutSetting() {
|
export function AboutSetting() {
|
||||||
const openLink = (url: string) => {
|
const openLink = (url: string) => {
|
||||||
window.open(url, "_blank");
|
window.electronAPI?.openExternal(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ function SettingRow({ label, desc, children }: { label: string; desc?: string; c
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openLink = (url: string) => {
|
const openLink = (url: string) => {
|
||||||
window.open(url, "_blank");
|
window.electronAPI?.openExternal(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
const licenses = [
|
const licenses = [
|
||||||
@@ -50,9 +50,9 @@ export function CopyrightSetting() {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-bold text-foreground mb-3">项目协议</h3>
|
<h3 className="text-lg font-bold text-foreground mb-3">项目协议</h3>
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
<SettingRow label="MIT License" desc="Copyright © 2024 Koring Launcher Contributors">
|
<SettingRow label="LL-1.0 (LingkeLice 1.0)" desc="Copyright © Shenzhen Lingke Network Technology Co., Ltd.">
|
||||||
<button
|
<button
|
||||||
onClick={() => openLink("https://opensource.org/licenses/MIT")}
|
onClick={() => openLink("https://support.lingke.ink/LL-1.0")}
|
||||||
className="inline-flex items-center gap-1.5 text-[13px] text-primary hover:underline"
|
className="inline-flex items-center gap-1.5 text-[13px] text-primary hover:underline"
|
||||||
>
|
>
|
||||||
查看
|
查看
|
||||||
|
|||||||
@@ -110,14 +110,9 @@ export function ThemeBgSetting() {
|
|||||||
const { image, opacity, setOpacity, blur, setBlur, setImage, reset } = useBackgroundStore();
|
const { image, opacity, setOpacity, blur, setBlur, setImage, reset } = useBackgroundStore();
|
||||||
|
|
||||||
const handlePickImage = async () => {
|
const handlePickImage = async () => {
|
||||||
// In Electron, use the native file dialog via IPC
|
const dataUrl = await (window as any).electronAPI?.pickBackgroundImage();
|
||||||
const result = await window.electronAPI?.invoke('dialog:openFile', {
|
if (dataUrl) {
|
||||||
filters: [
|
setImage(dataUrl);
|
||||||
{ name: "图片", extensions: ["png", "jpg", "jpeg", "webp", "gif", "bmp"] },
|
|
||||||
],
|
|
||||||
}) as string | null;
|
|
||||||
if (result) {
|
|
||||||
setImage(result);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
try {
|
try {
|
||||||
const { state, authUrl } = await microsoftLoginStart(clientId);
|
const { state, authUrl } = await microsoftLoginStart(clientId);
|
||||||
set({ msAuthUrl: authUrl, msAuthState: state, loading: false });
|
set({ msAuthUrl: authUrl, msAuthState: state, loading: false });
|
||||||
window.open(authUrl, "_blank");
|
window.electronAPI?.openExternal(authUrl);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
set({ error: e.message, loading: false });
|
set({ error: e.message, loading: false });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ import {
|
|||||||
listInstances,
|
listInstances,
|
||||||
deleteInstance,
|
deleteInstance,
|
||||||
getInstanceInfo,
|
getInstanceInfo,
|
||||||
|
installInstance,
|
||||||
|
launchInstance,
|
||||||
|
type InstanceInfo,
|
||||||
|
type InstanceRuntime,
|
||||||
} from "../api/instance";
|
} from "../api/instance";
|
||||||
import type { InstanceInfo } from "../api/instance";
|
|
||||||
|
|
||||||
interface InstanceState {
|
interface InstanceState {
|
||||||
instances: InstanceInfo[];
|
instances: InstanceInfo[];
|
||||||
@@ -13,18 +16,33 @@ interface InstanceState {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
|
||||||
fetchInstances: (instancesPath: string) => Promise<void>;
|
fetchInstances: (gamePath: string) => Promise<void>;
|
||||||
create: (
|
create: (
|
||||||
name: string,
|
name: string,
|
||||||
gamePath: string,
|
gamePath: string,
|
||||||
mcVersion: string,
|
runtime: InstanceRuntime,
|
||||||
loaderType?: string,
|
options?: {
|
||||||
loaderVersion?: string,
|
author?: string;
|
||||||
javaPath?: string,
|
description?: string;
|
||||||
memory?: { min?: string; max?: string }
|
java?: string;
|
||||||
|
minMemory?: number;
|
||||||
|
maxMemory?: number;
|
||||||
|
}
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
remove: (name: string, instancesPath: string) => Promise<void>;
|
remove: (name: string, gamePath: string) => Promise<void>;
|
||||||
select: (name: string, instancesPath: string) => Promise<void>;
|
select: (name: string, gamePath: string) => Promise<void>;
|
||||||
|
install: (name: string, gamePath: string) => Promise<string>;
|
||||||
|
launch: (
|
||||||
|
name: string,
|
||||||
|
gamePath: string,
|
||||||
|
options: {
|
||||||
|
username: string;
|
||||||
|
uuid: string;
|
||||||
|
accessToken?: string;
|
||||||
|
javaPath?: string;
|
||||||
|
server?: { host: string; port?: number };
|
||||||
|
}
|
||||||
|
) => Promise<string>;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,36 +52,20 @@ export const useInstanceStore = create<InstanceState>((set) => ({
|
|||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
fetchInstances: async (instancesPath: string) => {
|
fetchInstances: async (gamePath: string) => {
|
||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const instances = await listInstances(instancesPath);
|
const instances = await listInstances(gamePath);
|
||||||
set({ instances, loading: false });
|
set({ instances, loading: false });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
set({ error: e.message, loading: false });
|
set({ error: e.message, loading: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
create: async (
|
create: async (name, gamePath, runtime, options?) => {
|
||||||
name,
|
|
||||||
gamePath,
|
|
||||||
mcVersion,
|
|
||||||
loaderType?,
|
|
||||||
loaderVersion?,
|
|
||||||
javaPath?,
|
|
||||||
memory?
|
|
||||||
) => {
|
|
||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const instance = await createInstance(
|
const instance = await createInstance(name, gamePath, runtime, options);
|
||||||
name,
|
|
||||||
gamePath,
|
|
||||||
mcVersion,
|
|
||||||
loaderType,
|
|
||||||
loaderVersion,
|
|
||||||
javaPath,
|
|
||||||
memory
|
|
||||||
);
|
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
instances: [...state.instances, instance],
|
instances: [...state.instances, instance],
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -73,10 +75,10 @@ export const useInstanceStore = create<InstanceState>((set) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
remove: async (name: string, instancesPath: string) => {
|
remove: async (name: string, gamePath: string) => {
|
||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
try {
|
try {
|
||||||
await deleteInstance(name, instancesPath);
|
await deleteInstance(name, gamePath);
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
instances: state.instances.filter((i) => i.name !== name),
|
instances: state.instances.filter((i) => i.name !== name),
|
||||||
currentInstance:
|
currentInstance:
|
||||||
@@ -88,15 +90,39 @@ export const useInstanceStore = create<InstanceState>((set) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
select: async (name: string, instancesPath: string) => {
|
select: async (name: string, gamePath: string) => {
|
||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const instance = await getInstanceInfo(name, instancesPath);
|
const instance = await getInstanceInfo(name, gamePath);
|
||||||
set({ currentInstance: instance, loading: false });
|
set({ currentInstance: instance, loading: false });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
set({ error: e.message, loading: false });
|
set({ error: e.message, loading: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
install: async (name: string, gamePath: string) => {
|
||||||
|
set({ loading: true, error: null });
|
||||||
|
try {
|
||||||
|
const { requestId } = await installInstance(name, gamePath);
|
||||||
|
set({ loading: false });
|
||||||
|
return requestId;
|
||||||
|
} catch (e: any) {
|
||||||
|
set({ error: e.message, loading: false });
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
launch: async (name, gamePath, options) => {
|
||||||
|
set({ loading: true, error: null });
|
||||||
|
try {
|
||||||
|
const { requestId } = await launchInstance(name, gamePath, options);
|
||||||
|
set({ loading: false });
|
||||||
|
return requestId;
|
||||||
|
} catch (e: any) {
|
||||||
|
set({ error: e.message, loading: false });
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
clearError: () => set({ error: null }),
|
clearError: () => set({ error: null }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type RouteKey =
|
|||||||
| "today"
|
| "today"
|
||||||
| "play-link"
|
| "play-link"
|
||||||
| "setting"
|
| "setting"
|
||||||
|
| "gallery"
|
||||||
| "task-queue"
|
| "task-queue"
|
||||||
| "oobe"
|
| "oobe"
|
||||||
| "oobe/about-info"
|
| "oobe/about-info"
|
||||||
@@ -29,6 +30,7 @@ interface RouteItem {
|
|||||||
|
|
||||||
export const routes: RouteItem[] = [
|
export const routes: RouteItem[] = [
|
||||||
{ key: "home", label: "首页", path: "/home" },
|
{ key: "home", label: "首页", path: "/home" },
|
||||||
|
{ key: "gallery", label: "实例", path: "/gallery" },
|
||||||
{ key: "store", label: "资源", path: "/store" },
|
{ key: "store", label: "资源", path: "/store" },
|
||||||
{ key: "today", label: "资讯", path: "/today" },
|
{ key: "today", label: "资讯", path: "/today" },
|
||||||
{ key: "play-link", label: "联机", path: "/play-link" },
|
{ key: "play-link", label: "联机", path: "/play-link" },
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ interface ElectronAPI {
|
|||||||
onResized: (callback: () => void) => () => void;
|
onResized: (callback: () => void) => () => void;
|
||||||
|
|
||||||
getTheme: () => Promise<'light' | 'dark' | 'system' | null>;
|
getTheme: () => Promise<'light' | 'dark' | 'system' | null>;
|
||||||
|
|
||||||
|
openExternal: (url: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import path from "path";
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import pkg from "./package.json";
|
||||||
|
|
||||||
export default defineConfig(async () => ({
|
export default defineConfig(async () => ({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
@@ -10,6 +11,9 @@ export default defineConfig(async () => ({
|
|||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||||
|
},
|
||||||
base: "./",
|
base: "./",
|
||||||
build: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
|
|||||||