> ## 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.

# Plugin API reference

## Plugin API reference

Plugin scripts interact with the launcher through the global object `window.__PLUGIN_API__`. This page lists every callable method, its signature, required permissions, and examples.

## Global variables

### **PLUGIN\_API\_BASE** — plugin file URL prefix

Inside the L2 Sandbox (Sandbox), the launcher injects `window.__PLUGIN_API_BASE__` with the file access base URL of the current plugin:

```text theme={null}
http://localhost:5000/api/plugins/{pluginId}/files
```

Use it to build URLs for bundled resources such as icons and images:

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

The variable does not exist in Inline rendering (inline rendering)(no `l2`) or in Overlay windows (Overlay window)(overlay iframe); resolve relative paths another way there.

## How to call

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

// 1) generic call (most methods)
const data = await __PLUGIN_API__.call('getSettings')

// 2) dedicated shortcuts (the four below)
await __PLUGIN_API__.registerMethod('name', fn)      // register a method
await __PLUGIN_API__.callPlugin('id', 'method', ...) // call another plugin method
await __PLUGIN_API__.proxyFetchStream(req, handlers) // streaming request
await __PLUGIN_API__.registerHook('method', handler) // hook a launcher method
```

## Permission model

Every API method maps to one permission. On each call the launcher checks whether the plugin manifest's `permissions` contains that permission; otherwise it errors:

```text theme={null}
Permission denied: requires <permissionId>
```

### Full permission list

| Permission ID | Description | Risk |
| - | - | - |
| `instance:read` | Read the instance list | Normal |
| `instance:write` | Create, modify, or delete instances (including installing modpacks) | Warning |
| `account:read` | Read the account list | Normal |
| `license:read` | Read license information | Normal |
| `config:read` | Read launcher configuration | Normal |
| `config:write` | Modify launcher configuration | Warning |
| `cache:access` | Read and write the plugin cache | Normal |
| `endpoint:discover` | Discover backend API endpoints | Normal |
| `page:list` | List pages | Normal |
| `network:fetch` | Send HTTP requests | Warning |
| `network:cors_proxy` | CORS proxy requests | Warning |
| `network:websocket` | WebSocket connections | Warning |
| `network:proxy` | Modify proxy settings | Warning |
| `ui:inject_sidebar` | Inject sidebar menu items | Normal |
| `ui:inject_settings` | Inject settings pages | Normal |
| `ui:picture_in_picture` | Picture-in-picture window | Warning |
| `ui:sub_window` | Standalone sub-window | Warning |
| `ui:context_menu` | Inject context menu items | Normal |
| `ui:toast` | In-app notifications | Normal |
| `ui:navigate` | Navigate between pages | Normal |
| `system:info` | Read system and launcher information | Normal |
| `system:notification` | Send system notifications | Normal |
| `clipboard:read` | Read the clipboard | Warning |
| `clipboard:write` | Write to the clipboard | Warning |
| `wasm:execute` | Execute WASM modules | Warning |
| `plugin:install` | Install, uninstall, or update plugins | Dangerous |
| `plugin:list` | List installed plugins | Normal |
| `resource:read` | Read game resource files | Normal |
| `resource:write` | Write game resource files | Warning |
| `java:manage` | Manage Java runtimes | Warning |
| `game:process` | Start and stop the game process | Warning |
| `game:log` | Detect game logs | Normal |
| `connector:host` | Start and stop multiplayer hosting | Warning |
| `connector:scan` | Scan for LAN multiplayer games | Normal |
| `shell:execute` | Run system commands | Dangerous |
| `filesystem:read` | Read the filesystem | Warning |
| `filesystem:write` | Write to the filesystem | Dangerous |
| `process:manage` | Manage resident processes (spawn/stdin/kill) | Dangerous |
| `dialog:open` | Open file/directory picker dialogs | Normal |
| `hook:register` | Hook launcher methods (intercept or modify arguments, block execution) | Dangerous |
| `download:manage` | Manage download tasks | Warning |
| `plugin:log` | Write plugin logs | Normal |

The **risk** level drives the colour hint in the install details dialog: Normal = blue, Warning = yellow, Dangerous = red. Follow the **principle of least privilege** and request only the permissions you actually use.

## Method reference

### getSettings — Read plugin settings

Read the plugin's own `settings.json`.

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

* Permission: `config:read`
* Returns: `Record<string, unknown>` (`{}` when the file does not exist)
* Storage: `{dataDir}/plugins/{pluginId}/settings.json`

### setSettings — Write plugin settings

Merge settings by key (**merge, not replace the whole file**).

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

* Permission: `config:write`
* Parameters: `(key: string, value: any)`; value can be any JSON value
* Concurrent writes are protected by a backend lock

### setCache / getCache — Plugin cache

Read and write the plugin's own cache file by key, with TTL expiry.

```js theme={null}
// write to cache with a 1-hour TTL (3600 seconds)
await __PLUGIN_API__.call('setCache', 'modelList', { items: [...] }, 3600)

// read the cache (returns null when missing or expired)
const cached = await __PLUGIN_API__.call('getCache', 'modelList')
```

* Permission: `cache:access`
* Storage: `{dataDir}/plugins/{pluginId}/cache.json`
* Internal shape is `{ key: { v: value, e: expiresAt|null } }`; plugins do not need to care about it

### callBackend — Call the launcher backend API

Call the launcher backend directly (`http://localhost:5000/api/...`), bypassing CORS restrictions.

```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'
})
```

