> ## Documentation Index
> Fetch the complete documentation index at: https://docs.qomicex.top/llms.txt
> Use this file to discover all available pages before exploring further.

# 插件 API 完整参考

## 插件 API 完整参考

插件脚本通过全局对象 `window.__PLUGIN_API__` 与启动器交互。本文档列出全部可调用方法、签名、权限要求与示例。

## 全局变量

### **PLUGIN\_API\_BASE** — 插件文件地址前缀

L2 沙箱内，启动器会自动注入 `window.__PLUGIN_API_BASE__`，值为当前插件的文件访问基地址：

```text theme={null}
http://localhost:5000/api/plugins/{插件ID}/files
```

可用于动态拼包内资源地址（如图标、图片）：

```js theme={null}
const iconUrl = `${__PLUGIN_API_BASE__}/dist/icon.svg`
document.getElementById('icon').src = iconUrl
```

内联渲染（无 `l2`）和悬浮窗（overlay iframe）时该变量不存在，相对路径需通过其他方式解析。

## 调用方式

```js theme={null}
const __PLUGIN_API__ = window.__PLUGIN_API__

// ① 通用 call 方式（绝大多数方法）
const data = await __PLUGIN_API__.call('getSettings')

// ② 专用快捷方式（以下 4 个）
await __PLUGIN_API__.registerMethod('name', fn)      // 注册方法
await __PLUGIN_API__.callPlugin('id', 'method', ...) // 调用其他插件方法
await __PLUGIN_API__.proxyFetchStream(req, handlers) // 流式请求
await __PLUGIN_API__.registerHook('method', handler) // 注册启动器方法 Hook
```

## 权限机制

每个 API 方法对应一个权限。调用时启动器会检查插件 manifest 的 `permissions` 是否包含该权限，否则报错：

```text theme={null}
Permission denied: requires <权限id>
```

### 完整权限表

| 权限 ID | 中文 | 风险 |
| - | - | - |
| `instance:read` | 读取实例列表 | 普通 |
| `instance:write` | 创建/修改/删除实例（含安装整合包） | 警告 |
| `account:read` | 读取账号列表 | 普通 |
| `license:read` | 读取许可证信息 | 普通 |
| `config:read` | 读取启动器配置 | 普通 |
| `config:write` | 修改启动器配置 | 警告 |
| `cache:access` | 读写插件缓存 | 普通 |
| `endpoint:discover` | 获取后端 API 端点 | 普通 |
| `page:list` | 获取页面列表 | 普通 |
| `network:fetch` | 发送 HTTP 请求 | 警告 |
| `network:cors_proxy` | CORS 代理请求 | 警告 |
| `network:websocket` | WebSocket 连接 | 警告 |
| `network:proxy` | 修改代理设置 | 警告 |
| `ui:inject_sidebar` | 注入侧边栏菜单 | 普通 |
| `ui:inject_settings` | 注入设置页 | 普通 |
| `ui:picture_in_picture` | 画中画窗口 | 警告 |
| `ui:sub_window` | 独立子窗口 | 警告 |
| `ui:context_menu` | 注入右键菜单 | 普通 |
| `ui:toast` | 应用内通知 | 普通 |
| `ui:navigate` | 跳转页面 | 普通 |
| `system:info` | 读取系统和启动器信息 | 普通 |
| `system:notification` | 发送系统通知 | 普通 |
| `clipboard:read` | 读取剪贴板 | 警告 |
| `clipboard:write` | 写入剪贴板 | 警告 |
| `wasm:execute` | 执行 WASM 模块 | 警告 |
| `plugin:install` | 安装/卸载/更新插件 | 危险 |
| `plugin:list` | 读取已安装插件列表 | 普通 |
| `resource:read` | 读取游戏资源文件 | 普通 |
| `resource:write` | 写入游戏资源文件 | 警告 |
| `java:manage` | 管理 Java 运行时 | 警告 |
| `game:process` | 启停游戏进程 | 警告 |
| `game:log` | 检测游戏日志 | 普通 |
| `connector:host` | 启停联机 | 警告 |
| `connector:scan` | 扫描局域网联机 | 普通 |
| `shell:execute` | 执行系统命令 | 危险 |
| `filesystem:read` | 读取文件系统 | 警告 |
| `filesystem:write` | 写入文件系统 | 危险 |
| `process:manage` | 管理常驻进程（启动/输入/终止） | 危险 |
| `dialog:open` | 打开文件/目录选择对话框 | 普通 |
| `hook:register` | 钩住启动器方法（拦截/修改参数、阻止执行） | 危险 |
| `download:manage` | 管理下载中心任务 | 警告 |
| `plugin:log` | 输出插件日志 | 普通 |

「风险」用于安装详情弹窗的视觉提示：普通=蓝、警告=黄、危险=红。声明权限时遵循**最小权限原则**，只声明你真正用到的。

## 方法参考

### getSettings — 读取插件配置

读取插件自己的 `settings.json`。

```js theme={null}
const settings = await __PLUGIN_API__.call('getSettings')
```

* 权限：`config:read`
* 返回：`Record<string, unknown>`（无文件时返回 `{}`）
* 存储位置：`{数据目录}/plugins/{插件id}/settings.json`

### setSettings — 写入插件配置

按 key 合并写入配置（**合并而非覆盖整个文件**）。

```js theme={null}
await __PLUGIN_API__.call('setSettings', 'theme', 'dark')
```

* 权限：`config:write`
* 参数：`(key: string, value: any)`，value 可为任意 JSON 值
* 并发写入由后端加锁保护

### setCache / getCache — 插件缓存

按 key 读写插件自己的缓存文件，支持 TTL 过期。

```js theme={null}
// 写缓存，TTL 1 小时（3600 秒）
await __PLUGIN_API__.call('setCache', 'modelList', { items: [...] }, 3600)

// 读缓存（不存在或过期返回 null）
const cached = await __PLUGIN_API__.call('getCache', 'modelList')
```

* 权限：`cache:access`
* 存储位置：`{数据目录}/plugins/{插件id}/cache.json`
* 内部结构 `{ key: { v: 值, e: 过期时间戳|null } }`，无需插件关心

### callBackend — 调用启动器后端 API

直接请求启动器后端（`http://localhost:5000/api/...`），绕过 CORS 限制。

```js theme={null}
const instances = await __PLUGIN_API__.call('callBackend', '/instance')

const result = await __PLUGIN_API__.call('callBackend', '/resource-download/start', {
  instanceId: 'xxx', url: 'https://...', fileName: 'mod.jar', category: 'mods'
})
```

* 权限：`network:fetch`
* 参数：`(endpoint: string, data?: any)`
* 有 `data` → POST；无 `data` → GET
* `data` 对象中可传入 `_method` 字段覆盖 HTTP 方法（如 `{ _method: 'PUT', State: 'active' }`），`_method` 不会被发送到后端
* `endpoint` 以 `/` 开头（如 `/instance`），不含 `/api` 前缀
* 返回：后端 JSON 响应；非 2xx 抛错
* 可用端点清单：见启动器后端 OpenAPI（开发模式 `/openapi/v1.json`），常用如 `/instance`、`/resources/search`、`/settings` 等

