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