* Permission: `network:fetch`
* Parameters: `(endpoint: string, data?: any)`
* With `data` → POST; without `data` → GET
* You can pass a `_method` field in the `data` object to override the HTTP method (for example `{ _method: 'PUT', State: 'active' }`); `_method` is not sent to the backend
* `endpoint` starts with `/` (such as `/instance`) and excludes the `/api` prefix
* Returns: the backend JSON response; throws on non-2xx
* For the list of endpoints, see the launcher backend OpenAPI (dev mode `/openapi/v1.json`); common ones include `/instance`, `/resources/search`, `/settings`, and so on

In the L2 sandbox (`sandbox="allow-scripts"`, no `allow-popups`), `window.open(url, '_blank')` is blocked. To open external links, use the backend endpoint:

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

This endpoint opens `http/https` links in the system default browser (only these two schemes are accepted; anything else returns 400).

### proxyFetch — CORS proxy request

Forward a request to any external URL through the launcher backend, bypassing browser CORS restrictions, with SSRF protection.

```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)   // text response
}
```

* Permission: `network:cors_proxy`
* Request `ProxyRequest`:

```ts theme={null}
  {
    url: string                // required, http/https
    method?: string            // defaults to GET
    headers?: Record<string, string>
    body?: string              // POST body (string)
    timeoutMs?: number         // defaults to 15000, range 1000-60000
  }
```

* Response `ProxyResponse`:

```ts theme={null}
  {
    status: number
    headers: Record<string, string>
    body?: string | null       // text response
    bodyBase64?: string | null // binary response
  }
```

* **SSRF protection**: only http/https; private and reserved addresses are rejected (localhost, 127.x, 10.x, 172.16-31.x, 192.168.x, 169.254.x, and so on) with `PROXY_PRIVATE_ADDRESS`(400)

### proxyFetchStream — Streaming proxy request

Consume an SSE stream (such as word-by-word AI output) with a callback per chunk.

```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    // optional, aborts the stream
  },
  {
    onChunk(chunk) {
      // chunk is the SSE data: line payload (the data: prefix is already stripped)
      const json = JSON.parse(chunk)
      const delta = json.choices?.[0]?.delta?.content
      if (delta) appendText(delta)
    },
    onError(err) { console.error(err) }
  }
)
```

* Permission: `network:cors_proxy`
* The backend automatically adds `stream: true` when forwarding the upstream stream
* `signal` (AbortSignal) aborts the stream; `req.signal` is stripped automatically before postMessage and sent as a `__plugin_api_abort` message instead
* ⚠️ **Known limitation**: the Promise returned by `proxyFetchStream` resolves when the stream ends, but chunk callbacks may still fire after it resolves. Use `onError` or a `[DONE]` marker within the stream to detect completion

### registerMethod — Register a plugin method

Register a method of the current plugin in the global registry so that **other plugins** can call it via `callPlugin`.

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

// async methods are supported
__PLUGIN_API__.registerMethod('fetchTranslation', async function (text) {
  const res = await fetch('https://api.example.com/translate?text=' + encodeURIComponent(text))
  return res.json()
})
```

* Permission: `config:write`
* Parameters: `(method: string, fn: Function)`; `fn` may return a value or a Promise
* All methods are unregistered automatically when the plugin is disabled
* See [Plugin dependencies and inter-plugin calls](https://docs.qomicex.top/en/plugins/dependencies.md) for details

### callPlugin — Call another plugin's method

Call a method already registered by the target plugin.

```js theme={null}
try {
  const html = await __PLUGIN_API__.callPlugin('top.qomicex.markdown', 'renderMarkdown', '**bold**')
  document.getElementById('out').innerHTML = html
} catch (e) {
  console.error(e.message)   // rejects when the target is missing, inactive, or unregistered
}
```

* Permission: `network:fetch`
* Parameters: `(pluginId: string, method: string, ...args)`
* Rejects if the target is not installed / not active / has not registered the method

### registerHook — Register a hook on a launcher method

Register the current plugin as an interceptor of a launcher method, injecting logic before and after it runs (modify arguments or the return value, block execution, or replace the implementation entirely).

```js theme={null}
__PLUGIN_API__.registerHook('scanVersions', async (ctx, next) => {
  ctx.args[0] = ctx.args[0] + '/custom'   // before: modify the arguments
  await next()                              // run the default implementation
  ctx.result = [...ctx.result, { name: 'virtual-1.20.1', gameVersion: '1.20.1', state: 'Available' }]  // after: modify the result
})
```

* Permission: `hook:register` (**dangerous**)
* Parameters: `(method: string, handler: (ctx, next) => Promise<void>)`
* `handler` is Koa-style onion middleware: modify `ctx.args` before `next()`, modify `ctx.result` after `next()`, and use `ctx.prevent()` to block the default implementation
* All of the plugin's hooks are unregistered automatically when the plugin is disabled
* See [Hooks](https://docs.qomicex.top/plugins/hooks.md) for details

### callWasm — Call an exported WASM plugin function

Call a function exported by an L3 WASM plugin (`on_load` / `on_unload` / custom exports). It runs through the launcher's Rust gateway (wasmtime).

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

* Permission: `wasm:execute`
* Parameters: `(pluginId: string, exportName?: string)`; `exportName` defaults to `on_load`
* Returns: `{ ok: true, result: ... }` or an error
* See [WASM plugins](https://docs.qomicex.top/plugins/wasm-plugin.md) for details

### listWasmPlugins — List loaded WASM plugins

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

* Permission: `wasm:execute`

### readText — Read a text file (authorization required)

Read a **text** file (UTF-8) at the given path, with support for partial reads. **First access requires user authorization** (a confirmation dialog).

```js theme={null}
const file = await __PLUGIN_API__.call('readText', 'C:/Users/me/data.json')
// file = { path, content: 'full text content' }