L2 沙箱（`sandbox="allow-scripts"`，无 `allow-popups`）里 `window.open(url, '_blank')` 会被拦截。要打开外部链接，应通过后端端点：

```js theme={null}
await __PLUGIN_API__.call('callBackend', '/system/open-url', { url: 'https://example.com' })
```

该端点使用系统默认浏览器打开 `http/https` 链接（仅接受这两种协议，其余返回 400）。

### proxyFetch — CORS 代理请求

经启动器后端转发请求任意外部 URL，绕开浏览器 CORS 限制，且带 SSRF 防护。

```js theme={null}
const res = await __PLUGIN_API__.call('proxyFetch', {
  url: 'https://api.example.com/data',
  method: 'GET',
  headers: { 'Accept': 'application/json' },
  timeoutMs: 15000
})
if (res.status === 200) {
  const data = JSON.parse(res.body)   // 文本响应
}
```

* 权限：`network:cors_proxy`
* 请求 `ProxyRequest`：

```ts theme={null}
  {
    url: string                // 必填，http/https
    method?: string            // 默认 GET
    headers?: Record<string, string>
    body?: string              // POST body（字符串）
    timeoutMs?: number         // 默认 15000，范围 1000–60000
  }
```

* 响应 `ProxyResponse`：

```ts theme={null}
  {
    status: number
    headers: Record<string, string>
    body?: string | null       // 文本响应
    bodyBase64?: string | null // 二进制响应
  }
```

* **SSRF 防护**：仅 http/https；禁止内网/保留地址（localhost、127.x、10.x、172.16-31.x、192.168.x、169.254.x 等），返回 `PROXY_PRIVATE_ADDRESS`(400)

### proxyFetchStream — 流式代理请求

消费 SSE 流（如 AI 对话逐字输出），逐块回调。

```js theme={null}
await __PLUGIN_API__.proxyFetchStream(
  {
    url: 'https://api.deepseek.com/v1/chat/completions',
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + key },
    body: JSON.stringify({ model, messages, stream: true }),
    timeoutMs: 120000,
    signal: abortController.signal    // 可选，中断流
  },
  {
    onChunk(chunk) {
      // chunk 为 SSE data: 行内容（已去 data: 前缀）
      const json = JSON.parse(chunk)
      const delta = json.choices?.[0]?.delta?.content
      if (delta) appendText(delta)
    },
    onError(err) { console.error(err) }
  }
)
```

* 权限：`network:cors_proxy`
* 后端自动带 `stream: true` 转发上游流
* `signal`（AbortSignal）用于中断；`req.signal` 经 postMessage 前自动剥离，改走 `__plugin_api_abort` 消息
* ⚠️ **已知限制**：`proxyFetchStream` 返回的 Promise 在流结束时 resolve，但 chunk 回调可能在 resolve 之后仍触发，建议以 `onError` / 流内 `[DONE]` 标记判断完成

### registerMethod — 注册插件方法

将当前插件的方法注册到全局注册表，供**其他插件**通过 `callPlugin` 调用。

```js theme={null}
__PLUGIN_API__.registerMethod('renderMarkdown', function (md) {
  return marked.parse(md || '')
})

// 支持异步方法
__PLUGIN_API__.registerMethod('fetchTranslation', async function (text) {
  const res = await fetch('https://api.example.com/translate?text=' + encodeURIComponent(text))
  return res.json()
})
```

