Migrate app from Tauri to Electron
Replace the Tauri/sidecar architecture with an Electron main process. Adds an electron/ directory (main, preload, handlers, core integrations for @xmcl/*), electron-builder.yml, TypeScript electron config and declarations, and updated IPC utilities (ipc.ts) so frontend uses ipcRenderer/ipcMain. Removes Tauri sidecar and src-tauri artifacts, deletes sidecar sources and keys, updates .gitignore, VSCode recommendations, package scripts and icons, and updates documentation (README, AGENTS, DEV) and frontend stores/apis to reflect the Electron-based architecture and config/auth persistence changes.
@@ -10,6 +10,7 @@ lerna-debug.log*
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
dist-electron
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
@@ -22,3 +23,7 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Electron
|
||||
electron/*.js
|
||||
electron/*.d.ts
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
dW50cnVzdGVkIGNvbW1lbnQ6IHJzaWduIGVuY3J5cHRlZCBzZWNyZXQga2V5ClJXUlRZMEl5b29vWWcwSnZkbzlYQWNNQzJuVEZ2dlJJd3A0MUhTcEpCYmZubFdlVmVvY0FBQkFBQUFBQUFBQUFBQUlBQUFBQXEveUNRTnFXUEE5Q3M5Und2YTFlQWdRUVh1L3hudGJaTnZlRlRHM3FwZkVHQXBhNlpQWmF3bkcwTGJ5N1MwbEFIRFVXM3lCc3QxdUVOeDJqeVdVK29lZkllRmxWZ2FxcW1CK2cvYTFwMUptQ0hMZEpjZXhpdU1Ndm9CVWcrTk91aXBiSHB4MVBKdU09Cg==
|
||||
@@ -1 +0,0 @@
|
||||
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEYxRDZBQjc3QUQxNDBGNDUKUldSRkR4U3RkNnZXOGNtdHE4TS80d3p4UHlhaEVVMDFUTFRrQmhySnFVNWdZWHNXOWUraFFhRTcK
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
|
||||
"recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
|
||||
}
|
||||
|
||||
@@ -1,43 +1,56 @@
|
||||
# koring-launcher
|
||||
|
||||
Minecraft launcher built with Tauri 2 + React 19 + TypeScript + Sidecar (Node.js/@xmcl).
|
||||
Minecraft launcher built with Electron + React 19 + TypeScript + Node.js (@xmcl).
|
||||
|
||||
## Quick commands
|
||||
|
||||
```bash
|
||||
pnpm tauri dev # full app dev (frontend + Rust + sidecar skip)
|
||||
pnpm dev # frontend only (vite, port 1420)
|
||||
cd sidecar && pnpm dev # sidecar dev (tsx)
|
||||
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
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Frontend** (`src/`): React 19 + Vite 7 + Tailwind v4 + shadcn/ui + Zustand stores
|
||||
- **Backend** (`src-tauri/`): Rust/Tauri 2, manages sidecar process via stdin/stdout JSON
|
||||
- **Sidecar** (`sidecar/`): Node.js/TypeScript, wraps @xmcl/* packages (MC install/launch/auth/mods)
|
||||
- IPC: Frontend → `invoke()` → Rust commands → sidecar stdin → sidecar stdout → Tauri events → Frontend
|
||||
- **Main Process** (`electron/`): Node.js/TypeScript, manages windows, IPC handlers, @xmcl/* packages
|
||||
- IPC: Frontend → `ipcRenderer.invoke()` → `ipcMain.handle()` → main process → `webContents.send()` → Frontend
|
||||
|
||||
## Key gotchas
|
||||
|
||||
- **Sidecar binary required**: `src-tauri/binaries/koring-sidecar-<target-triple>.exe` must exist. In debug mode it's skipped automatically (`#[cfg(debug_assertions)]` in `lib.rs:setup`).
|
||||
- **Sidecar compile**: `cd sidecar && pnpm compile` (requires `bun`). Default target is ARM64 Windows. Use `pnpm compile:x64` for x86_64.
|
||||
- **@xmcl packages are Node.js only**: `@xmcl/core`, `@xmcl/installer` require `fs`/`child_process`. Only `@xmcl/user`, `@xmcl/modrinth`, `@xmcl/curseforge` work in browser.
|
||||
- **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.
|
||||
- **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`).
|
||||
- **Tauri commands**: All Rust commands are in `src-tauri/src/commands/mod.rs`. They forward to sidecar via `SidecarManager`.
|
||||
- **State management**: Zustand stores in `src/stores/` (install, auth, launch, mods, instance).
|
||||
- **Dev mode**: Vite runs on port 1420, Electron loads from localhost.
|
||||
|
||||
## Build & bundle
|
||||
|
||||
```bash
|
||||
pnpm tauri build # production build (requires sidecar binary in binaries/)
|
||||
cd sidecar && pnpm compile # compile sidecar to exe first
|
||||
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
|
||||
```
|
||||
|
||||
The `externalBin` in `tauri.conf.json` expects `binaries/koring-sidecar-<target-triple>.exe`.
|
||||
## Electron notes
|
||||
|
||||
## Rust notes
|
||||
- `electron/main.ts`: App entry, window management, IPC handler registration
|
||||
- `electron/preload.ts`: Context bridge for secure IPC
|
||||
- `electron/config.ts`: YAML config management (sparse save)
|
||||
- `electron/auth.ts`: Auth data persistence (JSON file)
|
||||
- `electron/core/`: @xmcl/* integrations (auth, installer, launcher, modrinth, instance)
|
||||
- `electron/handlers/`: IPC handlers (config, auth, install, launch, mods, instance, background, task, system, window)
|
||||
|
||||
- `src-tauri/src/lib.rs`: app entry, registers plugins and commands
|
||||
- `src-tauri/src/sidecar.rs`: `SidecarManager` — spawns sidecar, reads stdout, emits Tauri events
|
||||
- `src-tauri/src/commands/mod.rs`: all `#[tauri::command]` handlers
|
||||
- `uuid` crate used for request IDs
|
||||
## Frontend notes
|
||||
|
||||
- `src/api/ipc.ts`: Core IPC utilities (invoke, onIpcEvent)
|
||||
- `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
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
| 状态管理 | Zustand |
|
||||
| Backend | Rust / Tauri 2 |
|
||||
| Sidecar | Node.js / TypeScript / @xmcl/* |
|
||||
| 配置存储 | YAML (serde_yaml) + Windows Registry |
|
||||
| 包管理 | pnpm |
|
||||
| 目标平台 | ARM64 Windows (`aarch64-pc-windows-msvc`) |
|
||||
|
||||
@@ -47,12 +48,146 @@ VITE_START_POP_BOUTTON="..." # 弹窗按钮文字
|
||||
|
||||
---
|
||||
|
||||
## 配置存储架构
|
||||
|
||||
### 概览
|
||||
|
||||
| 数据类型 | 存储位置 | 格式 | 说明 |
|
||||
|---------|---------|------|------|
|
||||
| 用户设置 | 程序目录 `Koring.yml` | YAML | 所有可配置项 |
|
||||
| 账户凭证 | Windows Registry `HKCU\Software\KoringLauncher` | REG_SZ | token/xboxProfile |
|
||||
| 实例配置 | 实例目录 `koring-instance.json` | JSON | per-instance |
|
||||
| 任务历史 | localStorage `koring-task-history` | JSON | 临时,max 50 |
|
||||
|
||||
### Koring.yml 结构
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
|
||||
theme:
|
||||
darkMode: auto # auto | light | dark
|
||||
parallax: true
|
||||
|
||||
a11y:
|
||||
reduceMotion: false
|
||||
reduceTransparency: false
|
||||
highContrast: false
|
||||
contentBlurOpacity: 50 # 0-100
|
||||
|
||||
background:
|
||||
bgType: image # image | color
|
||||
image: /background.png
|
||||
blur: 0 # 0-20
|
||||
opacity: 100 # 0-100
|
||||
|
||||
game:
|
||||
gameDir: .minecraft
|
||||
resourceDir: ""
|
||||
savesDir: ""
|
||||
instancesDir: .minecraft/instances
|
||||
|
||||
java:
|
||||
javaPath: ""
|
||||
memMode: auto # auto | custom
|
||||
memGB: 4 # 1-16
|
||||
gc: auto # auto | zgc | g1
|
||||
jvmArgs: ""
|
||||
|
||||
advanced:
|
||||
afterLaunch: close # close | minimize | keep
|
||||
winMode: default # default | fullscreen | custom
|
||||
customWidth: 854
|
||||
customHeight: 480
|
||||
gameArgs: ""
|
||||
preLaunchCmd: ""
|
||||
debugMode: false
|
||||
|
||||
download:
|
||||
fileSource: mirror # mirror | official | official-only
|
||||
versionSource: mirror
|
||||
threads: 16 # 1-64
|
||||
speedLimit: 0 # KB/s, 0=不限速
|
||||
|
||||
network:
|
||||
securityId:
|
||||
enabled: false
|
||||
authUrl: ""
|
||||
```
|
||||
|
||||
### 注册表结构
|
||||
|
||||
```
|
||||
HKCU\Software\KoringLauncher
|
||||
└─ auth
|
||||
├─ username (REG_SZ)
|
||||
├─ uuid (REG_SZ)
|
||||
├─ accessToken (REG_SZ)
|
||||
├─ refreshToken (REG_SZ)
|
||||
└─ xboxProfile (REG_SZ, JSON string)
|
||||
```
|
||||
|
||||
### 向上兼容策略
|
||||
|
||||
1. **版本号** — `version` 字段,每次结构变更递增
|
||||
2. **默认值填充** — 加载时缺失字段自动补全,不丢数据
|
||||
3. **迁移函数** — `migrate_v0_to_v1()` 等,按版本链执行
|
||||
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()`
|
||||
|
||||
**Tauri 命令:**
|
||||
- `get_config` → 返回 `AppConfig` JSON
|
||||
- `save_config(cfg)` → 写入 `Koring.yml`
|
||||
- `get_auth` → 从注册表读取 `AuthData`
|
||||
- `save_auth(auth)` → 写入注册表
|
||||
- `delete_auth_cmd` → 删除注册表认证数据
|
||||
|
||||
### 前端实现
|
||||
|
||||
**新文件:**
|
||||
- `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({...})
|
||||
```
|
||||
|
||||
**Store 委托模式:**
|
||||
themeStore / a11yStore / backgroundStore / authStore 都是 configStore 的薄包装层:
|
||||
- 读取时从 `configStore.config.*` 同步
|
||||
- 写入时通过各自的 `set*()` 同时更新本地状态和 configStore
|
||||
- 提供 `sync*FromConfig()` 函数在 App 启动时同步
|
||||
|
||||
**App 启动流程:**
|
||||
```
|
||||
configStore.init() → syncThemeFromConfig() → syncA11yFromConfig() → syncBackgroundFromConfig() → authStore.initFromRegistry()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
koring-launcher/
|
||||
├── src/ # 前端源码
|
||||
│ ├── App.tsx # 路由入口 (Zustand 路由)
|
||||
│ ├── App.tsx # 路由入口 + configStore 初始化
|
||||
│ ├── index.css # 全局样式 + CSS 变量 + 动画
|
||||
│ ├── layouts/
|
||||
│ │ └── RootLayout.tsx # 三层布局: BackgroundLayer + ContentLayer + SystemLayer
|
||||
@@ -69,7 +204,6 @@ koring-launcher/
|
||||
│ │ │ └── Silk.tsx # WebGL 丝绸着色器 (Three.js)
|
||||
│ │ ├── task/
|
||||
│ │ │ ├── TaskButton.tsx # 标题栏任务指示器 (SVG 圆弧动画)
|
||||
│ │ │ ├── TaskSheet.tsx # 任务队列侧面板 (z-[110])
|
||||
│ │ │ └── TaskCard.tsx # 单个任务卡片 (进度条+日志)
|
||||
│ │ ├── ui/ # shadcn/ui 组件
|
||||
│ │ │ ├── sheet.tsx, button.tsx, switch.tsx, slider.tsx
|
||||
@@ -78,25 +212,52 @@ koring-launcher/
|
||||
│ │ ├── VersionCard.tsx # 版本/更新卡片 (Silk 背景+毛玻璃)
|
||||
│ │ ├── UnderConstruction.tsx # "装修中" 占位组件
|
||||
│ │ └── StartupPopup.tsx # 启动弹窗 (环境变量控制)
|
||||
│ ├── stores/ # Zustand 状态管理
|
||||
│ ├── 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() 工具函数
|
||||
│ ├── api/ # Sidecar IPC 封装
|
||||
│ ├── 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 窗口逻辑
|
||||
│ │ ├── commands/mod.rs # Tauri 命令 (→ sidecar)
|
||||
│ │ ├── lib.rs # 插件注册 + splash/main + 命令注册
|
||||
│ │ ├── config.rs # AppConfig 读写 + 迁移 + 默认值
|
||||
│ │ ├── registry.rs # Windows Registry 读写
|
||||
│ │ ├── commands/mod.rs # Tauri 命令 (→ sidecar + config + auth)
|
||||
│ │ └── sidecar.rs # Sidecar 进程管理
|
||||
│ └── binaries/ # Sidecar 二进制文件
|
||||
├── splash.html # 启动动画 HTML 入口 (加载 Splash.tsx)
|
||||
├── 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 环境开发脚本
|
||||
```
|
||||
@@ -109,274 +270,105 @@ koring-launcher/
|
||||
|
||||
| Key | Label | 组件 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `home` | 首页 | `pages/home/index.tsx` | 欢迎页 |
|
||||
| `home` | 首页 | `pages/home/index.tsx` | StartCard 启动组件 |
|
||||
| `store` | 资源 | `pages/store/index.tsx` | 🚧 装修中 |
|
||||
| `today` | 资讯 | `pages/today/index.tsx` | 🚧 装修中 |
|
||||
| `play-link` | 联机 | `pages/play-link/index.tsx` | 🚧 装修中 |
|
||||
| `setting` | 设置 | `pages/setting/index.tsx` | 侧边栏 + 内容区 |
|
||||
|
||||
### 隐藏路由 (debug)
|
||||
### 隐藏路由
|
||||
|
||||
| Key | Label | 组件 |
|
||||
|---|---|---|
|
||||
| `task-queue` | 任务队列 | `pages/task-queue.tsx` |
|
||||
| `debug` | 调试 | `pages/debug/index.tsx` |
|
||||
| `debug-splash` | 启动动画调试 | `pages/debug/splash-debug.tsx` |
|
||||
| `debug-display` | 显示效果调试 | `pages/debug/display-debug.tsx` |
|
||||
| `debug-version-card` | 版本卡片调试 | `pages/debug/version-card-debug.tsx` |
|
||||
| `debug-task` | 任务队列调试 | `pages/debug/task-debug.tsx` |
|
||||
|
||||
### 路由层级 (返回导航)
|
||||
### 路由导航 (历史栈)
|
||||
|
||||
```
|
||||
home / store / today / play-link / setting
|
||||
└─ debug
|
||||
├─ debug-splash
|
||||
├─ debug-display
|
||||
├─ debug-version-card
|
||||
└─ debug-task
|
||||
```
|
||||
|
||||
### 设置子页面 (setting 内部侧边栏)
|
||||
|
||||
| 分组 | Key | 组件 | 状态 |
|
||||
|---|---|---|---|
|
||||
| **通用** | `home` | `general/home.tsx` | ✅ 设置首页 (搜索+快捷入口) |
|
||||
| | `account` | `general/account.tsx` | ✅ Koring 账户 (微软登录) |
|
||||
| | `game-account` | `game/game-account.tsx` | 🚧 占位 |
|
||||
| | `about` | `general/about.tsx` | ✅ 关于 (版本卡+链接) |
|
||||
| | `copyright` | `general/copyright.tsx` | ✅ 版权声明 |
|
||||
| **游戏** | `java-mem` | `game/java-mem.tsx` | ✅ Java/内存/JVM 参数 |
|
||||
| | `game-dir` | `game/game-dir.tsx` | ✅ 游戏目录路径 |
|
||||
| | `advanced` | `game/advanced.tsx` | ✅ 高级设置 |
|
||||
| **个性化** | `theme-bg` | `personalization/theme-bg.tsx` | ✅ 主题+背景 (文件选择器) |
|
||||
| | `ui` | `personalization/ui.tsx` | ✅ UI 设置 |
|
||||
| | `lang` | `personalization/lang.tsx` | ✅ 语言设置 |
|
||||
| | `a11y` | `personalization/a11y.tsx` | ✅ 无障碍 |
|
||||
| **网络** | `download` | `network/download.tsx` | ✅ 下载设置 |
|
||||
| | `security-id` | `network/security-id.tsx` | ✅ 第三方认证 |
|
||||
| | `ether-online` | `network/ether-online.tsx` | 🚧 占位 |
|
||||
| | `tawa-online` | `network/tawa-online.tsx` | 🚧 占位 |
|
||||
| **其他** | `feedback` | `other/feedback.tsx` | 🚧 占位 |
|
||||
| | `sponsor` | `other/sponsor.tsx` | 🚧 占位 |
|
||||
| | `developer` | _(→ debug route)_ | 跳转调试页 |
|
||||
路由使用动态历史栈替代静态 parentMap。每次 `navigate()` 压入历史,`goBack()` 弹出。
|
||||
|
||||
---
|
||||
|
||||
## Zustand Stores
|
||||
|
||||
### routeStore
|
||||
### configStore (统一配置中心)
|
||||
|
||||
管理页面路由和标题栏模式。
|
||||
所有用户设置的单一数据源。读写通过 Tauri invoke 与 `Koring.yml` 同步。
|
||||
|
||||
```ts
|
||||
// State
|
||||
current: RouteKey // 当前路由 key
|
||||
titleBarMode: TitleBarMode // "default" | "sub" | "window"
|
||||
direction: TransitionDirection // "forward" | "backward"
|
||||
config: AppConfig // 完整配置
|
||||
loaded: boolean // 是否已从 Rust 加载
|
||||
|
||||
// Actions
|
||||
navigate(key) // 跳转路由 (View Transitions API 动画)
|
||||
goBack() // 返回父路由 (通过 parentMap)
|
||||
setTitleBarMode(m) // 手动设置标题栏模式
|
||||
init() // 从 Rust 加载配置
|
||||
setTheme(patch) // 部分更新 + debounce 300ms 写回
|
||||
setA11y(patch)
|
||||
setBackground(patch)
|
||||
setGame(patch)
|
||||
setJava(patch)
|
||||
setAdvanced(patch)
|
||||
setDownload(patch)
|
||||
setNetwork(patch)
|
||||
```
|
||||
|
||||
### themeStore
|
||||
|
||||
深色模式和视差设置。
|
||||
### themeStore (委托 configStore)
|
||||
|
||||
```ts
|
||||
// State
|
||||
darkMode: "auto" | "light" | "dark" // 默认 "auto"
|
||||
parallax: boolean // 默认 true
|
||||
|
||||
// Actions
|
||||
setDarkMode(mode) // 应用深色模式到 DOM (.dark class)
|
||||
setParallax(v) // 设置视差开关
|
||||
darkMode: "auto" | "light" | "dark"
|
||||
parallax: boolean
|
||||
setDarkMode(mode) // 更新 DOM + configStore
|
||||
setParallax(v) // configStore
|
||||
syncThemeFromConfig() // 启动时从 config 同步
|
||||
```
|
||||
|
||||
### backgroundStore
|
||||
|
||||
背景图片/颜色/模糊/透明度 (localStorage 持久化)。
|
||||
### a11yStore (委托 configStore)
|
||||
|
||||
```ts
|
||||
// State (localStorage: "koring-background")
|
||||
type: "image" | "color" // 默认 "image"
|
||||
image: string // 默认 "/background.png"
|
||||
blur: number // 默认 0 (0-20)
|
||||
opacity: number // 默认 1 (0-1)
|
||||
|
||||
// Actions
|
||||
setImage(url) // 设置背景图 (通过 convertFileSrc 转换本地路径)
|
||||
setColor(color) // 设置纯色背景
|
||||
setBlur(blur) // 设置模糊
|
||||
setOpacity(op) // 设置透明度
|
||||
reset() // 恢复默认
|
||||
reduceMotion, reduceTransparency, highContrast, contentBlurOpacity
|
||||
syncA11yFromConfig()
|
||||
```
|
||||
|
||||
### a11yStore
|
||||
|
||||
无障碍设置。
|
||||
### backgroundStore (委托 configStore)
|
||||
|
||||
```ts
|
||||
// State
|
||||
reduceMotion: boolean // 默认 false
|
||||
reduceTransparency: boolean // 默认 false
|
||||
highContrast: boolean // 默认 false
|
||||
contentBlurOpacity: number // 默认 50 (0-100)
|
||||
|
||||
// Actions
|
||||
setReduceMotion(v)
|
||||
setReduceTransparency(v)
|
||||
setHighContrast(v)
|
||||
setContentBlurOpacity(v)
|
||||
type: "image" | "color", image, blur, opacity
|
||||
setImage / setColor / setBlur / setOpacity / reset
|
||||
syncBackgroundFromConfig()
|
||||
```
|
||||
|
||||
### devStore
|
||||
|
||||
开发者调试控制。
|
||||
### authStore (委托 Registry)
|
||||
|
||||
```ts
|
||||
// State
|
||||
forceDisableContentBlur: boolean // 默认 false
|
||||
previewMode: string | null // 默认 null
|
||||
previewUpdateState: PreviewUpdateState | null // 默认 null
|
||||
overlayOpacity: number // 默认 30
|
||||
blurAmount: number // 默认 12
|
||||
|
||||
// Actions
|
||||
setForceDisableContentBlur(v)
|
||||
setPreviewMode(v)
|
||||
setPreviewUpdateState(v)
|
||||
setOverlayOpacity(v)
|
||||
setBlurAmount(v)
|
||||
user: AuthResult | null
|
||||
initFromRegistry() // 从 Windows Registry 加载
|
||||
loginOffline(username) // 通过 sidecar + 保存到 registry
|
||||
logout() // 清除 registry
|
||||
```
|
||||
|
||||
### updateStore
|
||||
|
||||
应用更新。
|
||||
### routeStore (历史栈导航)
|
||||
|
||||
```ts
|
||||
// State
|
||||
checking: boolean
|
||||
downloading: boolean
|
||||
installed: boolean
|
||||
progress: DownloadProgress | null
|
||||
update: Update | null
|
||||
error: string | null
|
||||
|
||||
// Actions
|
||||
check() // 检查更新 (调用 @tauri-apps/plugin-updater)
|
||||
install() // 下载并安装
|
||||
reset() // 清除状态
|
||||
current: RouteKey
|
||||
history: RouteKey[] // 导航历史栈
|
||||
navigate(key) // 压入历史
|
||||
goBack() // 弹出历史
|
||||
```
|
||||
|
||||
### taskStore
|
||||
### taskStore (localStorage)
|
||||
|
||||
任务队列系统 (localStorage 持久化历史)。
|
||||
任务队列。localStorage 持久化历史 (max 50)。
|
||||
|
||||
```ts
|
||||
// State (localStorage: "koring-task-history", max 50)
|
||||
tasks: Task[]
|
||||
sheetOpen: boolean
|
||||
### 其他 Store
|
||||
|
||||
// Derived
|
||||
isRunning() // 是否有运行中/等待中的任务
|
||||
activeTasks() // 运行中+等待中的任务列表
|
||||
completedTasks() // 已完成/失败/取消的任务列表
|
||||
|
||||
// Actions
|
||||
addTask(type, title, desc, executor) // 添加任务并自动开始
|
||||
cancelTask(id) // 取消任务 (AbortController)
|
||||
removeTask(id) // 删除任务
|
||||
retryTask(id) // 重试失败任务
|
||||
clearHistory() // 清空历史
|
||||
openSheet() / closeSheet()
|
||||
```
|
||||
|
||||
### authStore
|
||||
|
||||
账户认证 (localStorage: "koring-user")。
|
||||
|
||||
```ts
|
||||
// State
|
||||
user: AuthResult | null // { username, uuid, accessToken, expiresAt, xboxProfile }
|
||||
loading: boolean
|
||||
error: string | null
|
||||
|
||||
// Actions
|
||||
loginOffline(username)
|
||||
startMicrosoftLogin(clientId)
|
||||
completeMicrosoftLogin(code, clientId)
|
||||
logout()
|
||||
```
|
||||
|
||||
### installStore
|
||||
|
||||
Minecraft 安装。
|
||||
|
||||
```ts
|
||||
// State
|
||||
versions: VersionManifest | null
|
||||
installing: boolean
|
||||
loading: boolean
|
||||
error: string | null
|
||||
|
||||
// Actions
|
||||
fetchVersions(type?)
|
||||
install(version, gamePath, javaPath?)
|
||||
installLoader(mcVersion, gamePath, loaderType, loaderVersion?, javaPath?)
|
||||
```
|
||||
|
||||
### launchStore
|
||||
|
||||
游戏启动。
|
||||
|
||||
```ts
|
||||
// State
|
||||
launching: boolean
|
||||
launched: boolean
|
||||
gameResult: LaunchResult | null
|
||||
events: GameEvent[]
|
||||
error: string | null
|
||||
|
||||
// Actions
|
||||
launch(options: LaunchOptions)
|
||||
diagnose(gamePath, version)
|
||||
reset()
|
||||
```
|
||||
|
||||
### modsStore
|
||||
|
||||
Mod 搜索与安装。
|
||||
|
||||
```ts
|
||||
// State
|
||||
searchResults: ModSearchResult[]
|
||||
currentMod: ModSearchResult | null
|
||||
modVersions: ModVersionResult[]
|
||||
|
||||
// Actions
|
||||
search(query?, gameVersion?, loader?, source?)
|
||||
getDetail(projectId, source)
|
||||
getVersions(projectId, gameVersion?, loader?, source?)
|
||||
install(projectId, versionId, gamePath, source?)
|
||||
```
|
||||
|
||||
### instanceStore
|
||||
|
||||
游戏实例管理。
|
||||
|
||||
```ts
|
||||
// State
|
||||
instances: InstanceInfo[]
|
||||
currentInstance: InstanceInfo | null
|
||||
|
||||
// Actions
|
||||
fetchInstances(instancesPath)
|
||||
create(name, gamePath, mcVersion, ...)
|
||||
remove(name, instancesPath)
|
||||
select(name, instancesPath)
|
||||
```
|
||||
- `devStore` — 开发者调试 (内存)
|
||||
- `installStore` — Minecraft 安装 (内存,ephemeral)
|
||||
- `launchStore` — 游戏启动 (内存,ephemeral)
|
||||
- `modsStore` — Mod 搜索 (内存,ephemeral)
|
||||
- `updateStore` — 应用更新 (内存,ephemeral)
|
||||
- `instanceStore` — 实例管理 (sidecar 查询)
|
||||
|
||||
---
|
||||
|
||||
@@ -388,7 +380,6 @@ select(name, instancesPath)
|
||||
z-0 BackgroundLayer 全屏背景图 + 视差 + 模糊 + 强内容遮罩
|
||||
z-1 ContentLayer 页面内容区 (top: 40px, overflow-auto)
|
||||
z-100 SystemLayer 自定义标题栏 (TitleBar)
|
||||
z-110 TaskSheet 任务队列面板 (右滑入)
|
||||
z-200 StartupPopup 启动弹窗 (环境变量控制)
|
||||
```
|
||||
|
||||
@@ -406,12 +397,12 @@ z-200 StartupPopup 启动弹窗 (环境变量控制)
|
||||
- 强内容遮罩: 非 home 页面自动显示 (可通过 `contentBlurOpacity` 控制)
|
||||
- 深色模式叠加层: `bg-black/35`
|
||||
|
||||
### VersionCard 版本卡片
|
||||
### StartCard 启动组件 (首页)
|
||||
|
||||
- Silk WebGL 动画背景 + 毛玻璃叠加层
|
||||
- 三种更新状态: `latest` / `hasUpdate` / `installed`
|
||||
- 颜色方案: dev=amber, beta=emerald, run=blue
|
||||
- 可通过 props 覆盖 mode 和 state (用于 debug)
|
||||
胶囊形启动组件,位于首页左下角:
|
||||
- 左: 设置齿轮图标 (→ setting)
|
||||
- 中: "启动游戏" 按钮 (primary 色, rounded-full)
|
||||
- 右: 实例选择图标 (Package)
|
||||
|
||||
### TaskQueue 任务系统
|
||||
|
||||
@@ -432,22 +423,32 @@ z-200 StartupPopup 启动弹窗 (环境变量控制)
|
||||
|
||||
## 权限 (capabilities/default.json)
|
||||
|
||||
```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 插件
|
||||
## 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_request` → sidecar stdin (JSON) → sidecar stdout (JSON) → Tauri 事件 → 前端
|
||||
前端 → `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`, `background:*`
|
||||
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`
|
||||
|
||||
@@ -1,43 +1,42 @@
|
||||
# Koring Launcher
|
||||
|
||||
Minecraft launcher built with Tauri 2 + React 19 + TypeScript + Sidecar (Node.js/@xmcl).
|
||||
Minecraft launcher built with Electron + React 19 + TypeScript + Node.js (@xmcl).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev:t # full app (frontend + Rust + sidecar skip in dev)
|
||||
pnpm dev # frontend only (vite, port 1420)
|
||||
cd sidecar && pnpm dev # sidecar dev (tsx)
|
||||
pnpm dev # full app (frontend + electron)
|
||||
pnpm dev:renderer # frontend only (vite, port 1420)
|
||||
pnpm dev:main # electron main process only
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
pnpm tauri build # production (auto-switches to run icon)
|
||||
pnpm build:beta # beta build
|
||||
cd sidecar && pnpm compile # compile sidecar to exe first
|
||||
pnpm build # production build (vite + tsc)
|
||||
pnpm dist:win # build Windows installer
|
||||
pnpm dist:mac # build macOS DMG
|
||||
pnpm dist:linux # build Linux AppImage
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/ Frontend (React 19 + Vite 7 + Tailwind v4 + shadcn/ui + Zustand)
|
||||
src-tauri/ Backend (Rust/Tauri 2)
|
||||
sidecar/ Node.js sidecar (@xmcl/* packages)
|
||||
electron/ Main process (Node.js/TypeScript, @xmcl/* packages)
|
||||
public/ Static assets (icons, fonts, images)
|
||||
scripts/ Build scripts
|
||||
```
|
||||
|
||||
**IPC Flow:**
|
||||
Frontend → `invoke()` → Rust commands → sidecar stdin → sidecar stdout → Tauri events → Frontend
|
||||
Frontend → `ipcRenderer.invoke()` → `ipcMain.handle()` → main process → `webContents.send()` → Frontend
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── api/ # Frontend API layer (sidecar IPC wrappers)
|
||||
│ ├── sidecar.ts # Generic request/response
|
||||
├── api/ # Frontend API layer (IPC wrappers)
|
||||
│ ├── ipc.ts # Core IPC utilities
|
||||
│ ├── background.ts # Background control
|
||||
│ ├── install.ts # Minecraft install
|
||||
│ ├── launch.ts # Game launch
|
||||
@@ -58,6 +57,31 @@ src/
|
||||
│ ├── mode.ts # Build mode detection (dev/beta/run)
|
||||
│ └── utils.ts # cn() helper
|
||||
└── App.tsx # Root component with state router
|
||||
|
||||
electron/
|
||||
├── main.ts # Electron entry, window management
|
||||
├── preload.ts # Context bridge (window.electronAPI)
|
||||
├── config.ts # YAML config management
|
||||
├── auth.ts # Auth data persistence
|
||||
├── core/ # @xmcl/* integrations
|
||||
│ ├── auth.ts # Microsoft OAuth, Xbox Live, MC auth
|
||||
│ ├── installer.ts # @xmcl/installer
|
||||
│ ├── launcher.ts # @xmcl/core game launcher
|
||||
│ ├── modrinth.ts # Modrinth/CurseForge API
|
||||
│ └── instance.ts # Instance management
|
||||
├── handlers/ # IPC handlers
|
||||
│ ├── config.ts # Config load/save
|
||||
│ ├── auth.ts # Auth operations
|
||||
│ ├── install.ts # Install operations
|
||||
│ ├── launch.ts # Game launch
|
||||
│ ├── mods.ts # Mod operations
|
||||
│ ├── instance.ts # Instance operations
|
||||
│ ├── background.ts # Background operations
|
||||
│ ├── task.ts # Task system
|
||||
│ ├── system.ts # System info
|
||||
│ └── window.ts # Window controls
|
||||
└── types/
|
||||
└── electron.d.ts # TypeScript declarations
|
||||
```
|
||||
|
||||
## Three-Layer Page Structure
|
||||
@@ -96,8 +120,6 @@ Three icon variants in `public/`:
|
||||
| beta | `beta.png` / `beta.ico` | Testing |
|
||||
| run | `run.png` / `run.ico` | Production release |
|
||||
|
||||
**Auto-switching:** Build scripts automatically copy the correct icon to `src-tauri/icons/` before building.
|
||||
|
||||
**Frontend usage:**
|
||||
```tsx
|
||||
import { APP_ICON, BUILD_MODE, isDev } from "@/lib/mode";
|
||||
@@ -106,46 +128,31 @@ import { APP_ICON, BUILD_MODE, isDev } from "@/lib/mode";
|
||||
{isDev && <span>Dev Mode</span>}
|
||||
```
|
||||
|
||||
**Manual switch:**
|
||||
```bash
|
||||
pnpm icon:dev # switch to dev icon
|
||||
pnpm icon:beta # switch to beta icon
|
||||
pnpm icon:run # switch to release icon
|
||||
```
|
||||
## IPC Handlers
|
||||
|
||||
## Sidecar
|
||||
|
||||
Node.js process communicating with Rust via stdin/stdout JSON protocol.
|
||||
|
||||
**Handlers:**
|
||||
- `config:*` — Config load/save
|
||||
- `auth:*` — Microsoft OAuth, offline login
|
||||
- `install:*` — Minecraft install, mod loader, version lists
|
||||
- `launch:*` — Game launch, diagnose
|
||||
- `auth:*` — Microsoft OAuth, offline login
|
||||
- `mods:*` — Modrinth/CurseForge search, install
|
||||
- `instance:*` — Instance CRUD
|
||||
- `background:*` — Background image/color/blur/animation/theme
|
||||
|
||||
**Compile:**
|
||||
```bash
|
||||
cd sidecar
|
||||
pnpm compile # ARM64 Windows (default)
|
||||
pnpm compile:x64 # x86_64 Windows
|
||||
```
|
||||
- `task:*` — Task system progress
|
||||
- `system:*` — System info
|
||||
- `window:*` — Minimize/maximize/close
|
||||
|
||||
## Key Gotchas
|
||||
|
||||
- **Sidecar binary required**: `src-tauri/binaries/koring-sidecar-<target-triple>.exe` must exist. Skipped in debug mode (`#[cfg(debug_assertions)]`).
|
||||
- **@xmcl packages are Node.js only**: `@xmcl/core`, `@xmcl/installer` require `fs`/`child_process`. Only `@xmcl/user`, `@xmcl/modrinth`, `@xmcl/curseforge` work in browser.
|
||||
- **@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 `appWindow.startDragging()` API, not `data-tauri-drag-region` (unreliable with transparent windows).
|
||||
- **Transparent windows**: Require `decorations: false` + `transparent: true` in tauri.conf.json.
|
||||
- **Permissions**: Window creation needs `core:webview:allow-create-webview-window` (not `core:window`).
|
||||
- **Window dragging**: Use CSS `-webkit-app-region: drag` on titlebar.
|
||||
- **Transparent windows**: `transparent: true` + `frame: false` in BrowserWindow options.
|
||||
- **Auth storage**: JSON file (`koring-auth.json`) stored next to executable.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| Frontend | React 19, Vite 7, Tailwind CSS v4, shadcn/ui, Zustand |
|
||||
| Backend | Rust, Tauri 2 |
|
||||
| Sidecar | Node.js, TypeScript, @xmcl/* packages |
|
||||
| Build | pnpm, Cargo, bun (sidecar compile) |
|
||||
| Main Process | Node.js, TypeScript, @xmcl/* packages |
|
||||
| Build | pnpm, Vite, electron-builder |
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
@echo off
|
||||
call "C:\Program Files\Microsoft Visual Studio\18\Community\VC\Auxiliary\Build\vcvarsall.bat" arm64
|
||||
set PATH=C:\Users\OseasyVM\scoop\apps\llvm\current\bin;%PATH%
|
||||
set CC=clang
|
||||
cd /d "%~dp0"
|
||||
|
||||
if "%~1"=="--mode" if "%~2"=="beta" (
|
||||
echo [build-vs] Beta mode: building frontend first...
|
||||
call pnpm build:beta
|
||||
echo [build-vs] Running tauri build with skip beforeBuildCommand...
|
||||
call npx tauri build --config src-tauri\tauri.beta.json
|
||||
) else (
|
||||
echo [build-vs] Production mode
|
||||
call pnpm tauri build
|
||||
) else (
|
||||
echo [build-vs] Production mode
|
||||
call pnpm tauri build
|
||||
)
|
||||
@@ -1,6 +0,0 @@
|
||||
@echo off
|
||||
call "C:\Program Files\Microsoft Visual Studio\18\Community\VC\Auxiliary\Build\vcvarsall.bat" arm64
|
||||
set PATH=C:\Users\OseasyVM\scoop\apps\llvm\current\bin;%PATH%
|
||||
set CC=clang
|
||||
cd /d "%~dp0"
|
||||
pnpm dev:t
|
||||
@@ -0,0 +1,27 @@
|
||||
appId: com.lingke.koring.launcher
|
||||
productName: Koring Launcher
|
||||
directories:
|
||||
output: dist-electron
|
||||
files:
|
||||
- dist/**
|
||||
- electron/**/*.js
|
||||
- "!electron/**/*.ts"
|
||||
win:
|
||||
target: nsis
|
||||
icon: public/icon.ico
|
||||
artifactName: "koring-launcher-${version}-setup.${ext}"
|
||||
mac:
|
||||
target: dmg
|
||||
icon: public/icon.png
|
||||
linux:
|
||||
target: AppImage
|
||||
icon: public/icon.png
|
||||
nsis:
|
||||
oneClick: false
|
||||
allowToChangeInstallationDirectory: true
|
||||
installerIcon: public/icon.ico
|
||||
uninstallerIcon: public/icon.ico
|
||||
installerHeaderIcon: public/icon.ico
|
||||
createDesktopShortcut: true
|
||||
createStartMenuShortcut: true
|
||||
shortcutName: Koring Launcher
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import electron from 'electron';
|
||||
const { app } = electron;
|
||||
|
||||
export interface AuthData {
|
||||
username: string;
|
||||
uuid: string;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
xboxProfile: string;
|
||||
}
|
||||
|
||||
const authFile = (): string => {
|
||||
if (app.isPackaged) {
|
||||
return path.join(path.dirname(app.getPath('exe')), 'koring-auth.json');
|
||||
}
|
||||
return path.join(__dirname, '..', 'koring-auth.json');
|
||||
};
|
||||
|
||||
export function readAuth(): AuthData {
|
||||
const filePath = authFile();
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return { username: '', uuid: '', accessToken: '', refreshToken: '', xboxProfile: '' };
|
||||
}
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf-8');
|
||||
return JSON.parse(raw) as AuthData;
|
||||
} catch {
|
||||
return { username: '', uuid: '', accessToken: '', refreshToken: '', xboxProfile: '' };
|
||||
}
|
||||
}
|
||||
|
||||
export function writeAuth(auth: AuthData): void {
|
||||
const filePath = authFile();
|
||||
fs.writeFileSync(filePath, JSON.stringify(auth, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
export function deleteAuth(): void {
|
||||
const filePath = authFile();
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as yaml from 'js-yaml';
|
||||
import electron from 'electron';
|
||||
const { app } = electron;
|
||||
|
||||
const CONFIG_FILE = 'Koring.yml';
|
||||
const CURRENT_VERSION = 1;
|
||||
|
||||
function configPath(): string {
|
||||
if (app.isPackaged) {
|
||||
return path.join(path.dirname(app.getPath('exe')), CONFIG_FILE);
|
||||
}
|
||||
return path.join(__dirname, '..', CONFIG_FILE);
|
||||
}
|
||||
|
||||
export interface ThemeConfig {
|
||||
darkMode: string;
|
||||
parallax: boolean;
|
||||
}
|
||||
|
||||
export interface A11yConfig {
|
||||
reduceMotion: boolean;
|
||||
reduceTransparency: boolean;
|
||||
highContrast: boolean;
|
||||
contentBlurOpacity: number;
|
||||
}
|
||||
|
||||
export interface BackgroundConfig {
|
||||
bgType: string;
|
||||
image: string;
|
||||
blur: number;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
export interface GameConfig {
|
||||
gameDir: string;
|
||||
resourceDir: string;
|
||||
savesDir: string;
|
||||
instancesDir: string;
|
||||
}
|
||||
|
||||
export interface JavaConfig {
|
||||
javaPath: string;
|
||||
memMode: string;
|
||||
memGB: number;
|
||||
gc: string;
|
||||
jvmArgs: string;
|
||||
}
|
||||
|
||||
export interface AdvancedConfig {
|
||||
afterLaunch: string;
|
||||
winMode: string;
|
||||
customWidth: number;
|
||||
customHeight: number;
|
||||
gameArgs: string;
|
||||
preLaunchCmd: string;
|
||||
debugMode: boolean;
|
||||
}
|
||||
|
||||
export interface DownloadConfig {
|
||||
fileSource: string;
|
||||
versionSource: string;
|
||||
threads: number;
|
||||
speedLimit: number;
|
||||
}
|
||||
|
||||
export interface SecurityIdConfig {
|
||||
enabled: boolean;
|
||||
authUrl: string;
|
||||
}
|
||||
|
||||
export interface NetworkConfig {
|
||||
securityId: SecurityIdConfig;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
version: number;
|
||||
theme: ThemeConfig;
|
||||
a11y: A11yConfig;
|
||||
background: BackgroundConfig;
|
||||
game: GameConfig;
|
||||
java: JavaConfig;
|
||||
advanced: AdvancedConfig;
|
||||
download: DownloadConfig;
|
||||
network: NetworkConfig;
|
||||
}
|
||||
|
||||
const DEFAULTS: AppConfig = {
|
||||
version: CURRENT_VERSION,
|
||||
theme: { darkMode: 'auto', parallax: true },
|
||||
a11y: { reduceMotion: false, reduceTransparency: false, highContrast: false, contentBlurOpacity: 50 },
|
||||
background: { bgType: 'image', image: '/background.png', blur: 0, opacity: 100 },
|
||||
game: { gameDir: '.minecraft', resourceDir: '', savesDir: '', instancesDir: '.minecraft/instances' },
|
||||
java: { javaPath: '', memMode: 'auto', memGB: 4, gc: 'auto', jvmArgs: '' },
|
||||
advanced: { afterLaunch: 'close', winMode: 'default', customWidth: 854, customHeight: 480, gameArgs: '', preLaunchCmd: '', debugMode: false },
|
||||
download: { fileSource: 'mirror', versionSource: 'mirror', threads: 16, speedLimit: 0 },
|
||||
network: { securityId: { enabled: false, authUrl: '' } },
|
||||
};
|
||||
|
||||
function migrate(config: AppConfig): AppConfig {
|
||||
if (config.version < 1) {
|
||||
config.version = 1;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function diffValue(full: unknown, defaultVal: unknown): unknown {
|
||||
if (full === null || full === undefined) return undefined;
|
||||
if (typeof full !== 'object' || typeof defaultVal !== 'object') {
|
||||
return full === defaultVal ? undefined : full;
|
||||
}
|
||||
if (Array.isArray(full) && Array.isArray(defaultVal)) {
|
||||
return JSON.stringify(full) === JSON.stringify(defaultVal) ? undefined : full;
|
||||
}
|
||||
if (Array.isArray(full) !== Array.isArray(defaultVal)) return full;
|
||||
|
||||
const fullObj = full as Record<string, unknown>;
|
||||
const defaultObj = defaultVal as Record<string, unknown>;
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
for (const key of Object.keys(fullObj)) {
|
||||
const d = diffValue(fullObj[key], defaultObj[key]);
|
||||
if (d !== undefined) {
|
||||
result[key] = d;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(result).length === 0 ? undefined : result;
|
||||
}
|
||||
|
||||
export function loadConfig(): AppConfig {
|
||||
const filePath = configPath();
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return { ...DEFAULTS };
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf-8');
|
||||
if (!raw.trim()) return { ...DEFAULTS };
|
||||
const parsed = yaml.load(raw) as Partial<AppConfig>;
|
||||
const config = migrate({ ...DEFAULTS, ...parsed } as AppConfig);
|
||||
config.version = CURRENT_VERSION;
|
||||
return config;
|
||||
} catch {
|
||||
return { ...DEFAULTS };
|
||||
}
|
||||
}
|
||||
|
||||
export function saveConfig(config: AppConfig): void {
|
||||
const filePath = configPath();
|
||||
const sparse = diffValue(config, DEFAULTS) as Record<string, unknown> | undefined;
|
||||
|
||||
if (!sparse || Object.keys(sparse).length === 0) {
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
return;
|
||||
}
|
||||
|
||||
const yamlStr = yaml.dump(sparse, { lineWidth: -1 });
|
||||
fs.writeFileSync(filePath, yamlStr, 'utf-8');
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.microsoftLoginStart = microsoftLoginStart;
|
||||
exports.microsoftLoginCallback = microsoftLoginCallback;
|
||||
exports.offlineLogin = offlineLogin;
|
||||
exports.validateMinecraftToken = validateMinecraftToken;
|
||||
const crypto = __importStar(require("crypto"));
|
||||
const electron_1 = __importDefault(require("electron"));
|
||||
const { net } = electron_1.default;
|
||||
const MC_SERVICES_API = 'https://api.minecraftservices.com';
|
||||
const XBOX_AUTH_URL = 'https://user.auth.xboxlive.com/user/authenticate';
|
||||
const XBOX_XSTS_URL = 'https://xsts.auth.xboxlive.com/xsts/authorize';
|
||||
const MC_AUTH_URL = 'https://api.minecraftservices.com/authentication/login_with_xbox';
|
||||
const MC_PROFILE_URL = 'https://api.minecraftservices.com/minecraft/profile';
|
||||
function generateState() {
|
||||
return crypto.randomBytes(16).toString('hex');
|
||||
}
|
||||
function generateVerifier() {
|
||||
return crypto.randomBytes(32).toString('base64url');
|
||||
}
|
||||
function generateChallenge(verifier) {
|
||||
return crypto.createHash('sha256').update(verifier).digest('base64url');
|
||||
}
|
||||
async function microsoftLoginStart(clientId, redirectUri) {
|
||||
const state = generateState();
|
||||
const verifier = generateVerifier();
|
||||
const challenge = generateChallenge(verifier);
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
response_type: 'code',
|
||||
redirect_uri: redirectUri || 'http://localhost:3000/callback',
|
||||
scope: 'XboxLive.signin offline_access',
|
||||
state,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
const authUrl = `https://login.live.com/oauth20_authorize.srf?${params.toString()}`;
|
||||
return { state, authUrl, verifier };
|
||||
}
|
||||
async function exchangeCodeForToken(code, clientId, redirectUri, verifier) {
|
||||
const response = await net.fetch('https://login.live.com/oauth20_token.srf', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: verifier,
|
||||
}).toString(),
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error(`MS token exchange failed: ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
async function authenticateWithXbox(msToken) {
|
||||
const response = await net.fetch(XBOX_AUTH_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
Properties: {
|
||||
AuthMethod: 'RPS',
|
||||
SiteName: 'user.auth.xboxlive.com',
|
||||
RpsTicket: msToken,
|
||||
},
|
||||
RelyingParty: 'http://auth.xboxlive.com',
|
||||
TokenType: 'JWT',
|
||||
}),
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error(`Xbox auth failed: ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
async function authorizeWithXsts(xboxToken) {
|
||||
const response = await net.fetch(XBOX_XSTS_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
Properties: {
|
||||
SandboxId: 'RETAIL',
|
||||
UserTokens: [xboxToken],
|
||||
},
|
||||
RelyingParty: 'rp://api.minecraftservices.com/',
|
||||
TokenType: 'JWT',
|
||||
}),
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error(`Xbox XSTS failed: ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
async function authenticateWithMinecraft(xstsToken) {
|
||||
const response = await net.fetch(MC_AUTH_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
identityToken: `XBL3.0 x=${xstsToken}`,
|
||||
}),
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error(`MC auth failed: ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
async function getMinecraftProfile(accessToken) {
|
||||
const response = await net.fetch(MC_PROFILE_URL, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error(`MC profile failed: ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
async function microsoftLoginCallback(code, clientId, redirectUri) {
|
||||
const redirect = redirectUri || 'http://localhost:3000/callback';
|
||||
const msToken = await exchangeCodeForToken(code, clientId, redirect, '');
|
||||
const xboxAuth = await authenticateWithXbox(msToken.access_token);
|
||||
const xstsAuth = await authorizeWithXsts(xboxAuth.Token);
|
||||
const mcAuth = await authenticateWithMinecraft(xstsAuth.Token);
|
||||
const profile = await getMinecraftProfile(mcAuth.access_token);
|
||||
return {
|
||||
username: profile.name,
|
||||
uuid: profile.id,
|
||||
accessToken: mcAuth.access_token,
|
||||
expiresAt: Date.now() + mcAuth.expires_in * 1000,
|
||||
};
|
||||
}
|
||||
async function offlineLogin(username) {
|
||||
const uuid = crypto.createHash('md5').update(`OfflinePlayer:${username}`).digest('hex');
|
||||
const formattedUuid = `${uuid.slice(0, 8)}-${uuid.slice(8, 12)}-${uuid.slice(12, 16)}-${uuid.slice(16, 20)}-${uuid.slice(20)}`;
|
||||
return {
|
||||
username,
|
||||
uuid: formattedUuid,
|
||||
accessToken: '',
|
||||
};
|
||||
}
|
||||
async function validateMinecraftToken(accessToken) {
|
||||
try {
|
||||
const response = await net.fetch(`${MC_SERVICES_API}/minecraft/profile`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
return response.ok;
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import * as crypto from 'crypto';
|
||||
import electron from 'electron';
|
||||
|
||||
const { net } = electron;
|
||||
|
||||
const MC_SERVICES_API = 'https://api.minecraftservices.com';
|
||||
const XBOX_AUTH_URL = 'https://user.auth.xboxlive.com/user/authenticate';
|
||||
const XBOX_XSTS_URL = 'https://xsts.auth.xboxlive.com/xsts/authorize';
|
||||
const MC_AUTH_URL = 'https://api.minecraftservices.com/authentication/login_with_xbox';
|
||||
const MC_PROFILE_URL = 'https://api.minecraftservices.com/minecraft/profile';
|
||||
|
||||
interface XboxProfile {
|
||||
gamertag: string;
|
||||
gamerscore: string;
|
||||
displayPicRaw: string;
|
||||
}
|
||||
|
||||
interface AuthResult {
|
||||
username: string;
|
||||
uuid: string;
|
||||
accessToken: string;
|
||||
expiresAt?: number;
|
||||
xboxProfile?: XboxProfile;
|
||||
}
|
||||
|
||||
function generateState(): string {
|
||||
return crypto.randomBytes(16).toString('hex');
|
||||
}
|
||||
|
||||
function generateVerifier(): string {
|
||||
return crypto.randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
function generateChallenge(verifier: string): string {
|
||||
return crypto.createHash('sha256').update(verifier).digest('base64url');
|
||||
}
|
||||
|
||||
export async function microsoftLoginStart(clientId: string, redirectUri?: string) {
|
||||
const state = generateState();
|
||||
const verifier = generateVerifier();
|
||||
const challenge = generateChallenge(verifier);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
response_type: 'code',
|
||||
redirect_uri: redirectUri || 'http://localhost:3000/callback',
|
||||
scope: 'XboxLive.signin offline_access',
|
||||
state,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
|
||||
const authUrl = `https://login.live.com/oauth20_authorize.srf?${params.toString()}`;
|
||||
|
||||
return { state, authUrl, verifier };
|
||||
}
|
||||
|
||||
async function exchangeCodeForToken(code: string, clientId: string, redirectUri: string, verifier: string) {
|
||||
const response = await net.fetch('https://login.live.com/oauth20_token.srf', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: verifier,
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`MS token exchange failed: ${response.status}`);
|
||||
return response.json() as Promise<{ access_token: string; refresh_token: string }>;
|
||||
}
|
||||
|
||||
async function authenticateWithXbox(msToken: string) {
|
||||
const response = await net.fetch(XBOX_AUTH_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
Properties: {
|
||||
AuthMethod: 'RPS',
|
||||
SiteName: 'user.auth.xboxlive.com',
|
||||
RpsTicket: msToken,
|
||||
},
|
||||
RelyingParty: 'http://auth.xboxlive.com',
|
||||
TokenType: 'JWT',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Xbox auth failed: ${response.status}`);
|
||||
return response.json() as Promise<{ IssueInstant: string; Token: string; NotAfter: string }>;
|
||||
}
|
||||
|
||||
async function authorizeWithXsts(xboxToken: string) {
|
||||
const response = await net.fetch(XBOX_XSTS_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
Properties: {
|
||||
SandboxId: 'RETAIL',
|
||||
UserTokens: [xboxToken],
|
||||
},
|
||||
RelyingParty: 'rp://api.minecraftservices.com/',
|
||||
TokenType: 'JWT',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Xbox XSTS failed: ${response.status}`);
|
||||
return response.json() as Promise<{ IssueInstant: string; Token: string; NotAfter: string }>;
|
||||
}
|
||||
|
||||
async function authenticateWithMinecraft(xstsToken: string) {
|
||||
const response = await net.fetch(MC_AUTH_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
identityToken: `XBL3.0 x=${xstsToken}`,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`MC auth failed: ${response.status}`);
|
||||
return response.json() as Promise<{ username: string; access_token: string; token_type: string; expires_in: number }>;
|
||||
}
|
||||
|
||||
async function getMinecraftProfile(accessToken: string): Promise<{ id: string; name: string; skins: unknown[] }> {
|
||||
const response = await net.fetch(MC_PROFILE_URL, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`MC profile failed: ${response.status}`);
|
||||
return response.json() as Promise<{ id: string; name: string; skins: unknown[] }>;
|
||||
}
|
||||
|
||||
export async function microsoftLoginCallback(
|
||||
code: string,
|
||||
clientId: string,
|
||||
redirectUri?: string
|
||||
): Promise<AuthResult> {
|
||||
const redirect = redirectUri || 'http://localhost:3000/callback';
|
||||
|
||||
const msToken = await exchangeCodeForToken(code, clientId, redirect, '');
|
||||
const xboxAuth = await authenticateWithXbox(msToken.access_token);
|
||||
const xstsAuth = await authorizeWithXsts(xboxAuth.Token);
|
||||
const mcAuth = await authenticateWithMinecraft(xstsAuth.Token);
|
||||
const profile = await getMinecraftProfile(mcAuth.access_token);
|
||||
|
||||
return {
|
||||
username: profile.name,
|
||||
uuid: profile.id,
|
||||
accessToken: mcAuth.access_token,
|
||||
expiresAt: Date.now() + mcAuth.expires_in * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
export async function offlineLogin(username: string): Promise<AuthResult> {
|
||||
const uuid = crypto.createHash('md5').update(`OfflinePlayer:${username}`).digest('hex');
|
||||
const formattedUuid = `${uuid.slice(0, 8)}-${uuid.slice(8, 12)}-${uuid.slice(12, 16)}-${uuid.slice(16, 20)}-${uuid.slice(20)}`;
|
||||
|
||||
return {
|
||||
username,
|
||||
uuid: formattedUuid,
|
||||
accessToken: '',
|
||||
};
|
||||
}
|
||||
|
||||
export async function validateMinecraftToken(accessToken: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await net.fetch(`${MC_SERVICES_API}/minecraft/profile`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.getVersionList = getVersionList;
|
||||
exports.getForgeVersions = getForgeVersions;
|
||||
exports.getFabricVersions = getFabricVersions;
|
||||
exports.installMinecraft = installMinecraft;
|
||||
exports.installModLoader = installModLoader;
|
||||
const electron_1 = __importDefault(require("electron"));
|
||||
const fs = __importStar(require("fs"));
|
||||
const path = __importStar(require("path"));
|
||||
const https = __importStar(require("https"));
|
||||
const http = __importStar(require("http"));
|
||||
const { net } = electron_1.default;
|
||||
const VERSION_MANIFEST_URL = 'https://launchermeta.mojang.com/mc/game/version_manifest.json';
|
||||
const FORGE_VERSION_LIST_URL = 'https://files.minecraftforge.net/net/minecraftforge/forge/json';
|
||||
const FABRIC_VERSION_LIST_URL = 'https://meta.fabricmc.net/v2/versions';
|
||||
async function downloadFile(url, dest, onProgress) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = url.startsWith('https') ? https : http;
|
||||
const request = client.get(url, (response) => {
|
||||
if (response.statusCode === 302 || response.statusCode === 301) {
|
||||
downloadFile(response.headers.location, dest, onProgress).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
if (response.statusCode !== 200) {
|
||||
reject(new Error(`Download failed: ${response.statusCode}`));
|
||||
return;
|
||||
}
|
||||
const totalBytes = parseInt(response.headers['content-length'] || '0', 10);
|
||||
let downloadedBytes = 0;
|
||||
const dir = path.dirname(dest);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
const file = fs.createWriteStream(dest);
|
||||
response.on('data', (chunk) => {
|
||||
downloadedBytes += chunk.length;
|
||||
if (onProgress && totalBytes > 0) {
|
||||
onProgress({
|
||||
stage: 'downloading',
|
||||
current: downloadedBytes,
|
||||
total: totalBytes,
|
||||
message: `${Math.round((downloadedBytes / totalBytes) * 100)}%`,
|
||||
});
|
||||
}
|
||||
});
|
||||
response.pipe(file);
|
||||
file.on('finish', () => {
|
||||
file.close();
|
||||
resolve();
|
||||
});
|
||||
file.on('error', (err) => {
|
||||
fs.unlink(dest, () => { });
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
request.on('error', reject);
|
||||
});
|
||||
}
|
||||
async function getVersionList(type) {
|
||||
const response = await net.fetch(VERSION_MANIFEST_URL);
|
||||
if (!response.ok)
|
||||
throw new Error(`Failed to fetch version manifest: ${response.status}`);
|
||||
const manifest = await response.json();
|
||||
if (type && type !== 'all') {
|
||||
manifest.versions = manifest.versions.filter((v) => v.type === type);
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
async function getForgeVersions(mcVersion) {
|
||||
try {
|
||||
const response = await net.fetch(FORGE_VERSION_LIST_URL);
|
||||
if (!response.ok)
|
||||
throw new Error(`Forge version list failed: ${response.status}`);
|
||||
const data = await response.json();
|
||||
const versions = data.versions[mcVersion || ''] || [];
|
||||
return { versions };
|
||||
}
|
||||
catch {
|
||||
return { versions: [] };
|
||||
}
|
||||
}
|
||||
async function getFabricVersions(mcVersion) {
|
||||
try {
|
||||
const url = mcVersion
|
||||
? `${FABRIC_VERSION_LIST_URL}/loader/${mcVersion}`
|
||||
: `${FABRIC_VERSION_LIST_URL}/loader`;
|
||||
const response = await net.fetch(url);
|
||||
if (!response.ok)
|
||||
throw new Error(`Fabric version list failed: ${response.status}`);
|
||||
const data = await response.json();
|
||||
return { versions: data.map((v) => v.version) };
|
||||
}
|
||||
catch {
|
||||
return { versions: [] };
|
||||
}
|
||||
}
|
||||
async function installMinecraft(version, gamePath, javaPath, downloadThreads, callbacks) {
|
||||
const versionDir = path.join(gamePath, 'versions', version);
|
||||
if (!fs.existsSync(versionDir)) {
|
||||
fs.mkdirSync(versionDir, { recursive: true });
|
||||
}
|
||||
// Download version manifest
|
||||
const manifestResponse = await net.fetch(VERSION_MANIFEST_URL);
|
||||
const manifest = await manifestResponse.json();
|
||||
const versionInfo = manifest.versions.find((v) => v.id === version);
|
||||
if (!versionInfo)
|
||||
throw new Error(`Version ${version} not found`);
|
||||
callbacks?.onProgress?.({ stage: 'downloading', current: 0, total: 100, message: 'Downloading version manifest...' });
|
||||
// Download version JSON
|
||||
const versionJsonPath = path.join(versionDir, `${version}.json`);
|
||||
await downloadFile(versionInfo.url, versionJsonPath, callbacks?.onProgress);
|
||||
const versionJson = JSON.parse(fs.readFileSync(versionJsonPath, 'utf-8'));
|
||||
// Download client jar
|
||||
const clientJar = versionJson.downloads?.client;
|
||||
if (clientJar) {
|
||||
callbacks?.onProgress?.({ stage: 'downloading', current: 1, total: 3, message: 'Downloading client jar...' });
|
||||
const jarPath = path.join(versionDir, `${version}.jar`);
|
||||
await downloadFile(clientJar.url, jarPath, callbacks?.onProgress);
|
||||
}
|
||||
callbacks?.onProgress?.({ stage: 'downloading', current: 2, total: 3, message: 'Installing libraries...' });
|
||||
// Download libraries
|
||||
const libraries = versionJson.libraries || [];
|
||||
for (const lib of libraries) {
|
||||
if (lib.downloads?.artifact?.url) {
|
||||
const libPath = path.join(gamePath, 'libraries', lib.downloads.artifact.path);
|
||||
if (!fs.existsSync(libPath)) {
|
||||
await downloadFile(lib.downloads.artifact.url, libPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
callbacks?.onProgress?.({ stage: 'downloading', current: 3, total: 3, message: 'Installation complete' });
|
||||
return { version, gamePath };
|
||||
}
|
||||
async function installModLoader(mcVersion, gamePath, loaderType, loaderVersion, javaPath, callbacks) {
|
||||
callbacks?.onProgress?.({ stage: 'installing', current: 0, total: 100, message: `Installing ${loaderType}...` });
|
||||
// Placeholder: actual implementation depends on loader type
|
||||
// This would call the appropriate installer for Forge/Fabric/Quilt/NeoForge
|
||||
callbacks?.onProgress?.({ stage: 'installing', current: 100, total: 100, message: `${loaderType} installed` });
|
||||
return {
|
||||
loaderType,
|
||||
mcVersion,
|
||||
loaderVersion: loaderVersion || 'latest',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import electron from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as https from 'https';
|
||||
import * as http from 'http';
|
||||
|
||||
const { net } = electron;
|
||||
|
||||
const VERSION_MANIFEST_URL = 'https://launchermeta.mojang.com/mc/game/version_manifest.json';
|
||||
const FORGE_VERSION_LIST_URL = 'https://files.minecraftforge.net/net/minecraftforge/forge/json';
|
||||
const FABRIC_VERSION_LIST_URL = 'https://meta.fabricmc.net/v2/versions';
|
||||
|
||||
interface VersionInfo {
|
||||
id: string;
|
||||
type: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface VersionManifest {
|
||||
latest: { release: string; snapshot: string };
|
||||
versions: VersionInfo[];
|
||||
}
|
||||
|
||||
interface DownloadProgress {
|
||||
stage: string;
|
||||
current: number;
|
||||
total: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
async function downloadFile(url: string, dest: string, onProgress?: (progress: DownloadProgress) => void): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = url.startsWith('https') ? https : http;
|
||||
const request = client.get(url, (response) => {
|
||||
if (response.statusCode === 302 || response.statusCode === 301) {
|
||||
downloadFile(response.headers.location!, dest, onProgress).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
reject(new Error(`Download failed: ${response.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const totalBytes = parseInt(response.headers['content-length'] || '0', 10);
|
||||
let downloadedBytes = 0;
|
||||
|
||||
const dir = path.dirname(dest);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const file = fs.createWriteStream(dest);
|
||||
|
||||
response.on('data', (chunk) => {
|
||||
downloadedBytes += chunk.length;
|
||||
if (onProgress && totalBytes > 0) {
|
||||
onProgress({
|
||||
stage: 'downloading',
|
||||
current: downloadedBytes,
|
||||
total: totalBytes,
|
||||
message: `${Math.round((downloadedBytes / totalBytes) * 100)}%`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
response.pipe(file);
|
||||
|
||||
file.on('finish', () => {
|
||||
file.close();
|
||||
resolve();
|
||||
});
|
||||
|
||||
file.on('error', (err) => {
|
||||
fs.unlink(dest, () => {});
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
request.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getVersionList(type?: string): Promise<VersionManifest> {
|
||||
const response = await net.fetch(VERSION_MANIFEST_URL);
|
||||
if (!response.ok) throw new Error(`Failed to fetch version manifest: ${response.status}`);
|
||||
const manifest = await response.json() as VersionManifest;
|
||||
|
||||
if (type && type !== 'all') {
|
||||
manifest.versions = manifest.versions.filter((v) => v.type === type);
|
||||
}
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
export async function getForgeVersions(mcVersion?: string) {
|
||||
try {
|
||||
const response = await net.fetch(FORGE_VERSION_LIST_URL);
|
||||
if (!response.ok) throw new Error(`Forge version list failed: ${response.status}`);
|
||||
const data = await response.json() as { versions: Record<string, string[]> };
|
||||
const versions = data.versions[mcVersion || ''] || [];
|
||||
return { versions };
|
||||
} catch {
|
||||
return { versions: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFabricVersions(mcVersion?: string) {
|
||||
try {
|
||||
const url = mcVersion
|
||||
? `${FABRIC_VERSION_LIST_URL}/loader/${mcVersion}`
|
||||
: `${FABRIC_VERSION_LIST_URL}/loader`;
|
||||
const response = await net.fetch(url);
|
||||
if (!response.ok) throw new Error(`Fabric version list failed: ${response.status}`);
|
||||
const data = await response.json() as { version: string; stable: boolean }[];
|
||||
return { versions: data.map((v) => v.version) };
|
||||
} catch {
|
||||
return { versions: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function installMinecraft(
|
||||
version: string,
|
||||
gamePath: string,
|
||||
javaPath?: string,
|
||||
downloadThreads?: number,
|
||||
callbacks?: { onProgress?: (progress: DownloadProgress) => void }
|
||||
): Promise<{ version: string; gamePath: string }> {
|
||||
const versionDir = path.join(gamePath, 'versions', version);
|
||||
if (!fs.existsSync(versionDir)) {
|
||||
fs.mkdirSync(versionDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Download version manifest
|
||||
const manifestResponse = await net.fetch(VERSION_MANIFEST_URL);
|
||||
const manifest = await manifestResponse.json() as VersionManifest;
|
||||
const versionInfo = manifest.versions.find((v) => v.id === version);
|
||||
|
||||
if (!versionInfo) throw new Error(`Version ${version} not found`);
|
||||
|
||||
callbacks?.onProgress?.({ stage: 'downloading', current: 0, total: 100, message: 'Downloading version manifest...' });
|
||||
|
||||
// Download version JSON
|
||||
const versionJsonPath = path.join(versionDir, `${version}.json`);
|
||||
await downloadFile(versionInfo.url, versionJsonPath, callbacks?.onProgress);
|
||||
|
||||
const versionJson = JSON.parse(fs.readFileSync(versionJsonPath, 'utf-8'));
|
||||
|
||||
// Download client jar
|
||||
const clientJar = versionJson.downloads?.client;
|
||||
if (clientJar) {
|
||||
callbacks?.onProgress?.({ stage: 'downloading', current: 1, total: 3, message: 'Downloading client jar...' });
|
||||
const jarPath = path.join(versionDir, `${version}.jar`);
|
||||
await downloadFile(clientJar.url, jarPath, callbacks?.onProgress);
|
||||
}
|
||||
|
||||
callbacks?.onProgress?.({ stage: 'downloading', current: 2, total: 3, message: 'Installing libraries...' });
|
||||
|
||||
// Download libraries
|
||||
const libraries = versionJson.libraries || [];
|
||||
for (const lib of libraries) {
|
||||
if (lib.downloads?.artifact?.url) {
|
||||
const libPath = path.join(gamePath, 'libraries', lib.downloads.artifact.path);
|
||||
if (!fs.existsSync(libPath)) {
|
||||
await downloadFile(lib.downloads.artifact.url, libPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
callbacks?.onProgress?.({ stage: 'downloading', current: 3, total: 3, message: 'Installation complete' });
|
||||
|
||||
return { version, gamePath };
|
||||
}
|
||||
|
||||
export async function installModLoader(
|
||||
mcVersion: string,
|
||||
gamePath: string,
|
||||
loaderType: 'forge' | 'fabric' | 'quilt' | 'neoforge',
|
||||
loaderVersion?: string,
|
||||
javaPath?: string,
|
||||
callbacks?: { onProgress?: (progress: DownloadProgress) => void }
|
||||
): Promise<{ loaderType: string; mcVersion: string; loaderVersion: string }> {
|
||||
callbacks?.onProgress?.({ stage: 'installing', current: 0, total: 100, message: `Installing ${loaderType}...` });
|
||||
|
||||
// Placeholder: actual implementation depends on loader type
|
||||
// This would call the appropriate installer for Forge/Fabric/Quilt/NeoForge
|
||||
|
||||
callbacks?.onProgress?.({ stage: 'installing', current: 100, total: 100, message: `${loaderType} installed` });
|
||||
|
||||
return {
|
||||
loaderType,
|
||||
mcVersion,
|
||||
loaderVersion: loaderVersion || 'latest',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.createInstance = createInstance;
|
||||
exports.listInstances = listInstances;
|
||||
exports.deleteInstance = deleteInstance;
|
||||
exports.getInstanceInfo = getInstanceInfo;
|
||||
const fs = __importStar(require("fs"));
|
||||
const path = __importStar(require("path"));
|
||||
const INSTANCE_CONFIG_FILE = 'koring-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 });
|
||||
}
|
||||
const config = {
|
||||
name,
|
||||
mcVersion,
|
||||
loaderType,
|
||||
loaderVersion,
|
||||
javaPath,
|
||||
memory,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
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 });
|
||||
}
|
||||
// Count mods
|
||||
const mods = fs.readdirSync(modsDir).filter((f) => f.endsWith('.jar'));
|
||||
return {
|
||||
name,
|
||||
path: instancePath,
|
||||
config,
|
||||
modCount: mods.length,
|
||||
};
|
||||
}
|
||||
async function listInstances(instancesPath) {
|
||||
const instances = [];
|
||||
if (!fs.existsSync(instancesPath)) {
|
||||
return instances;
|
||||
}
|
||||
const entries = fs.readdirSync(instancesPath, { withFileTypes: true });
|
||||
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,
|
||||
});
|
||||
}
|
||||
catch {
|
||||
// Skip invalid config
|
||||
}
|
||||
}
|
||||
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);
|
||||
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;
|
||||
}
|
||||
return {
|
||||
name,
|
||||
path: instancePath,
|
||||
config,
|
||||
modCount,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
interface InstanceConfig {
|
||||
name: string;
|
||||
mcVersion: string;
|
||||
loaderType?: string;
|
||||
loaderVersion?: string;
|
||||
javaPath?: string;
|
||||
memory?: { min?: string; max?: string };
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface InstanceInfo {
|
||||
name: string;
|
||||
path: string;
|
||||
config: InstanceConfig;
|
||||
modCount?: number;
|
||||
}
|
||||
|
||||
const INSTANCE_CONFIG_FILE = 'koring-instance.json';
|
||||
|
||||
function getInstanceConfigPath(instancePath: string): string {
|
||||
return path.join(instancePath, INSTANCE_CONFIG_FILE);
|
||||
}
|
||||
|
||||
export async function createInstance(
|
||||
name: string,
|
||||
gamePath: string,
|
||||
mcVersion: string,
|
||||
loaderType?: string,
|
||||
loaderVersion?: string,
|
||||
javaPath?: string,
|
||||
memory?: { min?: string; max?: string }
|
||||
): Promise<InstanceInfo> {
|
||||
const instancePath = path.join(gamePath, 'instances', name);
|
||||
|
||||
if (!fs.existsSync(instancePath)) {
|
||||
fs.mkdirSync(instancePath, { recursive: true });
|
||||
}
|
||||
|
||||
const config: InstanceConfig = {
|
||||
name,
|
||||
mcVersion,
|
||||
loaderType,
|
||||
loaderVersion,
|
||||
javaPath,
|
||||
memory,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
// Count mods
|
||||
const mods = fs.readdirSync(modsDir).filter((f) => f.endsWith('.jar'));
|
||||
|
||||
return {
|
||||
name,
|
||||
path: instancePath,
|
||||
config,
|
||||
modCount: mods.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listInstances(instancesPath: string): Promise<InstanceInfo[]> {
|
||||
const instances: InstanceInfo[] = [];
|
||||
|
||||
if (!fs.existsSync(instancesPath)) {
|
||||
return instances;
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(instancesPath, { withFileTypes: true });
|
||||
|
||||
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,
|
||||
});
|
||||
} catch {
|
||||
// Skip invalid config
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
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;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
path: instancePath,
|
||||
config,
|
||||
modCount,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.launchMinecraft = launchMinecraft;
|
||||
exports.diagnoseVersion = diagnoseVersion;
|
||||
const child_process_1 = require("child_process");
|
||||
const fs = __importStar(require("fs"));
|
||||
const path = __importStar(require("path"));
|
||||
const runningProcesses = new Map();
|
||||
async function launchMinecraft(options) {
|
||||
const versionJsonPath = path.join(options.gamePath, 'versions', options.version, `${options.version}.json`);
|
||||
if (!fs.existsSync(versionJsonPath)) {
|
||||
throw new Error(`Version JSON not found: ${versionJsonPath}`);
|
||||
}
|
||||
const versionJson = JSON.parse(fs.readFileSync(versionJsonPath, 'utf-8'));
|
||||
const mainClass = versionJson.mainClass;
|
||||
if (!mainClass) {
|
||||
throw new Error('Main class not found in version JSON');
|
||||
}
|
||||
const args = [];
|
||||
// Memory
|
||||
const minMem = options.memory?.min || '512M';
|
||||
const maxMem = options.memory?.max || '4G';
|
||||
args.push(`-Xms${minMem}`);
|
||||
args.push(`-Xmx${maxMem}`);
|
||||
// JVM args
|
||||
if (options.jvmArgs) {
|
||||
args.push(...options.jvmArgs);
|
||||
}
|
||||
// Native libraries path
|
||||
const nativesDir = path.join(options.gamePath, 'versions', options.version, `${options.version}-natives`);
|
||||
if (fs.existsSync(nativesDir)) {
|
||||
args.push(`-Djava.library.path=${nativesDir}`);
|
||||
}
|
||||
// Classpath
|
||||
const libraries = versionJson.libraries || [];
|
||||
const classpath = libraries
|
||||
.filter((lib) => lib.downloads?.artifact?.path)
|
||||
.map((lib) => path.join(options.gamePath, 'libraries', lib.downloads.artifact.path));
|
||||
const clientJar = path.join(options.gamePath, 'versions', options.version, `${options.version}.jar`);
|
||||
if (fs.existsSync(clientJar)) {
|
||||
classpath.push(clientJar);
|
||||
}
|
||||
args.push('-cp');
|
||||
args.push(classpath.join(path.delimiter));
|
||||
args.push(mainClass);
|
||||
// Game args
|
||||
args.push(`--username`, options.username);
|
||||
args.push(`--version`, options.version);
|
||||
args.push(`--gameDir`, options.gamePath);
|
||||
args.push(`--assetsDir`, path.join(options.gamePath, 'assets'));
|
||||
args.push(`--assetIndex`, versionJson.assetIndex?.id || options.version);
|
||||
args.push(`--uuid`, options.uuid);
|
||||
if (options.accessToken) {
|
||||
args.push(`--accessToken`, options.accessToken);
|
||||
}
|
||||
if (options.server) {
|
||||
args.push(`--server`, options.server.ip);
|
||||
if (options.server.port) {
|
||||
args.push(`--port`, String(options.server.port));
|
||||
}
|
||||
}
|
||||
if (options.gameArgs) {
|
||||
args.push(...options.gameArgs);
|
||||
}
|
||||
const javaPath = options.javaPath || 'java';
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = (0, child_process_1.spawn)(javaPath, args, {
|
||||
cwd: options.gamePath,
|
||||
detached: options.detached,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
const requestId = `mc-${Date.now()}`;
|
||||
runningProcesses.set(requestId, child);
|
||||
child.stdout?.on('data', (data) => {
|
||||
const line = data.toString().trim();
|
||||
if (line) {
|
||||
options.onEvent?.({ event: 'stdout', message: line });
|
||||
}
|
||||
});
|
||||
child.stderr?.on('data', (data) => {
|
||||
const line = data.toString().trim();
|
||||
if (line) {
|
||||
options.onEvent?.({ event: 'stderr', message: line });
|
||||
}
|
||||
});
|
||||
child.on('error', (err) => {
|
||||
runningProcesses.delete(requestId);
|
||||
options.onEvent?.({ event: 'error', error: String(err) });
|
||||
reject(err);
|
||||
});
|
||||
child.on('exit', (code) => {
|
||||
runningProcesses.delete(requestId);
|
||||
options.onEvent?.({ event: 'exit', code });
|
||||
});
|
||||
resolve({
|
||||
pid: child.pid || 0,
|
||||
version: options.version,
|
||||
username: options.username,
|
||||
});
|
||||
});
|
||||
}
|
||||
async function diagnoseVersion(gamePath, version) {
|
||||
const issues = [];
|
||||
const versionDir = path.join(gamePath, 'versions', version);
|
||||
const versionJsonPath = path.join(versionDir, `${version}.json`);
|
||||
const jarPath = path.join(versionDir, `${version}.jar`);
|
||||
if (!fs.existsSync(versionJsonPath)) {
|
||||
issues.push(`Version JSON not found: ${versionJsonPath}`);
|
||||
}
|
||||
if (!fs.existsSync(jarPath)) {
|
||||
issues.push(`Client JAR not found: ${jarPath}`);
|
||||
}
|
||||
return {
|
||||
version,
|
||||
gamePath,
|
||||
healthy: issues.length === 0,
|
||||
issues,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
interface LaunchOptions {
|
||||
gamePath: string;
|
||||
javaPath: string;
|
||||
version: string;
|
||||
username: string;
|
||||
uuid: string;
|
||||
accessToken?: string;
|
||||
memory?: { min?: string; max?: string };
|
||||
jvmArgs?: string[];
|
||||
gameArgs?: string[];
|
||||
server?: { ip: string; port?: number };
|
||||
detached?: boolean;
|
||||
onEvent?: (event: { event: string; [key: string]: unknown }) => void;
|
||||
}
|
||||
|
||||
interface LaunchResult {
|
||||
pid: number;
|
||||
version: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
const runningProcesses = new Map<string, ChildProcess>();
|
||||
|
||||
export async function launchMinecraft(options: LaunchOptions): Promise<LaunchResult> {
|
||||
const versionJsonPath = path.join(options.gamePath, 'versions', options.version, `${options.version}.json`);
|
||||
|
||||
if (!fs.existsSync(versionJsonPath)) {
|
||||
throw new Error(`Version JSON not found: ${versionJsonPath}`);
|
||||
}
|
||||
|
||||
const versionJson = JSON.parse(fs.readFileSync(versionJsonPath, 'utf-8'));
|
||||
const mainClass = versionJson.mainClass;
|
||||
|
||||
if (!mainClass) {
|
||||
throw new Error('Main class not found in version JSON');
|
||||
}
|
||||
|
||||
const args: string[] = [];
|
||||
|
||||
// Memory
|
||||
const minMem = options.memory?.min || '512M';
|
||||
const maxMem = options.memory?.max || '4G';
|
||||
args.push(`-Xms${minMem}`);
|
||||
args.push(`-Xmx${maxMem}`);
|
||||
|
||||
// JVM args
|
||||
if (options.jvmArgs) {
|
||||
args.push(...options.jvmArgs);
|
||||
}
|
||||
|
||||
// Native libraries path
|
||||
const nativesDir = path.join(options.gamePath, 'versions', options.version, `${options.version}-natives`);
|
||||
if (fs.existsSync(nativesDir)) {
|
||||
args.push(`-Djava.library.path=${nativesDir}`);
|
||||
}
|
||||
|
||||
// Classpath
|
||||
const libraries = versionJson.libraries || [];
|
||||
const classpath = libraries
|
||||
.filter((lib: { downloads?: { artifact?: { path: string } } }) => lib.downloads?.artifact?.path)
|
||||
.map((lib: { downloads: { artifact: { path: string } } }) => path.join(options.gamePath, 'libraries', lib.downloads.artifact.path));
|
||||
|
||||
const clientJar = path.join(options.gamePath, 'versions', options.version, `${options.version}.jar`);
|
||||
if (fs.existsSync(clientJar)) {
|
||||
classpath.push(clientJar);
|
||||
}
|
||||
|
||||
args.push('-cp');
|
||||
args.push(classpath.join(path.delimiter));
|
||||
|
||||
args.push(mainClass);
|
||||
|
||||
// Game args
|
||||
args.push(`--username`, options.username);
|
||||
args.push(`--version`, options.version);
|
||||
args.push(`--gameDir`, options.gamePath);
|
||||
args.push(`--assetsDir`, path.join(options.gamePath, 'assets'));
|
||||
args.push(`--assetIndex`, versionJson.assetIndex?.id || options.version);
|
||||
args.push(`--uuid`, options.uuid);
|
||||
|
||||
if (options.accessToken) {
|
||||
args.push(`--accessToken`, options.accessToken);
|
||||
}
|
||||
|
||||
if (options.server) {
|
||||
args.push(`--server`, options.server.ip);
|
||||
if (options.server.port) {
|
||||
args.push(`--port`, String(options.server.port));
|
||||
}
|
||||
}
|
||||
|
||||
if (options.gameArgs) {
|
||||
args.push(...options.gameArgs);
|
||||
}
|
||||
|
||||
const javaPath = options.javaPath || 'java';
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(javaPath, args, {
|
||||
cwd: options.gamePath,
|
||||
detached: options.detached,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
const requestId = `mc-${Date.now()}`;
|
||||
runningProcesses.set(requestId, child);
|
||||
|
||||
child.stdout?.on('data', (data) => {
|
||||
const line = data.toString().trim();
|
||||
if (line) {
|
||||
options.onEvent?.({ event: 'stdout', message: line });
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (data) => {
|
||||
const line = data.toString().trim();
|
||||
if (line) {
|
||||
options.onEvent?.({ event: 'stderr', message: line });
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
runningProcesses.delete(requestId);
|
||||
options.onEvent?.({ event: 'error', error: String(err) });
|
||||
reject(err);
|
||||
});
|
||||
|
||||
child.on('exit', (code) => {
|
||||
runningProcesses.delete(requestId);
|
||||
options.onEvent?.({ event: 'exit', code });
|
||||
});
|
||||
|
||||
resolve({
|
||||
pid: child.pid || 0,
|
||||
version: options.version,
|
||||
username: options.username,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function diagnoseVersion(gamePath: string, version: string): Promise<Record<string, unknown>> {
|
||||
const issues: string[] = [];
|
||||
const versionDir = path.join(gamePath, 'versions', version);
|
||||
const versionJsonPath = path.join(versionDir, `${version}.json`);
|
||||
const jarPath = path.join(versionDir, `${version}.jar`);
|
||||
|
||||
if (!fs.existsSync(versionJsonPath)) {
|
||||
issues.push(`Version JSON not found: ${versionJsonPath}`);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(jarPath)) {
|
||||
issues.push(`Client JAR not found: ${jarPath}`);
|
||||
}
|
||||
|
||||
return {
|
||||
version,
|
||||
gamePath,
|
||||
healthy: issues.length === 0,
|
||||
issues,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.searchMods = searchMods;
|
||||
exports.getModDetail = getModDetail;
|
||||
exports.getModVersions = getModVersions;
|
||||
exports.installMod = installMod;
|
||||
const electron_1 = __importDefault(require("electron"));
|
||||
const fs = __importStar(require("fs"));
|
||||
const path = __importStar(require("path"));
|
||||
const { net } = electron_1.default;
|
||||
const MODRINTH_API = 'https://api.modrinth.com/v2';
|
||||
const CURSEFORGE_API = 'https://api.curseforge.com/v1';
|
||||
async function searchModrinth(query, gameVersion, loader, limit = 20, offset = 0) {
|
||||
const facets = [];
|
||||
if (gameVersion)
|
||||
facets.push([`versions:${gameVersion}`]);
|
||||
if (loader)
|
||||
facets.push([`categories:${loader}`]);
|
||||
const params = new URLSearchParams({
|
||||
query: query || '',
|
||||
limit: String(limit),
|
||||
offset: String(offset),
|
||||
});
|
||||
if (facets.length > 0) {
|
||||
params.set('facets', JSON.stringify(facets));
|
||||
}
|
||||
const response = await net.fetch(`${MODRINTH_API}/search?${params.toString()}`);
|
||||
if (!response.ok)
|
||||
throw new Error(`Modrinth search failed: ${response.status}`);
|
||||
const data = await response.json();
|
||||
return data.hits.map((hit) => ({
|
||||
id: hit.project_id,
|
||||
slug: hit.slug,
|
||||
name: hit.title,
|
||||
description: hit.description,
|
||||
downloads: hit.downloads,
|
||||
iconUrl: hit.icon_url,
|
||||
categories: hit.categories,
|
||||
versions: hit.versions,
|
||||
source: 'modrinth',
|
||||
}));
|
||||
}
|
||||
async function searchCurseForge(query, gameVersion, loader, limit = 20, offset = 0) {
|
||||
// CurseForge requires API key - return empty for now
|
||||
return [];
|
||||
}
|
||||
async function searchMods(query, gameVersion, loader, limit, offset, source = 'modrinth') {
|
||||
if (source === 'modrinth') {
|
||||
return searchModrinth(query, gameVersion, loader, limit, offset);
|
||||
}
|
||||
return searchCurseForge(query, gameVersion, loader, limit, offset);
|
||||
}
|
||||
async function getModDetail(projectId, source = 'modrinth') {
|
||||
if (source === 'modrinth') {
|
||||
const response = await net.fetch(`${MODRINTH_API}/project/${projectId}`);
|
||||
if (!response.ok)
|
||||
throw new Error(`Modrinth detail failed: ${response.status}`);
|
||||
const data = await response.json();
|
||||
return {
|
||||
id: data.id,
|
||||
slug: data.slug,
|
||||
name: data.title,
|
||||
description: data.description,
|
||||
downloads: data.downloads,
|
||||
iconUrl: data.icon_url,
|
||||
categories: data.categories,
|
||||
versions: data.versions,
|
||||
source: 'modrinth',
|
||||
};
|
||||
}
|
||||
throw new Error(`CurseForge detail not implemented`);
|
||||
}
|
||||
async function getModVersions(projectId, gameVersion, loader, source = 'modrinth') {
|
||||
if (source === 'modrinth') {
|
||||
const params = new URLSearchParams();
|
||||
if (gameVersion)
|
||||
params.set('game_versions', JSON.stringify([gameVersion]));
|
||||
if (loader)
|
||||
params.set('loaders', JSON.stringify([loader]));
|
||||
const response = await net.fetch(`${MODRINTH_API}/project/${projectId}/version?${params.toString()}`);
|
||||
if (!response.ok)
|
||||
throw new Error(`Modrinth versions failed: ${response.status}`);
|
||||
const data = await response.json();
|
||||
return data.map((v) => ({
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
versionNumber: v.version_number,
|
||||
gameVersions: v.game_versions,
|
||||
loaders: v.loaders,
|
||||
files: v.files.map((f) => ({
|
||||
filename: f.filename,
|
||||
url: f.url,
|
||||
size: f.size,
|
||||
primary: f.primary,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
throw new Error(`CurseForge versions not implemented`);
|
||||
}
|
||||
async function installMod(projectId, versionId, gamePath, source = 'modrinth') {
|
||||
const versions = await getModVersions(projectId, undefined, undefined, source);
|
||||
const targetVersion = versionId
|
||||
? versions.find((v) => v.id === versionId)
|
||||
: versions[0];
|
||||
if (!targetVersion)
|
||||
throw new Error('No version found');
|
||||
const primaryFile = targetVersion.files.find((f) => f.primary) || targetVersion.files[0];
|
||||
if (!primaryFile)
|
||||
throw new Error('No file found');
|
||||
// Download the mod file
|
||||
const modsDir = path.join(gamePath, 'mods');
|
||||
if (!fs.existsSync(modsDir)) {
|
||||
fs.mkdirSync(modsDir, { recursive: true });
|
||||
}
|
||||
const filePath = path.join(modsDir, primaryFile.filename);
|
||||
const response = await net.fetch(primaryFile.url);
|
||||
if (!response.ok)
|
||||
throw new Error(`Download failed: ${response.status}`);
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
return {
|
||||
projectId,
|
||||
versionId: targetVersion.id,
|
||||
filename: primaryFile.filename,
|
||||
path: filePath,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import electron from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const { net } = electron;
|
||||
|
||||
const MODRINTH_API = 'https://api.modrinth.com/v2';
|
||||
const CURSEFORGE_API = 'https://api.curseforge.com/v1';
|
||||
|
||||
interface ModSearchResult {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
downloads: number;
|
||||
iconUrl?: string;
|
||||
categories?: string[];
|
||||
versions?: string[];
|
||||
loaders?: string[];
|
||||
source: 'modrinth' | 'curseforge';
|
||||
}
|
||||
|
||||
interface ModVersionResult {
|
||||
id: string;
|
||||
name: string;
|
||||
versionNumber: string;
|
||||
gameVersions: string[];
|
||||
loaders: string[];
|
||||
files: {
|
||||
filename: string;
|
||||
url: string;
|
||||
size: number;
|
||||
primary: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
async function searchModrinth(
|
||||
query?: string,
|
||||
gameVersion?: string,
|
||||
loader?: string,
|
||||
limit: number = 20,
|
||||
offset: number = 0
|
||||
): Promise<ModSearchResult[]> {
|
||||
const facets: string[][] = [];
|
||||
if (gameVersion) facets.push([`versions:${gameVersion}`]);
|
||||
if (loader) facets.push([`categories:${loader}`]);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
query: query || '',
|
||||
limit: String(limit),
|
||||
offset: String(offset),
|
||||
});
|
||||
|
||||
if (facets.length > 0) {
|
||||
params.set('facets', JSON.stringify(facets));
|
||||
}
|
||||
|
||||
const response = await net.fetch(`${MODRINTH_API}/search?${params.toString()}`);
|
||||
if (!response.ok) throw new Error(`Modrinth search failed: ${response.status}`);
|
||||
|
||||
const data = await response.json() as { hits: Array<{
|
||||
project_id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
downloads: number;
|
||||
icon_url?: string;
|
||||
categories?: string[];
|
||||
versions?: string[];
|
||||
client_side?: string;
|
||||
server_side?: string;
|
||||
}> };
|
||||
|
||||
return data.hits.map((hit) => ({
|
||||
id: hit.project_id,
|
||||
slug: hit.slug,
|
||||
name: hit.title,
|
||||
description: hit.description,
|
||||
downloads: hit.downloads,
|
||||
iconUrl: hit.icon_url,
|
||||
categories: hit.categories,
|
||||
versions: hit.versions,
|
||||
source: 'modrinth' as const,
|
||||
}));
|
||||
}
|
||||
|
||||
async function searchCurseForge(
|
||||
query?: string,
|
||||
gameVersion?: string,
|
||||
loader?: string,
|
||||
limit: number = 20,
|
||||
offset: number = 0
|
||||
): Promise<ModSearchResult[]> {
|
||||
// CurseForge requires API key - return empty for now
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function searchMods(
|
||||
query?: string,
|
||||
gameVersion?: string,
|
||||
loader?: string,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
source: 'modrinth' | 'curseforge' = 'modrinth'
|
||||
): Promise<ModSearchResult[]> {
|
||||
if (source === 'modrinth') {
|
||||
return searchModrinth(query, gameVersion, loader, limit, offset);
|
||||
}
|
||||
return searchCurseForge(query, gameVersion, loader, limit, offset);
|
||||
}
|
||||
|
||||
export async function getModDetail(
|
||||
projectId: string,
|
||||
source: 'modrinth' | 'curseforge' = 'modrinth'
|
||||
): Promise<ModSearchResult> {
|
||||
if (source === 'modrinth') {
|
||||
const response = await net.fetch(`${MODRINTH_API}/project/${projectId}`);
|
||||
if (!response.ok) throw new Error(`Modrinth detail failed: ${response.status}`);
|
||||
|
||||
const data = await response.json() as {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
downloads: number;
|
||||
icon_url?: string;
|
||||
categories?: string[];
|
||||
versions?: string[];
|
||||
};
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
slug: data.slug,
|
||||
name: data.title,
|
||||
description: data.description,
|
||||
downloads: data.downloads,
|
||||
iconUrl: data.icon_url,
|
||||
categories: data.categories,
|
||||
versions: data.versions,
|
||||
source: 'modrinth',
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`CurseForge detail not implemented`);
|
||||
}
|
||||
|
||||
export async function getModVersions(
|
||||
projectId: string,
|
||||
gameVersion?: string,
|
||||
loader?: string,
|
||||
source: 'modrinth' | 'curseforge' = 'modrinth'
|
||||
): Promise<ModVersionResult[]> {
|
||||
if (source === 'modrinth') {
|
||||
const params = new URLSearchParams();
|
||||
if (gameVersion) params.set('game_versions', JSON.stringify([gameVersion]));
|
||||
if (loader) params.set('loaders', JSON.stringify([loader]));
|
||||
|
||||
const response = await net.fetch(`${MODRINTH_API}/project/${projectId}/version?${params.toString()}`);
|
||||
if (!response.ok) throw new Error(`Modrinth versions failed: ${response.status}`);
|
||||
|
||||
const data = await response.json() as Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
version_number: string;
|
||||
game_versions: string[];
|
||||
loaders: string[];
|
||||
files: Array<{
|
||||
filename: string;
|
||||
url: string;
|
||||
size: number;
|
||||
primary: boolean;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
return data.map((v) => ({
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
versionNumber: v.version_number,
|
||||
gameVersions: v.game_versions,
|
||||
loaders: v.loaders,
|
||||
files: v.files.map((f) => ({
|
||||
filename: f.filename,
|
||||
url: f.url,
|
||||
size: f.size,
|
||||
primary: f.primary,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
throw new Error(`CurseForge versions not implemented`);
|
||||
}
|
||||
|
||||
export async function installMod(
|
||||
projectId: string,
|
||||
versionId: string | undefined,
|
||||
gamePath: string,
|
||||
source: 'modrinth' | 'curseforge' = 'modrinth'
|
||||
): Promise<{ projectId: string; versionId: string; filename: string; path: string }> {
|
||||
const versions = await getModVersions(projectId, undefined, undefined, source);
|
||||
const targetVersion = versionId
|
||||
? versions.find((v) => v.id === versionId)
|
||||
: versions[0];
|
||||
|
||||
if (!targetVersion) throw new Error('No version found');
|
||||
|
||||
const primaryFile = targetVersion.files.find((f) => f.primary) || targetVersion.files[0];
|
||||
if (!primaryFile) throw new Error('No file found');
|
||||
|
||||
// Download the mod file
|
||||
const modsDir = path.join(gamePath, 'mods');
|
||||
if (!fs.existsSync(modsDir)) {
|
||||
fs.mkdirSync(modsDir, { recursive: true });
|
||||
}
|
||||
|
||||
const filePath = path.join(modsDir, primaryFile.filename);
|
||||
const response = await net.fetch(primaryFile.url);
|
||||
if (!response.ok) throw new Error(`Download failed: ${response.status}`);
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
|
||||
return {
|
||||
projectId,
|
||||
versionId: targetVersion.id,
|
||||
filename: primaryFile.filename,
|
||||
path: filePath,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.registerAuthHandlers = registerAuthHandlers;
|
||||
const electron_1 = __importDefault(require("electron"));
|
||||
const { ipcMain } = electron_1.default;
|
||||
const auth_1 = require("../auth");
|
||||
function registerAuthHandlers() {
|
||||
ipcMain.handle('auth:get', () => {
|
||||
try {
|
||||
const auth = (0, auth_1.readAuth)();
|
||||
return { success: true, data: auth, error: null };
|
||||
}
|
||||
catch (e) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
ipcMain.handle('auth:save', (_event, auth) => {
|
||||
try {
|
||||
(0, auth_1.writeAuth)(auth);
|
||||
return { success: true, data: null, error: null };
|
||||
}
|
||||
catch (e) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
ipcMain.handle('auth:delete', () => {
|
||||
try {
|
||||
(0, auth_1.deleteAuth)();
|
||||
return { success: true, data: null, error: null };
|
||||
}
|
||||
catch (e) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import electron from 'electron';
|
||||
const { ipcMain } = electron;
|
||||
import { readAuth, writeAuth, deleteAuth } from '../auth';
|
||||
|
||||
export function registerAuthHandlers() {
|
||||
ipcMain.handle('auth:get', () => {
|
||||
try {
|
||||
const auth = readAuth();
|
||||
return { success: true, data: auth, error: null };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('auth:save', (_event, auth) => {
|
||||
try {
|
||||
writeAuth(auth);
|
||||
return { success: true, data: null, error: null };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('auth:delete', () => {
|
||||
try {
|
||||
deleteAuth();
|
||||
return { success: true, data: null, error: null };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.registerBackgroundHandlers = registerBackgroundHandlers;
|
||||
const electron_1 = __importDefault(require("electron"));
|
||||
const config_1 = require("../config");
|
||||
const { ipcMain } = electron_1.default;
|
||||
function registerBackgroundHandlers() {
|
||||
ipcMain.handle('background:set-image', async (_event, payload) => {
|
||||
try {
|
||||
const config = (0, config_1.loadConfig)();
|
||||
config.background.bgType = 'image';
|
||||
config.background.image = payload.url;
|
||||
if (payload.blur !== undefined)
|
||||
config.background.blur = payload.blur;
|
||||
if (payload.opacity !== undefined)
|
||||
config.background.opacity = payload.opacity;
|
||||
(0, config_1.saveConfig)(config);
|
||||
return { success: true, data: config.background, error: null };
|
||||
}
|
||||
catch (e) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
ipcMain.handle('background:set-color', async (_event, payload) => {
|
||||
try {
|
||||
const config = (0, config_1.loadConfig)();
|
||||
config.background.bgType = 'color';
|
||||
config.background.image = payload.color;
|
||||
(0, config_1.saveConfig)(config);
|
||||
return { success: true, data: config.background, error: null };
|
||||
}
|
||||
catch (e) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
ipcMain.handle('background:set-blur', async (_event, payload) => {
|
||||
try {
|
||||
const config = (0, config_1.loadConfig)();
|
||||
config.background.blur = payload.blur;
|
||||
(0, config_1.saveConfig)(config);
|
||||
return { success: true, data: config.background, error: null };
|
||||
}
|
||||
catch (e) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
ipcMain.handle('background:set-opacity', async (_event, payload) => {
|
||||
try {
|
||||
const config = (0, config_1.loadConfig)();
|
||||
config.background.opacity = payload.opacity;
|
||||
(0, config_1.saveConfig)(config);
|
||||
return { success: true, data: config.background, error: null };
|
||||
}
|
||||
catch (e) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
ipcMain.handle('background:set-animation', async (_event, payload) => {
|
||||
try {
|
||||
// Animation config is not persisted in current design, return defaults
|
||||
return { success: true, data: { bgType: 'image', image: '/background.png', blur: 0, opacity: 100 }, error: null };
|
||||
}
|
||||
catch (e) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
ipcMain.handle('background:get', async () => {
|
||||
try {
|
||||
const config = (0, config_1.loadConfig)();
|
||||
return { success: true, data: config.background, error: null };
|
||||
}
|
||||
catch (e) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
ipcMain.handle('background:set-theme', async (_event, payload) => {
|
||||
try {
|
||||
const config = (0, config_1.loadConfig)();
|
||||
config.theme.darkMode = payload.theme;
|
||||
(0, config_1.saveConfig)(config);
|
||||
return { success: true, data: config.background, error: null };
|
||||
}
|
||||
catch (e) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
ipcMain.handle('background:reset', async () => {
|
||||
try {
|
||||
const config = (0, config_1.loadConfig)();
|
||||
config.background = { bgType: 'image', image: '/background.png', blur: 0, opacity: 100 };
|
||||
(0, config_1.saveConfig)(config);
|
||||
return { success: true, data: config.background, error: null };
|
||||
}
|
||||
catch (e) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import electron from 'electron';
|
||||
import { loadConfig, saveConfig } from '../config';
|
||||
|
||||
const { ipcMain } = electron;
|
||||
|
||||
export function registerBackgroundHandlers() {
|
||||
ipcMain.handle('background:set-image', async (_event, payload: { url: string; blur?: number; opacity?: number }) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
config.background.bgType = 'image';
|
||||
config.background.image = payload.url;
|
||||
if (payload.blur !== undefined) config.background.blur = payload.blur;
|
||||
if (payload.opacity !== undefined) config.background.opacity = payload.opacity;
|
||||
saveConfig(config);
|
||||
return { success: true, data: config.background, error: null };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('background:set-color', async (_event, payload: { color: string }) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
config.background.bgType = 'color';
|
||||
config.background.image = payload.color;
|
||||
saveConfig(config);
|
||||
return { success: true, data: config.background, error: null };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('background:set-blur', async (_event, payload: { blur: number }) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
config.background.blur = payload.blur;
|
||||
saveConfig(config);
|
||||
return { success: true, data: config.background, error: null };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('background:set-opacity', async (_event, payload: { opacity: number }) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
config.background.opacity = payload.opacity;
|
||||
saveConfig(config);
|
||||
return { success: true, data: config.background, error: null };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('background:set-animation', async (_event, payload: { type: string; speed?: number }) => {
|
||||
try {
|
||||
// Animation config is not persisted in current design, return defaults
|
||||
return { success: true, data: { bgType: 'image', image: '/background.png', blur: 0, opacity: 100 }, error: null };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('background:get', async () => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
return { success: true, data: config.background, error: null };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('background:set-theme', async (_event, payload: { theme: string }) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
config.theme.darkMode = payload.theme;
|
||||
saveConfig(config);
|
||||
return { success: true, data: config.background, error: null };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('background:reset', async () => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
config.background = { bgType: 'image', image: '/background.png', blur: 0, opacity: 100 };
|
||||
saveConfig(config);
|
||||
return { success: true, data: config.background, error: null };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.registerConfigHandlers = registerConfigHandlers;
|
||||
const electron_1 = __importDefault(require("electron"));
|
||||
const { ipcMain } = electron_1.default;
|
||||
const config_1 = require("../config");
|
||||
function registerConfigHandlers() {
|
||||
ipcMain.handle('config:get', () => {
|
||||
try {
|
||||
const config = (0, config_1.loadConfig)();
|
||||
return { success: true, data: config, error: null };
|
||||
}
|
||||
catch (e) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
ipcMain.handle('config:save', (_event, config) => {
|
||||
try {
|
||||
(0, config_1.saveConfig)(config);
|
||||
return { success: true, data: null, error: null };
|
||||
}
|
||||
catch (e) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import electron from 'electron';
|
||||
const { ipcMain } = electron;
|
||||
import { loadConfig, saveConfig, type AppConfig } from '../config';
|
||||
|
||||
export function registerConfigHandlers() {
|
||||
ipcMain.handle('config:get', () => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
return { success: true, data: config, error: null };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('config:save', (_event, config: AppConfig) => {
|
||||
try {
|
||||
saveConfig(config);
|
||||
return { success: true, data: null, error: null };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.registerInstallHandlers = registerInstallHandlers;
|
||||
const electron_1 = __importDefault(require("electron"));
|
||||
const installer_1 = require("../core/installer");
|
||||
const { ipcMain } = electron_1.default;
|
||||
function registerInstallHandlers(win) {
|
||||
ipcMain.handle('install:version-list', async (_event, payload) => {
|
||||
try {
|
||||
const data = await (0, installer_1.getVersionList)(payload.type);
|
||||
return { success: true, data, error: null };
|
||||
}
|
||||
catch (e) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
ipcMain.handle('install:forge-version-list', async (_event, payload) => {
|
||||
try {
|
||||
const data = await (0, installer_1.getForgeVersions)(payload.mcVersion);
|
||||
return { success: true, data, error: null };
|
||||
}
|
||||
catch (e) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
ipcMain.handle('install:fabric-version-list', async (_event, payload) => {
|
||||
try {
|
||||
const data = await (0, installer_1.getFabricVersions)(payload.mcVersion);
|
||||
return { success: true, data, error: null };
|
||||
}
|
||||
catch (e) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
ipcMain.handle('install:minecraft', async (_event, payload) => {
|
||||
try {
|
||||
const requestId = `install-mc-${Date.now()}`;
|
||||
(0, installer_1.installMinecraft)(payload.version, payload.gamePath, payload.javaPath, payload.downloadThreads, {
|
||||
onProgress: (progress) => {
|
||||
win.mainWindow?.webContents.send('install:progress', { requestId, ...progress });
|
||||
},
|
||||
}).then((result) => {
|
||||
win.mainWindow?.webContents.send('install:complete', { requestId, ...result });
|
||||
}).catch((err) => {
|
||||
win.mainWindow?.webContents.send('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('install:mod-loader', async (_event, payload) => {
|
||||
try {
|
||||
const requestId = `install-loader-${Date.now()}`;
|
||||
(0, installer_1.installModLoader)(payload.mcVersion, payload.gamePath, payload.loaderType, payload.loaderVersion, payload.javaPath, {
|
||||
onProgress: (progress) => {
|
||||
win.mainWindow?.webContents.send('install:progress', { requestId, ...progress });
|
||||
},
|
||||
}).then((result) => {
|
||||
win.mainWindow?.webContents.send('install:complete', { requestId, ...result });
|
||||
}).catch((err) => {
|
||||
win.mainWindow?.webContents.send('install:error', { requestId, error: String(err) });
|
||||
});
|
||||
return { success: true, data: { requestId }, error: null };
|
||||
}
|
||||
catch (e) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import electron from 'electron';
|
||||
import { installMinecraft, installModLoader, getVersionList, getForgeVersions, getFabricVersions } from '../core/installer';
|
||||
|
||||
const { ipcMain } = electron;
|
||||
|
||||
interface WinRef {
|
||||
mainWindow: electron.BrowserWindow | null;
|
||||
}
|
||||
|
||||
export function registerInstallHandlers(win: WinRef) {
|
||||
ipcMain.handle('install:version-list', async (_event, payload: { type?: string }) => {
|
||||
try {
|
||||
const data = await getVersionList(payload.type);
|
||||
return { success: true, data, error: null };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('install:forge-version-list', async (_event, payload: { mcVersion?: string }) => {
|
||||
try {
|
||||
const data = await getForgeVersions(payload.mcVersion);
|
||||
return { success: true, data, error: null };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('install:fabric-version-list', async (_event, payload: { mcVersion?: string }) => {
|
||||
try {
|
||||
const data = await getFabricVersions(payload.mcVersion);
|
||||
return { success: true, data, error: null };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('install:minecraft', async (_event, payload: {
|
||||
version: string;
|
||||
gamePath: string;
|
||||
javaPath?: string;
|
||||
downloadThreads?: number;
|
||||
}) => {
|
||||
try {
|
||||
const requestId = `install-mc-${Date.now()}`;
|
||||
|
||||
installMinecraft(payload.version, payload.gamePath, payload.javaPath, payload.downloadThreads, {
|
||||
onProgress: (progress) => {
|
||||
win.mainWindow?.webContents.send('install:progress', { requestId, ...progress });
|
||||
},
|
||||
}).then((result) => {
|
||||
win.mainWindow?.webContents.send('install:complete', { requestId, ...result });
|
||||
}).catch((err) => {
|
||||
win.mainWindow?.webContents.send('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('install:mod-loader', async (_event, payload: {
|
||||
mcVersion: string;
|
||||
gamePath: string;
|
||||
loaderType: string;
|
||||
loaderVersion?: string;
|
||||
javaPath?: string;
|
||||
}) => {
|
||||
try {
|
||||
const requestId = `install-loader-${Date.now()}`;
|
||||
|
||||
installModLoader(payload.mcVersion, payload.gamePath, payload.loaderType as 'forge' | 'fabric' | 'quilt' | 'neoforge', payload.loaderVersion, payload.javaPath, {
|
||||
onProgress: (progress) => {
|
||||
win.mainWindow?.webContents.send('install:progress', { requestId, ...progress });
|
||||
},
|
||||
}).then((result) => {
|
||||
win.mainWindow?.webContents.send('install:complete', { requestId, ...result });
|
||||
}).catch((err) => {
|
||||
win.mainWindow?.webContents.send('install:error', { requestId, error: String(err) });
|
||||
});
|
||||
|
||||
return { success: true, data: { requestId }, error: null };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.registerInstanceHandlers = registerInstanceHandlers;
|
||||
const electron_1 = __importDefault(require("electron"));
|
||||
const instance_1 = require("../core/instance");
|
||||
const { ipcMain } = electron_1.default;
|
||||
function registerInstanceHandlers() {
|
||||
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);
|
||||
return { success: true, data, error: null };
|
||||
}
|
||||
catch (e) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
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);
|
||||
return { success: true, data, error: null };
|
||||
}
|
||||
catch (e) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
ipcMain.handle('instance:info', async (_event, payload) => {
|
||||
try {
|
||||
const data = await (0, instance_1.getInstanceInfo)(payload.name, payload.instancesPath);
|
||||
return { success: true, data, error: null };
|
||||
}
|
||||
catch (e) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import electron from 'electron';
|
||||
import { createInstance, listInstances, deleteInstance, getInstanceInfo } from '../core/instance';
|
||||
|
||||
const { ipcMain } = electron;
|
||||
|
||||
export function registerInstanceHandlers() {
|
||||
ipcMain.handle('instance:create', async (_event, payload: {
|
||||
name: string;
|
||||
gamePath: string;
|
||||
mcVersion: string;
|
||||
loaderType?: string;
|
||||
loaderVersion?: string;
|
||||
javaPath?: string;
|
||||
memory?: { min?: string; max?: string };
|
||||
}) => {
|
||||
try {
|
||||
const data = await createInstance(
|
||||
payload.name,
|
||||
payload.gamePath,
|
||||
payload.mcVersion,
|
||||
payload.loaderType,
|
||||
payload.loaderVersion,
|
||||
payload.javaPath,
|
||||
payload.memory
|
||||
);
|
||||
return { success: true, data, error: null };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('instance:list', async (_event, payload: { instancesPath: string }) => {
|
||||
try {
|
||||
const data = await listInstances(payload.instancesPath);
|
||||
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 }) => {
|
||||
try {
|
||||
const data = await deleteInstance(payload.name, payload.instancesPath);
|
||||
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 }) => {
|
||||
try {
|
||||
const data = await getInstanceInfo(payload.name, payload.instancesPath);
|
||||
return { success: true, data, error: null };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.registerLaunchHandlers = registerLaunchHandlers;
|
||||
const electron_1 = __importDefault(require("electron"));
|
||||
const launcher_1 = require("../core/launcher");
|
||||
const { ipcMain } = electron_1.default;
|
||||
function registerLaunchHandlers(win) {
|
||||
ipcMain.handle('launch:launch', async (_event, payload) => {
|
||||
try {
|
||||
const requestId = `launch-${Date.now()}`;
|
||||
const result = await (0, launcher_1.launchMinecraft)({
|
||||
gamePath: payload.gamePath,
|
||||
javaPath: payload.javaPath,
|
||||
version: payload.version,
|
||||
username: payload.username,
|
||||
uuid: payload.uuid,
|
||||
accessToken: payload.accessToken,
|
||||
memory: payload.memory,
|
||||
jvmArgs: payload.jvmArgs,
|
||||
gameArgs: payload.gameArgs,
|
||||
server: payload.server,
|
||||
detached: payload.detached,
|
||||
onEvent: (event) => {
|
||||
win.mainWindow?.webContents.send('launch:event', { requestId, ...event });
|
||||
},
|
||||
});
|
||||
return { success: true, data: { ...result, requestId }, error: null };
|
||||
}
|
||||
catch (e) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
ipcMain.handle('launch:diagnose', async (_event, payload) => {
|
||||
try {
|
||||
const data = await (0, launcher_1.diagnoseVersion)(payload.gamePath, payload.version);
|
||||
return { success: true, data, error: null };
|
||||
}
|
||||
catch (e) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import electron from 'electron';
|
||||
import { launchMinecraft, diagnoseVersion } from '../core/launcher';
|
||||
|
||||
const { ipcMain } = electron;
|
||||
|
||||
interface WinRef {
|
||||
mainWindow: electron.BrowserWindow | null;
|
||||
}
|
||||
|
||||
export function registerLaunchHandlers(win: WinRef) {
|
||||
ipcMain.handle('launch:launch', async (_event, payload: {
|
||||
gamePath: string;
|
||||
javaPath: string;
|
||||
version: string;
|
||||
username: string;
|
||||
uuid: string;
|
||||
accessToken?: string;
|
||||
memory?: { min?: string; max?: string };
|
||||
jvmArgs?: string[];
|
||||
gameArgs?: string[];
|
||||
server?: { ip: string; port?: number };
|
||||
detached?: boolean;
|
||||
}) => {
|
||||
try {
|
||||
const requestId = `launch-${Date.now()}`;
|
||||
|
||||
const result = await launchMinecraft({
|
||||
gamePath: payload.gamePath,
|
||||
javaPath: payload.javaPath,
|
||||
version: payload.version,
|
||||
username: payload.username,
|
||||
uuid: payload.uuid,
|
||||
accessToken: payload.accessToken,
|
||||
memory: payload.memory,
|
||||
jvmArgs: payload.jvmArgs,
|
||||
gameArgs: payload.gameArgs,
|
||||
server: payload.server,
|
||||
detached: payload.detached,
|
||||
onEvent: (event) => {
|
||||
win.mainWindow?.webContents.send('launch:event', { requestId, ...event });
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, data: { ...result, requestId }, error: null };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('launch:diagnose', async (_event, payload: { gamePath: string; version: string }) => {
|
||||
try {
|
||||
const data = await diagnoseVersion(payload.gamePath, payload.version);
|
||||
return { success: true, data, error: null };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.registerModsHandlers = registerModsHandlers;
|
||||
const electron_1 = __importDefault(require("electron"));
|
||||
const modrinth_1 = require("../core/modrinth");
|
||||
const { ipcMain } = electron_1.default;
|
||||
function registerModsHandlers() {
|
||||
ipcMain.handle('mods:search', async (_event, payload) => {
|
||||
try {
|
||||
const data = await (0, modrinth_1.searchMods)(payload.query, payload.gameVersion, payload.loader, payload.limit, payload.offset, payload.source);
|
||||
return { success: true, data, error: null };
|
||||
}
|
||||
catch (e) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
ipcMain.handle('mods:detail', async (_event, payload) => {
|
||||
try {
|
||||
const data = await (0, modrinth_1.getModDetail)(payload.projectId, payload.source);
|
||||
return { success: true, data, error: null };
|
||||
}
|
||||
catch (e) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
ipcMain.handle('mods:versions', async (_event, payload) => {
|
||||
try {
|
||||
const data = await (0, modrinth_1.getModVersions)(payload.projectId, payload.gameVersion, payload.loader, payload.source);
|
||||
return { success: true, data, error: null };
|
||||
}
|
||||
catch (e) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
ipcMain.handle('mods:install', async (_event, payload) => {
|
||||
try {
|
||||
const data = await (0, modrinth_1.installMod)(payload.projectId, payload.versionId, payload.gamePath, payload.source);
|
||||
return { success: true, data, error: null };
|
||||
}
|
||||
catch (e) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import electron from 'electron';
|
||||
import { searchMods, getModDetail, getModVersions, installMod } from '../core/modrinth';
|
||||
|
||||
const { ipcMain } = electron;
|
||||
|
||||
export function registerModsHandlers() {
|
||||
ipcMain.handle('mods:search', async (_event, payload: {
|
||||
query?: string;
|
||||
gameVersion?: string;
|
||||
loader?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
source: string;
|
||||
}) => {
|
||||
try {
|
||||
const data = await searchMods(
|
||||
payload.query,
|
||||
payload.gameVersion,
|
||||
payload.loader,
|
||||
payload.limit,
|
||||
payload.offset,
|
||||
payload.source as 'modrinth' | 'curseforge'
|
||||
);
|
||||
return { success: true, data, error: null };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('mods:detail', async (_event, payload: { projectId: string; source: string }) => {
|
||||
try {
|
||||
const data = await getModDetail(
|
||||
payload.projectId,
|
||||
payload.source as 'modrinth' | 'curseforge'
|
||||
);
|
||||
return { success: true, data, error: null };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('mods:versions', async (_event, payload: {
|
||||
projectId: string;
|
||||
gameVersion?: string;
|
||||
loader?: string;
|
||||
source: string;
|
||||
}) => {
|
||||
try {
|
||||
const data = await getModVersions(
|
||||
payload.projectId,
|
||||
payload.gameVersion,
|
||||
payload.loader,
|
||||
payload.source as 'modrinth' | 'curseforge'
|
||||
);
|
||||
return { success: true, data, error: null };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('mods:install', async (_event, payload: {
|
||||
projectId: string;
|
||||
versionId?: string;
|
||||
gamePath: string;
|
||||
source: string;
|
||||
}) => {
|
||||
try {
|
||||
const data = await installMod(
|
||||
payload.projectId,
|
||||
payload.versionId,
|
||||
payload.gamePath,
|
||||
payload.source as 'modrinth' | 'curseforge'
|
||||
);
|
||||
return { success: true, data, error: null };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.registerSystemHandlers = registerSystemHandlers;
|
||||
const electron_1 = __importDefault(require("electron"));
|
||||
const child_process_1 = require("child_process");
|
||||
const { ipcMain, app } = electron_1.default;
|
||||
function getBiosId() {
|
||||
if (process.platform !== 'win32')
|
||||
return 'N/A (non-Windows)';
|
||||
try {
|
||||
const output = (0, child_process_1.execSync)('powershell -NoProfile -Command "(Get-CimInstance Win32_BIOS).SerialNumber"', {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
});
|
||||
return output.trim() || 'Unknown';
|
||||
}
|
||||
catch {
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
function getOsVersion() {
|
||||
if (process.platform !== 'win32')
|
||||
return 'N/A (non-Windows)';
|
||||
try {
|
||||
const output = (0, child_process_1.execSync)('powershell -NoProfile -Command "[System.Environment]::OSVersion.VersionString"', {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
});
|
||||
return output.trim() || 'Unknown';
|
||||
}
|
||||
catch {
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
function getOsName() {
|
||||
if (process.platform !== 'win32')
|
||||
return 'N/A (non-Windows)';
|
||||
try {
|
||||
const output = (0, child_process_1.execSync)('powershell -NoProfile -Command "(Get-CimInstance Win32_OperatingSystem).Caption"', {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
});
|
||||
return output.trim() || 'Unknown';
|
||||
}
|
||||
catch {
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
function registerSystemHandlers() {
|
||||
ipcMain.handle('system:info', () => {
|
||||
try {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
app_version: app.getVersion(),
|
||||
bios_id: getBiosId(),
|
||||
os_version: getOsVersion(),
|
||||
os_name: getOsName(),
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
catch (e) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import electron from 'electron';
|
||||
import { execSync } from 'child_process';
|
||||
import * as os from 'os';
|
||||
|
||||
const { ipcMain, app } = electron;
|
||||
|
||||
function getBiosId(): string {
|
||||
if (process.platform !== 'win32') return 'N/A (non-Windows)';
|
||||
try {
|
||||
const output = execSync('powershell -NoProfile -Command "(Get-CimInstance Win32_BIOS).SerialNumber"', {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
});
|
||||
return output.trim() || 'Unknown';
|
||||
} catch {
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function getOsVersion(): string {
|
||||
if (process.platform !== 'win32') return 'N/A (non-Windows)';
|
||||
try {
|
||||
const output = execSync('powershell -NoProfile -Command "[System.Environment]::OSVersion.VersionString"', {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
});
|
||||
return output.trim() || 'Unknown';
|
||||
} catch {
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function getOsName(): string {
|
||||
if (process.platform !== 'win32') return 'N/A (non-Windows)';
|
||||
try {
|
||||
const output = execSync('powershell -NoProfile -Command "(Get-CimInstance Win32_OperatingSystem).Caption"', {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
});
|
||||
return output.trim() || 'Unknown';
|
||||
} catch {
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
export function registerSystemHandlers() {
|
||||
ipcMain.handle('system:info', () => {
|
||||
try {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
app_version: app.getVersion(),
|
||||
bios_id: getBiosId(),
|
||||
os_version: getOsVersion(),
|
||||
os_name: getOsName(),
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.registerTaskHandlers = registerTaskHandlers;
|
||||
const electron_1 = __importDefault(require("electron"));
|
||||
const { ipcMain } = electron_1.default;
|
||||
const runningTasks = new Map();
|
||||
function registerTaskHandlers(win) {
|
||||
ipcMain.handle('task:start', async (_event, payload) => {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
runningTasks.set(payload.taskId, controller);
|
||||
win.mainWindow?.webContents.send('task:started', {
|
||||
taskId: payload.taskId,
|
||||
xmclPath: payload.executorName,
|
||||
});
|
||||
// Simulate task execution
|
||||
const steps = 20;
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
if (controller.signal.aborted) {
|
||||
win.mainWindow?.webContents.send('task:failed', {
|
||||
taskId: payload.taskId,
|
||||
error: 'Task cancelled',
|
||||
});
|
||||
runningTasks.delete(payload.taskId);
|
||||
return { success: true, data: null, error: null };
|
||||
}
|
||||
win.mainWindow?.webContents.send('task:progress', {
|
||||
taskId: payload.taskId,
|
||||
current: i,
|
||||
total: steps,
|
||||
stage: `步骤 ${i}/${steps}`,
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
}
|
||||
runningTasks.delete(payload.taskId);
|
||||
win.mainWindow?.webContents.send('task:completed', {
|
||||
taskId: payload.taskId,
|
||||
});
|
||||
return { success: true, data: null, error: null };
|
||||
}
|
||||
catch (e) {
|
||||
runningTasks.delete(payload.taskId);
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
ipcMain.handle('task:cancel', async (_event, payload) => {
|
||||
try {
|
||||
const controller = runningTasks.get(payload.taskId);
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
}
|
||||
return { success: true, data: null, error: null };
|
||||
}
|
||||
catch (e) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import electron from 'electron';
|
||||
|
||||
const { ipcMain } = electron;
|
||||
|
||||
const runningTasks = new Map<string, AbortController>();
|
||||
|
||||
interface WinRef {
|
||||
mainWindow: electron.BrowserWindow | null;
|
||||
}
|
||||
|
||||
export function registerTaskHandlers(win: WinRef) {
|
||||
ipcMain.handle('task:start', async (_event, payload: {
|
||||
taskId: string;
|
||||
type: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
executorName: string;
|
||||
params?: Record<string, unknown>;
|
||||
}) => {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
runningTasks.set(payload.taskId, controller);
|
||||
|
||||
win.mainWindow?.webContents.send('task:started', {
|
||||
taskId: payload.taskId,
|
||||
xmclPath: payload.executorName,
|
||||
});
|
||||
|
||||
// Simulate task execution
|
||||
const steps = 20;
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
if (controller.signal.aborted) {
|
||||
win.mainWindow?.webContents.send('task:failed', {
|
||||
taskId: payload.taskId,
|
||||
error: 'Task cancelled',
|
||||
});
|
||||
runningTasks.delete(payload.taskId);
|
||||
return { success: true, data: null, error: null };
|
||||
}
|
||||
|
||||
win.mainWindow?.webContents.send('task:progress', {
|
||||
taskId: payload.taskId,
|
||||
current: i,
|
||||
total: steps,
|
||||
stage: `步骤 ${i}/${steps}`,
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
}
|
||||
|
||||
runningTasks.delete(payload.taskId);
|
||||
win.mainWindow?.webContents.send('task:completed', {
|
||||
taskId: payload.taskId,
|
||||
});
|
||||
|
||||
return { success: true, data: null, error: null };
|
||||
} catch (e: unknown) {
|
||||
runningTasks.delete(payload.taskId);
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('task:cancel', async (_event, payload: { taskId: string }) => {
|
||||
try {
|
||||
const controller = runningTasks.get(payload.taskId);
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
}
|
||||
return { success: true, data: null, error: null };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, data: null, error: String(e) };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
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 isDev = !electron_1.default.app.isPackaged;
|
||||
function createSplashWindow() {
|
||||
const splash = new electron_1.default.BrowserWindow({
|
||||
width: 480,
|
||||
height: 320,
|
||||
transparent: true,
|
||||
frame: false,
|
||||
resizable: false,
|
||||
skipTaskbar: true,
|
||||
alwaysOnTop: true,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
},
|
||||
});
|
||||
if (isDev) {
|
||||
splash.loadURL('http://localhost:1420/splash.html');
|
||||
}
|
||||
else {
|
||||
splash.loadFile(path_1.default.join(__dirname, '../dist/splash.html'));
|
||||
}
|
||||
return splash;
|
||||
}
|
||||
function registerWindowHandlers(win) {
|
||||
ipcMain.handle('window:minimize', () => {
|
||||
win.mainWindow?.minimize();
|
||||
});
|
||||
ipcMain.handle('window:maximize', () => {
|
||||
if (win.mainWindow?.isMaximized()) {
|
||||
win.mainWindow.unmaximize();
|
||||
}
|
||||
else {
|
||||
win.mainWindow?.maximize();
|
||||
}
|
||||
});
|
||||
ipcMain.handle('window:close', () => {
|
||||
win.mainWindow?.close();
|
||||
});
|
||||
ipcMain.handle('window:isMaximized', () => {
|
||||
return win.mainWindow?.isMaximized() ?? false;
|
||||
});
|
||||
ipcMain.handle('window:getTheme', () => {
|
||||
return win.mainWindow?.themeSource ?? null;
|
||||
});
|
||||
// Splash window management
|
||||
ipcMain.handle('window:openSplash', () => {
|
||||
if (win.splashWindow && !win.splashWindow.isDestroyed()) {
|
||||
win.splashWindow.focus();
|
||||
return { success: true };
|
||||
}
|
||||
win.splashWindow = createSplashWindow();
|
||||
return { success: true };
|
||||
});
|
||||
ipcMain.handle('window:closeSplash', () => {
|
||||
if (win.splashWindow && !win.splashWindow.isDestroyed()) {
|
||||
win.splashWindow.close();
|
||||
win.splashWindow = null;
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
// File dialog
|
||||
ipcMain.handle('dialog:openFile', async (_event, payload) => {
|
||||
const result = await dialog.showOpenDialog(win.mainWindow, {
|
||||
properties: ['openFile'],
|
||||
filters: payload.filters,
|
||||
});
|
||||
if (result.canceled || result.filePaths.length === 0)
|
||||
return null;
|
||||
return result.filePaths[0];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import electron from 'electron';
|
||||
import path from 'path';
|
||||
|
||||
const { ipcMain, dialog } = electron;
|
||||
|
||||
const isDev = !electron.app.isPackaged;
|
||||
|
||||
interface WinRef {
|
||||
mainWindow: electron.BrowserWindow | null;
|
||||
splashWindow: electron.BrowserWindow | null;
|
||||
}
|
||||
|
||||
function createSplashWindow(): electron.BrowserWindow {
|
||||
const splash = new electron.BrowserWindow({
|
||||
width: 480,
|
||||
height: 320,
|
||||
transparent: true,
|
||||
frame: false,
|
||||
resizable: false,
|
||||
skipTaskbar: true,
|
||||
alwaysOnTop: true,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (isDev) {
|
||||
splash.loadURL('http://localhost:1420/splash.html');
|
||||
} else {
|
||||
splash.loadFile(path.join(__dirname, '../dist/splash.html'));
|
||||
}
|
||||
|
||||
return splash;
|
||||
}
|
||||
|
||||
export function registerWindowHandlers(win: WinRef) {
|
||||
ipcMain.handle('window:minimize', () => {
|
||||
win.mainWindow?.minimize();
|
||||
});
|
||||
|
||||
ipcMain.handle('window:maximize', () => {
|
||||
if (win.mainWindow?.isMaximized()) {
|
||||
win.mainWindow.unmaximize();
|
||||
} else {
|
||||
win.mainWindow?.maximize();
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('window:close', () => {
|
||||
win.mainWindow?.close();
|
||||
});
|
||||
|
||||
ipcMain.handle('window:isMaximized', () => {
|
||||
return win.mainWindow?.isMaximized() ?? false;
|
||||
});
|
||||
|
||||
ipcMain.handle('window:getTheme', () => {
|
||||
return (win.mainWindow as any)?.themeSource ?? null;
|
||||
});
|
||||
|
||||
// Splash window management
|
||||
ipcMain.handle('window:openSplash', () => {
|
||||
if (win.splashWindow && !win.splashWindow.isDestroyed()) {
|
||||
win.splashWindow.focus();
|
||||
return { success: true };
|
||||
}
|
||||
win.splashWindow = createSplashWindow();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('window:closeSplash', () => {
|
||||
if (win.splashWindow && !win.splashWindow.isDestroyed()) {
|
||||
win.splashWindow.close();
|
||||
win.splashWindow = null;
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// File dialog
|
||||
ipcMain.handle('dialog:openFile', async (_event, payload: {
|
||||
filters?: { name: string; extensions: string[] }[];
|
||||
}) => {
|
||||
const result = await dialog.showOpenDialog(win.mainWindow!, {
|
||||
properties: ['openFile'],
|
||||
filters: payload.filters,
|
||||
});
|
||||
if (result.canceled || result.filePaths.length === 0) return null;
|
||||
return result.filePaths[0];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import electron from 'electron';
|
||||
import path from 'path';
|
||||
import { registerConfigHandlers } from './handlers/config';
|
||||
import { registerAuthHandlers } from './handlers/auth';
|
||||
import { registerInstallHandlers } from './handlers/install';
|
||||
import { registerLaunchHandlers } from './handlers/launch';
|
||||
import { registerModsHandlers } from './handlers/mods';
|
||||
import { registerInstanceHandlers } from './handlers/instance';
|
||||
import { registerBackgroundHandlers } from './handlers/background';
|
||||
import { registerTaskHandlers } from './handlers/task';
|
||||
import { registerSystemHandlers } from './handlers/system';
|
||||
import { registerWindowHandlers } from './handlers/window';
|
||||
|
||||
const { app } = electron;
|
||||
|
||||
const isDev = !app.isPackaged;
|
||||
|
||||
// Mutable ref — handlers always read from this
|
||||
const win: { mainWindow: electron.BrowserWindow | null; splashWindow: electron.BrowserWindow | null } = {
|
||||
mainWindow: null,
|
||||
splashWindow: null,
|
||||
};
|
||||
|
||||
function createSplashWindow(): electron.BrowserWindow {
|
||||
const splash = new electron.BrowserWindow({
|
||||
width: 480,
|
||||
height: 320,
|
||||
transparent: true,
|
||||
frame: false,
|
||||
resizable: false,
|
||||
skipTaskbar: true,
|
||||
alwaysOnTop: true,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (isDev) {
|
||||
splash.loadURL('http://localhost:1420/splash.html');
|
||||
} else {
|
||||
splash.loadFile(path.join(__dirname, '../dist/splash.html'));
|
||||
}
|
||||
|
||||
return splash;
|
||||
}
|
||||
|
||||
function createMainWindow(): electron.BrowserWindow {
|
||||
const main = new electron.BrowserWindow({
|
||||
width: 1000,
|
||||
height: 700,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
transparent: true,
|
||||
frame: false,
|
||||
resizable: true,
|
||||
show: false,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (isDev) {
|
||||
main.loadURL('http://localhost:1420');
|
||||
} else {
|
||||
main.loadFile(path.join(__dirname, '../dist/index.html'));
|
||||
}
|
||||
|
||||
main.on('maximize', () => {
|
||||
main.webContents.send('window:resized');
|
||||
});
|
||||
|
||||
main.on('unmaximize', () => {
|
||||
main.webContents.send('window:resized');
|
||||
});
|
||||
|
||||
return main;
|
||||
}
|
||||
|
||||
function registerAllHandlers() {
|
||||
registerConfigHandlers();
|
||||
registerAuthHandlers();
|
||||
registerInstallHandlers(win);
|
||||
registerLaunchHandlers(win);
|
||||
registerModsHandlers();
|
||||
registerInstanceHandlers();
|
||||
registerBackgroundHandlers();
|
||||
registerTaskHandlers(win);
|
||||
registerSystemHandlers();
|
||||
registerWindowHandlers(win);
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
registerAllHandlers();
|
||||
|
||||
// 1. Show splash immediately
|
||||
win.splashWindow = createSplashWindow();
|
||||
|
||||
// 2. Create main window in background
|
||||
win.mainWindow = createMainWindow();
|
||||
|
||||
// 3. When main window finishes loading, wait a minimum time then transition
|
||||
let mainReady = false;
|
||||
let splashMinTimeDone = false;
|
||||
|
||||
const tryTransition = () => {
|
||||
if (mainReady && splashMinTimeDone) {
|
||||
if (win.mainWindow && !win.mainWindow.isDestroyed()) {
|
||||
win.mainWindow.show();
|
||||
win.mainWindow.focus();
|
||||
}
|
||||
if (win.splashWindow && !win.splashWindow.isDestroyed()) {
|
||||
win.splashWindow.close();
|
||||
win.splashWindow = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
win.mainWindow.once('ready-to-show', () => {
|
||||
mainReady = true;
|
||||
tryTransition();
|
||||
});
|
||||
|
||||
// Minimum splash display time (1.5s)
|
||||
setTimeout(() => {
|
||||
splashMinTimeDone = true;
|
||||
tryTransition();
|
||||
}, 1500);
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
app.quit();
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
if (electron.BrowserWindow.getAllWindows().length === 0) {
|
||||
win.mainWindow = createMainWindow();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import electron from 'electron';
|
||||
const { contextBridge, ipcRenderer } = electron;
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// Generic IPC
|
||||
invoke: (channel: string, ...args: unknown[]) =>
|
||||
ipcRenderer.invoke(channel, ...args),
|
||||
|
||||
on: (channel: string, callback: (...args: unknown[]) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, ...args: unknown[]) => callback(...args);
|
||||
ipcRenderer.on(channel, handler);
|
||||
return () => ipcRenderer.removeListener(channel, handler);
|
||||
},
|
||||
|
||||
send: (channel: string, ...args: unknown[]) =>
|
||||
ipcRenderer.send(channel, ...args),
|
||||
|
||||
// Window controls
|
||||
minimize: () => ipcRenderer.invoke('window:minimize'),
|
||||
maximize: () => ipcRenderer.invoke('window:maximize'),
|
||||
close: () => ipcRenderer.invoke('window:close'),
|
||||
isMaximized: () => ipcRenderer.invoke('window:isMaximized'),
|
||||
onResized: (callback: () => void) => {
|
||||
const handler = () => callback();
|
||||
ipcRenderer.on('window:resized', handler);
|
||||
return () => ipcRenderer.removeListener('window:resized', handler);
|
||||
},
|
||||
|
||||
// Theme
|
||||
getTheme: () => ipcRenderer.invoke('window:getTheme'),
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tauri + React + Typescript</title>
|
||||
<title>Koring Launcher</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"shadcn": {
|
||||
"type": "local",
|
||||
"command": [
|
||||
"npx",
|
||||
"shadcn@latest",
|
||||
"mcp"
|
||||
],
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +1,65 @@
|
||||
{
|
||||
"name": "koring-launcher",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"description": "Koring Launcher - Minecraft launcher built with Electron + React",
|
||||
"author": "Lingke Koring Studio",
|
||||
"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:main": "tsc -p tsconfig.electron.json",
|
||||
"build": "pnpm build:renderer && 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",
|
||||
"dev": "node scripts/switch-icon.js dev && vite --mode development",
|
||||
"build": "node scripts/switch-icon.js run && tsc && vite build --mode production",
|
||||
"build:beta": "node scripts/switch-icon.js beta && tsc && vite build --mode beta",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"dev:t": "node scripts/switch-icon.js dev && tauri dev",
|
||||
"dev:vs": "./dev-vs.cmd",
|
||||
"build:vs": "./build-vs.cmd",
|
||||
"build:beta:vs": "./build-vs.cmd --mode beta"
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"@fontsource-variable/geist": "^5.2.9",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@react-three/fiber": "^9.6.1",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"@types/three": "^0.184.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"lucide-react": "^1.21.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"shadcn": "^4.11.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"three": "^0.184.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"electron",
|
||||
"esbuild"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"concurrently": "^9.1.0",
|
||||
"electron": "^33.0.0",
|
||||
"electron-builder": "^25.1.8",
|
||||
"shadcn": "^4.11.0",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.0.4"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
# 迁移计划:Tauri 2 → Electron
|
||||
|
||||
## 概述
|
||||
|
||||
将 koring-launcher 从 Tauri 2(Rust 后端 + Node.js sidecar)迁移到 Electron(Node.js 主进程)。sidecar 的 @xmcl/* 包直接集成到 Electron 主进程中,不再需要独立的 sidecar 二进制文件和 stdin/stdout IPC。
|
||||
|
||||
---
|
||||
|
||||
## 架构变更
|
||||
|
||||
### 迁移前(Tauri)
|
||||
```
|
||||
前端 (React) ←→ Rust (Tauri) ←→ Sidecar (Node.js/@xmcl)
|
||||
↕ invoke() ↕ spawn stdin/stdout
|
||||
↕ listen events ↕ emit events
|
||||
```
|
||||
|
||||
### 迁移后(Electron)
|
||||
```
|
||||
前端 (React) ←→ Electron 主进程 (Node.js/@xmcl)
|
||||
↕ ipcRenderer.invoke() ↕ ipcMain.handle()
|
||||
↕ ipcRenderer.on() ↕ webContents.send()
|
||||
```
|
||||
|
||||
Rust 后端和 sidecar **完全移除**。所有功能都在 Electron 主进程中以 `ipcMain.handle()` 处理器的形式存在。
|
||||
|
||||
---
|
||||
|
||||
## 阶段一:项目初始化
|
||||
|
||||
### 1.1 初始化 Electron
|
||||
|
||||
- [ ] 在项目根目录创建 `electron/` 目录
|
||||
- [ ] 创建 `electron/main.ts`(Electron 主进程入口)
|
||||
- [ ] 创建 `electron/preload.ts`(context bridge 用于 IPC)
|
||||
- [ ] 更新根目录 `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 相关配置)
|
||||
|
||||
### 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 相关条目
|
||||
|
||||
---
|
||||
|
||||
## 阶段二:Electron 主进程
|
||||
|
||||
### 2.1 主进程入口(`electron/main.ts`)
|
||||
|
||||
```typescript
|
||||
import { app, BrowserWindow, ipcMain, dialog } from 'electron';
|
||||
import path from 'path';
|
||||
|
||||
// 窗口管理
|
||||
// Splash → Main 过渡(4秒计时器)
|
||||
// 单实例锁定
|
||||
// 应用生命周期事件
|
||||
```
|
||||
|
||||
核心职责:
|
||||
- 创建 splash 窗口(480x320,透明,无边框)
|
||||
- 创建主窗口(1000x700,透明,无边框,初始隐藏)
|
||||
- 4秒后关闭 splash,显示主窗口
|
||||
- 处理应用退出(终止所有运行中的 MC 进程)
|
||||
|
||||
### 2.2 IPC 处理器
|
||||
|
||||
将所有 16 个 Tauri 命令替换为 `ipcMain.handle()`:
|
||||
|
||||
| Tauri 命令 | Electron IPC 频道 | 说明 |
|
||||
|---|---|---|
|
||||
| `sidecar_request` | ❌ **已移除** | 不再需要,直接调用具体处理器 |
|
||||
| `install_minecraft` | `install:minecraft` | 直接使用 @xmcl/installer |
|
||||
| `install_mod_loader` | `install:mod-loader` | 直接使用 @xmcl/installer |
|
||||
| `get_version_list` | `install:version-list` | 直接使用 @xmcl/installer |
|
||||
| `launch_game` | `launch:launch` | 直接使用 @xmcl/core |
|
||||
| `offline_login` | `auth:offline-login` | 直接使用 @xmcl/user |
|
||||
| `search_mods` | `mods:search` | 直接使用 @xmcl/modrinth |
|
||||
| `install_mod` | `mods:install` | 直接使用 @xmcl/modrinth |
|
||||
| `create_instance` | `instance:create` | 直接文件 I/O |
|
||||
| `list_instances` | `instance:list` | 直接文件 I/O |
|
||||
| `get_config` | `config:get` | electron-store 或 YAML 文件 |
|
||||
| `save_config` | `config:save` | electron-store 或 YAML 文件 |
|
||||
| `get_auth` | `auth:get` | electron-store(替代注册表) |
|
||||
| `save_auth` | `auth:save` | electron-store(替代注册表) |
|
||||
| `delete_auth_cmd` | `auth:delete` | electron-store |
|
||||
| `get_system_info` | `system:info` | `process.versions`、`os` 模块 |
|
||||
|
||||
**新增频道**(替代 sidecar 事件转发):
|
||||
- `install:progress` → `webContents.send('install:progress', data)`
|
||||
- `install:complete` → `webContents.send('install:complete', data)`
|
||||
- `launch:events` → `webContents.send('launch:events', data)`
|
||||
- `task:progress` → `webContents.send('task:progress', data)`
|
||||
- `task:completed` → `webContents.send('task:completed', data)`
|
||||
|
||||
### 2.3 配置系统(`electron/config.ts`)
|
||||
|
||||
从 Rust `config.rs` 迁移到 Node.js:
|
||||
- 保持 YAML 格式(使用 `js-yaml` 库)
|
||||
- 保持稀疏保存逻辑(与默认值做 diff,只保存非默认值)
|
||||
- 存储路径:打包后使用 `app.getPath('exe')` 父目录
|
||||
|
||||
### 2.4 认证系统(`electron/auth.ts`)
|
||||
|
||||
从 Windows 注册表迁移到 `electron-store`:
|
||||
- 使用 `electron-store` 存储认证数据(跨平台 JSON)
|
||||
- 可选使用 `safeStorage` 加密
|
||||
|
||||
### 2.5 Sidecar → 主进程集成
|
||||
|
||||
将所有 sidecar 模块直接移入主进程:
|
||||
- `electron/core/auth.ts` ← `sidecar/src/core/auth.ts`(Microsoft OAuth、离线登录)
|
||||
- `electron/core/installer.ts` ← `sidecar/src/core/installer.ts`(@xmcl/installer)
|
||||
- `electron/core/launcher.ts` ← `sidecar/src/core/launcher.ts`(@xmcl/core)
|
||||
- `electron/core/modrinth.ts` ← `sidecar/src/core/modrinth.ts`(Mod API)
|
||||
- `electron/handlers/` ← `sidecar/src/handlers/`(将 stdin/stdout 改为 IPC)
|
||||
- `electron/utils/paths.ts` ← `sidecar/src/utils/paths.ts`
|
||||
|
||||
**关键变更**:sidecar 使用 `sendResult(id, data)` / `sendProgress(id, data)` 通过 stdout 发送。现在改用 `webContents.send(channel, data)` 直接发送。
|
||||
|
||||
### 2.6 窗口管理
|
||||
|
||||
替换 Tauri 窗口 API:
|
||||
- `@tauri-apps/api/window` → Electron `BrowserWindow` 方法
|
||||
- 自定义标题栏:移除 `data-tauri-drag-region`,改用 CSS `-webkit-app-region: drag` 实现无边框窗口拖拽
|
||||
- 窗口控制按钮:`ipcRenderer.invoke('window:minimize')`、`ipcRenderer.invoke('window:maximize')`、`ipcRenderer.invoke('window:close')`
|
||||
|
||||
---
|
||||
|
||||
## 阶段三:前端迁移
|
||||
|
||||
### 3.1 API 层重写
|
||||
|
||||
**`src/api/sidecar.ts`** → `src/api/ipc.ts`:
|
||||
- 替换 `invoke("sidecar_request", ...)` 为 `window.electronAPI.invoke('channel', payload)`
|
||||
- 替换 `listen("sidecar-response", ...)` 为 `window.electronAPI.on('channel', callback)`
|
||||
- 移除基于 requestId 的事件过滤(每个 IPC 频道是独立的)
|
||||
|
||||
**`src/api/config.ts`**:
|
||||
- `invoke("get_config")` → `window.electronAPI.invoke('config:get')`
|
||||
- `invoke("save_config", { cfg })` → `window.electronAPI.invoke('config:save', config)`
|
||||
|
||||
**`src/api/auth-registry.ts`**:
|
||||
- `invoke("get_auth")` → `window.electronAPI.invoke('auth:get')`
|
||||
- `invoke("save_auth", { auth })` → `window.electronAPI.invoke('auth:save', auth)`
|
||||
- `invoke("delete_auth_cmd")` → `window.electronAPI.invoke('auth:delete')`
|
||||
|
||||
**`src/api/update.ts`**:
|
||||
- 替换 `@tauri-apps/plugin-updater` → `electron-updater`
|
||||
- `checkForUpdates()` → `window.electronAPI.invoke('update:check')`
|
||||
- `downloadAndInstall()` → `window.electronAPI.invoke('update:install')`
|
||||
- `relaunchApp()` → `window.electronAPI.invoke('app:relaunch')`
|
||||
|
||||
**`src/api/system.ts`**:
|
||||
- `invoke("get_system_info")` → `window.electronAPI.invoke('system:info')`
|
||||
|
||||
### 3.2 移除 Tauri 导入
|
||||
|
||||
需要更新的文件(移除所有 `@tauri-apps/*` 导入):
|
||||
|
||||
| 文件 | Tauri 导入 | 替换为 |
|
||||
|---|---|---|
|
||||
| `src/api/sidecar.ts` | `@tauri-apps/api/core`, `@tauri-apps/api/event` | `window.electronAPI` |
|
||||
| `src/api/config.ts` | `@tauri-apps/api/core` | `window.electronAPI` |
|
||||
| `src/api/auth-registry.ts` | `@tauri-apps/api/core` | `window.electronAPI` |
|
||||
| `src/api/update.ts` | `@tauri-apps/plugin-updater`, `@tauri-apps/plugin-process` | `window.electronAPI` |
|
||||
| `src/api/system.ts` | `@tauri-apps/api/core` | `window.electronAPI` |
|
||||
| `src/hooks/useTheme.ts` | `@tauri-apps/api/window` | `window.electronAPI` |
|
||||
| `src/components/system/WindowControls.tsx` | `@tauri-apps/api/window` | `window.electronAPI` |
|
||||
| `src/components/system/TitleBar.tsx` | `@tauri-apps/api/window` | `window.electronAPI` |
|
||||
| `src/components/system/SystemLayer.tsx` | `@tauri-apps/api/window` | `window.electronAPI` |
|
||||
|
||||
### 3.3 Preload 脚本 / Context Bridge
|
||||
|
||||
创建 `electron/preload.ts`:
|
||||
```typescript
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
invoke: (channel: string, ...args: unknown[]) =>
|
||||
ipcRenderer.invoke(channel, ...args),
|
||||
on: (channel: string, callback: (...args: unknown[]) => void) => {
|
||||
ipcRenderer.on(channel, (_event, ...args) => callback(...args));
|
||||
return () => ipcRenderer.removeListener(channel, callback);
|
||||
},
|
||||
send: (channel: string, ...args: unknown[]) =>
|
||||
ipcRenderer.send(channel, ...args),
|
||||
|
||||
// 窗口控制
|
||||
minimize: () => ipcRenderer.invoke('window:minimize'),
|
||||
maximize: () => ipcRenderer.invoke('window:maximize'),
|
||||
close: () => ipcRenderer.invoke('window:close'),
|
||||
isMaximized: () => ipcRenderer.invoke('window:isMaximized'),
|
||||
onResized: (callback: () => void) => {
|
||||
ipcRenderer.on('window:resized', callback);
|
||||
return () => ipcRenderer.removeListener('window:resized', callback);
|
||||
},
|
||||
|
||||
// 主题
|
||||
getTheme: () => ipcRenderer.invoke('window:getTheme'),
|
||||
});
|
||||
```
|
||||
|
||||
在 `src/types/electron.d.ts` 中添加 TypeScript 类型声明。
|
||||
|
||||
### 3.4 窗口控制更新
|
||||
|
||||
**`src/components/system/WindowControls.tsx`**:
|
||||
- 移除 `getCurrentWindow()` from `@tauri-apps/api/window`
|
||||
- 使用 `window.electronAPI.minimize()`、`.maximize()`、`.close()`
|
||||
- 使用 `window.electronAPI.isMaximized()` 和 `window.electronAPI.onResized()`
|
||||
|
||||
**`src/components/system/TitleBar.tsx`**:
|
||||
- 移除 `getCurrentWindow()` 拖拽区域
|
||||
- 使用 CSS `-webkit-app-region: drag` 实现无边框窗口拖拽
|
||||
- 移除 `appWindow.startDragging()` 调用
|
||||
|
||||
**`src/hooks/useTheme.ts`**:
|
||||
- 移除 `getCurrentWindow().theme()` from `@tauri-apps/api/window`
|
||||
- 使用 `window.electronAPI.getTheme()` 替代
|
||||
- 保留 `matchMedia` 回退方案
|
||||
|
||||
### 3.5 Splash 屏幕
|
||||
|
||||
**`splash.html`**:无需更改(纯 HTML/CSS)
|
||||
**`src/components/splash/Splash.tsx`**:无需更改(纯 React)
|
||||
|
||||
### 3.6 Vite 配置更新
|
||||
|
||||
更新 `vite.config.ts`:
|
||||
- 移除 `ignore` 中的 `src-tauri/`
|
||||
- 如果使用 `electron-vite`,添加 Electron 特定配置
|
||||
- 确保 `base: './'` 以支持 Electron 的 file:// 协议
|
||||
|
||||
---
|
||||
|
||||
## 阶段四:构建与打包
|
||||
|
||||
### 4.1 Electron Builder 配置
|
||||
|
||||
创建 `electron-builder.yml`:
|
||||
```yaml
|
||||
appId: com.lingke.koring.launcher
|
||||
productName: Koring Launcher
|
||||
directories:
|
||||
output: dist-electron
|
||||
files:
|
||||
- dist/** # Vite 构建输出
|
||||
- electron/** # 主进程代码
|
||||
win:
|
||||
target: nsis
|
||||
icon: public/run.ico
|
||||
mac:
|
||||
target: dmg
|
||||
icon: public/run.png
|
||||
linux:
|
||||
target: AppImage
|
||||
nsis:
|
||||
oneClick: false
|
||||
allowToChangeInstallationDirectory: true
|
||||
```
|
||||
|
||||
### 4.2 开发脚本
|
||||
|
||||
```bash
|
||||
# 开发
|
||||
pnpm dev:renderer # Vite 开发服务器
|
||||
pnpm dev:main # electron-vite 启动 Electron
|
||||
pnpm dev # 同时运行两个(concurrently)
|
||||
|
||||
# 构建
|
||||
pnpm build # Vite 构建 + Electron 打包
|
||||
pnpm build:win # Windows 安装程序
|
||||
```
|
||||
|
||||
### 4.3 构建模式(dev/beta/run)
|
||||
|
||||
- 保留 `VITE_BUILD_MODE` 环境变量
|
||||
- 使用 `electron-builder` 不同配置:
|
||||
- `electron-builder.dev.yml` — 不签名,不更新
|
||||
- `electron-builder.beta.yml` — beta 更新通道
|
||||
- `electron-builder.yml` — 正式版
|
||||
|
||||
---
|
||||
|
||||
## 阶段五:自动更新
|
||||
|
||||
将 `@tauri-apps/plugin-updater` 替换为 `electron-updater`:
|
||||
|
||||
### 5.1 主进程
|
||||
|
||||
```typescript
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
|
||||
ipcMain.handle('update:check', async () => {
|
||||
return autoUpdater.checkForUpdates();
|
||||
});
|
||||
|
||||
ipcMain.handle('update:install', async () => {
|
||||
autoUpdater.downloadUpdate();
|
||||
});
|
||||
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
mainWindow.webContents.send('update:available', info);
|
||||
});
|
||||
|
||||
autoUpdater.on('download-progress', (progress) => {
|
||||
mainWindow.webContents.send('update:progress', progress);
|
||||
});
|
||||
```
|
||||
|
||||
### 5.2 前端
|
||||
|
||||
```typescript
|
||||
// src/api/update.ts
|
||||
export async function checkForUpdates() {
|
||||
return window.electronAPI.invoke('update:check');
|
||||
}
|
||||
|
||||
export function onUpdateAvailable(callback: (info: any) => void) {
|
||||
return window.electronAPI.on('update:available', callback);
|
||||
}
|
||||
|
||||
export function onUpdateProgress(callback: (progress: any) => void) {
|
||||
return window.electronAPI.on('update:progress', callback);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 阶段六:测试与清理
|
||||
|
||||
### 6.1 测试清单
|
||||
|
||||
- [ ] Splash 屏幕显示 4 秒后主窗口出现
|
||||
- [ ] 自定义标题栏正常工作(最小化/最大化/关闭)
|
||||
- [ ] 无边框窗口拖拽正常
|
||||
- [ ] 暗色模式跟随系统同步
|
||||
- [ ] 配置正确加载/保存(Koring.yml)
|
||||
- [ ] 认证正确存储/加载(electron-store)
|
||||
- [ ] Microsoft OAuth 流程正常(打开浏览器 → 回调)
|
||||
- [ ] 离线登录正常
|
||||
- [ ] Minecraft 安装带进度条正常
|
||||
- [ ] 游戏启动带事件流正常
|
||||
- [ ] Mod 搜索/安装正常(Modrinth/CurseForge)
|
||||
- [ ] 实例 创建/列表/删除 正常
|
||||
- [ ] 自动更新 检查/下载 正常
|
||||
- [ ] 任务队列正常(进度、取消、重试)
|
||||
- [ ] 构建模式(dev/beta/run)显示正确的图标/徽章
|
||||
- [ ] NSIS 安装程序正确构建
|
||||
- [ ] 打包后应用正常运行
|
||||
|
||||
### 6.2 文件清理
|
||||
|
||||
- [ ] 删除 `src-tauri/` 目录
|
||||
- [ ] 删除 `sidecar/` 目录
|
||||
- [ ] 删除 `.tauri/` 目录
|
||||
- [ ] 删除 `*.cmd` 构建脚本
|
||||
- [ ] 删除 `scripts/` 目录
|
||||
- [ ] 移除 `.vscode/extensions.json` 中的 `src-tauri` 引用
|
||||
- [ ] 更新 `AGENTS.md` 为新的 Electron 架构
|
||||
- [ ] 更新 `DEV.md` 为 Electron 开发说明
|
||||
- [ ] 更新 `README.md`
|
||||
|
||||
### 6.3 新文件结构
|
||||
|
||||
```
|
||||
koring-launcher/
|
||||
├── electron/ # Electron 主进程
|
||||
│ ├── main.ts # 主入口
|
||||
│ ├── preload.ts # Context bridge
|
||||
│ ├── config.ts # 配置(YAML)管理
|
||||
│ ├── auth.ts # 认证(electron-store)管理
|
||||
│ ├── core/ # 从 sidecar 迁移
|
||||
│ │ ├── auth.ts # @xmcl/user 认证
|
||||
│ │ ├── installer.ts # @xmcl/installer 安装
|
||||
│ │ ├── launcher.ts # @xmcl/core 启动
|
||||
│ │ └── modrinth.ts # @xmcl/modrinth Mod API
|
||||
│ ├── handlers/ # IPC 处理器
|
||||
│ │ ├── install.ts
|
||||
│ │ ├── launch.ts
|
||||
│ │ ├── auth.ts
|
||||
│ │ ├── mods.ts
|
||||
│ │ ├── instance.ts
|
||||
│ │ ├── background.ts
|
||||
│ │ └── task.ts
|
||||
│ └── utils/
|
||||
│ └── paths.ts
|
||||
├── src/ # 前端(基本不变)
|
||||
├── dist/ # Vite 构建输出
|
||||
├── electron-builder.yml
|
||||
├── vite.config.ts
|
||||
├── package.json
|
||||
└── index.html
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 迁移顺序
|
||||
|
||||
1. **阶段一** — 项目初始化(Electron 脚手架、移除 Tauri 依赖)
|
||||
2. **阶段二** — 主进程(IPC 处理器、配置、认证、sidecar 集成)
|
||||
3. **阶段三** — 前端迁移(API 层、导入、preload bridge)
|
||||
4. **阶段四** — 构建与打包(electron-builder、开发脚本)
|
||||
5. **阶段五** — 自动更新(electron-updater)
|
||||
6. **阶段六** — 测试与清理
|
||||
|
||||
预计工作量:大规模重构。每个阶段应独立测试后再进入下一阶段。
|
||||
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 416 KiB |
@@ -1,17 +1,16 @@
|
||||
import { cpSync, existsSync } from "fs";
|
||||
import { join } from "path";
|
||||
const { cpSync, existsSync } = require('fs');
|
||||
const { join } = require('path');
|
||||
|
||||
const mode = process.argv[2];
|
||||
const validModes = ["dev", "beta", "run"];
|
||||
const validModes = ['dev', 'beta', 'run'];
|
||||
|
||||
if (!mode || !validModes.includes(mode)) {
|
||||
console.error(`Usage: node scripts/switch-icon.js <${validModes.join("|")}>`);
|
||||
console.error(`Usage: node scripts/switch-icon.js <${validModes.join('|')}>`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const root = join(import.meta.dirname, "..");
|
||||
const publicDir = join(root, "public");
|
||||
const iconsDir = join(root, "src-tauri", "icons");
|
||||
const root = join(__dirname, '..');
|
||||
const publicDir = join(root, 'public');
|
||||
|
||||
const png = join(publicDir, `${mode}.png`);
|
||||
const ico = join(publicDir, `${mode}.ico`);
|
||||
@@ -25,9 +24,7 @@ if (!existsSync(ico)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Tauri 2 only needs icon.png (source) + icon.ico
|
||||
// It auto-generates 32x32, 128x128, 128x128@2x, icns from icon.png during build
|
||||
cpSync(png, join(iconsDir, "icon.png"), { overwrite: true });
|
||||
cpSync(ico, join(iconsDir, "icon.ico"), { overwrite: true });
|
||||
cpSync(png, join(publicDir, 'icon.png'), { overwrite: true });
|
||||
cpSync(ico, join(publicDir, 'icon.ico'), { overwrite: true });
|
||||
|
||||
console.log(`[switch-icon] Mode: ${mode} → icon.png + icon.ico updated`);
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"name": "koring-sidecar",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
"koring-sidecar": "dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsx src/index.ts",
|
||||
"build": "tsc",
|
||||
"compile": "bun build --compile --target=bun-windows-arm64 src/index.ts --outfile bin/koring-sidecar-aarch64-pc-windows-msvc.exe",
|
||||
"compile:x64": "bun build --compile --target=bun-windows-x64 src/index.ts --outfile bin/koring-sidecar-x86_64-pc-windows-msvc.exe"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xmcl/core": "^2.15.1",
|
||||
"@xmcl/installer": "^6.1.2",
|
||||
"@xmcl/user": "^4.3.0",
|
||||
"@xmcl/modrinth": "^2.5.0",
|
||||
"@xmcl/curseforge": "^2.2.0",
|
||||
"@xmcl/nbt": "^3.0.2",
|
||||
"@xmcl/system": "^2.2.9",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "~5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -1,718 +0,0 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@xmcl/core':
|
||||
specifier: ^2.15.1
|
||||
version: 2.15.1(yauzl@2.10.0)
|
||||
'@xmcl/curseforge':
|
||||
specifier: ^2.2.0
|
||||
version: 2.2.0
|
||||
'@xmcl/installer':
|
||||
specifier: ^6.1.2
|
||||
version: 6.1.2
|
||||
'@xmcl/modrinth':
|
||||
specifier: ^2.5.0
|
||||
version: 2.5.0
|
||||
'@xmcl/nbt':
|
||||
specifier: ^3.0.2
|
||||
version: 3.0.2
|
||||
'@xmcl/system':
|
||||
specifier: ^2.2.9
|
||||
version: 2.2.9
|
||||
'@xmcl/user':
|
||||
specifier: ^4.3.0
|
||||
version: 4.3.0
|
||||
uuid:
|
||||
specifier: ^11.1.0
|
||||
version: 11.1.1
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.0.0
|
||||
version: 22.19.21
|
||||
'@types/uuid':
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.0
|
||||
tsx:
|
||||
specifier: ^4.19.0
|
||||
version: 4.22.4
|
||||
typescript:
|
||||
specifier: ~5.8.3
|
||||
version: 5.8.3
|
||||
|
||||
packages:
|
||||
|
||||
'@esbuild/aix-ppc64@0.28.1':
|
||||
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/android-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.28.1':
|
||||
resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.28.1':
|
||||
resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/darwin-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.28.1':
|
||||
resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.28.1':
|
||||
resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/linux-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.28.1':
|
||||
resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.28.1':
|
||||
resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.28.1':
|
||||
resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.28.1':
|
||||
resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.28.1':
|
||||
resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.28.1':
|
||||
resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.28.1':
|
||||
resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.28.1':
|
||||
resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-x64@0.28.1':
|
||||
resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.28.1':
|
||||
resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openharmony-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@esbuild/sunos-x64@0.28.1':
|
||||
resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/win32-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.28.1':
|
||||
resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.28.1':
|
||||
resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@types/http-cache-semantics@4.2.0':
|
||||
resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==}
|
||||
|
||||
'@types/node@22.19.21':
|
||||
resolution: {integrity: sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==}
|
||||
|
||||
'@types/uuid@10.0.0':
|
||||
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
|
||||
|
||||
'@xmcl/asm@1.0.1':
|
||||
resolution: {integrity: sha512-7vCVgm1E1IZ2cujiitFk9550Vgu2XAOn1ff90di638fMmTK0XkFMXKsSR/nGZmYKt+XiTMI/0B3TvreqbVjOug==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
'@xmcl/bytebuffer@0.1.1':
|
||||
resolution: {integrity: sha512-Mja7XzIjXgXX/mDNqkVO6X3yltIYo18RYTo3J4UA8Di2TMZp90NRbLJ8PFRWAyDo/R+kNyErvfXMge2eB/q9pg==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
'@xmcl/core@2.15.1':
|
||||
resolution: {integrity: sha512-ldVWtFGRTnQ836oRnex3YiwCogQmy2XdKfdYz9uAoEbXofMrH/Yq/uEK593iQ9iVJa8Rlfik+LjzGAfsYzR1SQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@xmcl/curseforge@2.2.0':
|
||||
resolution: {integrity: sha512-W1b92Z1IYItIyplq2poWP1NL47buE4LOHWEU+duHCj0xWHI5y4ya2pIyXQhNzsBCSJ+ZZBXzPx/27hBl2PUwcA==}
|
||||
engines: {node: '>=16.4'}
|
||||
|
||||
'@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/modrinth@2.5.0':
|
||||
resolution: {integrity: sha512-OpPpBemZ/yYvphgLVXGV+jESR9n8OceEsMaMvxuFZrIpi9LdYTa2oyVlnZHD7Dte+S+ttD8Y2AUncSJUNFRgNg==}
|
||||
engines: {node: '>=16.4'}
|
||||
|
||||
'@xmcl/nbt@3.0.2':
|
||||
resolution: {integrity: sha512-RLv3bkre7wKHZzzaHYn3VUEa4B2HT1wYiP6gqaoxws/4CYT4IUGFO5YJAU/1IKVTUd7wMNQDXorJ9U/LGERPgQ==}
|
||||
engines: {node: '>=16.4'}
|
||||
|
||||
'@xmcl/system@2.2.9':
|
||||
resolution: {integrity: sha512-dY9G3SB0G9XYDT6ie/6sdYTMCuSlfTWUDkNOwfrYE8LIh3Cii2u+IcQ8bAT9XAxIcSG6qoe8KP0WOUC/Vn5HjA==}
|
||||
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
|
||||
|
||||
'@xmcl/user@4.3.0':
|
||||
resolution: {integrity: sha512-SHUI86qipZ+Eva1bO2xyKZFelQ8vO/e7Gatu6lEIPc8pkfGRfuAQuC+w+NmxYFUk3uNeDEKzkq6lP+DNJqUcEQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
boolbase@1.0.0:
|
||||
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
||||
|
||||
buffer-crc32@0.2.13:
|
||||
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
|
||||
|
||||
core-util-is@1.0.3:
|
||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||
|
||||
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'}
|
||||
|
||||
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==}
|
||||
|
||||
entities@4.5.0:
|
||||
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
esbuild@0.28.1:
|
||||
resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
fd-slicer@1.1.0:
|
||||
resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
he@1.2.0:
|
||||
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
|
||||
hasBin: true
|
||||
|
||||
http-cache-semantics@4.2.0:
|
||||
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
|
||||
|
||||
immediate@3.0.6:
|
||||
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
||||
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
isarray@1.0.0:
|
||||
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||
|
||||
jszip@3.10.1:
|
||||
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
||||
|
||||
lie@3.3.0:
|
||||
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
|
||||
|
||||
node-html-parser@6.1.13:
|
||||
resolution: {integrity: sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==}
|
||||
|
||||
nth-check@2.1.1:
|
||||
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
|
||||
|
||||
pako@1.0.11:
|
||||
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
||||
|
||||
pako@2.1.0:
|
||||
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
|
||||
|
||||
pend@1.2.0:
|
||||
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
|
||||
|
||||
process-nextick-args@2.0.1:
|
||||
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||
|
||||
readable-stream@2.3.8:
|
||||
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
|
||||
|
||||
safe-buffer@5.1.2:
|
||||
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
|
||||
|
||||
setimmediate@1.0.5:
|
||||
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
|
||||
|
||||
string_decoder@1.1.1:
|
||||
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
|
||||
|
||||
tsx@4.22.4:
|
||||
resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
typescript@5.8.3:
|
||||
resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
undici@7.1.1:
|
||||
resolution: {integrity: sha512-WZkQ6eH9f5ZT93gaIffsbUaDpBwjbpvmMbfaEhOnbdUneurTESeRxwPGwjI28mRFESH3W3e8Togijh37ptOQqA==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
undici@7.2.3:
|
||||
resolution: {integrity: sha512-2oSLHaDalSt2/O/wHA9M+/ZPAOcU2yrSP/cdBYJ+YxZskiPYDSqHbysLSlD7gq3JMqOoJI5O31RVU3BxX/MnAA==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
uuid@11.1.1:
|
||||
resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==}
|
||||
hasBin: true
|
||||
|
||||
uuid@9.0.1:
|
||||
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
|
||||
hasBin: true
|
||||
|
||||
yauzl@2.10.0:
|
||||
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
|
||||
|
||||
yazl@2.5.1:
|
||||
resolution: {integrity: sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@esbuild/aix-ppc64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openharmony-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@types/http-cache-semantics@4.2.0': {}
|
||||
|
||||
'@types/node@22.19.21':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/uuid@10.0.0': {}
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
dependencies:
|
||||
'@types/node': 22.19.21
|
||||
|
||||
'@xmcl/asm@1.0.1': {}
|
||||
|
||||
'@xmcl/bytebuffer@0.1.1': {}
|
||||
|
||||
'@xmcl/core@2.15.1(yauzl@2.10.0)':
|
||||
dependencies:
|
||||
'@xmcl/unzip': 2.1.2(yauzl@2.10.0)
|
||||
transitivePeerDependencies:
|
||||
- yauzl
|
||||
|
||||
'@xmcl/curseforge@2.2.0':
|
||||
dependencies:
|
||||
undici: 7.1.1
|
||||
|
||||
'@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/modrinth@2.5.0':
|
||||
dependencies:
|
||||
undici: 7.2.3
|
||||
|
||||
'@xmcl/nbt@3.0.2':
|
||||
dependencies:
|
||||
'@xmcl/bytebuffer': 0.1.1
|
||||
pako: 2.1.0
|
||||
|
||||
'@xmcl/system@2.2.9':
|
||||
dependencies:
|
||||
'@xmcl/unzip': 2.1.2(yauzl@2.10.0)
|
||||
jszip: 3.10.1
|
||||
yauzl: 2.10.0
|
||||
|
||||
'@xmcl/task@4.1.1': {}
|
||||
|
||||
'@xmcl/unzip@2.1.2(yauzl@2.10.0)':
|
||||
dependencies:
|
||||
'@types/yauzl': 2.10.3
|
||||
yauzl: 2.10.0
|
||||
|
||||
'@xmcl/user@4.3.0':
|
||||
dependencies:
|
||||
undici: 7.2.3
|
||||
uuid: 9.0.1
|
||||
|
||||
boolbase@1.0.0: {}
|
||||
|
||||
buffer-crc32@0.2.13: {}
|
||||
|
||||
core-util-is@1.0.3: {}
|
||||
|
||||
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: {}
|
||||
|
||||
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
|
||||
|
||||
entities@4.5.0: {}
|
||||
|
||||
esbuild@0.28.1:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.28.1
|
||||
'@esbuild/android-arm': 0.28.1
|
||||
'@esbuild/android-arm64': 0.28.1
|
||||
'@esbuild/android-x64': 0.28.1
|
||||
'@esbuild/darwin-arm64': 0.28.1
|
||||
'@esbuild/darwin-x64': 0.28.1
|
||||
'@esbuild/freebsd-arm64': 0.28.1
|
||||
'@esbuild/freebsd-x64': 0.28.1
|
||||
'@esbuild/linux-arm': 0.28.1
|
||||
'@esbuild/linux-arm64': 0.28.1
|
||||
'@esbuild/linux-ia32': 0.28.1
|
||||
'@esbuild/linux-loong64': 0.28.1
|
||||
'@esbuild/linux-mips64el': 0.28.1
|
||||
'@esbuild/linux-ppc64': 0.28.1
|
||||
'@esbuild/linux-riscv64': 0.28.1
|
||||
'@esbuild/linux-s390x': 0.28.1
|
||||
'@esbuild/linux-x64': 0.28.1
|
||||
'@esbuild/netbsd-arm64': 0.28.1
|
||||
'@esbuild/netbsd-x64': 0.28.1
|
||||
'@esbuild/openbsd-arm64': 0.28.1
|
||||
'@esbuild/openbsd-x64': 0.28.1
|
||||
'@esbuild/openharmony-arm64': 0.28.1
|
||||
'@esbuild/sunos-x64': 0.28.1
|
||||
'@esbuild/win32-arm64': 0.28.1
|
||||
'@esbuild/win32-ia32': 0.28.1
|
||||
'@esbuild/win32-x64': 0.28.1
|
||||
|
||||
fd-slicer@1.1.0:
|
||||
dependencies:
|
||||
pend: 1.2.0
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
he@1.2.0: {}
|
||||
|
||||
http-cache-semantics@4.2.0: {}
|
||||
|
||||
immediate@3.0.6: {}
|
||||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
isarray@1.0.0: {}
|
||||
|
||||
jszip@3.10.1:
|
||||
dependencies:
|
||||
lie: 3.3.0
|
||||
pako: 1.0.11
|
||||
readable-stream: 2.3.8
|
||||
setimmediate: 1.0.5
|
||||
|
||||
lie@3.3.0:
|
||||
dependencies:
|
||||
immediate: 3.0.6
|
||||
|
||||
node-html-parser@6.1.13:
|
||||
dependencies:
|
||||
css-select: 5.2.2
|
||||
he: 1.2.0
|
||||
|
||||
nth-check@2.1.1:
|
||||
dependencies:
|
||||
boolbase: 1.0.0
|
||||
|
||||
pako@1.0.11: {}
|
||||
|
||||
pako@2.1.0: {}
|
||||
|
||||
pend@1.2.0: {}
|
||||
|
||||
process-nextick-args@2.0.1: {}
|
||||
|
||||
readable-stream@2.3.8:
|
||||
dependencies:
|
||||
core-util-is: 1.0.3
|
||||
inherits: 2.0.4
|
||||
isarray: 1.0.0
|
||||
process-nextick-args: 2.0.1
|
||||
safe-buffer: 5.1.2
|
||||
string_decoder: 1.1.1
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
safe-buffer@5.1.2: {}
|
||||
|
||||
setimmediate@1.0.5: {}
|
||||
|
||||
string_decoder@1.1.1:
|
||||
dependencies:
|
||||
safe-buffer: 5.1.2
|
||||
|
||||
tsx@4.22.4:
|
||||
dependencies:
|
||||
esbuild: 0.28.1
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
typescript@5.8.3: {}
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
undici@7.1.1: {}
|
||||
|
||||
undici@7.2.3: {}
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
uuid@11.1.1: {}
|
||||
|
||||
uuid@9.0.1: {}
|
||||
|
||||
yauzl@2.10.0:
|
||||
dependencies:
|
||||
buffer-crc32: 0.2.13
|
||||
fd-slicer: 1.1.0
|
||||
|
||||
yazl@2.5.1:
|
||||
dependencies:
|
||||
buffer-crc32: 0.2.13
|
||||
@@ -1,146 +0,0 @@
|
||||
import { MicrosoftAuthenticator, offline } from "@xmcl/user";
|
||||
|
||||
export interface AuthResult {
|
||||
username: string;
|
||||
uuid: string;
|
||||
accessToken: string;
|
||||
expiresAt?: number;
|
||||
xboxProfile?: {
|
||||
gamertag: string;
|
||||
displayPicRaw: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function microsoftLoginStart(clientId: string, redirectUri?: string) {
|
||||
const state = Math.random().toString(36).substring(2, 15);
|
||||
const scope = "XboxLive.signin offline_access";
|
||||
const redirect = redirectUri || "https://login.live.com/oauth20_desktop.srf";
|
||||
|
||||
const authUrl = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?` +
|
||||
`client_id=${clientId}` +
|
||||
`&response_type=code` +
|
||||
`&scope=${encodeURIComponent(scope)}` +
|
||||
`&state=${state}` +
|
||||
`&redirect_uri=${encodeURIComponent(redirect)}`;
|
||||
|
||||
return { state, authUrl };
|
||||
}
|
||||
|
||||
export async function microsoftLoginCallback(
|
||||
code: string,
|
||||
clientId: string,
|
||||
redirectUri?: string
|
||||
): Promise<AuthResult> {
|
||||
const redirect = redirectUri || "https://login.live.com/oauth20_desktop.srf";
|
||||
|
||||
// Step 1: Exchange code for MS access token
|
||||
const tokenResponse = await fetch(
|
||||
"https://login.microsoftonline.com/common/oauth2/v2.0/token",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: redirect,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const err = await tokenResponse.text();
|
||||
throw new Error(`MS token exchange failed: ${err}`);
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json() as { access_token: string };
|
||||
const msAccessToken = tokenData.access_token;
|
||||
|
||||
// Step 2+3: Xbox + Minecraft auth
|
||||
const authenticator = new MicrosoftAuthenticator({ fetch });
|
||||
const { liveXstsResponse, minecraftXstsResponse } =
|
||||
await authenticator.acquireXBoxToken(msAccessToken);
|
||||
|
||||
const mcResponse = await authenticator.loginMinecraftWithXBox(
|
||||
minecraftXstsResponse.DisplayClaims.xui[0].uhs,
|
||||
minecraftXstsResponse.Token
|
||||
);
|
||||
|
||||
// Get Xbox profile
|
||||
let xboxProfile;
|
||||
try {
|
||||
const profile = await authenticator.getXboxGameProfile(
|
||||
liveXstsResponse.DisplayClaims.xui[0].xid,
|
||||
liveXstsResponse.DisplayClaims.xui[0].uhs,
|
||||
liveXstsResponse.Token
|
||||
);
|
||||
const settings = profile.profileUsers[0]?.settings;
|
||||
if (settings) {
|
||||
xboxProfile = {
|
||||
gamertag: settings.find(s => s.id === "Gamertag")?.value || "",
|
||||
displayPicRaw: settings.find(s => s.id === "PublicGamerpic")?.value || "",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Xbox profile is optional
|
||||
}
|
||||
|
||||
return {
|
||||
username: mcResponse.username,
|
||||
uuid: mcResponse.username,
|
||||
accessToken: mcResponse.access_token,
|
||||
expiresAt: mcResponse.expires_in
|
||||
? Date.now() + mcResponse.expires_in * 1000
|
||||
: undefined,
|
||||
xboxProfile,
|
||||
};
|
||||
}
|
||||
|
||||
export function offlineLogin(username: string, uuid?: string): AuthResult {
|
||||
const user = offline(username, uuid);
|
||||
return {
|
||||
username: user.selectedProfile.name,
|
||||
uuid: user.selectedProfile.id,
|
||||
accessToken: user.accessToken,
|
||||
};
|
||||
}
|
||||
|
||||
export async function validateMinecraftToken(accessToken: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
"https://api.minecraftservices.com/minecraft/profile",
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshMicrosoftToken(
|
||||
refreshToken: string,
|
||||
clientId: string
|
||||
) {
|
||||
const response = await fetch(
|
||||
"https://login.microsoftonline.com/common/oauth2/v2.0/token",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: "refresh_token",
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to refresh MS token");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import {
|
||||
getVersionList,
|
||||
install,
|
||||
installForge,
|
||||
installFabric,
|
||||
installDependencies,
|
||||
getForgeVersionList,
|
||||
getFabricLoaders,
|
||||
getQuiltLoaders,
|
||||
installQuiltVersion,
|
||||
} from "@xmcl/installer";
|
||||
import { sendProgress } from "../protocol/transport.js";
|
||||
|
||||
function progressCallback(requestId: string) {
|
||||
return (total: number, cur: number, message?: string) => {
|
||||
sendProgress(requestId, {
|
||||
stage: message || "downloading",
|
||||
current: cur,
|
||||
total,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export async function getVersionListAsync(type: string = "all") {
|
||||
const manifest = await getVersionList();
|
||||
if (type === "all") return manifest;
|
||||
const filtered = manifest.versions.filter((v) => v.type === type);
|
||||
return { latest: manifest.latest, versions: filtered };
|
||||
}
|
||||
|
||||
export async function installMinecraft(
|
||||
requestId: string,
|
||||
version: string,
|
||||
gamePath: string,
|
||||
javaPath?: string,
|
||||
_downloadThreads?: number
|
||||
) {
|
||||
await install({ id: version, url: "" }, gamePath, {
|
||||
java: javaPath,
|
||||
progress: progressCallback(requestId),
|
||||
} as any);
|
||||
|
||||
return { version, gamePath };
|
||||
}
|
||||
|
||||
export async function installForgeLoader(
|
||||
requestId: string,
|
||||
mcVersion: string,
|
||||
gamePath: string,
|
||||
javaPath?: string,
|
||||
forgeVersion?: string
|
||||
) {
|
||||
const version = forgeVersion || mcVersion;
|
||||
|
||||
await installForge({
|
||||
mcversion: mcVersion,
|
||||
version: version,
|
||||
}, gamePath, {
|
||||
java: javaPath,
|
||||
progress: progressCallback(requestId),
|
||||
} as any);
|
||||
|
||||
return { loaderType: "forge", mcVersion, forgeVersion: version };
|
||||
}
|
||||
|
||||
export async function installFabricLoader(
|
||||
requestId: string,
|
||||
mcVersion: string,
|
||||
gamePath: string,
|
||||
loaderVersion?: string
|
||||
) {
|
||||
const loaders = await getFabricLoaders();
|
||||
const targetLoader = loaderVersion
|
||||
? loaders.find((l) => l.version === loaderVersion)
|
||||
: loaders[loaders.length - 1];
|
||||
|
||||
if (!targetLoader) {
|
||||
throw new Error(`Fabric loader not found: ${loaderVersion}`);
|
||||
}
|
||||
|
||||
await installFabric({
|
||||
mcVersion,
|
||||
loaderVersion: targetLoader.version,
|
||||
gamePath,
|
||||
progress: progressCallback(requestId),
|
||||
} as any);
|
||||
|
||||
return { loaderType: "fabric", mcVersion, loaderVersion: targetLoader.version };
|
||||
}
|
||||
|
||||
export async function installQuiltLoader(
|
||||
requestId: string,
|
||||
mcVersion: string,
|
||||
gamePath: string,
|
||||
loaderVersion?: string
|
||||
) {
|
||||
const loaders = await getQuiltLoaders();
|
||||
const targetLoader = loaderVersion
|
||||
? loaders.find((l) => l.version === loaderVersion)
|
||||
: loaders[loaders.length - 1];
|
||||
|
||||
if (!targetLoader) {
|
||||
throw new Error(`Quilt loader not found: ${loaderVersion}`);
|
||||
}
|
||||
|
||||
await installQuiltVersion({
|
||||
minecraftVersion: mcVersion,
|
||||
version: targetLoader.version,
|
||||
minecraft: gamePath,
|
||||
});
|
||||
|
||||
return { loaderType: "quilt", mcVersion, loaderVersion: targetLoader.version };
|
||||
}
|
||||
|
||||
export async function getForgeVersions(mcVersion?: string) {
|
||||
const list = await getForgeVersionList();
|
||||
if (mcVersion) {
|
||||
const versions = list.versions.filter((v) => v.mcversion === mcVersion);
|
||||
return { mcVersion, versions };
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
export async function getFabricVersions(mcVersion?: string) {
|
||||
const loaders = await getFabricLoaders();
|
||||
if (mcVersion) {
|
||||
return { mcVersion, loaders };
|
||||
}
|
||||
return { loaders };
|
||||
}
|
||||
|
||||
export async function ensureDependencies(
|
||||
requestId: string,
|
||||
gamePath: string,
|
||||
versionId: string
|
||||
) {
|
||||
const { Version } = await import("@xmcl/core");
|
||||
const resolved = await Version.parse(gamePath, versionId);
|
||||
await installDependencies(resolved, {
|
||||
progress: progressCallback(requestId),
|
||||
} as any);
|
||||
return { version: versionId };
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import {
|
||||
launch as xmclLaunch,
|
||||
diagnose as xmclDiagnose,
|
||||
createMinecraftProcessWatcher,
|
||||
Version,
|
||||
type LaunchOption,
|
||||
} from "@xmcl/core";
|
||||
import { sendEvent } from "../protocol/transport.js";
|
||||
|
||||
export async function launchMinecraft(requestId: string, options: {
|
||||
gamePath: string;
|
||||
javaPath: string;
|
||||
version: string;
|
||||
username: string;
|
||||
uuid: string;
|
||||
accessToken?: string;
|
||||
memory?: { min?: string; max?: string };
|
||||
jvmArgs?: string[];
|
||||
gameArgs?: string[];
|
||||
server?: { ip: string; port?: number };
|
||||
detached?: boolean;
|
||||
}) {
|
||||
const launchOption: LaunchOption = {
|
||||
gamePath: options.gamePath,
|
||||
javaPath: options.javaPath,
|
||||
version: options.version,
|
||||
gameProfile: {
|
||||
name: options.username,
|
||||
id: options.uuid,
|
||||
},
|
||||
accessToken: options.accessToken || "",
|
||||
extraExecOption: options.detached ? { detached: true } : undefined,
|
||||
minMemory: options.memory?.min ? parseInt(options.memory.min) : undefined,
|
||||
maxMemory: options.memory?.max ? parseInt(options.memory.max) : undefined,
|
||||
extraJVMArgs: options.jvmArgs,
|
||||
extraMCArgs: options.gameArgs,
|
||||
server: options.server,
|
||||
};
|
||||
|
||||
const proc = await xmclLaunch(launchOption);
|
||||
|
||||
const watcher = createMinecraftProcessWatcher(proc);
|
||||
|
||||
watcher.on("error", (error: any) => {
|
||||
sendEvent(requestId, { event: "error", message: error.message || String(error) });
|
||||
});
|
||||
|
||||
watcher.on("minecraft-exit", (event) => {
|
||||
sendEvent(requestId, { event: "exit", code: event.code, signal: event.signal });
|
||||
});
|
||||
|
||||
watcher.on("minecraft-window-ready", () => {
|
||||
sendEvent(requestId, { event: "window-ready" });
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
sendEvent(requestId, { event: "process-error", message: err.message });
|
||||
});
|
||||
|
||||
return {
|
||||
pid: proc.pid,
|
||||
version: options.version,
|
||||
username: options.username,
|
||||
};
|
||||
}
|
||||
|
||||
export async function diagnoseVersion(gamePath: string, versionId: string) {
|
||||
const report = await xmclDiagnose(versionId, gamePath);
|
||||
return report;
|
||||
}
|
||||
|
||||
export async function parseVersion(gamePath: string, versionId: string) {
|
||||
const resolved = await Version.parse(gamePath, versionId);
|
||||
return {
|
||||
id: resolved.id,
|
||||
type: resolved.type,
|
||||
mainClass: resolved.mainClass,
|
||||
};
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
import { ModrinthV2Client, type SearchResultHit, type ProjectVersion, type Project } from "@xmcl/modrinth";
|
||||
import { CurseforgeV1Client } from "@xmcl/curseforge";
|
||||
import { sendProgress } from "../protocol/transport.js";
|
||||
|
||||
const modrinth = new ModrinthV2Client();
|
||||
const curseforge = new CurseforgeV1Client("");
|
||||
|
||||
export interface ModSearchResult {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
downloads: number;
|
||||
iconUrl?: string;
|
||||
categories?: string[];
|
||||
versions?: string[];
|
||||
loaders?: string[];
|
||||
source: "modrinth" | "curseforge";
|
||||
}
|
||||
|
||||
export interface ModVersionResult {
|
||||
id: string;
|
||||
name: string;
|
||||
versionNumber: string;
|
||||
gameVersions: string[];
|
||||
loaders: string[];
|
||||
files: {
|
||||
filename: string;
|
||||
url: string;
|
||||
size: number;
|
||||
primary: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
export async function searchMods(
|
||||
query?: string,
|
||||
gameVersion?: string,
|
||||
loader?: string,
|
||||
limit: number = 20,
|
||||
offset: number = 0,
|
||||
source: "modrinth" | "curseforge" = "modrinth"
|
||||
): Promise<ModSearchResult[]> {
|
||||
if (source === "modrinth") {
|
||||
const facets: string[] = [];
|
||||
if (gameVersion) facets.push(`versions:${gameVersion}`);
|
||||
if (loader) facets.push(`categories:${loader}`);
|
||||
|
||||
const result = await modrinth.searchProjects({
|
||||
query: query || "",
|
||||
limit,
|
||||
offset,
|
||||
facets: facets.length > 0 ? JSON.stringify([facets]) : undefined,
|
||||
});
|
||||
|
||||
return result.hits.map((hit: SearchResultHit) => ({
|
||||
id: hit.project_id,
|
||||
slug: hit.slug,
|
||||
name: hit.title,
|
||||
description: hit.description,
|
||||
downloads: hit.downloads,
|
||||
iconUrl: hit.icon_url,
|
||||
categories: hit.categories,
|
||||
versions: hit.versions,
|
||||
source: "modrinth" as const,
|
||||
}));
|
||||
}
|
||||
|
||||
// CurseForge
|
||||
const result = await curseforge.searchMods({
|
||||
gameId: 432,
|
||||
classId: 6,
|
||||
searchFilter: query,
|
||||
gameVersion,
|
||||
pageSize: limit,
|
||||
index: offset,
|
||||
});
|
||||
|
||||
return result.data.map((mod) => ({
|
||||
id: String(mod.id),
|
||||
slug: mod.slug,
|
||||
name: mod.name,
|
||||
description: mod.summary,
|
||||
downloads: mod.downloadCount,
|
||||
iconUrl: mod.logo?.thumbnailUrl,
|
||||
source: "curseforge" as const,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getModDetail(
|
||||
projectId: string,
|
||||
source: "modrinth" | "curseforge"
|
||||
) {
|
||||
if (source === "modrinth") {
|
||||
const project = await modrinth.getProject(projectId);
|
||||
return {
|
||||
id: project.id,
|
||||
slug: project.slug,
|
||||
name: project.title,
|
||||
description: project.description,
|
||||
body: project.body,
|
||||
downloads: project.downloads,
|
||||
iconUrl: project.icon_url,
|
||||
categories: project.categories,
|
||||
versions: project.versions,
|
||||
source: "modrinth" as const,
|
||||
};
|
||||
}
|
||||
|
||||
const mod = await curseforge.getMod(Number(projectId));
|
||||
return {
|
||||
id: String(mod.id),
|
||||
slug: mod.slug,
|
||||
name: mod.name,
|
||||
description: mod.summary,
|
||||
body: mod.summary,
|
||||
downloads: mod.downloadCount,
|
||||
iconUrl: mod.logo?.thumbnailUrl,
|
||||
source: "curseforge" as const,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getModVersions(
|
||||
projectId: string,
|
||||
gameVersion?: string,
|
||||
loader?: string,
|
||||
source: "modrinth" | "curseforge" = "modrinth"
|
||||
): Promise<ModVersionResult[]> {
|
||||
if (source === "modrinth") {
|
||||
const versions = await modrinth.getProjectVersions(projectId, {
|
||||
loaders: loader ? [loader] : undefined,
|
||||
gameVersions: gameVersion ? [gameVersion] : undefined,
|
||||
});
|
||||
|
||||
return versions.map((v: ProjectVersion) => ({
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
versionNumber: v.version_number,
|
||||
gameVersions: v.game_versions,
|
||||
loaders: v.loaders,
|
||||
files: v.files.map((f) => ({
|
||||
filename: f.filename,
|
||||
url: f.url,
|
||||
size: f.size,
|
||||
primary: f.primary,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
const versions = await curseforge.getModFiles({
|
||||
modId: Number(projectId),
|
||||
gameVersion,
|
||||
});
|
||||
|
||||
return versions.data.map((v) => ({
|
||||
id: String(v.id),
|
||||
name: v.displayName,
|
||||
versionNumber: v.fileName,
|
||||
gameVersions: v.gameVersions,
|
||||
loaders: [],
|
||||
files: [
|
||||
{
|
||||
filename: v.fileName,
|
||||
url: v.downloadUrl || "",
|
||||
size: v.fileLength,
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
export async function installMod(
|
||||
requestId: string,
|
||||
projectId: string,
|
||||
versionId: string | undefined,
|
||||
gamePath: string,
|
||||
source: "modrinth" | "curseforge"
|
||||
) {
|
||||
if (source === "modrinth") {
|
||||
const versions = await modrinth.getProjectVersions(projectId);
|
||||
const version = versionId
|
||||
? versions.find((v: ProjectVersion) => v.id === versionId)
|
||||
: versions[0];
|
||||
|
||||
if (!version) {
|
||||
throw new Error("No version found");
|
||||
}
|
||||
|
||||
const primaryFile = version.files.find((f) => f.primary) || version.files[0];
|
||||
|
||||
if (!primaryFile) {
|
||||
throw new Error("No file found in version");
|
||||
}
|
||||
|
||||
// Download the mod file
|
||||
const modsDir = `${gamePath}/mods`;
|
||||
const { ensureDir } = await import("../utils/paths.js");
|
||||
ensureDir(modsDir);
|
||||
|
||||
const response = await fetch(primaryFile.url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download mod: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
const { writeFileSync } = await import("fs");
|
||||
const { join } = await import("path");
|
||||
const filePath = join(modsDir, primaryFile.filename);
|
||||
writeFileSync(filePath, buffer);
|
||||
|
||||
sendProgress(requestId, {
|
||||
stage: "mod-installed",
|
||||
current: 1,
|
||||
total: 1,
|
||||
message: primaryFile.filename,
|
||||
});
|
||||
|
||||
return {
|
||||
projectId,
|
||||
versionId: version.id,
|
||||
filename: primaryFile.filename,
|
||||
path: filePath,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("CurseForge mod install not yet implemented");
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { sendResult, sendError } from "../protocol/transport.js";
|
||||
import type {
|
||||
MicrosoftLoginStartRequest,
|
||||
MicrosoftLoginCallbackRequest,
|
||||
OfflineLoginRequest,
|
||||
ValidateTokenRequest,
|
||||
} from "../protocol/types.js";
|
||||
import * as auth from "../core/auth.js";
|
||||
|
||||
export async function handleMicrosoftLoginStart(
|
||||
id: string,
|
||||
payload: MicrosoftLoginStartRequest
|
||||
) {
|
||||
try {
|
||||
const result = await auth.microsoftLoginStart(
|
||||
payload.client_id,
|
||||
payload.redirect_uri
|
||||
);
|
||||
sendResult(id, result);
|
||||
} catch (e: any) {
|
||||
sendError(id, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleMicrosoftLoginCallback(
|
||||
id: string,
|
||||
payload: MicrosoftLoginCallbackRequest
|
||||
) {
|
||||
try {
|
||||
const result = await auth.microsoftLoginCallback(
|
||||
payload.code,
|
||||
payload.client_id
|
||||
);
|
||||
sendResult(id, result);
|
||||
} catch (e: any) {
|
||||
sendError(id, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleOfflineLogin(id: string, payload: OfflineLoginRequest) {
|
||||
try {
|
||||
const result = auth.offlineLogin(payload.username);
|
||||
sendResult(id, result);
|
||||
} catch (e: any) {
|
||||
sendError(id, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleValidateToken(id: string, payload: ValidateTokenRequest) {
|
||||
try {
|
||||
const valid = await auth.validateMinecraftToken(payload.accessToken);
|
||||
sendResult(id, { valid });
|
||||
} catch (e: any) {
|
||||
sendError(id, e.message);
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { sendResult, sendError } from "../protocol/transport.js";
|
||||
import type {
|
||||
SetBackgroundImageRequest,
|
||||
SetBackgroundColorRequest,
|
||||
SetBackgroundBlurRequest,
|
||||
SetBackgroundOpacityRequest,
|
||||
SetBackgroundAnimationRequest,
|
||||
SetBackgroundThemeRequest,
|
||||
BackgroundConfig,
|
||||
} from "../protocol/types.js";
|
||||
|
||||
// In-memory background config (persisted to config file in production)
|
||||
let config: BackgroundConfig = {
|
||||
type: "color",
|
||||
color: "#1a1a2e",
|
||||
blur: 0,
|
||||
opacity: 1,
|
||||
animation: "none",
|
||||
animationSpeed: 1,
|
||||
theme: "dark",
|
||||
};
|
||||
|
||||
export async function handleSetBackgroundImage(id: string, payload: SetBackgroundImageRequest) {
|
||||
try {
|
||||
config.type = "image";
|
||||
config.image = payload.url;
|
||||
if (payload.blur !== undefined) config.blur = payload.blur;
|
||||
if (payload.opacity !== undefined) config.opacity = payload.opacity;
|
||||
sendResult(id, config);
|
||||
} catch (e: any) {
|
||||
sendError(id, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleSetBackgroundColor(id: string, payload: SetBackgroundColorRequest) {
|
||||
try {
|
||||
config.type = "color";
|
||||
config.color = payload.color;
|
||||
sendResult(id, config);
|
||||
} catch (e: any) {
|
||||
sendError(id, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleSetBackgroundBlur(id: string, payload: SetBackgroundBlurRequest) {
|
||||
try {
|
||||
config.blur = Math.max(0, Math.min(20, payload.blur));
|
||||
sendResult(id, config);
|
||||
} catch (e: any) {
|
||||
sendError(id, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleSetBackgroundOpacity(id: string, payload: SetBackgroundOpacityRequest) {
|
||||
try {
|
||||
config.opacity = Math.max(0, Math.min(1, payload.opacity));
|
||||
sendResult(id, config);
|
||||
} catch (e: any) {
|
||||
sendError(id, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleSetBackgroundAnimation(id: string, payload: SetBackgroundAnimationRequest) {
|
||||
try {
|
||||
config.animation = payload.type;
|
||||
if (payload.speed !== undefined) config.animationSpeed = payload.speed;
|
||||
if (payload.type === "gradient" || payload.type === "particles") {
|
||||
config.type = payload.type;
|
||||
}
|
||||
sendResult(id, config);
|
||||
} catch (e: any) {
|
||||
sendError(id, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleGetBackground(id: string) {
|
||||
try {
|
||||
sendResult(id, config);
|
||||
} catch (e: any) {
|
||||
sendError(id, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleSetBackgroundTheme(id: string, payload: SetBackgroundThemeRequest) {
|
||||
try {
|
||||
config.theme = payload.theme;
|
||||
sendResult(id, config);
|
||||
} catch (e: any) {
|
||||
sendError(id, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleResetBackground(id: string) {
|
||||
try {
|
||||
config = {
|
||||
type: "color",
|
||||
color: "#1a1a2e",
|
||||
blur: 0,
|
||||
opacity: 1,
|
||||
animation: "none",
|
||||
animationSpeed: 1,
|
||||
theme: "dark",
|
||||
};
|
||||
sendResult(id, config);
|
||||
} catch (e: any) {
|
||||
sendError(id, e.message);
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { sendProgress, sendResult, sendError } from "../protocol/transport.js";
|
||||
import type {
|
||||
InstallMinecraftRequest,
|
||||
InstallModLoaderRequest,
|
||||
GetVersionListRequest,
|
||||
GetForgeVersionListRequest,
|
||||
GetFabricVersionListRequest,
|
||||
} from "../protocol/types.js";
|
||||
import * as installer from "../core/installer.js";
|
||||
|
||||
export async function handleInstallMinecraft(id: string, payload: InstallMinecraftRequest) {
|
||||
try {
|
||||
sendProgress(id, { stage: "starting", current: 0, total: 1 });
|
||||
const result = await installer.installMinecraft(
|
||||
id,
|
||||
payload.version,
|
||||
payload.gamePath,
|
||||
payload.javaPath,
|
||||
payload.downloadThreads
|
||||
);
|
||||
sendResult(id, result);
|
||||
} catch (e: any) {
|
||||
sendError(id, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleInstallModLoader(id: string, payload: InstallModLoaderRequest) {
|
||||
try {
|
||||
sendProgress(id, { stage: "starting", current: 0, total: 1 });
|
||||
let result;
|
||||
|
||||
switch (payload.loaderType) {
|
||||
case "forge":
|
||||
result = await installer.installForgeLoader(
|
||||
id,
|
||||
payload.mcVersion,
|
||||
payload.gamePath,
|
||||
payload.javaPath,
|
||||
payload.loaderVersion
|
||||
);
|
||||
break;
|
||||
case "fabric":
|
||||
result = await installer.installFabricLoader(
|
||||
id,
|
||||
payload.mcVersion,
|
||||
payload.gamePath,
|
||||
payload.loaderVersion
|
||||
);
|
||||
break;
|
||||
case "quilt":
|
||||
result = await installer.installQuiltLoader(
|
||||
id,
|
||||
payload.mcVersion,
|
||||
payload.gamePath,
|
||||
payload.loaderVersion
|
||||
);
|
||||
break;
|
||||
case "neoforge":
|
||||
throw new Error("NeoForge not yet supported");
|
||||
default:
|
||||
throw new Error(`Unknown loader type: ${payload.loaderType}`);
|
||||
}
|
||||
|
||||
sendResult(id, result);
|
||||
} catch (e: any) {
|
||||
sendError(id, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleGetVersionList(id: string, payload: GetVersionListRequest) {
|
||||
try {
|
||||
const result = await installer.getVersionListAsync(payload.type || "all");
|
||||
sendResult(id, result);
|
||||
} catch (e: any) {
|
||||
sendError(id, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleGetForgeVersionList(id: string, payload: GetForgeVersionListRequest) {
|
||||
try {
|
||||
const result = await installer.getForgeVersions(payload.mcVersion);
|
||||
sendResult(id, result);
|
||||
} catch (e: any) {
|
||||
sendError(id, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleGetFabricVersionList(id: string, payload: GetFabricVersionListRequest) {
|
||||
try {
|
||||
const result = await installer.getFabricVersions(payload.mcVersion);
|
||||
sendResult(id, result);
|
||||
} catch (e: any) {
|
||||
sendError(id, e.message);
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import { existsSync, readdirSync, rmSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { sendResult, sendError } from "../protocol/transport.js";
|
||||
import type {
|
||||
CreateInstanceRequest,
|
||||
ListInstancesRequest,
|
||||
DeleteInstanceRequest,
|
||||
GetInstanceInfoRequest,
|
||||
} from "../protocol/types.js";
|
||||
import {
|
||||
ensureDir,
|
||||
getInstancePath,
|
||||
readInstanceConfig,
|
||||
writeInstanceConfig,
|
||||
type InstanceConfig,
|
||||
} from "../utils/paths.js";
|
||||
|
||||
export async function handleCreateInstance(id: string, payload: CreateInstanceRequest) {
|
||||
try {
|
||||
const instancePath = getInstancePath(payload.instancesPath, payload.name);
|
||||
|
||||
if (existsSync(instancePath)) {
|
||||
throw new Error(`Instance "${payload.name}" already exists`);
|
||||
}
|
||||
|
||||
ensureDir(instancePath);
|
||||
|
||||
const config: InstanceConfig = {
|
||||
name: payload.name,
|
||||
mcVersion: payload.mcVersion,
|
||||
loaderType: payload.loaderType,
|
||||
loaderVersion: payload.loaderVersion,
|
||||
javaPath: payload.javaPath,
|
||||
memory: payload.memory,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
writeInstanceConfig(instancePath, config);
|
||||
|
||||
// Create standard directories
|
||||
ensureDir(join(instancePath, "mods"));
|
||||
ensureDir(join(instancePath, "resourcepacks"));
|
||||
ensureDir(join(instancePath, "saves"));
|
||||
ensureDir(join(instancePath, "config"));
|
||||
|
||||
sendResult(id, {
|
||||
name: payload.name,
|
||||
path: instancePath,
|
||||
config,
|
||||
});
|
||||
} catch (e: any) {
|
||||
sendError(id, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleListInstances(id: string, payload: ListInstancesRequest) {
|
||||
try {
|
||||
const instancesPath = payload.instancesPath;
|
||||
|
||||
if (!existsSync(instancesPath)) {
|
||||
ensureDir(instancesPath);
|
||||
sendResult(id, []);
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = readdirSync(instancesPath, { withFileTypes: true });
|
||||
const instances = entries
|
||||
.filter((e) => e.isDirectory())
|
||||
.map((e) => {
|
||||
const config = readInstanceConfig(join(instancesPath, e.name));
|
||||
return {
|
||||
name: e.name,
|
||||
path: join(instancesPath, e.name),
|
||||
config,
|
||||
};
|
||||
})
|
||||
.filter((i) => i.config !== null);
|
||||
|
||||
sendResult(id, instances);
|
||||
} catch (e: any) {
|
||||
sendError(id, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleDeleteInstance(id: string, payload: DeleteInstanceRequest) {
|
||||
try {
|
||||
const instancePath = getInstancePath(payload.instancesPath, payload.name);
|
||||
|
||||
if (!existsSync(instancePath)) {
|
||||
throw new Error(`Instance "${payload.name}" not found`);
|
||||
}
|
||||
|
||||
rmSync(instancePath, { recursive: true, force: true });
|
||||
|
||||
sendResult(id, { deleted: payload.name });
|
||||
} catch (e: any) {
|
||||
sendError(id, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleGetInstanceInfo(id: string, payload: GetInstanceInfoRequest) {
|
||||
try {
|
||||
const instancePath = getInstancePath(payload.instancesPath, payload.name);
|
||||
|
||||
if (!existsSync(instancePath)) {
|
||||
throw new Error(`Instance "${payload.name}" not found`);
|
||||
}
|
||||
|
||||
const config = readInstanceConfig(instancePath);
|
||||
if (!config) {
|
||||
throw new Error(`Instance "${payload.name}" has no config`);
|
||||
}
|
||||
|
||||
// Count mods
|
||||
const modsPath = join(instancePath, "mods");
|
||||
let modCount = 0;
|
||||
if (existsSync(modsPath)) {
|
||||
modCount = readdirSync(modsPath).filter((f) => f.endsWith(".jar")).length;
|
||||
}
|
||||
|
||||
sendResult(id, {
|
||||
name: payload.name,
|
||||
path: instancePath,
|
||||
config,
|
||||
modCount,
|
||||
});
|
||||
} catch (e: any) {
|
||||
sendError(id, e.message);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { sendResult, sendError } from "../protocol/transport.js";
|
||||
import type { LaunchRequest, DiagnoseRequest } from "../protocol/types.js";
|
||||
import * as launcher from "../core/launcher.js";
|
||||
|
||||
export async function handleLaunch(id: string, payload: LaunchRequest) {
|
||||
try {
|
||||
const result = await launcher.launchMinecraft(id, payload);
|
||||
sendResult(id, result);
|
||||
} catch (e: any) {
|
||||
sendError(id, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleDiagnose(id: string, payload: DiagnoseRequest) {
|
||||
try {
|
||||
const result = await launcher.diagnoseVersion(payload.gamePath, payload.version);
|
||||
sendResult(id, result);
|
||||
} catch (e: any) {
|
||||
sendError(id, e.message);
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { sendResult, sendError } from "../protocol/transport.js";
|
||||
import type {
|
||||
SearchModsRequest,
|
||||
GetModDetailRequest,
|
||||
GetModVersionsRequest,
|
||||
InstallModRequest,
|
||||
} from "../protocol/types.js";
|
||||
import * as modrinth from "../core/modrinth.js";
|
||||
|
||||
export async function handleSearchMods(id: string, payload: SearchModsRequest) {
|
||||
try {
|
||||
const result = await modrinth.searchMods(
|
||||
payload.query,
|
||||
payload.gameVersion,
|
||||
payload.loader,
|
||||
payload.limit,
|
||||
payload.offset,
|
||||
payload.source
|
||||
);
|
||||
sendResult(id, result);
|
||||
} catch (e: any) {
|
||||
sendError(id, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleGetModDetail(id: string, payload: GetModDetailRequest) {
|
||||
try {
|
||||
const result = await modrinth.getModDetail(payload.projectId, payload.source);
|
||||
sendResult(id, result);
|
||||
} catch (e: any) {
|
||||
sendError(id, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleGetModVersions(id: string, payload: GetModVersionsRequest) {
|
||||
try {
|
||||
const result = await modrinth.getModVersions(
|
||||
payload.projectId,
|
||||
payload.gameVersion,
|
||||
payload.loader,
|
||||
payload.source
|
||||
);
|
||||
sendResult(id, result);
|
||||
} catch (e: any) {
|
||||
sendError(id, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleInstallMod(id: string, payload: InstallModRequest) {
|
||||
try {
|
||||
const result = await modrinth.installMod(
|
||||
id,
|
||||
payload.projectId,
|
||||
payload.versionId,
|
||||
payload.gamePath,
|
||||
payload.source
|
||||
);
|
||||
sendResult(id, result);
|
||||
} catch (e: any) {
|
||||
sendError(id, e.message);
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { startTransport, sendError, sendEvent } from "./protocol/transport.js";
|
||||
import type { Request } from "./protocol/types.js";
|
||||
|
||||
import {
|
||||
handleInstallMinecraft,
|
||||
handleInstallModLoader,
|
||||
handleGetVersionList,
|
||||
handleGetForgeVersionList,
|
||||
handleGetFabricVersionList,
|
||||
} from "./handlers/install.js";
|
||||
|
||||
import {
|
||||
handleLaunch,
|
||||
handleDiagnose,
|
||||
} from "./handlers/launch.js";
|
||||
|
||||
import {
|
||||
handleMicrosoftLoginStart,
|
||||
handleMicrosoftLoginCallback,
|
||||
handleOfflineLogin,
|
||||
handleValidateToken,
|
||||
} from "./handlers/auth.js";
|
||||
|
||||
import {
|
||||
handleSearchMods,
|
||||
handleGetModDetail,
|
||||
handleGetModVersions,
|
||||
handleInstallMod,
|
||||
} from "./handlers/mods.js";
|
||||
|
||||
import {
|
||||
handleCreateInstance,
|
||||
handleListInstances,
|
||||
handleDeleteInstance,
|
||||
handleGetInstanceInfo,
|
||||
} from "./handlers/instance.js";
|
||||
|
||||
import {
|
||||
handleSetBackgroundImage,
|
||||
handleSetBackgroundColor,
|
||||
handleSetBackgroundBlur,
|
||||
handleSetBackgroundOpacity,
|
||||
handleSetBackgroundAnimation,
|
||||
handleGetBackground,
|
||||
handleSetBackgroundTheme,
|
||||
handleResetBackground,
|
||||
} from "./handlers/background.js";
|
||||
|
||||
const handlers: Record<string, (id: string, payload: any) => Promise<void>> = {
|
||||
// Install
|
||||
"install:minecraft": handleInstallMinecraft,
|
||||
"install:mod-loader": handleInstallModLoader,
|
||||
"install:version-list": handleGetVersionList,
|
||||
"install:forge-version-list": handleGetForgeVersionList,
|
||||
"install:fabric-version-list": handleGetFabricVersionList,
|
||||
|
||||
// Launch
|
||||
"launch:launch": handleLaunch,
|
||||
"launch:diagnose": handleDiagnose,
|
||||
|
||||
// Auth
|
||||
"auth:microsoft-login-start": handleMicrosoftLoginStart,
|
||||
"auth:microsoft-login-callback": handleMicrosoftLoginCallback,
|
||||
"auth:offline-login": handleOfflineLogin,
|
||||
"auth:validate-token": handleValidateToken,
|
||||
|
||||
// Mods
|
||||
"mods:search": handleSearchMods,
|
||||
"mods:detail": handleGetModDetail,
|
||||
"mods:versions": handleGetModVersions,
|
||||
"mods:install": handleInstallMod,
|
||||
|
||||
// Instance
|
||||
"instance:create": handleCreateInstance,
|
||||
"instance:list": handleListInstances,
|
||||
"instance:delete": handleDeleteInstance,
|
||||
"instance:info": handleGetInstanceInfo,
|
||||
|
||||
// Background
|
||||
"background:set-image": handleSetBackgroundImage,
|
||||
"background:set-color": handleSetBackgroundColor,
|
||||
"background:set-blur": handleSetBackgroundBlur,
|
||||
"background:set-opacity": handleSetBackgroundOpacity,
|
||||
"background:set-animation": handleSetBackgroundAnimation,
|
||||
"background:get": handleGetBackground,
|
||||
"background:set-theme": handleSetBackgroundTheme,
|
||||
"background:reset": handleResetBackground,
|
||||
};
|
||||
|
||||
async function onMessage(msg: Request) {
|
||||
const handler = handlers[msg.type];
|
||||
if (!handler) {
|
||||
sendError(msg.id, `Unknown handler: ${msg.type}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await handler(msg.id, msg.payload);
|
||||
} catch (e: any) {
|
||||
sendError(msg.id, e.message || "Internal error");
|
||||
}
|
||||
}
|
||||
|
||||
process.stderr.write("[koring-sidecar] starting\n");
|
||||
startTransport(onMessage);
|
||||
@@ -1,58 +0,0 @@
|
||||
import { Request, Response, ProgressPayload, ResultPayload } from "./types.js";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
type MessageHandler = (msg: Request) => void;
|
||||
|
||||
let buffer = "";
|
||||
|
||||
function sendResponse<T>(id: string, type: Response["type"], payload: T) {
|
||||
const msg: Response<T> = { id, type, payload };
|
||||
process.stdout.write(JSON.stringify(msg) + "\n");
|
||||
}
|
||||
|
||||
export function sendProgress(id: string, progress: ProgressPayload) {
|
||||
sendResponse(id, "progress", progress);
|
||||
}
|
||||
|
||||
export function sendResult<T>(id: string, data: T) {
|
||||
sendResponse<ResultPayload<T>>(id, "result", { success: true, data });
|
||||
}
|
||||
|
||||
export function sendError(id: string, error: string) {
|
||||
sendResponse<ResultPayload>(id, "result", { success: false, error });
|
||||
}
|
||||
|
||||
export function sendEvent<T>(id: string, event: T) {
|
||||
sendResponse(id, "event", event);
|
||||
}
|
||||
|
||||
export function startTransport(onMessage: MessageHandler) {
|
||||
process.stdin.setEncoding("utf-8");
|
||||
|
||||
process.stdin.on("data", (chunk: string) => {
|
||||
buffer += chunk;
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const msg: Request = JSON.parse(trimmed);
|
||||
onMessage(msg);
|
||||
} catch (e) {
|
||||
process.stderr.write(`[transport] parse error: ${e}\n`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
process.stdin.on("end", () => {
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.stderr.write("[transport] ready\n");
|
||||
}
|
||||
|
||||
export function createRequestId(): string {
|
||||
return randomUUID();
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
export interface Request<T = unknown> {
|
||||
id: string;
|
||||
type: string;
|
||||
payload: T;
|
||||
}
|
||||
|
||||
export interface Response<T = unknown> {
|
||||
id: string;
|
||||
type: "result" | "error" | "progress" | "event";
|
||||
payload: T;
|
||||
}
|
||||
|
||||
export interface ProgressPayload {
|
||||
stage: string;
|
||||
current: number;
|
||||
total: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface ResultPayload<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// === Install ===
|
||||
|
||||
export interface InstallMinecraftRequest {
|
||||
version: string;
|
||||
gamePath: string;
|
||||
javaPath?: string;
|
||||
downloadThreads?: number;
|
||||
}
|
||||
|
||||
export interface InstallModLoaderRequest {
|
||||
gamePath: string;
|
||||
javaPath?: string;
|
||||
mcVersion: string;
|
||||
loaderType: "forge" | "fabric" | "quilt" | "neoforge";
|
||||
loaderVersion?: string;
|
||||
}
|
||||
|
||||
export interface GetVersionListRequest {
|
||||
type?: "release" | "snapshot" | "all";
|
||||
}
|
||||
|
||||
export interface GetForgeVersionListRequest {
|
||||
mcVersion?: string;
|
||||
}
|
||||
|
||||
export interface GetFabricVersionListRequest {
|
||||
mcVersion?: string;
|
||||
}
|
||||
|
||||
// === Launch ===
|
||||
|
||||
export interface LaunchRequest {
|
||||
gamePath: string;
|
||||
javaPath: string;
|
||||
version: string;
|
||||
username: string;
|
||||
uuid: string;
|
||||
accessToken?: string;
|
||||
memory?: { min?: string; max?: string };
|
||||
jvmArgs?: string[];
|
||||
gameArgs?: string[];
|
||||
server?: { ip: string; port?: number };
|
||||
detached?: boolean;
|
||||
}
|
||||
|
||||
export interface DiagnoseRequest {
|
||||
gamePath: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
// === Auth ===
|
||||
|
||||
export interface MicrosoftLoginStartRequest {
|
||||
client_id: string;
|
||||
redirect_uri?: string;
|
||||
}
|
||||
|
||||
export interface MicrosoftLoginCallbackRequest {
|
||||
state: string;
|
||||
code: string;
|
||||
client_id: string;
|
||||
}
|
||||
|
||||
export interface OfflineLoginRequest {
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface ValidateTokenRequest {
|
||||
accessToken: string;
|
||||
clientToken?: string;
|
||||
}
|
||||
|
||||
// === Mods ===
|
||||
|
||||
export interface SearchModsRequest {
|
||||
query?: string;
|
||||
gameVersion?: string;
|
||||
loader?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
source: "modrinth" | "curseforge";
|
||||
}
|
||||
|
||||
export interface GetModDetailRequest {
|
||||
projectId: string;
|
||||
source: "modrinth" | "curseforge";
|
||||
}
|
||||
|
||||
export interface GetModVersionsRequest {
|
||||
projectId: string;
|
||||
gameVersion?: string;
|
||||
loader?: string;
|
||||
source: "modrinth" | "curseforge";
|
||||
}
|
||||
|
||||
export interface InstallModRequest {
|
||||
projectId: string;
|
||||
versionId?: string;
|
||||
gamePath: string;
|
||||
source: "modrinth" | "curseforge";
|
||||
}
|
||||
|
||||
// === Instance ===
|
||||
|
||||
export interface CreateInstanceRequest {
|
||||
name: string;
|
||||
instancesPath: string;
|
||||
gamePath: string;
|
||||
mcVersion: string;
|
||||
loaderType?: string;
|
||||
loaderVersion?: string;
|
||||
javaPath?: string;
|
||||
memory?: { min?: string; max?: string };
|
||||
}
|
||||
|
||||
export interface ListInstancesRequest {
|
||||
instancesPath: string;
|
||||
}
|
||||
|
||||
export interface DeleteInstanceRequest {
|
||||
name: string;
|
||||
instancesPath: string;
|
||||
}
|
||||
|
||||
export interface GetInstanceInfoRequest {
|
||||
name: string;
|
||||
instancesPath: string;
|
||||
}
|
||||
|
||||
// === Background ===
|
||||
|
||||
export type AnimationType = "none" | "gradient" | "particles";
|
||||
export type Theme = "light" | "dark" | "system";
|
||||
|
||||
export interface BackgroundConfig {
|
||||
type: "image" | "color" | "gradient" | "particles";
|
||||
image?: string;
|
||||
color?: string;
|
||||
blur: number;
|
||||
opacity: number;
|
||||
animation: AnimationType;
|
||||
animationSpeed: number;
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
export interface SetBackgroundImageRequest {
|
||||
url: string;
|
||||
blur?: number;
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
export interface SetBackgroundColorRequest {
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface SetBackgroundBlurRequest {
|
||||
blur: number;
|
||||
}
|
||||
|
||||
export interface SetBackgroundOpacityRequest {
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
export interface SetBackgroundAnimationRequest {
|
||||
type: AnimationType;
|
||||
speed?: number;
|
||||
}
|
||||
|
||||
export interface SetBackgroundThemeRequest {
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
// === Handler Map ===
|
||||
|
||||
export type HandlerMap = {
|
||||
// Install
|
||||
"install:minecraft": InstallMinecraftRequest;
|
||||
"install:mod-loader": InstallModLoaderRequest;
|
||||
"install:version-list": GetVersionListRequest;
|
||||
"install:forge-version-list": GetForgeVersionListRequest;
|
||||
"install:fabric-version-list": GetFabricVersionListRequest;
|
||||
|
||||
// Launch
|
||||
"launch:launch": LaunchRequest;
|
||||
"launch:diagnose": DiagnoseRequest;
|
||||
|
||||
// Auth
|
||||
"auth:microsoft-login-start": MicrosoftLoginStartRequest;
|
||||
"auth:microsoft-login-callback": MicrosoftLoginCallbackRequest;
|
||||
"auth:offline-login": OfflineLoginRequest;
|
||||
"auth:validate-token": ValidateTokenRequest;
|
||||
|
||||
// Mods
|
||||
"mods:search": SearchModsRequest;
|
||||
"mods:detail": GetModDetailRequest;
|
||||
"mods:versions": GetModVersionsRequest;
|
||||
"mods:install": InstallModRequest;
|
||||
|
||||
// Instance
|
||||
"instance:create": CreateInstanceRequest;
|
||||
"instance:list": ListInstancesRequest;
|
||||
"instance:delete": DeleteInstanceRequest;
|
||||
"instance:info": GetInstanceInfoRequest;
|
||||
|
||||
// Background
|
||||
"background:set-image": SetBackgroundImageRequest;
|
||||
"background:set-color": SetBackgroundColorRequest;
|
||||
"background:set-blur": SetBackgroundBlurRequest;
|
||||
"background:set-opacity": SetBackgroundOpacityRequest;
|
||||
"background:set-animation": SetBackgroundAnimationRequest;
|
||||
"background:get": never;
|
||||
"background:set-theme": SetBackgroundThemeRequest;
|
||||
"background:reset": never;
|
||||
};
|
||||
@@ -1,61 +0,0 @@
|
||||
import { join } from "path";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
||||
|
||||
export function ensureDir(dir: string) {
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function getMinecraftPath(gamePath: string) {
|
||||
return gamePath;
|
||||
}
|
||||
|
||||
export function getVersionPath(gamePath: string, versionId: string) {
|
||||
return join(gamePath, "versions", versionId);
|
||||
}
|
||||
|
||||
export function getVersionJsonPath(gamePath: string, versionId: string) {
|
||||
return join(getVersionPath(gamePath, versionId), `${versionId}.json`);
|
||||
}
|
||||
|
||||
export function getLibrariesPath(gamePath: string) {
|
||||
return join(gamePath, "libraries");
|
||||
}
|
||||
|
||||
export function getAssetsPath(gamePath: string) {
|
||||
return join(gamePath, "assets");
|
||||
}
|
||||
|
||||
export function getInstancesPath(instancesPath: string) {
|
||||
return instancesPath;
|
||||
}
|
||||
|
||||
export function getInstancePath(instancesPath: string, name: string) {
|
||||
return join(instancesPath, name);
|
||||
}
|
||||
|
||||
export interface InstanceConfig {
|
||||
name: string;
|
||||
mcVersion: string;
|
||||
loaderType?: string;
|
||||
loaderVersion?: string;
|
||||
javaPath?: string;
|
||||
memory?: { min?: string; max?: string };
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function readInstanceConfig(instancePath: string): InstanceConfig | null {
|
||||
const configPath = join(instancePath, "koring-instance.json");
|
||||
if (!existsSync(configPath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(configPath, "utf-8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeInstanceConfig(instancePath: string, config: InstanceConfig) {
|
||||
const configPath = join(instancePath, "koring-instance.json");
|
||||
writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist", "bin"]
|
||||
}
|
||||
@@ -22,7 +22,7 @@
|
||||
<div id="root">
|
||||
<div class="container">
|
||||
<div class="logo-area">
|
||||
<img src="/koring-licon.svg" alt="Koring Launcher" />
|
||||
<img src="./koring-licon.svg" alt="Koring Launcher" />
|
||||
</div>
|
||||
<div class="bottom-bar">
|
||||
<span>Provided by Koring Studio</span>
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
@@ -1,31 +0,0 @@
|
||||
[package]
|
||||
name = "koring-launcher"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "koring_launcher_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
tokio = { version = "1", features = ["time"] }
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["*"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:default",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-maximize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-set-focus",
|
||||
"core:webview:allow-create-webview-window",
|
||||
"opener:default",
|
||||
"process:default",
|
||||
"shell:allow-execute",
|
||||
"updater:default",
|
||||
"dialog:default",
|
||||
{
|
||||
"identifier": "shell:allow-execute",
|
||||
"allow": [
|
||||
{
|
||||
"name": "binaries/koring-sidecar",
|
||||
"sidecar": true,
|
||||
"args": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 393 KiB |
|
Before Width: | Height: | Size: 393 KiB |
|
Before Width: | Height: | Size: 393 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 903 B |
|
Before Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 365 KiB |
@@ -1,321 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
use crate::sidecar::{Request, SidecarManager};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CommandResult {
|
||||
pub success: bool,
|
||||
pub data: Option<serde_json::Value>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn sidecar_request(
|
||||
sidecar: State<'_, std::sync::Mutex<SidecarManager>>,
|
||||
msg_type: String,
|
||||
payload: serde_json::Value,
|
||||
) -> Result<CommandResult, String> {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let request = Request {
|
||||
id: id.clone(),
|
||||
msg_type,
|
||||
payload,
|
||||
};
|
||||
|
||||
let mut manager = sidecar.lock().map_err(|e| e.to_string())?;
|
||||
manager.send_request(&request)?;
|
||||
|
||||
Ok(CommandResult {
|
||||
success: true,
|
||||
data: Some(serde_json::json!({ "requestId": id })),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn install_minecraft(
|
||||
sidecar: State<'_, std::sync::Mutex<SidecarManager>>,
|
||||
version: String,
|
||||
game_path: String,
|
||||
java_path: Option<String>,
|
||||
download_threads: Option<u32>,
|
||||
) -> Result<CommandResult, String> {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let request = Request {
|
||||
id: id.clone(),
|
||||
msg_type: "install:minecraft".to_string(),
|
||||
payload: serde_json::json!({
|
||||
"version": version,
|
||||
"gamePath": game_path,
|
||||
"javaPath": java_path,
|
||||
"downloadThreads": download_threads,
|
||||
}),
|
||||
};
|
||||
|
||||
let mut manager = sidecar.lock().map_err(|e| e.to_string())?;
|
||||
manager.send_request(&request)?;
|
||||
|
||||
Ok(CommandResult {
|
||||
success: true,
|
||||
data: Some(serde_json::json!({ "requestId": id })),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn install_mod_loader(
|
||||
sidecar: State<'_, std::sync::Mutex<SidecarManager>>,
|
||||
mc_version: String,
|
||||
game_path: String,
|
||||
loader_type: String,
|
||||
loader_version: Option<String>,
|
||||
java_path: Option<String>,
|
||||
) -> Result<CommandResult, String> {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let request = Request {
|
||||
id: id.clone(),
|
||||
msg_type: "install:mod-loader".to_string(),
|
||||
payload: serde_json::json!({
|
||||
"mcVersion": mc_version,
|
||||
"gamePath": game_path,
|
||||
"loaderType": loader_type,
|
||||
"loaderVersion": loader_version,
|
||||
"javaPath": java_path,
|
||||
}),
|
||||
};
|
||||
|
||||
let mut manager = sidecar.lock().map_err(|e| e.to_string())?;
|
||||
manager.send_request(&request)?;
|
||||
|
||||
Ok(CommandResult {
|
||||
success: true,
|
||||
data: Some(serde_json::json!({ "requestId": id })),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_version_list(
|
||||
sidecar: State<'_, std::sync::Mutex<SidecarManager>>,
|
||||
version_type: Option<String>,
|
||||
) -> Result<CommandResult, String> {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let request = Request {
|
||||
id: id.clone(),
|
||||
msg_type: "install:version-list".to_string(),
|
||||
payload: serde_json::json!({
|
||||
"type": version_type,
|
||||
}),
|
||||
};
|
||||
|
||||
let mut manager = sidecar.lock().map_err(|e| e.to_string())?;
|
||||
manager.send_request(&request)?;
|
||||
|
||||
Ok(CommandResult {
|
||||
success: true,
|
||||
data: Some(serde_json::json!({ "requestId": id })),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn launch_game(
|
||||
sidecar: State<'_, std::sync::Mutex<SidecarManager>>,
|
||||
game_path: String,
|
||||
java_path: String,
|
||||
version: String,
|
||||
username: String,
|
||||
uuid: String,
|
||||
access_token: Option<String>,
|
||||
memory: Option<serde_json::Value>,
|
||||
jvm_args: Option<Vec<String>>,
|
||||
game_args: Option<Vec<String>>,
|
||||
server: Option<serde_json::Value>,
|
||||
detached: Option<bool>,
|
||||
) -> Result<CommandResult, String> {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let request = Request {
|
||||
id: id.clone(),
|
||||
msg_type: "launch:launch".to_string(),
|
||||
payload: serde_json::json!({
|
||||
"gamePath": game_path,
|
||||
"javaPath": java_path,
|
||||
"version": version,
|
||||
"username": username,
|
||||
"uuid": uuid,
|
||||
"accessToken": access_token,
|
||||
"memory": memory,
|
||||
"jvmArgs": jvm_args,
|
||||
"gameArgs": game_args,
|
||||
"server": server,
|
||||
"detached": detached,
|
||||
}),
|
||||
};
|
||||
|
||||
let mut manager = sidecar.lock().map_err(|e| e.to_string())?;
|
||||
manager.send_request(&request)?;
|
||||
|
||||
Ok(CommandResult {
|
||||
success: true,
|
||||
data: Some(serde_json::json!({ "requestId": id })),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn offline_login(
|
||||
sidecar: State<'_, std::sync::Mutex<SidecarManager>>,
|
||||
username: String,
|
||||
) -> Result<CommandResult, String> {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let request = Request {
|
||||
id: id.clone(),
|
||||
msg_type: "auth:offline-login".to_string(),
|
||||
payload: serde_json::json!({
|
||||
"username": username,
|
||||
}),
|
||||
};
|
||||
|
||||
let mut manager = sidecar.lock().map_err(|e| e.to_string())?;
|
||||
manager.send_request(&request)?;
|
||||
|
||||
Ok(CommandResult {
|
||||
success: true,
|
||||
data: Some(serde_json::json!({ "requestId": id })),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn search_mods(
|
||||
sidecar: State<'_, std::sync::Mutex<SidecarManager>>,
|
||||
query: Option<String>,
|
||||
game_version: Option<String>,
|
||||
loader: Option<String>,
|
||||
limit: Option<u32>,
|
||||
offset: Option<u32>,
|
||||
source: String,
|
||||
) -> Result<CommandResult, String> {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let request = Request {
|
||||
id: id.clone(),
|
||||
msg_type: "mods:search".to_string(),
|
||||
payload: serde_json::json!({
|
||||
"query": query,
|
||||
"gameVersion": game_version,
|
||||
"loader": loader,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"source": source,
|
||||
}),
|
||||
};
|
||||
|
||||
let mut manager = sidecar.lock().map_err(|e| e.to_string())?;
|
||||
manager.send_request(&request)?;
|
||||
|
||||
Ok(CommandResult {
|
||||
success: true,
|
||||
data: Some(serde_json::json!({ "requestId": id })),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn install_mod(
|
||||
sidecar: State<'_, std::sync::Mutex<SidecarManager>>,
|
||||
project_id: String,
|
||||
version_id: Option<String>,
|
||||
game_path: String,
|
||||
source: String,
|
||||
) -> Result<CommandResult, String> {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let request = Request {
|
||||
id: id.clone(),
|
||||
msg_type: "mods:install".to_string(),
|
||||
payload: serde_json::json!({
|
||||
"projectId": project_id,
|
||||
"versionId": version_id,
|
||||
"gamePath": game_path,
|
||||
"source": source,
|
||||
}),
|
||||
};
|
||||
|
||||
let mut manager = sidecar.lock().map_err(|e| e.to_string())?;
|
||||
manager.send_request(&request)?;
|
||||
|
||||
Ok(CommandResult {
|
||||
success: true,
|
||||
data: Some(serde_json::json!({ "requestId": id })),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_instance(
|
||||
sidecar: State<'_, std::sync::Mutex<SidecarManager>>,
|
||||
name: String,
|
||||
game_path: String,
|
||||
mc_version: String,
|
||||
loader_type: Option<String>,
|
||||
loader_version: Option<String>,
|
||||
java_path: Option<String>,
|
||||
memory: Option<serde_json::Value>,
|
||||
) -> Result<CommandResult, String> {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let request = Request {
|
||||
id: id.clone(),
|
||||
msg_type: "instance:create".to_string(),
|
||||
payload: serde_json::json!({
|
||||
"name": name,
|
||||
"gamePath": game_path,
|
||||
"mcVersion": mc_version,
|
||||
"loaderType": loader_type,
|
||||
"loaderVersion": loader_version,
|
||||
"javaPath": java_path,
|
||||
"memory": memory,
|
||||
}),
|
||||
};
|
||||
|
||||
let mut manager = sidecar.lock().map_err(|e| e.to_string())?;
|
||||
manager.send_request(&request)?;
|
||||
|
||||
Ok(CommandResult {
|
||||
success: true,
|
||||
data: Some(serde_json::json!({ "requestId": id })),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_instances(
|
||||
sidecar: State<'_, std::sync::Mutex<SidecarManager>>,
|
||||
instances_path: String,
|
||||
) -> Result<CommandResult, String> {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let request = Request {
|
||||
id: id.clone(),
|
||||
msg_type: "instance:list".to_string(),
|
||||
payload: serde_json::json!({
|
||||
"instancesPath": instances_path,
|
||||
}),
|
||||
};
|
||||
|
||||
let mut manager = sidecar.lock().map_err(|e| e.to_string())?;
|
||||
manager.send_request(&request)?;
|
||||
|
||||
Ok(CommandResult {
|
||||
success: true,
|
||||
data: Some(serde_json::json!({ "requestId": id })),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
mod sidecar;
|
||||
mod commands;
|
||||
|
||||
use sidecar::SidecarManager;
|
||||
use std::sync::Mutex;
|
||||
use tauri::Manager;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.manage(Mutex::new(SidecarManager::new()))
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::sidecar_request,
|
||||
commands::install_minecraft,
|
||||
commands::install_mod_loader,
|
||||
commands::get_version_list,
|
||||
commands::launch_game,
|
||||
commands::offline_login,
|
||||
commands::search_mods,
|
||||
commands::install_mod,
|
||||
commands::create_instance,
|
||||
commands::list_instances,
|
||||
])
|
||||
.setup(|app| {
|
||||
// Hide main window initially
|
||||
if let Some(main) = app.get_webview_window("main") {
|
||||
main.hide().ok();
|
||||
}
|
||||
|
||||
// After splash animation, close splash and show main
|
||||
let handle = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(4)).await;
|
||||
|
||||
// Close splash screen
|
||||
if let Some(splash) = handle.get_webview_window("splashscreen") {
|
||||
splash.close().ok();
|
||||
}
|
||||
|
||||
// Show main window
|
||||
if let Some(main) = handle.get_webview_window("main") {
|
||||
main.show().ok();
|
||||
main.set_focus().ok();
|
||||
}
|
||||
});
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
eprintln!("[koring] dev mode: sidecar spawn skipped (compile sidecar first)");
|
||||
return Ok(());
|
||||
}
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
let handle = app.handle().clone();
|
||||
let state = app.state::<Mutex<SidecarManager>>();
|
||||
match state.inner().lock() {
|
||||
Ok(mut sidecar) => {
|
||||
if let Err(e) = sidecar.spawn(&handle) {
|
||||
eprintln!("[koring] sidecar spawn failed (non-fatal): {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[koring] sidecar lock failed (non-fatal): {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
koring_launcher_lib::run()
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Request {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub msg_type: String,
|
||||
pub payload: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Response {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub msg_type: String,
|
||||
pub payload: serde_json::Value,
|
||||
}
|
||||
|
||||
pub struct SidecarManager {
|
||||
child: Option<CommandChild>,
|
||||
}
|
||||
|
||||
impl SidecarManager {
|
||||
pub fn new() -> Self {
|
||||
Self { child: None }
|
||||
}
|
||||
|
||||
pub fn spawn(&mut self, app: &AppHandle) -> Result<(), String> {
|
||||
let sidecar_command = app
|
||||
.shell()
|
||||
.sidecar("koring-sidecar")
|
||||
.map_err(|e| format!("Failed to create sidecar command: {}", e))?;
|
||||
|
||||
let (mut rx, child) = sidecar_command
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn sidecar: {}", e))?;
|
||||
|
||||
self.child = Some(child);
|
||||
|
||||
let app_handle = app.clone();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
while let Some(event) = rx.recv().await {
|
||||
match event {
|
||||
CommandEvent::Stdout(line_bytes) => {
|
||||
let line = String::from_utf8_lossy(&line_bytes);
|
||||
for line in line.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(response) = serde_json::from_str::<Response>(trimmed) {
|
||||
let _ = app_handle.emit("sidecar-response", &response);
|
||||
}
|
||||
}
|
||||
}
|
||||
CommandEvent::Stderr(line_bytes) => {
|
||||
let line = String::from_utf8_lossy(&line_bytes);
|
||||
eprintln!("[sidecar stderr] {}", line);
|
||||
}
|
||||
CommandEvent::Error(err) => {
|
||||
eprintln!("[sidecar error] {}", err);
|
||||
}
|
||||
CommandEvent::Terminated(status) => {
|
||||
eprintln!("[sidecar] terminated with status: {:?}", status);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn send_request(&mut self, request: &Request) -> Result<(), String> {
|
||||
let child = self.child.as_mut().ok_or("Sidecar not running")?;
|
||||
let json = serde_json::to_string(request).map_err(|e| format!("Serialize error: {}", e))?;
|
||||
child
|
||||
.write((json + "\n").as_bytes())
|
||||
.map_err(|e| format!("Write error: {}", e))
|
||||
}
|
||||
|
||||
pub fn kill(&mut self) -> Result<(), String> {
|
||||
if let Some(child) = self.child.take() {
|
||||
child.kill().map_err(|e| format!("Kill error: {}", e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"build": {
|
||||
"beforeBuildCommand": "echo skip-beta-build"
|
||||
},
|
||||
"bundle": {
|
||||
"targets": []
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "koring-launcher",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.lingke.koring.launcher",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "pnpm build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "splashscreen",
|
||||
"title": "Koring Launcher",
|
||||
"url": "/splash.html",
|
||||
"width": 480,
|
||||
"height": 320,
|
||||
"decorations": false,
|
||||
"transparent": true,
|
||||
"center": true,
|
||||
"visible": true,
|
||||
"resizable": false,
|
||||
"minWidth": 480,
|
||||
"maxWidth": 480,
|
||||
"minHeight": 320,
|
||||
"maxHeight": 320
|
||||
},
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Koring Launcher",
|
||||
"width": 900,
|
||||
"height": 600,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600,
|
||||
"decorations": false,
|
||||
"transparent": true,
|
||||
"center": true,
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["nsis"],
|
||||
"icon": [
|
||||
"icons/icon.png"
|
||||
],
|
||||
"externalBin": [
|
||||
"binaries/koring-sidecar"
|
||||
],
|
||||
"createUpdaterArtifacts": true
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"endpoints": [
|
||||
"https://cdn.crabnebula.app/update/lingke-network/koring-launcher/{{target}}-{{arch}}/{{current_version}}"
|
||||
],
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEYxRDZBQjc3QUQxNDBGNDUKUldSRkR4U3RkNnZXOGNtdHE4TS80d3p4UHlhaEVVMDFUTFRrQmhySnFVNWdZWHNXOWUraFFhRTcK"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import { useEffect } from "react";
|
||||
import { RootLayout } from "./layouts/RootLayout";
|
||||
import { useRouteStore } from "./stores/routeStore";
|
||||
import { useTheme } from "./hooks/useTheme";
|
||||
import { useConfigStore } from "./stores/configStore";
|
||||
import { syncThemeFromConfig } from "./stores/themeStore";
|
||||
import { syncA11yFromConfig } from "./stores/a11yStore";
|
||||
import { syncBackgroundFromConfig } from "./stores/backgroundStore";
|
||||
import { useAuthStore } from "./stores/authStore";
|
||||
import { Home } from "./pages/home";
|
||||
import { Store } from "./pages/store";
|
||||
import { Today } from "./pages/today";
|
||||
@@ -12,6 +18,8 @@ import { SplashDebug } from "./pages/debug/splash-debug";
|
||||
import { DisplayDebug } from "./pages/debug/display-debug";
|
||||
import { VersionCardDebug } from "./pages/debug/version-card-debug";
|
||||
import { TaskDebug } from "./pages/debug/task-debug";
|
||||
import { Oobe } from "./pages/oobe";
|
||||
import { OobeAboutInfo } from "./pages/oobe/about-info";
|
||||
|
||||
const pageMap = {
|
||||
home: Home,
|
||||
@@ -20,6 +28,8 @@ const pageMap = {
|
||||
"play-link": PlayLink,
|
||||
setting: Setting,
|
||||
"task-queue": TaskQueue,
|
||||
oobe: Oobe,
|
||||
"oobe/about-info": OobeAboutInfo,
|
||||
debug: Debug,
|
||||
"debug-splash": SplashDebug,
|
||||
"debug-display": DisplayDebug,
|
||||
@@ -32,6 +42,15 @@ function App() {
|
||||
const current = useRouteStore((s) => s.current);
|
||||
const Page = pageMap[current];
|
||||
|
||||
useEffect(() => {
|
||||
useConfigStore.getState().init().then(() => {
|
||||
syncThemeFromConfig();
|
||||
syncA11yFromConfig();
|
||||
syncBackgroundFromConfig();
|
||||
useAuthStore.getState().initFromRegistry();
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<RootLayout>
|
||||
<Page key={current} />
|
||||
|
||||