// read 100 characters starting at offset 5
const seg = await __PLUGIN_API__.call('readText', 'C:/Users/me/log.txt', { start: 5, length: 100 })
```

* Permission: `filesystem:read`
* Parameters: `(path: string, options?: { start?: number; length?: number })`
* `start`: start offset (characters), defaults to 0
* `length`: number of characters to read, defaults to the rest of the file
* Returns: `{ path, content: string }`
* **Authorization**: the first access to an unauthorized path returns 403 `FS_AUTHORIZATION_REQUIRED`; the launcher automatically asks "allow this plugin to access this path?" and, once the user agrees, grants access (persisted per path prefix). The same plugin and path no longer prompts
* Missing file → `FS_FILE_NOT_FOUND`(404)

### readBytes — Read a binary file (authorization required)

Read a **binary** file at the given path and return base64, with support for partial reads.

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

// read the first 4 bytes
const head = await __PLUGIN_API__.call('readBytes', 'C:/Users/me/img.png', { start: 0, length: 4 })
```

* Permission: `filesystem:read`
* Parameters: `(path: string, options?: { start?: number; length?: number })`
* `start`: start offset (bytes), defaults to 0
* `length`: number of bytes to read, defaults to the rest of the file
* Returns: `{ path, contentBase64: string }`
* Authorization works the same as `readText`

### writeText — Write a text file (authorization required)

```js theme={null}
await __PLUGIN_API__.call('writeText', 'C:/Users/me/out.txt', 'file contents')
```

* Permission: `filesystem:write`
* Parameters: `(path: string, content: string)`, written as UTF-8
* Authorization works the same as `readText`; parent directories are created automatically
* Returns: `{ path }`

### writeBytes — Write a binary file (authorization required)

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

* Permission: `filesystem:write`
* Parameters: `(path: string, bytes: Uint8Array)`, converted to base64 for transport automatically
* Authorization works the same as `readText`; parent directories are created automatically
* Returns: `{ path }`

### deleteFile — Delete a file (authorization required)

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

* Permission: `filesystem:write`
* Parameters: `(path: string)`; deletes **files** only — use `deleteDir` for directories
* Authorization works the same as `readText` (destructive operations also require confirmation)
* Missing file → `FS_FILE_NOT_FOUND`(404)
* Returns: `{ path }`

### deleteDir — Delete a directory (authorization required)

```js theme={null}
// Empty directories only (a non-empty one is rejected, preventing accidents)
await __PLUGIN_API__.call('deleteDir', 'C:/Servers/old')

// Delete the directory together with everything inside
const { removed } = await __PLUGIN_API__.call('deleteDir', 'C:/Servers/old', { recursive: true })
// removed = number of entries deleted (files + subdirectories)
```

* Permission: `filesystem:write`
* Parameters: `(path: string, options?: { recursive?: boolean })`
* Authorization works the same as `readText`; the path must be inside a granted prefix
* With `{ recursive: false }` only an **empty** directory is accepted; otherwise
  `FS_DIR_NOT_EMPTY`(400)