* 权限：`config:write`
* 参数：`(method: string, fn: Function)`，fn 返回值为值或 Promise
* 插件停用时自动注销所有方法
* 详细见 [插件依赖与互调用](https://docs.qomicex.top/plugins/dependencies.md)

### callPlugin — 调用其他插件方法

调用目标插件已注册的方法。

```js theme={null}
try {
  const html = await __PLUGIN_API__.callPlugin('top.qomicex.markdown', 'renderMarkdown', '**加粗**')
  document.getElementById('out').innerHTML = html
} catch (e) {
  console.error(e.message)   // 目标未安装/未激活/未注册都会 reject
}
```

* 权限：`network:fetch`
* 参数：`(pluginId: string, method: string, ...args)`
* 目标未安装 / 未激活 / 方法未注册 → reject

### registerHook — 注册启动器方法 Hook

将当前插件注册为启动器某方法的拦截者，在该方法执行前后注入逻辑（修改参数/返回值、阻止执行、完全替换实现）。

```js theme={null}
__PLUGIN_API__.registerHook('scanVersions', async (ctx, next) => {
  ctx.args[0] = ctx.args[0] + '/custom'   // before：修改参数
  await next()                              // 执行默认实现
  ctx.result = [...ctx.result, { name: 'virtual-1.20.1', gameVersion: '1.20.1', state: 'Available' }]  // after：修改结果
})
```

* 权限：`hook:register`（**危险**）
* 参数：`(method: string, handler: (ctx, next) => Promise<void>)`
* `handler` 为 Koa 式洋葱中间件：`next()` 前改 `ctx.args`，`next()` 后改 `ctx.result`，`ctx.prevent()` 阻止默认实现
* 插件停用时自动注销其全部 hooks
* 详细见 [Hook 系统](https://docs.qomicex.top/plugins/hooks.md)

### callWasm — 调用 WASM 插件导出函数

调用 L3 WASM 插件导出的函数（`on_load` / `on_unload` / 自定义导出）。经启动器 Rust 网关（wasmtime）执行。

```js theme={null}
const result = await __PLUGIN_API__.callWasm('dev.example.wasmplugin', 'on_load')
```

* 权限：`wasm:execute`
* 参数：`(pluginId: string, exportName?: string)`，`exportName` 缺省 `on_load`
* 返回：`{ ok: true, result: ... }` 或错误
* 详细见 [WASM 插件](https://docs.qomicex.top/plugins/wasm-plugin.md)

### listWasmPlugins — 列出已加载的 WASM 插件

```js theme={null}
const ids = await __PLUGIN_API__.listWasmPlugins()   // ['dev.example.wasmplugin', ...]
```

* 权限：`wasm:execute`

### readText — 读取文本文件（授权制）

读取指定路径的**文本**文件（UTF-8），支持分段读取。**首次访问需用户授权**（弹窗确认）。

```js theme={null}
const file = await __PLUGIN_API__.call('readText', 'C:/Users/me/data.json')
// file = { path, content: '完整文本内容' }

// 从第 5 个字符开始读 100 个字符
const seg = await __PLUGIN_API__.call('readText', 'C:/Users/me/log.txt', { start: 5, length: 100 })
```

* 权限：`filesystem:read`
* 参数：`(path: string, options?: { start?: number; length?: number })`
* `start`：起始位置（字符），默认 0
* `length`：读取长度（字符），默认读到文件尾
* 返回：`{ path, content: string }`
* **授权机制**：首次访问未授权路径返回 403 `FS_AUTHORIZATION_REQUIRED`，启动器自动弹窗询问「是否允许插件访问该路径」，用户同意后授权（按路径前缀持久化），同插件同路径不再弹窗
* 文件不存在 → `FS_FILE_NOT_FOUND`(404)

### readBytes — 读取二进制文件（授权制）

读取指定路径的**二进制**文件，返回 base64，支持分段读取。

```js theme={null}
const bin = await __PLUGIN_API__.call('readBytes', 'C:/Users/me/img.png')
// bin = { path, contentBase64: 'base64...' }

// 读前 4 字节
const head = await __PLUGIN_API__.call('readBytes', 'C:/Users/me/img.png', { start: 0, length: 4 })
```

* 权限：`filesystem:read`
* 参数：`(path: string, options?: { start?: number; length?: number })`
* `start`：起始位置（字节），默认 0
* `length`：读取长度（字节），默认读到文件尾
* 返回：`{ path, contentBase64: string }`
* 授权机制同 `readText`

### writeText — 写入文本文件（授权制）

```js theme={null}
await __PLUGIN_API__.call('writeText', 'C:/Users/me/out.txt', '文件内容')
```

* 权限：`filesystem:write`
* 参数：`(path: string, content: string)`，UTF-8 写入
* 授权机制同 `readText`；自动创建父目录
* 返回：`{ path }`

### writeBytes — 写入二进制文件（授权制）

```js theme={null}
await __PLUGIN_API__.call('writeBytes', 'C:/Users/me/out.bin', new Uint8Array([1,2,3]))
```

* 权限：`filesystem:write`
* 参数：`(path: string, bytes: Uint8Array)`，自动转 base64 传输
* 授权机制同 `readText`；自动创建父目录
* 返回：`{ path }`

### deleteFile — 删除文件（授权制）

```js theme={null}
await __PLUGIN_API__.call('deleteFile', 'C:/Users/me/tmp.txt')
```

* 权限：`filesystem:write`
* 参数：`(path: string)`，仅删除**文件**；目录请用 `deleteDir`
* 授权机制同 `readText`（破坏性操作同样需授权确认）
* 文件不存在 → `FS_FILE_NOT_FOUND`(404)
* 返回：`{ path }`

### deleteDir — 删除目录（授权制）

```js theme={null}
// 只删空目录（非空会报错，防止误删）
await __PLUGIN_API__.call('deleteDir', 'C:/Servers/old')

// 连同目录内容一起删
const { removed } = await __PLUGIN_API__.call('deleteDir', 'C:/Servers/old', { recursive: true })
// removed = 实际删除的条目数（文件 + 子目录）
```

* 权限：`filesystem:write`
* 参数：`(path: string, options?: { recursive?: boolean })`
* 授权机制同 `readText`；路径必须在已授权前缀内
* 请求 `{ recursive: false }` 时只接受**空目录**，非空返回 `FS_DIR_NOT_EMPTY`(400)
* **危险根永远拒绝**：盘符根（`D:`、`D:\`）与文件系统根返回 `FS_REFUSE_ROOT`(403)。
  授权是按路径前缀覆盖的，误把整个盘授权给插件后一次递归删除就是灾难——
  插件侧仍建议自己做业务层校验（例如拒绝层级过浅的目录）
* 目录不存在 → `FS_DIR_NOT_FOUND`(404)
* 返回：`{ path, removed }`

> 典型用途：删除实例/服务端目录。此前后端只能删单个文件，插件想删目录只能
> 递归 `deleteFile` 再把空壳 rename 挪走，会在磁盘上留下无法清理的垃圾目录。

### execCommand — 执行 shell 命令

执行系统命令，返回退出码与输出。**Windows 用 powershell，Linux/macOS 用 /bin/sh**。

```js theme={null}
const result = await __PLUGIN_API__.call('execCommand', 'echo hello')
// result = { exitCode: 0, stdout: 'hello\n', stderr: '' }

const fail = await __PLUGIN_API__.call('execCommand', 'nonexistent-cmd')
// fail = { exitCode: 127, stdout: '', stderr: '...' }
```

* 权限：`shell:execute`（**危险权限**，安装时红色提示）
* 参数：`(command: string, timeoutMs?: number)`，`timeoutMs` 默认 15000，范围 1000–120000
* 返回：`{ exitCode: number, stdout: string, stderr: string }`
* 超时 → `SHELL_TIMEOUT`(400)，命令被强制终止
* 命令为空 → `SHELL_COMMAND_REQUIRED`(400)

⚠️ `execCommand` 是**一次性执行**模型：stdout/stderr 全量缓冲到进程结束才返回、无进程句柄、无 stdin。**不要用它拉起常驻进程**（服务端、守护进程等）——超时会被强制杀掉。这类场景用下面的 `process.*`。

### process.spawn — 启动常驻进程

拉起一个长期运行的外部进程（如 Minecraft 服务端 `java -jar server.jar nogui`），返回进程句柄。

```js theme={null}
const { pid, startedAtMs } = await __PLUGIN_API__.call('process.spawn', {
  command: 'C:/Program Files/Java/jdk-21/bin/java.exe',
  args: ['-Xmx2048M', '-jar', 'server.jar', 'nogui'],
  cwd: 'C:/servers/my-server',
  env: { JAVA_HOME: '...' }   // 可选
})
```

* 权限：`process:manage`（**危险权限**）
* 参数：`{ command: string, args?: string[], cwd?: string, env?: Record<string, string> }`
* 返回：`{ pid: number, startedAtMs: number }`
* stdout/stderr 逐行进入环形缓冲（2000 行），经 `process.logs` 游标增量拉取
* **归属隔离**：插件只能管理自己 spawn 的进程（其他插件的 pid 一律 404 `PROCESS_NOT_FOUND`）
* 每插件同时管理的进程数上限 32（`PROCESS_SPAWN_FAILED`）
* 进程退出后句柄保留 10 分钟供收尾日志，之后自动回收

### process.stdin — 向进程标准输入写入

```js theme={null}
await __PLUGIN_API__.call('process.stdin', pid, 'stop')          // 默认补 \n
await __PLUGIN_API__.call('process.stdin', pid, 'raw', { appendNewline: false })
```

* 权限：`process:manage`
* 参数：`(pid: number, text: string, opts?: { appendNewline?: boolean })`
* 交互式控制台命令（如服务端 `stop` / `say` / `whitelist add`）由此实现
* 进程已退出 → `PROCESS_STDIN_FAILED`

### process.kill — 终止进程树

```js theme={null}
await __PLUGIN_API__.call('process.kill', pid)
// { message: '进程 1234 已终止' }
```

* 权限：`process:manage`
* 终止**整个进程树**（Windows `taskkill /T /F`，Unix 逐个 kill 子孙进程）
* 对已退出进程幂等返回成功

### process.alive — 查询进程状态

```js theme={null}
const snap = await __PLUGIN_API__.call('process.alive', pid)
// { pid, command, args, startedAtMs, alive, exitCode, oldestSeq, nextSeq }
```

* 权限：`process:manage`
* `alive: false` 时 `exitCode` 为退出码（被信号终止或无法获取时为 `null`）

### process.logs — 游标式增量日志

```js theme={null}
// 轮询示例：保存 nextSeq 作为下次 afterSeq
let seq = 0
const timer = setInterval(async () => {
  const r = await __PLUGIN_API__.call('process.logs', pid, seq)
  seq = r.nextSeq
  for (const line of r.lines) {
    // line = { seq, stream: 'out'|'err', text }
    appendConsole(line)
  }
  if (!r.alive) { clearInterval(timer); onExit(r.exitCode) }
}, 800)
```

* 权限：`process:manage`
* 参数：`(pid: number, afterSeq?: number)`，缺省 `afterSeq` 从缓冲最旧行开始
* 返回：`{ lines: [{ seq, stream, text }], nextSeq, oldestSeq, alive, exitCode }`
* `oldestSeq`：环形缓冲淘汰后仍在的最旧 seq —— 若你的游标落后超过缓冲容量（2000 行），中间的行已丢失，建议据此提示用户
* 进程退出后日志仍可拉取（保留期内）

### process.list — 列出本插件的全部受管进程

```js theme={null}
const procs = await __PLUGIN_API__.call('process.list')
// [process.alive 同结构的快照数组]
```

* 权限：`process:manage`
* 典型用途：插件页面重载/启动器重启后，凭此找回仍在运行的进程（配合持久化的 pid）

### pickFile — 文件选择对话框

弹出系统文件选择对话框（主窗口 Tauri 原生实现）。沙箱里 `<input type="file">` 拿不到绝对路径，`window.showOpenFilePicker` 不可用 —— 需要**绝对路径**时必须用此方法。

```js theme={null}
const path = await __PLUGIN_API__.call('pickFile', {
  title: '选择服务端压缩包',
  defaultPath: 'C:/Users/me/Downloads',
  filters: [{ name: '压缩包', extensions: ['zip'] }],
  multiple: false
})
// 取消 / 浏览器 dev 环境返回 null
```

* 权限：`dialog:open`
* 参数：`{ title?, defaultPath?, filters?: [{ name, extensions: string[] }], multiple? }` 全部可选
* 返回：`string | null`（选中的绝对路径；取消返回 `null`）

### pickDirectory — 目录选择对话框

```js theme={null}
const dir = await __PLUGIN_API__.call('pickDirectory', { title: '选择服务器根目录' })
```

* 权限：`dialog:open`
* 返回：`string | null`

### extractArchive — 解压本地 zip 到目录（授权制）

```js theme={null}
const { destPath } = await __PLUGIN_API__.call('extractArchive',
  'C:/Users/me/Downloads/server-pack.zip',
  'C:/servers/my-server')
```

* 权限：`filesystem:write`
* 参数：`(archivePath: string, destPath: string, opts?: { prefix?: string, stripPrefix?: boolean })`，目标目录自动创建
* `prefix` 只解压该子目录下的条目；搭配 `stripPrefix: true` 时剥掉前缀——整合包的 `overrides/` 要提到服务器根目录就靠这两个选项（`overrides/mods/a.jar` → `mods/a.jar`）
* 防护：zip-slip（非法路径条目拒绝）+ 条目数 / 单文件 / 总量体积上限（复用整合包同一解压器）
* **授权机制**：压缩包路径需读授权、目标目录需写授权 —— 首次使用弹一次确认、同时授权两个路径前缀
* 仅支持 zip（rar/7z 暂不支持）

### listDir — 列举目录条目（授权制）

```js theme={null}
const entries = await __PLUGIN_API__.call('listDir', 'C:/servers/my-server', { recursive: true, maxEntries: 3000 })
// [{ name, path, isDir, size, modifiedMs }, ...]  目录优先、名称排序
```

* 权限：`filesystem:read`
* 参数：`(path: string, opts?: { recursive?: boolean, maxEntries?: number })`
* `maxEntries` 默认 1000，上限 10000
* 授权机制同 `readText`
* 典型用途：导入后探测服务端 jar、检查 `eula.txt` / `server.properties` 是否存在

### sendNotification — 系统级通知

发送系统级桌面通知（WebView Notification API，首次调用触发系统授权询问）。插件页面不在前台时也能触达用户；失败/被拒返回 `false`，可降级为 `showToast`。

```js theme={null}
const ok = await __PLUGIN_API__.call('sendNotification', { title: '服务端已停止', body: '退出码 1' })
if (!ok) await __PLUGIN_API__.call('showToast', '服务端已停止', 'error')
```

* 权限：`system:notification`
* 参数：`{ title: string, body?: string }`
* 返回：`boolean`（通知已发出为 `true`；API 不可用 / 未授权 / 被拒为 `false`）

### openUrl — 打开外部链接

用系统默认浏览器打开外部 URL。L2 沙箱内 `window.open` 会被 `sandbox` 拦截，请用此方法打开外链。

```js theme={null}
await __PLUGIN_API__.call('openUrl', 'https://example.com')
```

* 权限：`system:notification`
* 后台走 `POST /api/system/open-url`，仅接受 `http://` / `https://` 协议，非法 URL 返回 400

### getSystemInfo — 读取系统与启动器信息

读取操作系统、启动器与内存信息，可用于 `minLauncherVersion` 兼容检查或运行时判断平台。

```js theme={null}
const info = await __PLUGIN_API__.call('getSystemInfo')
// { Os, Architecture, OsName, OsVersion, OsVersionId, OsDisplayName, GitCommit,
//   Memory, AvailableMemory, DataDir,
//   LauncherName, LauncherVersion, VersionType, Developers, CoreDependencies }
```

* 权限：`system:info`
* 后台走 `GET /api/systeminfo`（与 `/api/system/info` 等价）
* `GitCommit` 为启动器 Git 提交哈希；`Memory` / `AvailableMemory` 为字节数
* `DataDir`：启动器数据目录（如 `%LOCALAPPDATA%/qomicex-launcher`）。插件需要默认工作区（存放自己的服务器/存档/下载等数据）时以此定位，如 `{DataDir}/Servers`
* 启动器元信息字段：
* `LauncherName`：启动器名称（如 `Qomicex Launcher`）
* `LauncherVersion`：启动器版本号（程序集版本，如 `0.1.1`）
* `VersionType`：版本类型，按版本号预发布标签动态判断（`alpha` / `beta` / `rc` / `dev`，否则 `stable`）
* `Developers`：`[{ name, role }]` 开发者列表
* `CoreDependencies`：`[{ name, version, license }]` 核心库列表（.NET / Tauri / Rust / Qomicex.Core 等）

### listPlugins — 读取已安装插件列表

返回已安装插件列表，可用于依赖检测或商店联动。

```js theme={null}
const plugins = await __PLUGIN_API__.call('listPlugins')
// [{ id, name, version, status }, ...]  status: 'installed'|'active'|'disabled'
```

* 权限：`plugin:list`
* 后台走 `GET /api/plugins/`

### uploadPlugin — 安装插件

上传 `.qplugin` 包安装插件。

```js theme={null}
const bytes = new TextEncoder().encode('...')  // .qplugin 文件字节
await __PLUGIN_API__.call('uploadPlugin', Array.from(bytes), 'my-plugin.qplugin')
```

* 权限：`plugin:install`（**危险权限**）
* 参数：`(fileData: number[], fileName: string)`，multipart 字段名 `plugin`

### navigate — 跳转页面

跳转到启动器内部路由。

```js theme={null}
await __PLUGIN_API__.call('navigate', '/settings')
```

* 权限：`config:read`
* 注意：应使用启动器内部路由，勿用外部 URL

### openPluginSettings — 打开插件设置页

跳转到「设置 → 插件 → 插件设置」并定位到指定插件的设置 tab。适合在插件页面 / 悬浮窗里提供"设置"按钮。

```js theme={null}
// 定位到本插件的设置 tab
await __PLUGIN_API__.call('openPluginSettings')

// 定位到指定插件的设置 tab
await __PLUGIN_API__.call('openPluginSettings', 'top.qomicex.weather')
```

* 权限：`config:read`
* 参数：`(targetPluginId?: string)`，缺省定位到**当前插件**
* 目标插件须在 manifest 声明 `contributes.settingsPages` 才有对应 tab（见 [manifest](https://docs.qomicex.top/plugins/manifest.md#settingspages-数组)）；目标无设置页时仍会打开插件设置区，停留在默认位置
* 同插件有多个设置页时定位到第一个

### showToast — 应用内通知

弹出一条 toast 提示。

```js theme={null}
await __PLUGIN_API__.call('showToast', '操作成功', 'success')
```

* 权限：`ui:toast`
* 参数：`(message: string, type?: 'info' | 'error' | 'success')`，默认 `info`

### log — 输出插件日志

把日志写入启动器日志，便于在「设置 → 日志」中统一排查。

```js theme={null}
await __PLUGIN_API__.call('log', '插件初始化完成', 'info')
```

* 权限：`plugin:log`
* 参数：`(message: string, level?: 'debug' | 'info' | 'warn' | 'error')`，默认 `info`

### 文件拖放事件（file-drop）

把文件拖入启动器窗口时，主窗口广播 `file-drop` 事件，payload 为拖入文件的**绝对路径数组**。前端（主界面）可用 `@tauri-apps/api/event` 监听：

```js theme={null}
import { listen } from '@tauri-apps/api/event'
listen<string[]>('file-drop', (event) => {
  const paths = event.payload   // 例如 ['C:/Users/me/a.pdf', 'D:/b.docx']
})
```

* 由启动器 Rust `WindowEvent::DragDrop::Drop { paths, .. }` 触发（`dragDropEnabled` 开启时）
* 路径为系统绝对路径，适合"拖文件自动填入路径"等交互
* **注意**：前端原生 `drop` 事件的 `File` 对象拿不到 `.path`（Tauri 拦截系统拖放），必须走该事件
* L2 沙箱插件如需使用：由主界面前端监听后经 `__pluginRegistry.call(pluginId, '方法', [paths])` / `callPlugin` 转发，插件侧 `registerMethod` 接收

### getThemeColor — 获取当前主题种子色

返回当前启动器主题的种子色 hex 值，供插件根据主题色调整自身 UI。

```js theme={null}
const hex = await __PLUGIN_API__.call('getThemeColor')
// hex: '#3b82f6' 或 null（未设置主题色时）
```

* 权限：`config:read`
* 返回：`string | null`（`#rrggbb` 格式，或 `null`）
* 逻辑：优先取外观设置中的静态 hex 色值；若为 Monet 动态模式（`background`），取当前 `--primary` CSS 变量转换为 hex

### applyThemeOverride — 应用主题覆盖

动态覆盖启动器 UI 的 CSS 变量（颜色主题），实现插件驱动的全局换肤。

```js theme={null}
await __PLUGIN_API__.call('applyThemeOverride', {
  'background': '240 23% 9%',
  'foreground': '220 20% 93%',
  'primary': '142 71% 48%',
  'accent.foreground': '220 20% 93%'
})
```

* 权限：`config:write`
* 参数：`(vars: Record<string, string>)` — 键为语义 token（支持点分或横杠命名），值为 `H S% L%` 格式字符串
* 安全限制：
* **token 白名单**：仅允许 `background`、`foreground`、`primary`、`accent`、`border` 等 30 个预定义语义 token
* **HSL 格式校验**：值必须严格匹配 `^\d{1,3} \d{1,3}% \d{1,3}%$`，拒绝任意 CSS 注入
* 实现：注入 `<style id="qomicex-plugin-theme-override">` 到 `document.head`，选择器 `:root:root` 保证高优先级
* 重复调用会覆盖前一次的 override 样式
* 插件停用时**不会**自动清除，需手动调用 `clearThemeOverride`

### clearThemeOverride — 清除主题覆盖

移除由 `applyThemeOverride` 注入的覆盖样式，恢复到原主题。

```js theme={null}
await __PLUGIN_API__.call('clearThemeOverride')
```

* 权限：`config:write`
* 移除 `id="qomicex-plugin-theme-override"` 的 `<style>` 元素
* 建议在插件停用钩子或用户取消自定义主题时调用

### overlay.create — 创建悬浮窗

创建一个可拖拽的独立悬浮窗，返回悬浮窗 id。

```js theme={null}
const overlayId = await __PLUGIN_API__.call('overlay.create', {
  title: '我的悬浮窗',
  html: '<div class="p-card">你好</div>',
  x: 120, y: 80,
  width: 320, height: 240,
  minimizable: true,
  resizable: true
})
```

* 权限：`ui:sub_window`
* 返回：悬浮窗 id（string）
* 详细见 [悬浮窗开发](https://docs.qomicex.top/plugins/overlay.md)

### overlay.show / hide / destroy — 悬浮窗控制

```js theme={null}
await __PLUGIN_API__.call('overlay.show', overlayId)    // 显示（含最小化恢复）
await __PLUGIN_API__.call('overlay.hide', overlayId)    // 隐藏（最小化）
await __PLUGIN_API__.call('overlay.destroy', overlayId) // 销毁
```

* 权限：`ui:sub_window`

### overlay.setHtml — 更新悬浮窗内容

```js theme={null}
await __PLUGIN_API__.call('overlay.setHtml', overlayId, '<p>新内容</p>')
```

* 权限：`ui:sub_window`

### overlay.setPosition — 移动悬浮窗

```js theme={null}
await __PLUGIN_API__.call('overlay.setPosition', overlayId, 300, 200)
```

* 权限：`ui:sub_window`

### addMenuItem — 动态注册侧边栏菜单项

运行时动态向侧边栏添加菜单项，无需修改 manifest。

```js theme={null}
__PLUGIN_API__.addMenuItem({
  path: '/plugins/p/xxx',
  label: '动态菜单',
  icon: '🤖',
  action: 'page'
})
```

* 权限：`ui:inject_sidebar`
* 参数：`PluginMenuItem`（同 `contributes.menuItems` 元素）
* 插件停用时自动移除动态注册的菜单项

### onLauncherExit — 启动器退出事件

注册启动器**关窗前**的回调（尽力而为）。适合做同步快速收尾：保存状态、提示用户插件拉起的进程的去留等。

```js theme={null}
__PLUGIN_API__.onLauncherExit((event) => {
  // event = 'launcher-exit'
  localStorage.setItem('lastActive', String(Date.now()))   // 同步快操作
})
```

* **无需权限**（被动事件）
* 回调是**同步、尽力而为**的：启动器不会等待插件完成，关闭流程照常进行 —— 回调里**不能**发网络请求或做耗时异步操作（不保证执行完）
* 仅在启动器主窗口正常关闭流程中触发；进程被强杀 / 崩溃不保证触发

### getPluginStrings — 读取插件自己的 i18n 包

返回本插件 `l10n/` 目录合并后的字符串包（`bundle.json` 打底、当前 locale 的 `bundle.<locale>.json` 覆盖）。与 manifest `%key%` 解析同源，插件运行时 UI 用它渲染自己的文案。

```js theme={null}
const strings = await __PLUGIN_API__.call('getPluginStrings')
// { "plugin.name": "服务端管理", ... }  未声明 l10n 时返回 {}
```

* **无需权限**（读取的是插件自带数据）
* 需在 manifest 声明 `"l10n": "l10n"`（见 [manifest](https://docs.qomicex.top/plugins/manifest.md#插件自持-i18n-l10n)）

### getLocale — 当前启动器语言

```js theme={null}
const locale = await __PLUGIN_API__.call('getLocale')   // 'zh-CN'
```

* **无需权限**；与 `getPluginStrings` 的覆盖层一致

### 图标

#### 推荐：`<Glyph>` + 单图标子路径（零 IPC、可 tree-shake）

插件页面里的图标**不要走桥**。图标数据随 `@qomicex/plugin-ui` 分发，静态导入后同步渲染：

```tsx theme={null}
import { Glyph } from '@qomicex/plugin-ui'
import { ServerIcon } from '@qomicex/plugin-ui/icons/server'
import { PlayIcon } from '@qomicex/plugin-ui/icons/play'

<Glyph icon={ServerIcon} className="h-4 w-4" />
```

子路径 = 类名去掉 `fa-solid fa-` 前缀（`fa-solid fa-folder-open` →
`@qomicex/plugin-ui/icons/folder-open`）。品牌图标加 `brand-` 前缀
（`fa-brands fa-github` → `@qomicex/plugin-ui/icons/brand-github`）。导出名是
PascalCase + `Icon`。

打包器只保留真正导入的图标（27 个图标实测 0 条冗余），且不产生任何 IPC 往返。

#### `<Icon name>` — 图标名来自运行期数据时

```tsx theme={null}
import { Icon } from '@qomicex/plugin-ui'

<Icon name={iconNameFromSettings} className="h-4 w-4" />
```

因为 `name` 是运行期字符串，打包器无法静态分析，**整张图标表（约 160KB）会作为
独立 chunk 懒加载**——只在真的渲染 `<Icon>` 时才下载。图标名在编译期已知时请用
上面的 `<Glyph>`。

未登记的类名渲染 `null`（不留空档）并在开发构建 `console.warn`。

#### 发现可用图标：`listIcons`

```js theme={null}
const names = await __PLUGIN_API__.call('listIcons')
// ['fa-solid fa-arrow-left', 'fa-solid fa-server', …]  按字典序
```

* **无需权限**
* 返回内置表 + 当前颜色主题映射 + 本插件 `contributes.iconTheme` 的全部可用键
* 做图标选择器或自检时以它为准，**不要硬编码清单**（内置表随启动器演进）

#### 底层：`getIconSvg`

```js theme={null}
const svg = await __PLUGIN_API__.call('getIconSvg', 'fa-solid fa-server')
// { width: 448, height: 512, path: 'M64…', source: 'builtin' }
// 未命中 → null，并在控制台 console.warn
```

* **无需权限**
* 查找顺序：本插件 `contributes.iconTheme` → 当前颜色主题映射 → 启动器内置表；
  `source` 字段标明命中来源（`plugin` / `theme` / `builtin`）
* 未命中**不再静默返回 null**：控制台会给出告警，附 `listIcons()` 与
  `contributes.iconTheme` 的修复提示
* 仅当图标名来自运行期数据、又不方便用 `<Icon>` 时才直接调它

#### 图标名必须在启动器内置表内

`fa-solid fa-*` / `fa-brands fa-*` 写错会渲染成空白（历史上是静默的，问题能一路
发到用户侧）。现在两道闸门：

1. `qomicex verify` / `qomicex lint` 扫描源码字面量与 manifest `icon` 字段，
   表外类名报 **error** 并给出相近的内置图标建议；
2. `<Icon>` 运行时未命中会 `console.warn`。

需要内置表里没有的图标时，用 `contributes.iconTheme` 声明私有图标（见
[manifest — icon 字段规范](https://docs.qomicex.top/plugins/manifest.md#icon-字段规范菜单--安装方式--顶层图标)），
或随包附一个 `.svg` 用 `<img>` 渲染。

### 语言切换事件（plugin:locale-changed）

启动器切换语言时向所有插件沙箱广播；插件收到后重拉 `getPluginStrings` 并重渲染（与主题跟随对齐）：

```js theme={null}
window.addEventListener('plugin:locale-changed', (e) => {
  reloadStrings()          // 重新 getPluginStrings()（e.detail.locale 为新 locale）
})
```

* **无需权限**（被动事件）

## 下载与安装

### download.addTask — 创建下载任务

创建下载任务，任务自动出现在启动器「下载中心」，进度由 SSE 推送驱动，可取消。

```js theme={null}
// 方式一：指定绝对目标路径
const { taskId } = await __PLUGIN_API__.call('download.addTask', {
  url: 'https://example.com/asset.zip',
  targetPath: 'C:/games/instances/foo/mods/asset.zip',
  extract: true,                 // 可选：zip 下载后自动解压并删除原包
  headers: { 'X-Token': 'abc' }, // 可选：自定义请求头
  name: '我的资源'                // 可选：下载中心显示名（默认 fileName）
})

// 方式二：实例 + 类别（自动解析隔离目录）
await __PLUGIN_API__.call('download.addTask', {
  url: 'https://.../mod.jar',
  instanceId: 'inst-uuid',
  category: 'mods',   // mods / resourcepacks / shaderpacks / datapacks / saves / screenshots
  fileName: 'mod.jar'
})
```

* 权限：`download:manage`
* 返回：`{ taskId, status, targetPath }`
* 后端复用 `DownloadSessionManager`（type `"resource"`），与 mod 下载同一条线

### download.progress — 查询下载进度

```js theme={null}
const snap = await __PLUGIN_API__.call('download.progress', taskId)
// { sessionId, status, stage, progress, speed, currentFile, totalFiles, completedFiles, failedFiles, error, isPaused, instanceId }
```

* 权限：`download:manage`
* `taskId` 不存在返回 `null`

### download.list — 列出全部下载会话

```js theme={null}
const list = await __PLUGIN_API__.call('download.list')
```

* 权限：`download:manage`
* 返回：`download.progress` 同结构的快照数组

### download.cancel — 取消下载

```js theme={null}
await __PLUGIN_API__.call('download.cancel', taskId)
```

* 权限：`download:manage`

### download.addBatch — 批量下载（一张卡片）

一次调用下载多个文件，但**只占下载中心一张卡片**——下载几百个 mod 不会刷屏。底层与安装管线同一套下载器（并发、同目标去重、镜像兜底、`.part` 原子改名）。

```js theme={null}
const { taskId, batchId, totalFiles } = await __PLUGIN_API__.call('download.addBatch', {
  files: [
    { url: 'https://.../a.jar', destPath: 'C:/servers/s1/mods/a.jar' },
    { url: 'https://.../b.jar', destPath: 'C:/servers/s1/mods/b.jar' }
  ],
  // taskId + stepId 可选：把进度写进已有任务（task.register 注册）的某个步骤
  taskId: 'my-task', stepId: 'download-files',
  // 省略 taskId 时自建一个只含单步的任务，title 为卡片名
  title: '下载模组'
})
```

* 权限：`download:manage`
* 返回：`{ taskId, batchId, totalFiles }`
* 下载在**后台进行**，完成状态用 `download.batchStatus(batchId)` 轮询
* 镜像兜底自动生效：Modrinth/CF 文件挂 MCIM、Forge/NeoForge maven 挂 ForgeCDN/BMCLAPI、原版库挂 BMCLAPI——插件传官方 URL 即可

### download.batchStatus — 批量下载批次状态

```js theme={null}
const st = await __PLUGIN_API__.call('download.batchStatus', batchId)
// { status: 'running' | 'completed' | 'failed', totalFiles, error? }
// 批次不存在（后端重启）返回 null
```

* 权限：`download:manage`
* 轮询建议间隔 800ms～1s；`failed` 时 `error` 带首个失败原因

### download.registerInstall — 在下载中心登记安装任务

仅在前端下载中心登记一个「游戏安装」任务（type `'game'`），不创建真实下载。适合插件发起安装后让用户能在下载中心看到进度。

```js theme={null}
await __PLUGIN_API__.call('download.registerInstall', {
  instanceId: 'inst-uuid',
  name: '我的整合包',
  gameVersion: '1.20.1',
  loader: 'forge',
  loaderVersion: '47.1.0'
})
```

* 权限：`instance:write`

## 插件自报任务（分步进度）

插件自己编排的长流程（解析 → 下载 → 安装 → 写配置）用 `task.*` 上报：任务进 `InstallTracker`，**自动出现在下载中心**，与内置安装任务同一套分步卡片渲染。

### task.register — 注册分步任务

```js theme={null}
const { taskId } = await __PLUGIN_API__.call('task.register', {
  taskId: 'my-plugin:setup:1690000000', // 可选；建议带插件前缀避免冲突，缺省自动生成
  title: '服务端搭建 · 我的生存服',
  steps: [
    { id: 'parse-pack',     weight: 10, label: '解析整合包' },
    { id: 'server-runtime', weight: 35, label: '安装服务端运行时' },
    { id: 'download-files', weight: 40, label: '下载模组' }
  ]
})
```

* 权限：`download:manage`
* `weight` 只影响合成总进度的占比（内部按 Σweight 归一化）；`label` 为卡片上的步骤文案（插件自带，无需改启动器 i18n）
* 同 `taskId` 已有非终态任务时抛 `TASK_BUSY`（409）

### task.update — 推进步骤/状态

```js theme={null}
await __PLUGIN_API__.call('task.update', taskId, {
  stepId: 'server-runtime', stepStatus: 'active',   // pending | active | done | failed
  // stepPercent: 42.5,                              // 字节级进度（如配合 download.addBatch 自动推进）
  // currentFile: '正在下载 xxx.jar',
  // stage: 'server-runtime',                        // 阶段文案
  // status: 'downloading'                           // 任务级状态（终态走 task.complete）
})
```

* 权限：`download:manage`
* 所有字段可选，只更新提供的部分；`status` 传终态（completed/failed/cancelled）会被 400 拒绝——终态必须走 `task.complete`

### task.complete — 任务收尾

```js theme={null}
await __PLUGIN_API__.call('task.complete', taskId)               // 成功 → completed
await __PLUGIN_API__.call('task.complete', taskId, '原因')        // 失败 → failed（error 进卡片）
```

* 权限：`download:manage`
* 任务上有 `blockedFiles`（受限文件数）时成功收尾会转为 `awaiting-files` 而非 completed（提醒用户补文件）

### task.progress — 查询任务进度

```js theme={null}
const snap = await __PLUGIN_API__.call('task.progress', taskId)
// InstallProgress：{ status, progress, stage, steps, currentFile, blockedFiles, error, title }
// 任务不存在返回 null
```

* 权限：`download:manage`

### curseforge.files — CurseForge 元数据代理

把 CurseForge 的 `projectID:fileID` 批量换成下载直链。API key 始终留在启动器进程内，插件拿不到——这是插件生态里唯一的 CF 查询通道。

```js theme={null}
const infos = await __PLUGIN_API__.call('curseforge.files', [
  { projectId: 238222, fileId: 4800000 },
  { fileId: 4800001 }
])
// [{ projectId, fileId, fileName, downloadUrl, fileLength, blockedReason? }]
// downloadUrl === null 表示作者关闭第三方分发（受限文件），应引导用户手动补全
```

* 权限：`network:fetch`
* 单次最多 20000 个文件（`CF_TOO_MANY_FILES`）

## 文件系统扩展

### readArchiveEntry — 读 zip 内单个条目（授权制）

不解压整包，直接读压缩包里一个条目的文本。模组/整合包的元数据都在包里（`fabric.mod.json`、`META-INF/mods.toml`、`modrinth.index.json`），这是读它们的最快路径。

```js theme={null}
const res = await __PLUGIN_API__.call('readArchiveEntry',
  'C:/packs/pack.mrpack', 'modrinth.index.json', { maxBytes: 16 * 1024 * 1024 })
// { content, size }；条目不存在返回 null（可用于探测）
```

* 权限：`filesystem:read`，且压缩包路径需逐路径授权（与 `readText` 同一套授权流程，首次弹确认）
* `maxBytes` 缺省 4 MiB、上限 32 MiB，超限报 `ARCHIVE_ENTRY_TOO_LARGE`
* 仅支持 zip 系（jar/mrpack/litemod 均为 zip 容器）

### renamePath — 移动/重命名（授权制）

```js theme={null}
const { path } = await __PLUGIN_API__.call('renamePath',
  'C:/servers/s1/mods/a.jar', 'C:/servers/s1/mods-client-only/a.jar')
```

* 权限：`filesystem:write`，源与目标两个路径都需授权（一次确认同时授两端）
* 支持目录；目标父目录自动创建
* 没有「移动」时插件只能 readBytes + writeBytes + deleteFile 三倍搬运大文件——把 client-only 模组隔离到别的目录这类操作就该用它

### modpack.install — 一键安装整合包

直接安装整合包，走启动器**正常整合包安装流程**（与前端「整合包」页同一管线）：解析来源 → 创建实例 → 下载安装。

```js theme={null}
// 方式一：本地整合包文件（.zip / .mrpack）
const { instanceId } = await __PLUGIN_API__.call('modpack.install', {
  id: 'MyPack',                   // 实例名（必填）
  path: 'C:/packs/pack.zip',      // 本地整合包路径
  gameDir: 'C:/games/instances',  // 实例根目录（必填）
  maxMemory: 8192,                // 可选：默认 4096
  versionIsolation: true          // 可选：版本隔离，默认 false
})

// 方式二：在线整合包（Modrinth / CurseForge / FTB）
await __PLUGIN_API__.call('modpack.install', {
  id: 'MyPack',
  type: 'mr',          // mr | cf | ftb（也接受 modrinth/curseforge）
  projectId: 'abc',    // 项目 id
  fileId: '123',       // 版本 id
  gameDir: 'C:/games/instances'
})
```

* 权限：`instance:write`
* 走 `POST /api/modpack/install-direct`，复用 `ModpackService`（`ParseFileAsync`/`ResolveOnlineAsync`/`InstallAsync`）与 `InstallTracker`
* 返回：`{ instanceId }`，安装异步进行（进度在下载中心 / SSE 查看）
* 错误码：`MODPACK_NAME_REQUIRED`、`MODPACK_GAME_DIR_REQUIRED`、`MODPACK_FILE_NOT_FOUND`、`MODPACK_SOURCE_REQUIRED`、`MODPACK_SOURCE_INVALID`
* 需要下载中心显示安装进度时，可配合 `download.registerInstall` 登记

### 整合包一键开服（参考实现）

「把整合包装成可运行的 Minecraft 服务端」没有专用端点——启动器只提供通用能力，流程由插件用上面这些 API 编排。官方插件 `top.qomicex.server-manager`（`src/api/setup.js`）是完整范例，管线与要点：

1. **解析包**：`readArchiveEntry` 读包内 `modrinth.index.json`（Modrinth）或 `manifest.json`（CurseForge），得到 MC 版本 / 加载器 / 文件清单。服务端侧按 `env.server != "unsupported"` 过滤（与客户端的 `env.client == "required"` 不同）。
2. **装运行时**（`download.addBatch` + `process.spawn`）：
   * vanilla：`proxyFetch` 拿版本清单 → 下载 `server.jar`（`download.addBatch` 自动挂 BMCLAPI 兜底）
   * fabric：fabric meta → 下载 server-launcher jar
   * forge / neoforge：下载官方安装器 → **先预填 `libraries/`**（用 `readArchiveEntry` 读安装器内嵌 `install_profile.json` / `version.json` 里的库清单，经 `download.addBatch` 并发下载，NeoForge maven 因此拿到 ForgeCDN 兜底）→ 再 `process.spawn` 执行 `java -jar installer --installServer`，轮询 `process.alive` 等退出码。安装器对已存在且 sha1 匹配的文件直接跳过，`--mirror` 参数对 neoforged.net 无效（实测）
3. **下载 mods**：Modrinth 清单自带直链；CurseForge 经 `curseforge.files` 换直链，取不到直链（作者关闭分发）的记入 `{dir}/.qml-server/blocked-mods.json`，进度经 `task.update` 上报
4. **释放 overrides**：`extractArchive(packPath, dir, { prefix: 'overrides', stripPrefix: true })`
5. **隔离 client-only**：`listDir` 扫 `mods/*.jar`，`readArchiveEntry` 读 `fabric.mod.json` 的 `environment` 或 `(neo)forge.mods.toml` 的 `clientSideOnly`，命中的 `renamePath` 进 `{dir}/mods-client-only/`（不删，可恢复）。注意：**元数据没声明 side 的漏标 mod 无法静态识别**，只能靠启动崩溃后手动隔离
6. **写基础配置**：`writeText` 写 `eula.txt`（false，同意流程由用户负责）/ `server.properties` / `user_jvm_args.txt`；已存在的文件不覆盖（overrides 可能带正式配置）
7. **写启动清单**：`{dir}/.qml-server/launch.json`（`readText` 读取需该目录授权）：

```json theme={null}
{
  "mode": "argfile",                     // "argfile"（forge/neo 1.17+，安装器生成 run.bat/run.sh 后从脚本提取 @args 路径）| "jar"
  "gameVersion": "1.21.1",
  "loader": "neoforge",
  "loaderVersion": "21.1.233",
  "windowsArgfile": "libraries/net/neoforged/neoforge/21.1.233/win_args.txt",
  "unixArgfile": "libraries/.../unix_args.txt",
  "jar": "fabric-server-launch.jar"      // mode=jar 时
}
```

* `mode=argfile`：`java @<windowsArgfile 或 unixArgfile>`（按平台选）；`mode=jar`：`java -jar <jar>`
* 服务端务必带 `nogui`：GUI 会接管控制台输出且不读 stdin 管道（stop 命令失效）
* 进度全程经 `task.register` / `task.update` / `task.complete` 上报，下载中心可看分步卡片

## 错误处理

所有 API 调用失败会 reject，错误信息形如：

```js theme={null}
try {
  await __PLUGIN_API__.call('getSettings')
} catch (e) {
  console.error(e.message)
}
```

常见错误：

* `Permission denied: requires xxx` — manifest 权限未包含
* `Backend error: 404` — `callBackend` 端点不存在
* `Proxy failed: 400` — `proxyFetch` 参数错误（含 SSRF 拦截）
* `插件 xxx 未提供方法 yyy（可能未安装或未激活）` — `callPlugin` 目标不可用


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.