# 插件编写说明

客户端一切功能皆有插件组成。

插件可以给客户端加自己的功能入口，界面由客户端渲染。
你可以用任意语言写一个独立程序，用标准输入输出和客户端对话。

适用客户端 ≥ 1.3.39；协议 apiVersion 1.0。

## 1. HelloWorld

插件须一个目录，里面有两个文件：

```
plugins/
└─ cn.example.hello/
   ├─ plugin.json      # 清单：用于客户端加载插件数据
   └─ plugin.py        # 入口：一个普通程序，读写标准输入输出
```

**plugin.json**

```json
{
  "schemaVersion": 1,
  "id": "cn.example.hello",
  "name": "示例插件",
  "version": "1.0.0",
  "entry": "plugin.py",
  "author": "你的名字",
  "description": "一个最小插件，什么都不做，只回答 hello。",
  "capabilities": ["speedtester"]
}
```

**plugin.py**（Python 3，无第三方依赖）

```python
import json
import sys


def reply(obj):
    sys.stdout.write(json.dumps(obj, ensure_ascii=False) + "\n")
    sys.stdout.flush()


for line in sys.stdin:
    line = line.strip()
    if not line:
        continue

    msg = json.loads(line)
    method = msg.get("method")

    if method == "hello":
        reply({"id": msg["id"], "result": {"apiVersion": "1.0", "name": "示例插件"}})
    elif method == "shutdown":
        reply({"id": msg["id"], "result": {}})
        break
    else:
        reply({"id": msg["id"], "error": "不支持的方法：" + str(method)})
```

把目录放进客户端的插件目录。入口在「设置 → 插件 → 打开插件目录」。
重启或重连后就能看到。

## 2. 清单格式

| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `schemaVersion` | 是 | 目前固定写 `1`。 |
| `id` | 是 | 唯一标识。只能用小写字母、数字、`.` 和 `-`，长度 6 到 64 字符，首尾不能是点。建议写成反向域名，如 `cn.example.hello`。 |
| `name` | 是 | 显示名，可以用中文。 |
| `version` | 是 | 语义化版本，如 `1.0.0`。 |
| `entry` | 是 | 入口文件，路径相对插件目录。不能含 `..`，不能是绝对路径。 |
| `capabilities` | 是 | 至少一项，取值见第 4 节。 |
| `hooks` | 否 | 要接收的生命周期事件，见第 4 节。 |
| `tasks` | 否 | 周期任务列表，见第 4 节。 |
| `actions` | 否 | 功能入口列表，见第 4 节。 |
| `author` | 否 | 填作者名。 |
| `description` | 否 | 声明插件身份、版本、入口与能力，供客户端发现、校验、加载并在插件列表中展示。|
| `minHostVersion` | 否 | 要求的客户端最低版本。 |

清单里可以写 `//` 注释和结尾逗号，客户端能容错解析。

## 3. 通信协议

与客户端通信必须使用json，并且不可将json拆分为多行

客户端发送：

```json
{"id": 1, "method": "hello", "params": {"hostVersion": "1.3.33", "apiVersion": "1.0"}}
```

插件回给客户端：

```json
{"id": 1, "result": {"apiVersion": "1.0", "name": "示例插件"}}
{"id": 2, "error": {"message": "出错了，原因"}}
```

| 方法 | 谁发起 | 说明 |
| --- | --- | --- |
| `hello` | 客户端 | 必做。握手，返回 `apiVersion` 和 `name`。版本不匹配时，客户端拒绝加载。 |
| `shutdown` | 客户端 | 必做。收到后清理并退出。客户端等 1.5 秒，超时就强制结束进程。 |
| 其余方法 | 客户端 | 由声明的能力决定，见第 4 节。 |

没有 `id` 的行会被当成日志。
调试时往 stdout 写文字不会破坏协议。
正式版建议写到 stderr，避免和协议行混在一起。

## 4. 能力与调用

声明了哪项能力，客户端才会调对应的方法。所有 `params` 都是对象，返回值放在 `result` 里。

清单里 `capabilities` 一共十个取值。前九个让客户端在某个环节调用插件，`actions` 是给客户端加功能入口。
写了列表外的值，客户端会跳过这一项。