* **Dangerous roots are always refused**: drive roots (`D:`, `D:\`) and the filesystem root
  return `FS_REFUSE_ROOT`(403). Grants are prefix-based, so a plugin granted a whole drive
  could otherwise wipe it in one call — plugins should still validate in their own domain
  (for example, refuse directories that are too shallow)
* Missing directory → `FS_DIR_NOT_FOUND`(404)
* Returns: `{ path, removed }`

> Typical use: deleting an instance or server directory. Previously the backend could only
> delete individual files, so a plugin had to recursively `deleteFile` and then rename the
> empty shell away — leaving unremovable junk directories on disk.

### execCommand — Run a shell command

Run a system command and return the exit code and output. **Uses powershell on Windows and /bin/sh on Linux/macOS**.

```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: '...' }
```

* Permission: `shell:execute` (**dangerous permission**, shown in red at install time)
* Parameters: `(command: string, timeoutMs?: number)`; `timeoutMs` defaults to 15000, range 1000–120000
* Returns: `{ exitCode: number, stdout: string, stderr: string }`
* Timeout → `SHELL_TIMEOUT`(400); the command is terminated forcibly
* Empty command → `SHELL_COMMAND_REQUIRED`(400)

⚠️ `execCommand` is a **one-shot** execution model: stdout/stderr are fully buffered until the process exits, and there is no process handle and no stdin. **Do not use it to start resident processes** (game servers, daemons, and so on) — they would be killed on timeout. Use `process.*` below for those scenarios.

### process.spawn — Start a resident process

Start a long-running external process (such as a Minecraft server `java -jar server.jar nogui`) and get a handle back.

```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: '...' }   // optional
})
```

* Permission: `process:manage` (**dangerous permission**)
* Parameters: `{ command: string, args?: string[], cwd?: string, env?: Record<string, string> }`
* Returns: `{ pid: number, startedAtMs: number }`
* stdout/stderr are buffered line by line into a ring buffer (2000 lines), fetched incrementally via `process.logs`
* **Ownership isolation**: a plugin can only manage processes it spawned itself (pids of other plugins always return 404 `PROCESS_NOT_FOUND`)
* Up to 32 concurrently managed processes per plugin (`PROCESS_SPAWN_FAILED`)
* After the process exits, the handle is kept for 10 minutes for final log reads, then reclaimed

### process.stdin — Write to the process stdin

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

* Permission: `process:manage`
* Parameters: `(pid: number, text: string, opts?: { appendNewline?: boolean })`
* This is how interactive console commands work (server `stop` / `say` / `whitelist add` and so on)
* Process already exited → `PROCESS_STDIN_FAILED`

### process.kill — Kill the process tree

```js theme={null}
await __PLUGIN_API__.call('process.kill', pid)
// { message: 'Process 1234 killed' }
```

* Permission: `process:manage`
* Kills the **whole process tree** (Windows `taskkill /T /F`; Unix kills descendants one by one)
* Idempotent: killing an already-exited process succeeds

### process.alive — Query process state

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

* Permission: `process:manage`
* When `alive: false`, `exitCode` holds the exit code (`null` if killed by a signal or unavailable)

### process.logs — Cursor-based incremental logs

```js theme={null}
// Polling example: keep nextSeq as the afterSeq of the next call
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)
```

* Permission: `process:manage`
* Parameters: `(pid: number, afterSeq?: number)`; omitting `afterSeq` starts from the oldest buffered line
* Returns: `{ lines: [{ seq, stream, text }], nextSeq, oldestSeq, alive, exitCode }`
* `oldestSeq`: the oldest seq still held after ring-buffer eviction — if your cursor lags beyond the buffer capacity (2000 lines), the middle lines are lost; surface this to the user
* Logs remain fetchable after the process exits (within the retention window)

### process.list — List this plugin's managed processes

```js theme={null}
const procs = await __PLUGIN_API__.call('process.list')
// [array of snapshots in the same shape as process.alive]
```

* Permission: `process:manage`
* Typical use: after a plugin page reload or launcher restart, recover still-running processes this way (together with a persisted pid)

### pickFile — File picker dialog

Open a native file picker dialog (implemented in the main window via Tauri). In the sandbox, `<input type="file">` cannot get absolute paths and `window.showOpenFilePicker` is unavailable — when you need an **absolute path**, this is the way.

```js theme={null}
const path = await __PLUGIN_API__.call('pickFile', {
  title: 'Pick a server archive',
  defaultPath: 'C:/Users/me/Downloads',
  filters: [{ name: 'Archives', extensions: ['zip'] }],
  multiple: false
})
// returns null on cancel / in browser dev mode
```

* Permission: `dialog:open`
* Parameters: `{ title?, defaultPath?, filters?: [{ name, extensions: string[] }], multiple? }`, all optional
* Returns: `string | null` (absolute path of the selection; `null` on cancel)

### pickDirectory — Directory picker dialog

```js theme={null}
const dir = await __PLUGIN_API__.call('pickDirectory', { title: 'Pick the server root directory' })
```

* Permission: `dialog:open`
* Returns: `string | null`

### extractArchive — Extract a local zip to a directory (authorization required)

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

* Permission: `filesystem:write`
* Parameters: `(archivePath: string, destPath: string, opts?: { prefix?: string, stripPrefix?: boolean })`; the destination directory is created automatically
* `prefix` extracts only entries under that sub-directory; with `stripPrefix: true` the prefix is stripped — use these to lift a modpack's `overrides/` to the server root (`overrides/mods/a.jar` → `mods/a.jar`)
* Guards: zip-slip (illegal path entries rejected) plus entry-count / per-file / total-size limits (same extractor as modpacks)
* **Authorization**: the archive path needs read authorization and the destination needs write authorization — the first use shows one confirmation that authorizes both path prefixes
* zip only (rar/7z not supported yet)

### listDir — List directory entries (authorization required)

```js theme={null}
const entries = await __PLUGIN_API__.call('listDir', 'C:/servers/my-server', { recursive: true, maxEntries: 3000 })
// [{ name, path, isDir, size, modifiedMs }, ...]  directories first, then by name
```

* Permission: `filesystem:read`
* Parameters: `(path: string, opts?: { recursive?: boolean, maxEntries?: number })`
* `maxEntries` defaults to 1000, capped at 10000
* Authorization works the same as `readText`
* Typical use: detect the server jar after an import, check whether `eula.txt` / `server.properties` exist

### sendNotification — System-level notification

Send a system-level desktop notification (WebView Notification API; the first call triggers the browser permission prompt). This reaches users even when the plugin page is not in the foreground. Returns `false` on failure/denial — fall back to `showToast`.

```js theme={null}
const ok = await __PLUGIN_API__.call('sendNotification', { title: 'Server stopped', body: 'exit code 1' })
if (!ok) await __PLUGIN_API__.call('showToast', 'Server stopped', 'error')
```

* Permission: `system:notification`
* Parameters: `{ title: string, body?: string }`
* Returns: `boolean` (`true` when the notification was shown; `false` when the API is unavailable / not granted / denied)

### openUrl — Open an external link

Open an external URL in the system default browser. Inside the L2 sandbox, `window.open` is blocked by the `sandbox` attribute, so use this method for external links.

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

* Permission: `system:notification`
* Uses `POST /api/system/open-url` under the hood; only `http://` / `https://` are accepted; invalid URLs return 400

### getSystemInfo — Read system and launcher information

Read OS, launcher, and memory information. Useful for `minLauncherVersion` compatibility checks or detecting the platform at runtime.

```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 }
```

* Permission: `system:info`
* Uses `GET /api/systeminfo` under the hood (equivalent to `/api/system/info`)
* `GitCommit` is the launcher's Git commit hash; `Memory` / `AvailableMemory` are byte counts
* `DataDir`: the launcher data directory (such as `%LOCALAPPDATA%/qomicex-launcher`). Use it to locate a default workspace for the plugin's own data (servers, saves, downloads), e.g. `{DataDir}/Servers`
* Launcher metadata fields:
* `LauncherName`: launcher name (such as `Qomicex Launcher`)
* `LauncherVersion`: launcher version (assembly version, such as `0.1.1`)
* `VersionType`: version type, derived from the prerelease tag of the version number (`alpha` / `beta` / `rc` / `dev`, otherwise `stable`)
* `Developers`: developer list as `[{ name, role }]`
* `CoreDependencies`: core library list as `[{ name, version, license }]` (.NET / Tauri / Rust / Qomicex.Core and so on)

