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.
This commit is contained in:
2026-06-28 03:37:07 +08:00
parent 5b2e2860de
commit 9cb6a6e571
152 changed files with 8449 additions and 9562 deletions
+5
View File
@@ -10,6 +10,7 @@ lerna-debug.log*
node_modules node_modules
dist dist
dist-ssr dist-ssr
dist-electron
*.local *.local
# Editor directories and files # Editor directories and files
@@ -22,3 +23,7 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
# Electron
electron/*.js
electron/*.d.ts
-1
View File
@@ -1 +0,0 @@
dW50cnVzdGVkIGNvbW1lbnQ6IHJzaWduIGVuY3J5cHRlZCBzZWNyZXQga2V5ClJXUlRZMEl5b29vWWcwSnZkbzlYQWNNQzJuVEZ2dlJJd3A0MUhTcEpCYmZubFdlVmVvY0FBQkFBQUFBQUFBQUFBQUlBQUFBQXEveUNRTnFXUEE5Q3M5Und2YTFlQWdRUVh1L3hudGJaTnZlRlRHM3FwZkVHQXBhNlpQWmF3bkcwTGJ5N1MwbEFIRFVXM3lCc3QxdUVOeDJqeVdVK29lZkllRmxWZ2FxcW1CK2cvYTFwMUptQ0hMZEpjZXhpdU1Ndm9CVWcrTk91aXBiSHB4MVBKdU09Cg==
-1
View File
@@ -1 +0,0 @@
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEYxRDZBQjc3QUQxNDBGNDUKUldSRkR4U3RkNnZXOGNtdHE4TS80d3p4UHlhaEVVMDFUTFRrQmhySnFVNWdZWHNXOWUraFFhRTcK
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"] "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
} }
+33 -20
View File
@@ -1,43 +1,56 @@
# koring-launcher # 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 ## Quick commands
```bash ```bash
pnpm tauri dev # full app dev (frontend + Rust + sidecar skip) pnpm dev # full app dev (renderer + main process)
pnpm dev # frontend only (vite, port 1420) pnpm dev:renderer # frontend only (vite, port 1420)
cd sidecar && pnpm dev # sidecar dev (tsx) pnpm dev:main # electron main process only
pnpm build # production build (vite + tsc)
pnpm dist:win # build Windows installer
``` ```
## Architecture ## Architecture
- **Frontend** (`src/`): React 19 + Vite 7 + Tailwind v4 + shadcn/ui + Zustand stores - **Frontend** (`src/`): React 19 + Vite 7 + Tailwind v4 + shadcn/ui + Zustand stores
- **Backend** (`src-tauri/`): Rust/Tauri 2, manages sidecar process via stdin/stdout JSON - **Main Process** (`electron/`): Node.js/TypeScript, manages windows, IPC handlers, @xmcl/* packages
- **Sidecar** (`sidecar/`): Node.js/TypeScript, wraps @xmcl/* packages (MC install/launch/auth/mods) - IPC: Frontend → `ipcRenderer.invoke()``ipcMain.handle()` → main process → `webContents.send()` → Frontend
- IPC: Frontend → `invoke()` → Rust commands → sidecar stdin → sidecar stdout → Tauri events → Frontend
## Key gotchas ## 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`). - **Electron main process**: `electron/main.ts` is the entry point. All @xmcl/* packages run here.
- **Sidecar compile**: `cd sidecar && pnpm compile` (requires `bun`). Default target is ARM64 Windows. Use `pnpm compile:x64` for x86_64. - **Preload script**: `electron/preload.ts` exposes `window.electronAPI` via context bridge.
- **@xmcl packages are Node.js only**: `@xmcl/core`, `@xmcl/installer` require `fs`/`child_process`. Only `@xmcl/user`, `@xmcl/modrinth`, `@xmcl/curseforge` work in browser. - **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`). - **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`. - **Dev mode**: Vite runs on port 1420, Electron loads from localhost.
- **State management**: Zustand stores in `src/stores/` (install, auth, launch, mods, instance).
## Build & bundle ## Build & bundle
```bash ```bash
pnpm tauri build # production build (requires sidecar binary in binaries/) pnpm build # Vite build + TypeScript compile
cd sidecar && pnpm compile # compile sidecar to exe first 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 ## Frontend notes
- `src-tauri/src/sidecar.rs`: `SidecarManager` — spawns sidecar, reads stdout, emits Tauri events
- `src-tauri/src/commands/mod.rs`: all `#[tauri::command]` handlers - `src/api/ipc.ts`: Core IPC utilities (invoke, onIpcEvent)
- `uuid` crate used for request IDs - `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
+237 -236
View File
@@ -9,6 +9,7 @@
| 状态管理 | Zustand | | 状态管理 | Zustand |
| Backend | Rust / Tauri 2 | | Backend | Rust / Tauri 2 |
| Sidecar | Node.js / TypeScript / @xmcl/* | | Sidecar | Node.js / TypeScript / @xmcl/* |
| 配置存储 | YAML (serde_yaml) + Windows Registry |
| 包管理 | pnpm | | 包管理 | pnpm |
| 目标平台 | ARM64 Windows (`aarch64-pc-windows-msvc`) | | 目标平台 | 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/ koring-launcher/
├── src/ # 前端源码 ├── src/ # 前端源码
│ ├── App.tsx # 路由入口 (Zustand 路由) │ ├── App.tsx # 路由入口 + configStore 初始化
│ ├── index.css # 全局样式 + CSS 变量 + 动画 │ ├── index.css # 全局样式 + CSS 变量 + 动画
│ ├── layouts/ │ ├── layouts/
│ │ └── RootLayout.tsx # 三层布局: BackgroundLayer + ContentLayer + SystemLayer │ │ └── RootLayout.tsx # 三层布局: BackgroundLayer + ContentLayer + SystemLayer
@@ -69,7 +204,6 @@ koring-launcher/
│ │ │ └── Silk.tsx # WebGL 丝绸着色器 (Three.js) │ │ │ └── Silk.tsx # WebGL 丝绸着色器 (Three.js)
│ │ ├── task/ │ │ ├── task/
│ │ │ ├── TaskButton.tsx # 标题栏任务指示器 (SVG 圆弧动画) │ │ │ ├── TaskButton.tsx # 标题栏任务指示器 (SVG 圆弧动画)
│ │ │ ├── TaskSheet.tsx # 任务队列侧面板 (z-[110])
│ │ │ └── TaskCard.tsx # 单个任务卡片 (进度条+日志) │ │ │ └── TaskCard.tsx # 单个任务卡片 (进度条+日志)
│ │ ├── ui/ # shadcn/ui 组件 │ │ ├── ui/ # shadcn/ui 组件
│ │ │ ├── sheet.tsx, button.tsx, switch.tsx, slider.tsx │ │ │ ├── sheet.tsx, button.tsx, switch.tsx, slider.tsx
@@ -78,25 +212,52 @@ koring-launcher/
│ │ ├── VersionCard.tsx # 版本/更新卡片 (Silk 背景+毛玻璃) │ │ ├── VersionCard.tsx # 版本/更新卡片 (Silk 背景+毛玻璃)
│ │ ├── UnderConstruction.tsx # "装修中" 占位组件 │ │ ├── UnderConstruction.tsx # "装修中" 占位组件
│ │ └── StartupPopup.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/ │ ├── hooks/
│ │ └── useTheme.ts # 同步 darkMode → .dark class │ │ └── useTheme.ts # 同步 darkMode → .dark class
│ ├── lib/ │ ├── lib/
│ │ ├── mode.ts # BUILD_MODE 常量 │ │ ├── mode.ts # BUILD_MODE 常量
│ │ └── utils.ts # cn() 工具函数 │ │ └── utils.ts # cn() 工具函数
│ ├── api/ # Sidecar IPC 封装
│ ├── types/ │ ├── types/
│ │ └── task.ts # Task 类型定义 │ │ └── task.ts # Task 类型定义
│ └── pages/ # 页面组件 │ └── pages/ # 页面组件
├── src-tauri/ # Rust 后端 ├── src-tauri/ # Rust 后端
│ ├── Cargo.toml # 依赖: serde, serde_json, serde_yaml, winreg
│ ├── tauri.conf.json # 窗口配置 + Bundle + Updater │ ├── tauri.conf.json # 窗口配置 + Bundle + Updater
│ ├── capabilities/default.json # 权限声明 │ ├── capabilities/default.json # 权限声明
│ ├── src/ │ ├── src/
│ │ ├── lib.rs # 插件注册 + splash/main 窗口逻辑 │ │ ├── lib.rs # 插件注册 + splash/main + 命令注册
│ │ ├── commands/mod.rs # Tauri 命令 (→ sidecar) │ │ ├── config.rs # AppConfig 读写 + 迁移 + 默认值
│ │ ├── registry.rs # Windows Registry 读写
│ │ ├── commands/mod.rs # Tauri 命令 (→ sidecar + config + auth)
│ │ └── sidecar.rs # Sidecar 进程管理 │ │ └── sidecar.rs # Sidecar 进程管理
│ └── binaries/ # 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 环境编译脚本 ├── build-vs.cmd # VS 环境编译脚本
└── dev-vs.cmd # VS 环境开发脚本 └── dev-vs.cmd # VS 环境开发脚本
``` ```
@@ -109,274 +270,105 @@ koring-launcher/
| Key | Label | 组件 | 说明 | | Key | Label | 组件 | 说明 |
|---|---|---|---| |---|---|---|---|
| `home` | 首页 | `pages/home/index.tsx` | 欢迎页 | | `home` | 首页 | `pages/home/index.tsx` | StartCard 启动组件 |
| `store` | 资源 | `pages/store/index.tsx` | 🚧 装修中 | | `store` | 资源 | `pages/store/index.tsx` | 🚧 装修中 |
| `today` | 资讯 | `pages/today/index.tsx` | 🚧 装修中 | | `today` | 资讯 | `pages/today/index.tsx` | 🚧 装修中 |
| `play-link` | 联机 | `pages/play-link/index.tsx` | 🚧 装修中 | | `play-link` | 联机 | `pages/play-link/index.tsx` | 🚧 装修中 |
| `setting` | 设置 | `pages/setting/index.tsx` | 侧边栏 + 内容区 | | `setting` | 设置 | `pages/setting/index.tsx` | 侧边栏 + 内容区 |
### 隐藏路由 (debug) ### 隐藏路由
| Key | Label | 组件 | | Key | Label | 组件 |
|---|---|---| |---|---|---|
| `task-queue` | 任务队列 | `pages/task-queue.tsx` |
| `debug` | 调试 | `pages/debug/index.tsx` | | `debug` | 调试 | `pages/debug/index.tsx` |
| `debug-splash` | 启动动画调试 | `pages/debug/splash-debug.tsx` | | `debug-splash` | 启动动画调试 | `pages/debug/splash-debug.tsx` |
| `debug-display` | 显示效果调试 | `pages/debug/display-debug.tsx` | | `debug-display` | 显示效果调试 | `pages/debug/display-debug.tsx` |
| `debug-version-card` | 版本卡片调试 | `pages/debug/version-card-debug.tsx` | | `debug-version-card` | 版本卡片调试 | `pages/debug/version-card-debug.tsx` |
| `debug-task` | 任务队列调试 | `pages/debug/task-debug.tsx` | | `debug-task` | 任务队列调试 | `pages/debug/task-debug.tsx` |
### 路由层级 (返回导航) ### 路由导航 (历史栈)
``` 路由使用动态历史栈替代静态 parentMap。每次 `navigate()` 压入历史,`goBack()` 弹出。
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)_ | 跳转调试页 |
--- ---
## Zustand Stores ## Zustand Stores
### routeStore ### configStore (统一配置中心)
管理页面路由和标题栏模式 所有用户设置的单一数据源。读写通过 Tauri invoke 与 `Koring.yml` 同步
```ts ```ts
// State config: AppConfig // 完整配置
current: RouteKey // 当前路由 key loaded: boolean // 是否已从 Rust 加载
titleBarMode: TitleBarMode // "default" | "sub" | "window"
direction: TransitionDirection // "forward" | "backward"
// Actions init() // 从 Rust 加载配置
navigate(key) // 跳转路由 (View Transitions API 动画) setTheme(patch) // 部分更新 + debounce 300ms 写回
goBack() // 返回父路由 (通过 parentMap) setA11y(patch)
setTitleBarMode(m) // 手动设置标题栏模式 setBackground(patch)
setGame(patch)
setJava(patch)
setAdvanced(patch)
setDownload(patch)
setNetwork(patch)
``` ```
### themeStore ### themeStore (委托 configStore)
深色模式和视差设置。
```ts ```ts
// State darkMode: "auto" | "light" | "dark"
darkMode: "auto" | "light" | "dark" // 默认 "auto" parallax: boolean
parallax: boolean // 默认 true setDarkMode(mode) // 更新 DOM + configStore
setParallax(v) // configStore
// Actions syncThemeFromConfig() // 启动时从 config 同步
setDarkMode(mode) // 应用深色模式到 DOM (.dark class)
setParallax(v) // 设置视差开关
``` ```
### backgroundStore ### a11yStore (委托 configStore)
背景图片/颜色/模糊/透明度 (localStorage 持久化)。
```ts ```ts
// State (localStorage: "koring-background") reduceMotion, reduceTransparency, highContrast, contentBlurOpacity
type: "image" | "color" // 默认 "image" syncA11yFromConfig()
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() // 恢复默认
``` ```
### a11yStore ### backgroundStore (委托 configStore)
无障碍设置。
```ts ```ts
// State type: "image" | "color", image, blur, opacity
reduceMotion: boolean // 默认 false setImage / setColor / setBlur / setOpacity / reset
reduceTransparency: boolean // 默认 false syncBackgroundFromConfig()
highContrast: boolean // 默认 false
contentBlurOpacity: number // 默认 50 (0-100)
// Actions
setReduceMotion(v)
setReduceTransparency(v)
setHighContrast(v)
setContentBlurOpacity(v)
``` ```
### devStore ### authStore (委托 Registry)
开发者调试控制。
```ts ```ts
// State user: AuthResult | null
forceDisableContentBlur: boolean // 默认 false initFromRegistry() // 从 Windows Registry 加载
previewMode: string | null // 默认 null loginOffline(username) // 通过 sidecar + 保存到 registry
previewUpdateState: PreviewUpdateState | null // 默认 null logout() // 清除 registry
overlayOpacity: number // 默认 30
blurAmount: number // 默认 12
// Actions
setForceDisableContentBlur(v)
setPreviewMode(v)
setPreviewUpdateState(v)
setOverlayOpacity(v)
setBlurAmount(v)
``` ```
### updateStore ### routeStore (历史栈导航)
应用更新。
```ts ```ts
// State current: RouteKey
checking: boolean history: RouteKey[] // 导航历史栈
downloading: boolean navigate(key) // 压入历史
installed: boolean goBack() // 弹出历史
progress: DownloadProgress | null
update: Update | null
error: string | null
// Actions
check() // 检查更新 (调用 @tauri-apps/plugin-updater)
install() // 下载并安装
reset() // 清除状态
``` ```
### taskStore ### taskStore (localStorage)
任务队列系统 (localStorage 持久化历史)。 任务队列localStorage 持久化历史 (max 50)。
```ts ### 其他 Store
// State (localStorage: "koring-task-history", max 50)
tasks: Task[]
sheetOpen: boolean
// Derived - `devStore` — 开发者调试 (内存)
isRunning() // 是否有运行中/等待中的任务 - `installStore` — Minecraft 安装 (内存,ephemeral)
activeTasks() // 运行中+等待中的任务列表 - `launchStore` — 游戏启动 (内存,ephemeral)
completedTasks() // 已完成/失败/取消的任务列表 - `modsStore` — Mod 搜索 (内存,ephemeral)
- `updateStore` — 应用更新 (内存,ephemeral)
// Actions - `instanceStore` — 实例管理 (sidecar 查询)
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)
```
--- ---
@@ -388,7 +380,6 @@ select(name, instancesPath)
z-0 BackgroundLayer 全屏背景图 + 视差 + 模糊 + 强内容遮罩 z-0 BackgroundLayer 全屏背景图 + 视差 + 模糊 + 强内容遮罩
z-1 ContentLayer 页面内容区 (top: 40px, overflow-auto) z-1 ContentLayer 页面内容区 (top: 40px, overflow-auto)
z-100 SystemLayer 自定义标题栏 (TitleBar) z-100 SystemLayer 自定义标题栏 (TitleBar)
z-110 TaskSheet 任务队列面板 (右滑入)
z-200 StartupPopup 启动弹窗 (环境变量控制) z-200 StartupPopup 启动弹窗 (环境变量控制)
``` ```
@@ -406,12 +397,12 @@ z-200 StartupPopup 启动弹窗 (环境变量控制)
- 强内容遮罩: 非 home 页面自动显示 (可通过 `contentBlurOpacity` 控制) - 强内容遮罩: 非 home 页面自动显示 (可通过 `contentBlurOpacity` 控制)
- 深色模式叠加层: `bg-black/35` - 深色模式叠加层: `bg-black/35`
### VersionCard 版本卡片 ### StartCard 启动组件 (首页)
- Silk WebGL 动画背景 + 毛玻璃叠加层 胶囊形启动组件,位于首页左下角:
- 三种更新状态: `latest` / `hasUpdate` / `installed` - 左: 设置齿轮图标 (→ setting)
- 颜色方案: dev=amber, beta=emerald, run=blue - 中: "启动游戏" 按钮 (primary 色, rounded-full)
- 可通过 props 覆盖 mode 和 state (用于 debug) - 右: 实例选择图标 (Package)
### TaskQueue 任务系统 ### TaskQueue 任务系统
@@ -432,22 +423,32 @@ z-200 StartupPopup 启动弹窗 (环境变量控制)
## 权限 (capabilities/default.json) ## 权限 (capabilities/default.json)
```json ```
core:default, core:window:default, core:window:allow-*, core:default, core:window:default, core:window:allow-*,
core:webview:allow-create-webview-window, core:webview:allow-create-webview-window,
opener:default, process:default, shell:allow-execute, opener:default, process:default, shell:allow-execute,
updater:default, dialog:default updater:default, dialog:default
``` ```
## Rust 插件 ## Rust 依赖
```toml ```toml
tauri = "2"
tauri-plugin-opener, tauri-plugin-process, tauri-plugin-dialog, tauri-plugin-opener, tauri-plugin-process, tauri-plugin-dialog,
tauri-plugin-shell, tauri-plugin-updater 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 协议 ## 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`
+49 -42
View File
@@ -1,43 +1,42 @@
# Koring Launcher # 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 ## Quick Start
```bash ```bash
pnpm install pnpm install
pnpm dev:t # full app (frontend + Rust + sidecar skip in dev) pnpm dev # full app (frontend + electron)
pnpm dev # frontend only (vite, port 1420) pnpm dev:renderer # frontend only (vite, port 1420)
cd sidecar && pnpm dev # sidecar dev (tsx) pnpm dev:main # electron main process only
``` ```
## Build ## Build
```bash ```bash
pnpm tauri build # production (auto-switches to run icon) pnpm build # production build (vite + tsc)
pnpm build:beta # beta build pnpm dist:win # build Windows installer
cd sidecar && pnpm compile # compile sidecar to exe first pnpm dist:mac # build macOS DMG
pnpm dist:linux # build Linux AppImage
``` ```
## Architecture ## Architecture
``` ```
src/ Frontend (React 19 + Vite 7 + Tailwind v4 + shadcn/ui + Zustand) src/ Frontend (React 19 + Vite 7 + Tailwind v4 + shadcn/ui + Zustand)
src-tauri/ Backend (Rust/Tauri 2) electron/ Main process (Node.js/TypeScript, @xmcl/* packages)
sidecar/ Node.js sidecar (@xmcl/* packages)
public/ Static assets (icons, fonts, images) public/ Static assets (icons, fonts, images)
scripts/ Build scripts
``` ```
**IPC Flow:** **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 ## Project Structure
``` ```
src/ src/
├── api/ # Frontend API layer (sidecar IPC wrappers) ├── api/ # Frontend API layer (IPC wrappers)
│ ├── sidecar.ts # Generic request/response │ ├── ipc.ts # Core IPC utilities
│ ├── background.ts # Background control │ ├── background.ts # Background control
│ ├── install.ts # Minecraft install │ ├── install.ts # Minecraft install
│ ├── launch.ts # Game launch │ ├── launch.ts # Game launch
@@ -58,6 +57,31 @@ src/
│ ├── mode.ts # Build mode detection (dev/beta/run) │ ├── mode.ts # Build mode detection (dev/beta/run)
│ └── utils.ts # cn() helper │ └── utils.ts # cn() helper
└── App.tsx # Root component with state router └── 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 ## Three-Layer Page Structure
@@ -96,8 +120,6 @@ Three icon variants in `public/`:
| beta | `beta.png` / `beta.ico` | Testing | | beta | `beta.png` / `beta.ico` | Testing |
| run | `run.png` / `run.ico` | Production release | | run | `run.png` / `run.ico` | Production release |
**Auto-switching:** Build scripts automatically copy the correct icon to `src-tauri/icons/` before building.
**Frontend usage:** **Frontend usage:**
```tsx ```tsx
import { APP_ICON, BUILD_MODE, isDev } from "@/lib/mode"; 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>} {isDev && <span>Dev Mode</span>}
``` ```
**Manual switch:** ## IPC Handlers
```bash
pnpm icon:dev # switch to dev icon
pnpm icon:beta # switch to beta icon
pnpm icon:run # switch to release icon
```
## Sidecar - `config:*` — Config load/save
- `auth:*` — Microsoft OAuth, offline login
Node.js process communicating with Rust via stdin/stdout JSON protocol.
**Handlers:**
- `install:*` — Minecraft install, mod loader, version lists - `install:*` — Minecraft install, mod loader, version lists
- `launch:*` — Game launch, diagnose - `launch:*` — Game launch, diagnose
- `auth:*` — Microsoft OAuth, offline login
- `mods:*` — Modrinth/CurseForge search, install - `mods:*` — Modrinth/CurseForge search, install
- `instance:*` — Instance CRUD - `instance:*` — Instance CRUD
- `background:*` — Background image/color/blur/animation/theme - `background:*` — Background image/color/blur/animation/theme
- `task:*` — Task system progress
**Compile:** - `system:*` — System info
```bash - `window:*` — Minimize/maximize/close
cd sidecar
pnpm compile # ARM64 Windows (default)
pnpm compile:x64 # x86_64 Windows
```
## Key Gotchas ## Key Gotchas
- **Sidecar binary required**: `src-tauri/binaries/koring-sidecar-<target-triple>.exe` must exist. Skipped in debug mode (`#[cfg(debug_assertions)]`). - **@xmcl packages run in main process**: `@xmcl/core`, `@xmcl/installer` require `fs`/`child_process`. All run in Electron main process.
- **@xmcl packages are Node.js only**: `@xmcl/core`, `@xmcl/installer` require `fs`/`child_process`. Only `@xmcl/user`, `@xmcl/modrinth`, `@xmcl/curseforge` work in browser.
- **Path alias**: `@/` maps to `src/`. - **Path alias**: `@/` maps to `src/`.
- **Window dragging**: Use `appWindow.startDragging()` API, not `data-tauri-drag-region` (unreliable with transparent windows). - **Window dragging**: Use CSS `-webkit-app-region: drag` on titlebar.
- **Transparent windows**: Require `decorations: false` + `transparent: true` in tauri.conf.json. - **Transparent windows**: `transparent: true` + `frame: false` in BrowserWindow options.
- **Permissions**: Window creation needs `core:webview:allow-create-webview-window` (not `core:window`). - **Auth storage**: JSON file (`koring-auth.json`) stored next to executable.
## Tech Stack ## Tech Stack
| Layer | Technology | | Layer | Technology |
|-------|-----------| |-------|-----------|
| Frontend | React 19, Vite 7, Tailwind CSS v4, shadcn/ui, Zustand | | Frontend | React 19, Vite 7, Tailwind CSS v4, shadcn/ui, Zustand |
| Backend | Rust, Tauri 2 | | Main Process | Node.js, TypeScript, @xmcl/* packages |
| Sidecar | Node.js, TypeScript, @xmcl/* packages | | Build | pnpm, Vite, electron-builder |
| Build | pnpm, Cargo, bun (sidecar compile) |
-18
View File
@@ -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
)
-6
View File
@@ -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
+27
View File
@@ -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
+44
View File
@@ -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);
}
}
+161
View File
@@ -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');
}
+180
View File
@@ -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;
}
}
+175
View File
@@ -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;
}
}
+181
View File
@@ -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',
};
}
+195
View File
@@ -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',
};
}
+138
View File
@@ -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,
};
}
+154
View File
@@ -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,
};
}
+152
View File
@@ -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,
};
}
+165
View File
@@ -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,
};
}
+163
View File
@@ -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,
};
}
+228
View File
@@ -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,
};
}
+38
View File
@@ -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) };
}
});
}
+32
View File
@@ -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) };
}
});
}
+101
View File
@@ -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) };
}
});
}
+94
View File
@@ -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) };
}
});
}
+29
View File
@@ -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) };
}
});
}
+23
View File
@@ -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) };
}
});
}
+74
View File
@@ -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) };
}
});
}
+88
View File
@@ -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) };
}
});
}
+47
View File
@@ -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) };
}
});
}
+58
View File
@@ -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) };
}
});
}
+45
View File
@@ -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) };
}
});
}
+58
View File
@@ -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) };
}
});
}
+47
View File
@@ -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) };
}
});
}
+79
View File
@@ -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) };
}
});
}
+70
View File
@@ -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) };
}
});
}
+63
View File
@@ -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) };
}
});
}
+61
View File
@@ -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) };
}
});
}
+74
View File
@@ -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) };
}
});
}
+80
View File
@@ -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];
});
}
+91
View File
@@ -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];
});
}
+142
View File
@@ -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();
}
});
+31
View File
@@ -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'),
});
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri + React + Typescript</title> <title>Koring Launcher</title>
</head> </head>
<body> <body>
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"shadcn": {
"type": "local",
"command": [
"npx",
"shadcn@latest",
"mcp"
],
"enabled": true
}
}
}
+32 -20
View File
@@ -1,53 +1,65 @@
{ {
"name": "koring-launcher", "name": "koring-launcher",
"private": true, "private": true,
"version": "0.1.0", "version": "1.0.0",
"type": "module", "description": "Koring Launcher - Minecraft launcher built with Electron + React",
"author": "Lingke Koring Studio",
"main": "electron/main.js",
"scripts": { "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:dev": "node scripts/switch-icon.js dev",
"icon:beta": "node scripts/switch-icon.js beta", "icon:beta": "node scripts/switch-icon.js beta",
"icon:run": "node scripts/switch-icon.js run", "icon:run": "node scripts/switch-icon.js run",
"dev": "node scripts/switch-icon.js dev && vite --mode development", "pack": "electron-builder --dir",
"build": "node scripts/switch-icon.js run && tsc && vite build --mode production", "dist": "electron-builder",
"build:beta": "node scripts/switch-icon.js beta && tsc && vite build --mode beta", "dist:win": "electron-builder --win",
"preview": "vite preview", "dist:mac": "electron-builder --mac",
"tauri": "tauri", "dist:linux": "electron-builder --linux",
"dev:t": "node scripts/switch-icon.js dev && tauri dev", "dist:dev": "pnpm icon:dev && electron-builder --win",
"dev:vs": "./dev-vs.cmd", "dist:beta": "pnpm icon:beta && electron-builder --win",
"build:vs": "./build-vs.cmd", "dist:run": "pnpm icon:run && electron-builder --win"
"build:beta:vs": "./build-vs.cmd --mode beta"
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.6.0", "@base-ui/react": "^1.6.0",
"@fontsource-variable/geist": "^5.2.9", "@fontsource-variable/geist": "^5.2.9",
"@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/inter": "^5.2.8",
"@react-three/fiber": "^9.6.1", "@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", "@types/three": "^0.184.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"js-yaml": "^4.1.0",
"lucide-react": "^1.21.0", "lucide-react": "^1.21.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"shadcn": "^4.11.0",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"three": "^0.184.0", "three": "^0.184.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"zustand": "^5.0.14" "zustand": "^5.0.14"
}, },
"pnpm": {
"onlyBuiltDependencies": [
"electron",
"esbuild"
]
},
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.3.1", "@tailwindcss/vite": "^4.3.1",
"@tauri-apps/cli": "^2", "@types/js-yaml": "^4.0.9",
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0", "@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", "tailwindcss": "^4.3.1",
"tsx": "^4.19.0",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"vite": "^7.0.4" "vite": "^7.0.4"
} }
+419
View File
@@ -0,0 +1,419 @@
# 迁移计划:Tauri 2 → Electron
## 概述
将 koring-launcher 从 Tauri 2Rust 后端 + Node.js sidecar)迁移到 ElectronNode.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. **阶段六** — 测试与清理
预计工作量:大规模重构。每个阶段应独立测试后再进入下一阶段。
+2854 -202
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

+8 -11
View File
@@ -1,17 +1,16 @@
import { cpSync, existsSync } from "fs"; const { cpSync, existsSync } = require('fs');
import { join } from "path"; const { join } = require('path');
const mode = process.argv[2]; const mode = process.argv[2];
const validModes = ["dev", "beta", "run"]; const validModes = ['dev', 'beta', 'run'];
if (!mode || !validModes.includes(mode)) { 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); process.exit(1);
} }
const root = join(import.meta.dirname, ".."); const root = join(__dirname, '..');
const publicDir = join(root, "public"); const publicDir = join(root, 'public');
const iconsDir = join(root, "src-tauri", "icons");
const png = join(publicDir, `${mode}.png`); const png = join(publicDir, `${mode}.png`);
const ico = join(publicDir, `${mode}.ico`); const ico = join(publicDir, `${mode}.ico`);
@@ -25,9 +24,7 @@ if (!existsSync(ico)) {
process.exit(1); process.exit(1);
} }
// Tauri 2 only needs icon.png (source) + icon.ico cpSync(png, join(publicDir, 'icon.png'), { overwrite: true });
// It auto-generates 32x32, 128x128, 128x128@2x, icns from icon.png during build cpSync(ico, join(publicDir, 'icon.ico'), { overwrite: true });
cpSync(png, join(iconsDir, "icon.png"), { overwrite: true });
cpSync(ico, join(iconsDir, "icon.ico"), { overwrite: true });
console.log(`[switch-icon] Mode: ${mode} → icon.png + icon.ico updated`); console.log(`[switch-icon] Mode: ${mode} → icon.png + icon.ico updated`);
-32
View File
@@ -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"
}
}
-718
View File
@@ -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
-146
View File
@@ -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();
}
-143
View File
@@ -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 };
}
-79
View File
@@ -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,
};
}
-226
View File
@@ -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");
}
-56
View File
@@ -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);
}
}
-108
View File
@@ -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);
}
}
-95
View File
@@ -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);
}
}
-130
View File
@@ -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);
}
}
-21
View File
@@ -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);
}
}
-62
View File
@@ -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);
}
}
-105
View File
@@ -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);
-58
View File
@@ -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();
}
-239
View File
@@ -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;
};
-61
View File
@@ -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");
}
-20
View File
@@ -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"]
}
+1 -1
View File
@@ -22,7 +22,7 @@
<div id="root"> <div id="root">
<div class="container"> <div class="container">
<div class="logo-area"> <div class="logo-area">
<img src="/koring-licon.svg" alt="Koring Launcher" /> <img src="./koring-licon.svg" alt="Koring Launcher" />
</div> </div>
<div class="bottom-bar"> <div class="bottom-bar">
<span>Provided by Koring Studio</span> <span>Provided by Koring Studio</span>
-7
View File
@@ -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
-5497
View File
File diff suppressed because it is too large Load Diff
-31
View File
@@ -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"] }
-3
View File
@@ -1,3 +0,0 @@
fn main() {
tauri_build::build()
}
-35
View File
@@ -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
}
]
}
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 393 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 393 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 393 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 365 KiB

-321
View File
@@ -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,
})
}
-76
View File
@@ -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");
}
-6
View File
@@ -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()
}
-92
View File
@@ -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(())
}
}
-8
View File
@@ -1,8 +0,0 @@
{
"build": {
"beforeBuildCommand": "echo skip-beta-build"
},
"bundle": {
"targets": []
}
}
-66
View File
@@ -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"
}
}
}
+19
View File
@@ -1,6 +1,12 @@
import { useEffect } from "react";
import { RootLayout } from "./layouts/RootLayout"; import { RootLayout } from "./layouts/RootLayout";
import { useRouteStore } from "./stores/routeStore"; import { useRouteStore } from "./stores/routeStore";
import { useTheme } from "./hooks/useTheme"; 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 { Home } from "./pages/home";
import { Store } from "./pages/store"; import { Store } from "./pages/store";
import { Today } from "./pages/today"; import { Today } from "./pages/today";
@@ -12,6 +18,8 @@ import { SplashDebug } from "./pages/debug/splash-debug";
import { DisplayDebug } from "./pages/debug/display-debug"; import { DisplayDebug } from "./pages/debug/display-debug";
import { VersionCardDebug } from "./pages/debug/version-card-debug"; import { VersionCardDebug } from "./pages/debug/version-card-debug";
import { TaskDebug } from "./pages/debug/task-debug"; import { TaskDebug } from "./pages/debug/task-debug";
import { Oobe } from "./pages/oobe";
import { OobeAboutInfo } from "./pages/oobe/about-info";
const pageMap = { const pageMap = {
home: Home, home: Home,
@@ -20,6 +28,8 @@ const pageMap = {
"play-link": PlayLink, "play-link": PlayLink,
setting: Setting, setting: Setting,
"task-queue": TaskQueue, "task-queue": TaskQueue,
oobe: Oobe,
"oobe/about-info": OobeAboutInfo,
debug: Debug, debug: Debug,
"debug-splash": SplashDebug, "debug-splash": SplashDebug,
"debug-display": DisplayDebug, "debug-display": DisplayDebug,
@@ -32,6 +42,15 @@ function App() {
const current = useRouteStore((s) => s.current); const current = useRouteStore((s) => s.current);
const Page = pageMap[current]; const Page = pageMap[current];
useEffect(() => {
useConfigStore.getState().init().then(() => {
syncThemeFromConfig();
syncA11yFromConfig();
syncBackgroundFromConfig();
useAuthStore.getState().initFromRegistry();
});
}, []);
return ( return (
<RootLayout> <RootLayout>
<Page key={current} /> <Page key={current} />

Some files were not shown because too many files have changed in this diff Show More