| capabilities 取值 | 客户端会调的方法 | 用途 |
| --- | --- | --- |
| `noderoute` | `getNodes` | 提供节点 |
| `selector` | `select` | 决定用哪个节点 |
| `speedtester` | `test` | 给节点测速 |
| `transformer` | `transform` | 改配置文本 |
| `rules` | `getRules` | 追加分流规则 |
| `notify` | `notify` | 发通知 |
| `subsource` | `listSubscriptions` | 提供订阅 |
| `hooks` | `hook` | 在连接流程的几个节点被调用 |
| `tasks` | `task` | 周期任务，到点被调用 |
| `actions` | `invoke` | 插件自带的功能入口 |

### speedtester：给节点测速

```json
// 客户端 → 插件
{"id": 3, "method": "test", "params": {
  "nodes": ["香港 01", "日本 02"],
  "timeoutMs": 5000
}}

// 插件 → 客户端（每条一个节点，可以分批多次返回）
{"id": 3, "result": {"results": [
  {"node": "香港 01", "delayMs": 42},
  {"node": "日本 02", "delayMs": 118}
]}}
```

### selector：决定用哪个节点

```json
// 客户端 → 插件
{"id": 4, "method": "select", "params": {
  "nodes": [{"name": "香港 01", "delayMs": 42}, {"name": "日本 02", "delayMs": 118}],
  "current": "香港 01"
}}

// 插件 → 客户端
{"id": 4, "result": {"name": "香港 01", "reason": "延迟最低"}}
```

### noderoute：提供节点

```json
// 客户端 → 插件
{"id": 5, "method": "getNodes", "params": {}}

// 插件 → 客户端
{"id": 5, "result": {"nodes": [
  {"name": "自定义节点", "type": "ss", "server": "1.2.3.4", "port": 8388,
   "cipher": "aes-256-gcm", "password": "..."}
]}}
```

### transformer：改配置

```json
// 客户端 → 插件（yaml 是完整的配置文本）
{"id": 6, "method": "transform", "params": {"yaml": "proxies:\n  - name: A\n..."}}

// 插件 → 客户端
{"id": 6, "result": {"yaml": "……改过之后的完整配置……"}}
```

插件获取到的是去除敏感信息后的配置。
节点地址、密码和 UUID 都会换成 `0x…xxx` 这样的占位符。
结构照常改，比如增删节点、改规则、调分组。返回后，客户端按同一张对照表换回真值。

有三条规则。占位符不透明，别去解析。原值相同的占位符也相同。
不要把占位符删掉，改成自己编的值，否则客户端会判定配置已损坏，整份退回内置实现。
你自己新增的节点写真实地址，这部分不参与还原。

### subsource：提供订阅

```json
{"id": 7, "method": "listSubscriptions"}
{"id": 7, "result": {"subscriptions": [
  {"id": "my-sub", "name": "我的机场", "type": "clash", "url": "https://…"}
]}}
```

### rules：提供分流规则

```json
// 客户端 → 插件
{"id": 9, "method": "getRules", "params": {"existing": "<已有规则>"}}

// 插件 → 客户端（path 相对插件自己的目录，客户端会复制到内核目录）
{"id": 9, "result": {"rules": ["DOMAIN-SUFFIX,example.com,PROXY"]}}
```

### notify：通知

```json
{"id": 8, "method": "notify", "params": {"title": "连上了", "body": "当前节点：香港 01", "level": "info"}}
{"id": 8, "result": {}}
```

### actions：给客户端加自己的功能入口

其余九项都是客户端在某个环节调用插件：选节点、测速、改配置、连接前后、定时任务。
`actions` 走另一条路，它给客户端加本来没有的功能。
须在清单里写好入口，客户端在插件窗口里把它们画出来，用户点一下就调用插件。

清单里怎么写：

```json
{
  "schemaVersion": 1,
  "id": "cn.example.actions",
  "name": "示例功能插件",
  "version": "1.0.0",
  "entry": "plugin.py",
  "capabilities": ["actions"],
  "actions": [
    {"id": "show-status", "title": "看看当前状态"},
    {"id": "echo", "title": "回显", "kind": "prompt", "placeholder": "随便输点什么"},
    {"id": "enabled-marker", "title": "记住这个开关", "kind": "toggle"}
  ]
}
```

声明了 `actions` 就要写 `actions` 数组，写了数组就要声明 `actions`。少一边清单校验不过，插件不加载。

| actions 里的字段 | 必填 | 说明 |
| --- | --- | --- |
| `id` | 是 | 动作标识，调用时原样传回。字母、数字、`.`、`-`、`_`，最多 64 字符，同一个插件里不能重复。 |
| `title` | 是 | 按钮或勾选框上的字，最多 40 字。 |
| `description` | 否 | 一行说明，最多 200 字。 |
| `kind` | 否 | `command`、`prompt`、`toggle`，默认 `command`。 |
| `placeholder` | 否 | `prompt` 型输入框的提示文字。 |
| `confirm` | 否 | 调用前先弹一次确认，默认 `false`。 |
| `confirmText` | 否 | 确认框正文。不写就用 `description`。 |