### listPlugins — List installed plugins

Return the list of installed plugins, useful for dependency checks or store integration.

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

* Permission: `plugin:list`
* Uses `GET /api/plugins/` under the hood

### uploadPlugin — Install a plugin

Upload a `.qplugin` package to install a plugin.

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

* Permission: `plugin:install` (**dangerous permission**)
* Parameters: `(fileData: number[], fileName: string)`; the multipart field name is `plugin`

### navigate — Navigate to a page

Navigate to an internal launcher route.

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

* Permission: `config:read`
* Note: use internal launcher routes, not external URLs

### openPluginSettings — Open the plugin settings page

Navigate to "Settings → Plugins → Plugin settings" and focus the settings tab of the given plugin. Useful for a "settings" button on plugin pages and Overlay windows.

```js theme={null}
// focus this plugin's settings tab
await __PLUGIN_API__.call('openPluginSettings')

// focus a specific plugin's settings tab
await __PLUGIN_API__.call('openPluginSettings', 'top.qomicex.weather')
```

* Permission: `config:read`
* Parameters: `(targetPluginId?: string)`; defaults to the **current plugin**
* The target plugin must declare `contributes.settingsPages` in its manifest to have a tab (see [manifest](https://docs.qomicex.top/en/plugins/manifest.md#settingspages-array)); if the target has no settings page, the plugin settings area still opens and stays at the default position
* If a plugin has several settings pages, the first one is focused

### showToast — In-app notification

Show a toast message.

```js theme={null}
await __PLUGIN_API__.call('showToast', 'Operation succeeded', 'success')
```

* Permission: `ui:toast`
* Parameters: `(message: string, type?: 'info' | 'error' | 'success')`, defaults to `info`

### log — Write plugin logs

Write a log line into the launcher log so it shows up in **Settings → Logs**.

```js theme={null}
await __PLUGIN_API__.call('log', 'Plugin initialized', 'info')
```

* Permission: `plugin:log`
* Parameters: `(message: string, level?: 'debug' | 'info' | 'warn' | 'error')`, defaults to `info`

### File drag-and-drop event (file-drop)

When files are dropped onto the launcher window, the main window emits a `file-drop` event whose payload is the **array of absolute paths** of the dropped files. The frontend (main UI) can listen with `@tauri-apps/api/event`:

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

* Emitted by the launcher's Rust `WindowEvent::DragDrop::Drop { paths, .. }` (when `dragDropEnabled` is on)
* Paths are absolute system paths; well suited to interactions such as "drop a file to fill in its path"
* **Note**: the native `drop` event's `File` object does not expose `.path` in the frontend (Tauri intercepts system drag-and-drop); you must use this event
* For L2 sandbox plugins: the main UI frontend listens and relays via `__pluginRegistry.call(pluginId, 'methodName', [paths])` / `callPlugin`, and the plugin receives it with `registerMethod`

### getThemeColor — Get the current theme seed color

Return the seed color hex value of the current launcher theme so plugins can adapt their UI to it.

```js theme={null}
const hex = await __PLUGIN_API__.call('getThemeColor')
// hex: '#3b82f6' or null (when no accent color is set)
```

* Permission: `config:read`
* Returns: `string | null` (`#rrggbb` format, or `null`)
* Logic: prefers the static hex color from appearance settings; in Monet dynamic mode (`background`), takes the current `--primary` CSS variable and converts it to hex

### applyThemeOverride — Apply a theme override

Dynamically override the launcher UI's CSS variables (color theme) to implement plugin-driven global theming.

```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%'
})
```

* Permission: `config:write`
* Parameters: `(vars: Record<string, string>)` — keys are semantic tokens (dot or hyphen naming supported), values are `H S% L%` strings
* Safety restrictions:
* **Token allowlist**: only 30 predefined semantic tokens such as `background`, `foreground`, `primary`, `accent`, and `border` are allowed
* **HSL format validation**: values must strictly match `^\d{1,3} \d{1,3}% \d{1,3}%$`, preventing arbitrary CSS injection
* Implementation: injects `<style id="qomicex-plugin-theme-override">` into `document.head`; the `:root:root` selector guarantees high priority
* Repeated calls replace the previous override styles
* The override is **not** cleared automatically when the plugin is disabled; call `clearThemeOverride` manually

### clearThemeOverride — Clear the theme override

Remove the override styles injected by `applyThemeOverride` and restore the original theme.

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

* Permission: `config:write`
* Removes the `<style>` element with `id="qomicex-plugin-theme-override"`
* Recommended in the plugin's disable hook or when the user cancels the custom theme

### overlay.create — Create an Overlay window

Create a draggable standalone Overlay window and return its id.

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

* Permission: `ui:sub_window`
* Returns: the Overlay window id (string)
* See [Overlay window development](https://docs.qomicex.top/plugins/overlay.md) for details

### overlay.show / hide / destroy — Overlay window control

```js theme={null}
await __PLUGIN_API__.call('overlay.show', overlayId)    // show (including restore from minimized)
await __PLUGIN_API__.call('overlay.hide', overlayId)    // hide (minimize)
await __PLUGIN_API__.call('overlay.destroy', overlayId) // destroy
```

* Permission: `ui:sub_window`

### overlay.setHtml — Update Overlay window content

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

* Permission: `ui:sub_window`

### overlay.setPosition — Move an Overlay window

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

* Permission: `ui:sub_window`

### addMenuItem — Register a sidebar menu item dynamically

Add a sidebar menu item at runtime without editing the manifest.

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

* Permission: `ui:inject_sidebar`
* Parameters: `PluginMenuItem` (the same element as `contributes.menuItems`)
* Dynamically registered menu items are removed automatically when the plugin is disabled

### onLauncherExit — Launcher exit event

Register a callback that runs **just before the launcher window closes** (best effort). Suitable for fast synchronous cleanup: saving state, informing the user about processes the plugin spawned, and so on.

```js theme={null}
__PLUGIN_API__.onLauncherExit((event) => {
  // event = 'launcher-exit'
  localStorage.setItem('lastActive', String(Date.now()))   // fast synchronous work
})
```

* **No permission required** (passive event)
* The callback is **synchronous and best-effort**: the launcher does not wait for plugins and proceeds with the close flow — **do not** send network requests or start long async work inside the callback (it is not guaranteed to finish)
* Only fires during the normal close flow of the main launcher window; force-killed / crashed processes cannot guarantee it

### getPluginStrings — Read the plugin's own i18n bundle

Returns the merged string bundle of this plugin's `l10n/` directory (`bundle.json` as the base, overridden by `bundle.<locale>.json` for the current locale). The same source the manifest `%key%` resolution uses; render the plugin's own UI with it.

```js theme={null}
const strings = await __PLUGIN_API__.call('getPluginStrings')
// { "plugin.name": "Server Manager", ... }  returns {} when l10n is not declared
```

* **No permission required** (reads the plugin's own data)
* Requires `"l10n": "l10n"` in the manifest (see [manifest](https://docs.qomicex.top/en/plugins/manifest.md#plugin-owned-i18n-l10n))

### getLocale — Current launcher language

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

* **No permission required**; consistent with the overlay layer of `getPluginStrings`

### Icons

#### Recommended: `<Glyph>` + per-icon subpath (zero IPC, tree-shakeable)

Do **not** route in-page icons through the bridge. Icon data ships with `@qomicex/plugin-ui`;
static imports render synchronously:

```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" />
```

The subpath is the class name without the `fa-solid fa-` prefix (`fa-solid fa-folder-open` →
`@qomicex/plugin-ui/icons/folder-open`). Brand icons use a `brand-` prefix
(`fa-brands fa-github` → `@qomicex/plugin-ui/icons/brand-github`). The export name is
PascalCase + `Icon`.

The bundler keeps only the icons you import (27 icons measured 0 redundant entries) and there
is no IPC round-trip at all.

#### `<Icon name>` — when the icon name comes from runtime data

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

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

Because `name` is a runtime string the bundler cannot analyse it, so the **entire icon table
(\~160KB) is lazily loaded as a separate chunk** — only downloaded when an `<Icon>` actually
renders. Prefer `<Glyph>` whenever the name is known at compile time.

Unknown names render `null` (no empty gap) and `console.warn` in dev builds.

#### Discovering icons: `listIcons`

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

* **No permission required**
* Returns every usable key: built-in table + current colour-theme mapping + this plugin's
  `contributes.iconTheme`
* Use it for icon pickers and self-checks rather than **hardcoding a list** (the built-in table
  evolves with the launcher)

#### Low level: `getIconSvg`

```js theme={null}
const svg = await __PLUGIN_API__.call('getIconSvg', 'fa-solid fa-server')
// { width: 448, height: 512, path: 'M64…', source: 'builtin' }
// miss → null, plus a console.warn
```

* **No permission required**
* Lookup order: this plugin's `contributes.iconTheme` → current colour-theme mapping → the
  launcher's built-in table; the `source` field reports which one matched
  (`plugin` / `theme` / `builtin`)
* A miss is **no longer silent**: the console warns with pointers to `listIcons()` and
  `contributes.iconTheme`
* Only call it directly when the name comes from runtime data and `<Icon>` is not a good fit

#### Icon names must exist in the launcher's built-in table

A wrong `fa-solid fa-*` / `fa-brands fa-*` renders blank (historically silently, which let the
problem reach users). There are now two gates:

1. `qomicex verify` / `qomicex lint` scan source literals and the manifest `icon` field, report
   an **error** for names outside the table, and suggest the closest built-in icons;
2. `<Icon>` warns on a runtime miss.

To use an icon the built-in table lacks, declare a private icon via
`contributes.iconTheme` (see [manifest — icon field spec](https://docs.qomicex.top/en/plugins/manifest.md#icon-field-spec-menus-install-modes-top-level-icon)),
or ship an `.svg` and render it with `<img>`.

### Language change event (plugin:locale-changed)

Broadcast to all plugin sandboxes when the launcher language changes; re-fetch `getPluginStrings` and re-render (aligned with theme following):

```js theme={null}
window.addEventListener('plugin:locale-changed', (e) => {
  reloadStrings()          // getPluginStrings() again (e.detail.locale is the new locale)
})
```

* **No permission required** (passive event)

## Downloads and installation

### download.addTask — Create a download task

Create a download task. The task appears in the launcher's "Download center", its progress is driven by SSE pushes, and it can be cancelled.

```js theme={null}
// option 1: specify an absolute target path
const { taskId } = await __PLUGIN_API__.call('download.addTask', {
  url: 'https://example.com/asset.zip',
  targetPath: 'C:/games/instances/foo/mods/asset.zip',
  extract: true,                 // optional: unzip after download and delete the archive
  headers: { 'X-Token': 'abc' }, // optional: custom request headers
  name: 'My resource'                // optional: display name in the Download Center (default fileName)
})

// Option 2: instance + category (resolves the isolated directory automatically)
await __PLUGIN_API__.call('download.addTask', {
  url: 'https://.../mod.jar',
  instanceId: 'inst-uuid',
  category: 'mods',   // mods / resourcepacks / shaderpacks / datapacks / saves / screenshots
  fileName: 'mod.jar'
})
```

* Permission: `download:manage`
* Returns: `{ taskId, status, targetPath }`
* The backend reuses `DownloadSessionManager` (type `"resource"`), the same pipeline as mod downloads

### download.progress — Query 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 }
```

* Permission: `download:manage`
* Returns `null` if `taskId` does not exist

### download.list — List all download sessions

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

* Permission: `download:manage`
* Returns: an array of snapshots with the same shape as `download.progress`

### download.cancel — Cancel a download

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

* Permission: `download:manage`

### download.addBatch — Batch download (one card)

Download many files with a single call while occupying **one card** in the download center — downloading hundreds of mods won't flood the list. Under the hood it shares the install pipeline's downloader (concurrency, same-destination dedup, mirror fallback, atomic `.part` rename).

```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 are optional: report progress into a step of an existing task (from task.register)
  taskId: 'my-task', stepId: 'download-files',
  // omitting taskId creates a single-step task; title is the card name
  title: 'Download mods'
})
```

* Permission: `download:manage`
* Returns: `{ taskId, batchId, totalFiles }`
* The download runs **in the background**; poll `download.batchStatus(batchId)` for completion
* Mirror fallbacks apply automatically: Modrinth/CF files → MCIM, Forge/NeoForge maven → ForgeCDN/BMCLAPI, vanilla libraries → BMCLAPI — pass official URLs and the launcher picks the rest

### download.batchStatus — Batch download status

```js theme={null}
const st = await __PLUGIN_API__.call('download.batchStatus', batchId)
// { status: 'running' | 'completed' | 'failed', totalFiles, error? }
// returns null when the batch no longer exists (backend restarted)
```

* Permission: `download:manage`
* Poll every 800ms–1s; on `failed`, `error` carries the first failure reason

### download.registerInstall — Register an install task in the download center

Register only a "game install" task (type `'game'`) in the frontend download center without creating a real download. Useful so users can see progress in the download center after a plugin starts an installation.

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

* Permission: `instance:write`

## Plugin-defined tasks (step progress)

Long pipelines orchestrated by the plugin itself (parse → download → install → configure) report progress via `task.*`: tasks go into `InstallTracker` and **show up in the download center automatically**, rendered with the same step cards as built-in installs.

### task.register — Register a step task

```js theme={null}
const { taskId } = await __PLUGIN_API__.call('task.register', {
  taskId: 'my-plugin:setup:1690000000', // optional; prefix with your plugin id to avoid clashes, auto-generated by default
  title: 'Server setup · my-server',
  steps: [
    { id: 'parse-pack',     weight: 10, label: 'Parse modpack' },
    { id: 'server-runtime', weight: 35, label: 'Install server runtime' },
    { id: 'download-files', weight: 40, label: 'Download mods' }
  ]
})
```

* Permission: `download:manage`
* `weight` only affects the aggregate progress ratio (normalized internally); `label` is the step text on the card (plugin-supplied, no launcher i18n changes needed)
* Throws `TASK_BUSY` (409) when the same `taskId` already has a non-terminal task

### task.update — Advance steps / status

```js theme={null}
await __PLUGIN_API__.call('task.update', taskId, {
  stepId: 'server-runtime', stepStatus: 'active',   // pending | active | done | failed
  // stepPercent: 42.5,                              // byte-level progress (download.addBatch advances it automatically)
  // currentFile: 'Downloading xxx.jar',
  // stage: 'server-runtime',                        // stage text
  // status: 'downloading'                           // task-level status (terminal states go through task.complete)
})
```

* Permission: `download:manage`
* All fields optional; only the provided ones are updated. Terminal statuses (completed/failed/cancelled) passed via `status` are rejected with 400 — finish tasks through `task.complete`

### task.complete — Finish a task

```js theme={null}
await __PLUGIN_API__.call('task.complete', taskId)               // success → completed
await __PLUGIN_API__.call('task.complete', taskId, 'reason')     // failure → failed (error shows on the card)
```

* Permission: `download:manage`
* When the task carries `blockedFiles` (restricted-file count), a successful finish becomes `awaiting-files` instead of completed (to remind the user to add files)

### task.progress — Query task progress

```js theme={null}
const snap = await __PLUGIN_API__.call('task.progress', taskId)
// InstallProgress: { status, progress, stage, steps, currentFile, blockedFiles, error, title }
// returns null when the task does not exist
```

* Permission: `download:manage`

### curseforge.files — CurseForge metadata proxy

Resolves CurseForge `projectID:fileID` pairs into direct download URLs. The API key never leaves the launcher process — this is the only CF lookup channel available to plugins.

```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 means the author disabled third-party distribution (restricted file); guide the user to add it manually
```

* Permission: `network:fetch`
* Up to 20000 files per call (`CF_TOO_MANY_FILES`)

## Filesystem extensions

### readArchiveEntry — Read a single zip entry (authorization required)

Read one entry from an archive without extracting the whole thing. Mod/pack metadata lives inside the archives (`fabric.mod.json`, `META-INF/mods.toml`, `modrinth.index.json`) — this is the fastest way to read it.

```js theme={null}
const res = await __PLUGIN_API__.call('readArchiveEntry',
  'C:/packs/pack.mrpack', 'modrinth.index.json', { maxBytes: 16 * 1024 * 1024 })
// { content, size }; returns null when the entry does not exist (usable as a probe)
```

* Permission: `filesystem:read`, plus per-path authorization for the archive (same flow as `readText`, first use shows a confirmation)
* `maxBytes` defaults to 4 MiB, capped at 32 MiB; exceeding it raises `ARCHIVE_ENTRY_TOO_LARGE`
* zip containers only (jar/mrpack/litemod are all zip)

### renamePath — Move / rename (authorization required)

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

* Permission: `filesystem:write`; both the source and destination paths need authorization (one confirmation grants both)
* Supports directories; the destination parent directory is created automatically
* Without "move", a plugin would have to readBytes + writeBytes + deleteFile (triple-copying large files) — use this for things like quarantining client-only mods into another directory

### modpack.install — One-click modpack install

Install a modpack directly through the launcher's **normal modpack installation flow** (the same pipeline as the frontend "Modpacks" page): parse the source → create the instance → download and install.

```js theme={null}
// Option 1: local modpack file (.zip / .mrpack)
const { instanceId } = await __PLUGIN_API__.call('modpack.install', {
  id: 'MyPack',                   // instance name (Required)
  path: 'C:/packs/pack.zip',      // local modpack path
  gameDir: 'C:/games/instances',  // instance root directory (Required)
  maxMemory: 8192,                // optional, defaults to 4096
  versionIsolation: true          // optional: version isolation, defaults to false
})

// Option 2: online modpack (Modrinth / CurseForge / FTB)
await __PLUGIN_API__.call('modpack.install', {
  id: 'MyPack',
  type: 'mr',          // mr | cf | ftb (also accepts modrinth/curseforge)
  projectId: 'abc',    // project id
  fileId: '123',       // version id
  gameDir: 'C:/games/instances'
})
```

* Permission: `instance:write`
* Uses `POST /api/modpack/install-direct`, reusing `ModpackService` (`ParseFileAsync`/`ResolveOnlineAsync`/`InstallAsync`) and `InstallTracker`
* Returns: `{ instanceId }`; installation runs asynchronously (check progress in the download center / via SSE)
* Error codes: `MODPACK_NAME_REQUIRED`, `MODPACK_GAME_DIR_REQUIRED`, `MODPACK_FILE_NOT_FOUND`, `MODPACK_SOURCE_REQUIRED`, `MODPACK_SOURCE_INVALID`
* To show install progress in the download center, register it with `download.registerInstall`

### Modpack-to-server (reference implementation)

"Turning a modpack into a runnable Minecraft server" has **no dedicated endpoint** — the launcher only provides generic capabilities, and the flow is orchestrated by the plugin using the APIs above. The official plugin `top.qomicex.server-manager` (`src/api/setup.js`) is a complete example. Pipeline and key points:

1. **Parse the pack**: `readArchiveEntry` reads `modrinth.index.json` (Modrinth) or `manifest.json` (CurseForge) from the pack to get the MC version / loader / file list. For servers filter by `env.server != "unsupported"` (different from the client's `env.client == "required"`).
2. **Install the runtime** (`download.addBatch` + `process.spawn`):
   * vanilla: `proxyFetch` the version manifest → download `server.jar` (`download.addBatch` attaches the BMCLAPI fallback automatically)
   * fabric: fabric meta → download the server-launcher jar
   * forge / neoforge: download the official installer → **pre-fill `libraries/` first** (read the library list embedded in the installer via `readArchiveEntry` from `install_profile.json` / `version.json`, then download them concurrently through `download.addBatch` so the NeoForge maven gets its ForgeCDN fallback) → then `process.spawn` with `java -jar installer --installServer`, polling `process.alive` for the exit code. The installer skips files that already exist with a valid sha1; its `--mirror` flag does not cover neoforged.net (verified empirically)
3. **Download mods**: Modrinth manifests carry direct URLs; CurseForge goes through `curseforge.files` for direct links. Files whose link is unavailable (author disabled distribution) are recorded in `{dir}/.qml-server/blocked-mods.json`, with progress reported via `task.update`
4. **Release overrides**: `extractArchive(packPath, dir, { prefix: 'overrides', stripPrefix: true })`
5. **Quarantine client-only**: `listDir` scans `mods/*.jar`, `readArchiveEntry` reads `fabric.mod.json`'s `environment` or `(neo)forge.mods.toml`'s `clientSideOnly`, and matches are moved with `renamePath` into `{dir}/mods-client-only/` (not deleted, recoverable). Note: mods whose metadata **does not declare a side** cannot be detected statically — they can only be quarantined manually after a startup crash
6. **Write base config**: `writeText` creates `eula.txt` (false; the consent flow belongs to the user) / `server.properties` / `user_jvm_args.txt`; existing files are left untouched (overrides may carry the real config)
7. **Write the launch manifest**: `{dir}/.qml-server/launch.json` (read via `readText`, which requires authorization for that directory):

```json theme={null}
{
  "mode": "argfile",                     // "argfile" (forge/neo 1.17+; run.bat/run.sh generated by the installer, @args paths extracted from them) | "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"      // when mode=jar
}
```

* `mode=argfile`: `java @<windowsArgfile or unixArgfile>` (pick by platform); `mode=jar`: `java -jar <jar>`
* Always run servers with `nogui`: the GUI takes over console output and never reads the stdin pipe (the stop command stops working)
* Progress is reported throughout via `task.register` / `task.update` / `task.complete`, so the download center shows the step cards

## Error handling

All API calls reject on failure with messages like:

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

Common errors:

* `Permission denied: requires xxx` — the manifest permissions do not include it
* `Backend error: 404` — the `callBackend` endpoint does not exist
* `Proxy failed: 400` — `proxyFetch` parameter error (including SSRF blocking)
* `Plugin xxx does not provide method yyy (it may not be installed or activated)` — the `callPlugin` target is unavailable


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