一个插件最多 32 个动作。界面形状只有三种，客户端负责渲染，插件不能塞自己的 HTML 或窗口。

| `kind` | 界面上是什么 | 调用时的 `input` |
| --- | --- | --- |
| `command` | 一个按钮 | 空串 |
| `prompt` | 一个输入框加一个按钮 | 用户填的内容 |
| `toggle` | 一个勾选框 | `"true"` 或 `"false"` |

界面由客户端画，插件只写声明。
插件跑在 AppContainer 容器里，拿不到桌面，也没有 UI 框架。
让外部插件往用户界面上随便画东西，本身也不该做。
所以插件只描述「我要一个什么形状的入口」，长什么样、放在哪，由客户端决定。
代价是表现能力有意收窄：只有按钮、输入框加按钮、勾选框三种。
想更花哨就得放宽这条，那是另一个决定，我们不打算顺手放宽。

调用协议：

```json
// 客户端 → 插件
{"id": 10, "method": "invoke", "params": {
  "action": "echo",
  "input": "你好",
  "host": {
    "hostVersion": "1.3.37",
    "connected": true,
    "selectedNodeName": "香港 01",
    "mode": "rule",
    "connectionMode": "system"
  }
}}

// 插件 → 客户端：成功，message 会直接弹给用户
{"id": 10, "result": {"ok": true, "message": "输入的是：你好"}}

// 插件 → 客户端：失败，message 写清原因
{"id": 10, "result": {"ok": false, "message": "输入框是空的，先输点内容"}}
```

`params.host` 是客户端下发的状态快照，只有上面五个字段。
它刻意不含节点地址、端口、密码、UUID 和订阅地址，插件拿不到真实节点凭据。
要按节点做判断，用名字就够了。

单次调用最多等 30 秒。其它能力默认只等 5 秒。
插件进程在客户端启动时拉起，`invoke` 复用它。
进程可能中途退出，别假设上次的内存还在，要记状态就写自己的插件目录。
超时、报错、返回坏数据都由客户端兜住，只弹一句提示，不影响客户端本身。

### hooks：在连接流程里被调用

声明 `hooks` 能力后，客户端在三个时机调用你，方法名是 `hook`，事件名放在 `params.event`：

| 事件 | 什么时候触发 |
| --- | --- |
| `connect.before` | 内核启动之前 |
| `connect.after` | 连接成功之后 |
| `disconnect` | 断开之后 |

清单里写要接收的事件：

```json
{
  "capabilities": ["hooks"],
  "hooks": ["connect.before", "connect.after", "disconnect"]
}
```

客户端调用插件的示例：

```json
{"id": 11, "method": "hook", "params": {
  "event": "connect.after",
  "host": {
    "hostVersion": "1.3.37",
    "connected": true,
    "selectedNodeName": "香港 01",
    "mode": "rule",
    "connectionMode": "system"
  }
}}
```

`host` 与 `invoke` 的 host 快照是同一份，只有版本、是否连接、节点名字、模式和接管方式五个字段。
它不含节点地址、端口、密码、UUID 和订阅地址。要按节点做判断，用名字就够了。

返回值不参与连接流程。钩子是顺带做的事，报错或超时只写一行日志，连接照常继续。

### tasks：周期任务

声明 `tasks` 能力后，在 `plugin.json` 里写任务和间隔，客户端到点调用 `task`：

```json
{
  "capabilities": ["tasks"],
  "tasks": [{"id": "daily", "everySeconds": 3600}]
}
```

```json
{"id": 12, "method": "task", "params": {"id": "daily"}}
```

每个插件最多 8 个任务，`everySeconds` 取 30 到 86400 秒。
任务 id 只能用字母、数字、`.`、`-`、`_`，最多 64 个字符，同一个插件里不能重复。
任务的结果只用来记日志，不影响连接。

### 宿主服务：让客户端代插件发请求

宿主服务不需要写进 `capabilities`，任何插件都能用。
插件向客户端发请求时用 `call` 信封，不要用 `id`：

```json
{"call": 1, "method": "http", "params": {"url": "https://example.com", "method": "GET"}}
```

客户端代发之后，按同一个 `call` 回包：

```json
{"call": 1, "result": {"status": 200, "body": "...", "truncated": false}}
```

请求出错时回包带 `error`：

```json
{"call": 1, "error": {"message": "不允许访问内网或本机地址"}}
```

取客户端状态快照：

```json
{"call": 2, "method": "snapshot", "params": {}}
```

```json
{"call": 2, "result": {
  "hostVersion": "1.3.37",
  "connected": true,
  "selectedNodeName": "香港 01",
  "mode": "rule",
  "connectionMode": "system"
}}
```

边界：

- 只允许 http 和 https。其它协议会被拒绝。
- 不允许访问内网、回环、链路本地地址，域名解析之后是内网也一样拒绝。这条挡的是本机接口，比如 `http://127.0.0.1:18081/`。
- 请求头只放行 `Content-Type` 和 `Accept`，其余丢弃，所以不能带自定义 Cookie。
- 响应上限 1 MB，超过就截断，`truncated` 变 `true`。单次请求最多等 15 秒。
- 插件拿不到客户端的配置文件，也不能读节点凭据，这是有意的。要改配置，只有 `transformer` 那条路。
- 不认识的方法会返回错误，不会静默成功。

插件在沙箱里本来也能联网。宿主代发不是为了限制插件上网，而是把地址检查和超时、体积上限收到一处。

## 5. 发布到 GitHub 插件仓库

仓库是一个普通 Git 仓库，也可以是任意静态服务器上的目录。结构如下：

```
你的仓库/
├─ index.json                  # 索引：客户端读这个文件
└─ packages/
   ├─ cn.example.hello-1.0.0.zip
   └─ cn.example.other-2.1.0.zip
```

**index.json**

```json
{
  "schemaVersion": 1,
  "name": "示例插件仓库",
  "updated": "2026-10-18",
  "plugins": [
    {
      "id": "cn.example.hello",
      "name": "示例插件",
      "version": "1.0.0",
      "description": "一个最小插件。",
      "author": "插件名",
      "url": "packages/cn.example.hello-1.0.0.zip",
      "sha256": "把压缩包的 sha256 填在这里",
      "capabilities": ["speedtester"]
    }
  ]
}
```

`url` 可以是相对仓库根目录的路径，也可以是 `https://` 开头的绝对地址。
只允许 https，http 和 file 地址会被拒绝。

压缩包要求：

- 根目录就是插件目录，里面有 `plugin.json`。外面多套一层也能装，客户端会自动进一层找。
- 压缩包里不能有 `..` 这类越界路径，有就拒绝。
- `sha256` 必填。客户端下载后校验，对不上就拒绝安装。

算 sha256：

```
# Linux / macOS
sha256sum packages/cn.example.hello-1.0.0.zip

# Windows PowerShell
Get-FileHash packages\cn.example.hello-1.0.0.zip -Algorithm SHA256
```

把结果填进 `index.json`，是一串 64 位小写十六进制字符。

发布新版分三步：改 `version`，打新包，更新索引里的 `url` 和 `sha256`。
客户端安装时先把旧版本备份到 `.backup/`，安装失败会回滚。

## 6. 调试与排查

| 现象 | 常见原因 |
| --- | --- |
| 插件显示「加载失败」 | 清单字段不合法，或 `entry` 指向的文件不存在。插件列表里会写明原因。 |
| 显示「找不到解释器」 | `.py` 需要系统里有 `python`，`.js` 需要 `node`。也可以改用 `.exe`。 |
| 调用超时 | 你的程序没有输出，或输出被缓冲了。每行写完后要 flush。 |
| 握手被拒绝 | `hello` 返回的 `apiVersion` 不是 `1.0`。 |
| 日志里有你的输出 | 正常。没有 `id` 的行会被当成日志，方便调试。 |

客户端日志在 `%LOCALAPPDATA%\Cncp\logs\cncp.log`。
插件进程崩溃、调用超时、协议错误都记在这里。

启动方式对照：

| 扩展名 | 启动方式 |
| --- | --- |
| `.exe` | 直接启动 |
| `.py` | `python`、`python3` 或 `py`（在 PATH 里找） |
| `.js` | `node` |
| `.ps1` | `powershell -NoProfile -ExecutionPolicy Bypass -File` |
| `.cmd`、`.bat` | `cmd /c` |

## 7. 插件优先级

客户端自带的选线、测速也是插件，叫内置插件。开发的插件和它们实现同一套接口。
规则只有一条：外部插件优先。

同一个能力装了外部插件，就用外部插件的结果。外部插件报错、超时，或返回列表里没有的节点，就退回内置实现。
不需要「关掉内置功能」，装上外部插件就接管。

反过来也一样。插件不可使用时，用户不会失去测速，只是回到内置实现。
界面上看不出这次切换。

## 8. 各能力什么时候被调用

| 能力 | 调用对应的行为                        |
| --- |--------------------------------|
| `noderoute` | 每次连接时，节点并进配置（重名自动加「·插件」后缀）     |
| `selector` | 测速完成后，决定下次连接用哪个节点              |
| `speedtester` | 点「测速」时                         |
| `transformer` | 每次连接时，拿到配置之后、启动内核之前            |
| `rules` | 每次连接时，规则插到 `rules:` 段最前面       |
| `notify` | 连接成功、断开时                       |
| `subsource` | 启动后扫描插件，订阅导入「管理订阅」列表           |
| `actions` | 用户在插件窗口里点按钮、提交输入或勾选勾选框时        |
| `hooks` | 连接前、连接成功后、断开后，按 `hooks` 里声明的事件 |
| `tasks` | 按 `tasks` 里写的间隔，客户端到点调用        |

所有调用都遵循一条：失败不影响主流程。插件超时、报错或返回坏数据，客户端退回内置行为，并写一行日志。
用户看到的只是「插件好像没生效」。
`actions` 是例外：功能入口没有内置实现，失败时只弹一句提示，不会退回别的行为。
`hooks` 和 `tasks` 也一样：没有内置替代，失败只写日志，不打断连接。

## 9. 安全提醒

插件在容器里，网络没有隔离。

协议层已经去除敏感信息：改配置的插件只看到占位符，选线的插件只拿得到节点名。
客户端还会把插件放进 AppContainer 隔离容器运行。
容器里的插件读不到 `%LOCALAPPDATA%\Cncp\` 下的订阅、设置和运行时配置，只能读写自己的插件目录。
容器不可用时，客户端退化成普通进程，并在插件窗口写明。
隔离的是文件和系统资源，网络不隔离。插件仍能把我们交给它的内容发出去，
所以底线不变：只安装你信任的插件。不确定时，用「插件 → 停用全部外部插件」回到内置实现。

- 插件拿不到你的账号密码，密码只在客户端主进程里。
- 插件之间互相隔离。一个插件崩溃或卡死，不影响其它插件，也不影响上网。
- 插件进程跑在 AppContainer 隔离容器里，读不到客户端数据目录。容器只有 `internetClient` 能力，可以联网，没有文件系统能力。
- 从仓库安装会校验 `sha256`。这只保证下载完整，不代表作者可信。
- 仓库请求走代理链，默认是 `gh-proxy.com`。一个地址超时就换下一个。
- 客户端启动后会自动检查插件更新，有新版本会提示。

## 10. 本机数据怎么保存

插件能拿到的只是一部分数据。本机上还有别的数据，处理原则相同：能不暴露就不暴露。

| 东西 | 怎么处理 |
| --- | --- |
| 账号口令 | 用 DPAPI（当前用户）加密后存进 `settings.json`。DPAPI 不可用时拒绝保存，不退回明文。 |
| 设备私钥 | 用 DPAPI 加密。换机器或换用户就解不开。降级时用机器和用户派生密钥，绝不写明文。 |
| 节点订阅缓存<br>`subscriptions.cache` | 每条记录都带 `password`、`uuid` 和 `server`，还是常驻文件，断网时靠它出节点。现在整段 JSON 用 DPAPI 加密落盘。拷走文件或翻备份，只能拿到密文。 |
| 订阅列表<br>`subscriptions.json` | 订阅 URL 里带 token，等同于凭据。同样用 DPAPI 加密落盘。 |
| 运行期配置 | **不落盘**（1.3.38 起）。mihomo 支持 `-f -` 从标准输入读配置，客户端把配置放在内存里直接喂给内核，写完关流。老版本留下的 `runtime\runtime-config.yaml` 在断开时擦除。 |

DPAPI 阻挡范围。
DPAPI（`CurrentUser` 范围）的主密钥绑在这台机器和这个用户上。
文件拷到别的机器、放进备份、被别人读到，都解不开。
它挡不住以你同一用户身份运行的恶意程序，因为对方本来就能调 DPAPI 解回来。
同一个用户下没有密码学意义上的隔离，这一点我们不夸大。

旧版本升级上来的情况：磁盘上原有的明文缓存还能读出来，不会让你一次性丢掉全部节点。
下一次保存时自动变成密文。

# 此文档DeepSeek -v4.1-flash-1017参与编写