Files
dbx/docs/content/docs/plugin-development.cn.mdx
T

1167 lines
97 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: 开发和提交 DBX 插件
description: 从 CLI、Manifest、Workbench 和 Sidecar 开始开发 DBX 插件,完成本地调试、打包、自动上架和官方签名发布。
---
DBX 插件是独立安装的 `.dbxp` 包,可以提供连接类型、工作台、文件系统和原生 Sidecar。插件源码、DBX 插件框架和官方商店目录由不同仓库负责。
<Callout type="info">**推荐路径:** 使用 `dbx-plugin create` 创建项目,在自己的仓库开发并发布未签名候选包;`dbx-store` 会为已注册的插件自动创建或更新候选 PR,维护者审核并签名后才会进入官方目录。</Callout>
<Callout type="warn">
**插件上架 Pull Request 提交到 [`t8y2/dbx-store`](https://github.com/t8y2/dbx-store),不是 `t8y2/dbx`。** 插件自己的功能代码通常保留在开发者自己的源码仓库;只有修改 DBX Host、SDK、CLI、协议、Schema、文档或官方示例时,才向 [`t8y2/dbx`](https://github.com/t8y2/dbx) 提交 PR。
</Callout>
## 插件快捷入口
在 **插件中心 → 设置 → 全局配置** 中管理快捷入口。默认开启并显示在右侧顶部;可选择左侧或右侧的顶部/底部优先、连接树底部,或更新按钮左侧的浮动栏。浮动栏默认显示 3 个图标,可设置 0–10 个,空间不足的入口放入下拉菜单,以图标和名称逐行显示。
连接树底部区域的「插件」标题栏右上角默认提供「插件设置」入口,其他显示位置则将该入口固定在末尾,点击直达插件中心的设置页,不参与拖动排序。浮动栏溢出时,该入口位于下拉菜单末尾。在「全局配置」中关闭「显示“插件设置”快捷入口」可隐藏它;仍可从插件中心的「设置」页重新开启。没有可显示的插件快捷入口时,整个快捷区域(包括设置入口)隐藏,不占用布局空间。
每个功能有独立图标,可以拖动排序;显示开关按插件生效,关闭后隐藏该插件的所有入口。所有位置共用保存的顺序。隐藏或卸载不会删除已有排序记录,重新出现的相同入口恢复原位置,未记录的入口排列在已保存入口之后。排序标识由插件 ID、入口类型和功能 ID 组成,修改这些 ID 会被视为新入口。
拖动预览跟随光标,靠近窗口边缘时自动调整方向和位置;右侧停靠优先显示在光标左侧,长名称会根据可用宽度省略显示。
选择「插件中心右侧(下拉)」可通过插件中心按钮旁的箭头展开全部快捷入口,每行显示图标和名称,支持拖动排序;该位置不受浮动栏显示数量限制。
连接树底部采用左侧图标、右侧名称的单列列表,行样式和字号与连接树一致。默认按入口数量显示所需行数,最多五行,超出后竖向滚动。拖动分隔线可保存自定义高度,双击恢复自动高度。拖动图标时按 Esc、切换窗口或在区域外释放会取消排序;保存失败会提示并回滚。
快捷入口保留 `appToolbar` 声明的可见条件及命令执行条件。无连接类型的插件还可从快捷栏打开已有工作台;有对应命令时优先执行命令,没有命令体系的旧式插件也可显示文件浏览入口。连接型插件只显示明确声明的全局命令,不自动生成缺少连接上下文的工作台入口。不兼容插件不展示。
显示位置只决定图标位置,内容继续按原有 `panel/tab` 语义打开。隐藏快捷栏、切换位置或隐藏插件均不会关闭已打开的会话。面板快捷入口仅切换当前选中且匹配的命令面板,恢复时保留该实例。无需修改插件 Manifest 或后端协议;旧 `left/right` 设置迁移为对应侧顶部优先。
## 应该提交到哪个仓库
| 你要修改的内容 | 提交位置 | 是否向 DBX 官方提 PR |
| -------------------------------------------------------------- | ----------------------------------------------------- | --------------------------------------- |
| 你的插件前端、Rust/Go 后端、测试和发布脚本 | 插件自己的源码仓库 | 不向 `dbx` 提交;在自己的仓库开发和发布 |
| 新插件上架、版本更新、商店图标和介绍 | [`t8y2/dbx-store`](https://github.com/t8y2/dbx-store) | 是,目标分支为 `main` |
| DBX 插件 Host、Manifest/Marketplace Schema、SDK、CLI、Packager | [`t8y2/dbx`](https://github.com/t8y2/dbx) | 是,目标分支为 `main` |
| DBX 官方示例或插件开发文档 | [`t8y2/dbx`](https://github.com/t8y2/dbx) | 是 |
| 某个第三方插件自身的 Bug | 该插件的源码仓库 | 不要提交到 `dbx-store` |
| 自定义或企业私有仓库 | 你的私有仓库 | 不需要向官方商店提交 |
| 数据库厂商 JDBC Driver JAR | 不提交到 Git 仓库 | 用户在本机或服务器中导入 |
DBX 官方维护但需要独立发布节奏的插件,通常也应使用独立源码仓库。只有与 Host 强耦合的最小示例、SDK 验证项目或维护者明确同意的组件才放进 `t8y2/dbx`。
## 插件由哪些部分组成
```text
my-plugin/
├── manifest.json
├── dbx-plugin.toml
├── assets/
├── ui/
├── backend/ # frontend 模板没有此目录
├── .github/workflows/
│ └── plugin-release.yml
└── dist/ # 构建输出,不提交到 Git
```
- `manifest.json` 声明插件身份、版本、权限、入口和 Contributions。
- `ui/` 在沙箱工作台中运行,通过 Host API 与 DBX 交互。
- `backend/` 是可选的原生 Sidecar,可以使用 Rust、Go 或其他能够实现 DBX JSON-RPC 协议的语言。
- `.dbxp` 是安装包,不是源码仓库。
- 前端插件可以发布一个 `universal` 包;包含原生 Sidecar 的插件需要按操作系统和 CPU 架构分别构建。
## Manifest 完整说明
`manifest.json` 是运行时契约,文件必须位于插件包根目录。Manifest v1 不接受未声明字段,建议在编辑器中引用仓库内的 [`plugins/manifest.schema.json`](https://github.com/t8y2/dbx/blob/main/plugins/manifest.schema.json)。最小的纯前端 Manifest 如下:
```json
{
"$schema": "https://raw.githubusercontent.com/t8y2/dbx/main/plugins/manifest.schema.json",
"manifest_version": 1,
"id": "com.example.files",
"name": "Example Files",
"version": "0.1.0",
"publisher": "example",
"description": "Browse files from an example service.",
"icon": "assets/plugin.svg",
"source": "https://github.com/example/dbx-plugin-files",
"homepage": "https://github.com/example/dbx-plugin-files",
"engines": { "host_api": "1" },
"permissions": [],
"entrypoints": { "ui": { "root": "ui", "entry": "ui/index.html" } },
"contributions": [{ "type": "workbench", "id": "com.example.files.main", "label": "Files", "icon": "assets/plugin.svg" }]
}
```
Manifest 中的路径相对于包根目录,不能以 `/` 开头、不能包含 `..`、反斜杠或重复斜杠。`ui.entry` 必须位于 `ui.root` 内:当 root 为 `ui` 时,入口写 `ui/index.html`,不是 `index.html`。`signingKeyId` 属于商店 artifact metadata,不是 Manifest 字段。
| 字段 | 必需 | 说明 |
| ------------------ | ---- | ---------------------------------------------------------------------------------------------------- |
| `$schema` | 否 | 编辑器使用的 JSON Schema 地址,不参与身份校验。 |
| `manifest_version` | 是 | 当前必须为数字 `1`。 |
| `id` | 是 | 全局稳定 ID,只能使用小写字母、数字、`.`, `_`, `-`,例如 `io.github.example.files`;发布后不要更改。 |
| `name` | 是 | 默认显示名称。 |
| `icon` | 否 | 插件默认图标的包内相对路径;推荐 SVG 或 PNG。 |
| `version` | 是 | `major.minor.patch` 语义化版本,可带 prerelease/build 后缀。相同 ID 和版本的正式包不能覆盖。 |
| `publisher` | 是 | 发布者标识;首次上架后应保持不变。 |
| `description` | 否 | 插件说明。 |
| `source` | 否 | 源码仓库 HTTPS 地址,安装后可在插件信息中查看。 |
| `homepage` | 否 | 项目主页、文档或支持地址。 |
| `engines` | 是 | 运行时兼容范围,至少包含 `host_api`。 |
| `permissions` | 否 | 最小权限集合,见下文。 |
| `entrypoints` | 否 | UI 和原生 Sidecar 入口;贡献点存在时按需声明。 |
| `contributions` | 否 | 连接、工作台、文件系统、连接右键菜单、结果视图、命令/菜单和 MCP 工具面声明。 |
| `localizations` | 否 | 按 locale 覆盖名称、说明、贡献点、字段和动作文案。 |
### `engines`、权限和入口
```json
{
"engines": { "dbx": ">=0.6.0", "host_api": "1" },
"permissions": [
"host.workbench",
"host.events",
"host.filesystem",
"host.binary",
"host.plans:read",
"host.schema:read",
"host.storage",
"host.clipboard:read",
"host.data:read",
"host.connections:read",
"host.network:https://s3.example.com:443"
],
"entrypoints": {
"ui": { "root": "ui", "entry": "ui/index.html" },
"backend": {
"protocol_versions": [1],
"transport": "stdio-jsonl",
"executable": "bin/linux-x64/backend"
}
}
}
```
- `engines.dbx` 是可选的 DBX 版本范围;`engines.host_api` 是必需的 Host API 版本声明。
- `host.workbench` 允许打开另一个工作台,`host.events` 接收宿主事件,`host.binary` 使用二进制通道,`host.filesystem` 使用文件系统入口。
- `host.plans:read` 允许针对 DBX 自己的连接读取估算执行计划(见 [估算执行计划](#估算执行计划))。它是只读权限:不授予普通 SQL 执行、写操作、DDL 或真实执行计划,且这是唯一存在的计划类权限。
- `host.schema:read` 允许通过 `host.getTableMetadata` 读取已打开连接的窄化 table schema metadata;它不会授予任意 SQL、写入或隐式重连(见 [Table Schema Metadata](#table-schema-metadata))。
- `host.storage` 为工作台 UI 提供本插件独享的持久化键值存储(见 [持久化 UI 状态](#持久化-ui-状态));数据只落在插件自己的插件数据目录里。
- `host.ai` 允许插件接入 DBX 全局 AI 面板:可以打开 Ask 快照对话、选择性打开使用实时插件工具的 Agent 对话,以及注册当前工作台的推荐问题。旧版 DBX 会在安装时因未知权限而失败,需要兼容旧宿主的插件应把 `host.ai` 作为可选能力,并通过 `capabilities.ai` / `capabilities.aiRecommendations` 判断运行时支持。
- `host.clipboard:read` 允许工作台 UI 读取系统剪贴板(见 [系统剪贴板](#系统剪贴板))。写剪贴板不需要权限;读取会把用户数据(密码、令牌)交给插件代码,因此需要门控。
- `host.data:read` 允许 `window.dbxPlugin.queryData` 在用户授权给插件的连接上执行一条只读 SQL(见 [只读数据查询](#只读数据查询))。每个连接都需要用户同意;它不会授予写入、DDL 或重连。
- `host.connections:read` 允许通过 `window.dbxPlugin.getConnections()` 或 `window.dbxPlugin.connections.list()` 获取当前所有已配置连接的只读脱敏信息(见 [连接信息读取](#连接信息读取))。
- `host.network:https://host[:port]` 声明浏览器可访问的 HTTPS origin,DBX 将它加入 CSP 的 `connect-src`;最多 8 个,不允许路径、通配符或 Token。仍受目标服务 CORS 规则约束。该权限不是原生 Sidecar 的网络防火墙。
- `backend.transport` 默认是 `stdio-jsonl`;需要二进制帧时使用 `stdio-framed`,并同时声明 `host.binary`。
- `backend.executable`、`ui.root` 和 `ui.entry` 必须指向包内文件;带原生后端的包必须为每个目标平台提供对应可执行文件。
### Connection Provider
连接 Provider 负责声明连接表单,DBX 负责渲染表单和保存生命周期;插件只处理自己的连接协议。`binding` 决定字段语义:`config` 写入外部配置,`secret` 写入 Secret Store,`name`、`host`、`port`、`username`、`password`、`database` 映射到标准连接字段。
```json
{
"type": "connection-provider",
"id": "com.example.files.connection",
"label": "Example Service",
"icon": "assets/connection.svg",
"database_type": "example-files",
"description": "Connect to an Example Service.",
"fields": [
{ "key": "display_name", "label": "Name", "type": "text", "binding": "name", "required": true },
{ "key": "endpoint", "label": "Endpoint", "type": "text", "binding": "host", "required": true },
{ "key": "port", "label": "Port", "type": "number", "binding": "port", "default": 443 },
{
"key": "region",
"label": "Region",
"type": "radio",
"options": [
{ "label": "US", "value": "us" },
{ "label": "EU", "value": "eu" }
],
"binding": "config"
},
{ "key": "token", "label": "Access token", "type": "password", "binding": "secret", "required": true }
],
"workbench": "com.example.files.main",
"capabilities": ["test", "connect", "disconnect"],
"actions": [{ "id": "refresh", "label": "Refresh metadata", "variant": "outline", "when": "edit", "requires_valid_form": true, "timeout_ms": 30000 }]
}
```
字段类型为 `text`、`password`、`number`、`boolean`、`select`、`radio` 或 `textarea`;`select`/`radio` 必须提供非空 `options`,每项包含 `label` 和字符串 `value`。`binding: "port"` 必须使用 `number`。不要把密码、Token 或私钥放进 `config`、Workbench context、事件或日志。
每个字段必须声明 `key`、`label`、`type`,可选 `description`、`placeholder`、`required`、`default`、`options` 和 `binding`;默认值必须符合字段类型。省略 binding 的 `password` 字段默认按 `secret` 处理。`database_type` 是插件自定义类型标识,不会给 DBX 内置数据库枚举添加成员。
字段还支持条件显隐/条件必填。`visible_when` 与 `required_when` 接受单条子句 `{ "field": "mode", "one_of": ["custom"] }`(引用字段取到列表中的值时命中,按字符串比较,因此 `[false]` 与 `["false"]` 都能匹配布尔 `false`),并用 `all_of` / `any_of` / `not` 组合:
```json
{
"key": "sudo_command",
"label": "Sudo command",
"type": "text",
"visible_when": {
"all_of": [
{ "field": "sudo_source", "one_of": ["custom"] },
{ "field": "read_only", "one_of": [false] }
]
}
}
```
条件会级联:子句引用的字段自身处于隐藏状态时该子句不参与判定,因此隐藏容器的默认值不会把孙级字段点亮;未填写或空值永不命中;组合树最多 8 层、64 个节点。DBX 在对话框与保存/测试/连接校验中使用同一套求值,不会出现「表单放行、宿主拒绝」的分歧。
`text`、`password`、`textarea` 字段可以用 `picker` 声明**本机文件选择**(例如 SSH 私钥、keystore):
```json
{
"key": "private_key_path",
"label": "Private key path",
"type": "text",
"binding": "config",
"picker": { "kind": "file", "accept": [".pem", ".key"], "content_field": "private_key" }
}
```
桌面端打开系统文件选择器,把选中的**绝对路径**写回该字段(插件后端同机运行,可自行读取文件);网页端拿不到客户端路径,同一操作变成**上传**:DBX 读取文件内容写入声明的 `content_field` 兄弟字段,并清空路径字段。两者互相清除——选了路径就丢弃已上传内容,上传内容就清空路径,避免同时存在两份互相矛盾的来源。`kind` 还支持 `directory`(仅桌面端),`accept` 最多 16 条扩展名或 MIME 过滤,上传上限 1 MiB。
插件在连接过程中需要用户输入时(堡垒机登录期 keyboard-interactive 验证码、主机密钥确认、账号选择等),可以调用 Host API 1.1 新增的 `host/requestUserInput`,通过 DBX 同一个阻塞对话框拿到用户输入,`连接测试` 与真实连接走同一条通道:
```json
{
"jsonrpc": "2.0",
"id": "prompt-1",
"method": "host/requestUserInput",
"params": { "prompt": "Verification code (6 digits)", "title": "JumpServer login", "echo": false, "timeoutSecs": 300 }
}
```
返回值为 `{ "action": "submit", "value": "123456" }`、`{ "action": "cancel" }` 或 `{ "action": "timeout" }`;只有 `submit` 带值,其余必须按「没有答案」处理并失败关闭。插件发起的请求使用字符串 id,DBX 自己的请求与响应继续使用数字 id;提示期间 DBX 会暂停等待该回答的请求超时计时,用户输入验证码不会被误判成连接超时。错误码 `-32001` 表示没有可用的用户界面(headless/MCP),`-32602` 参数非法,`-32601` 宿主不支持该方法,三种情况都要优雅降级。调用前请确认 `plugin/initialize` 通告的 `hostApiVersion` 为 `1.1.0`(或 `host.features` 含 `host.requestUserInput`);Rust SDK 对应 `dbx_plugin_sdk::host_client()` 与 `HostClient::supports("host/requestUserInput")`、`HostClient::request_user_input(&UserInputPrompt::secret("Verification code"))`。
连接生命周期使用固定的 `connection/test`、`connection/connect`、`connection/disconnect`,自定义表单动作使用 `connection/action`,参数包含 `provider`、`connection`、`runtime`;动作额外包含 `action: { id }`。后端使用 `connection.id` 保存会话,并使用 `runtime.host`/`runtime.port` 连接经过 DBX 隧道或代理转换后的端点。仅在后端生命周期请求中接收补齐 Secret 的连接;前端通过 `connectionId` 使用已经建立的会话。
多端点协议(Kafka `advertised.listeners`、集群发现等)在贡献点上声明 `proxy_route`。配置了传输层后,DBX 会在 payload 中下发 SOCKS5 路由而非静态转发:`runtime.host`/`runtime.port` 保留逻辑端点,插件通过 `runtime.proxy`(`{ "type": "socks5", "host": "...", "port": 1080, "username": "...", "password": "..." }`)拨号每个广播端点——SSH 末层暴露其动态 SOCKS5 端点,SOCKS5 代理层直接使用;代理凭据与连接 Secret 走同一加密通道,插件不得记录日志。未声明该旗标时传输层保持静态转发行为,要求标准 `host`/`port` 字段给出单一远端端点;对会隧道到空端点的插件连接,DBX 直接给出可行动的报错而非静默超时。
动作的 `when` 可为 `always`、`create`、`edit`;`variant` 可为 `default`、`outline`、`secondary`、`destructive`、`ghost`;`timeout_ms` 为 1–120000。`requires_valid_form` 决定是否要求完整表单,`close_on_success` 决定成功后是否关闭。返回 `{ "success": true, "message": "...", "fieldValues": { "port": 443 } }` 可以更新已声明的字段;返回 `success: false` 表示失败。
### 其他 Contributions
| 类型 | 必需字段 | 用途 |
| --------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `workbench` | `type`, `id`, `label` | 注册可从侧边栏或插件入口打开的工作台。 |
| `filesystem-provider` | `type`, `id`, `label`, `schemes` | 注册虚拟文件系统;`root_uri` 例如 `s3://bucket`,能力可选 `read`、`write`、`delete`、`rename`、`mkdir`。 |
| `context-menu` | `type`、`id`、`label`、`menu: "connection"` 或 `"table"`、可选 `action` | 向 Sidebar Tree 的连接或 table 右键菜单增加后端动作,或打开已声明的 Workbench。 |
| `result-view` | `type`, `id`, `label` | 注册查询或任务结果视图。 |
`menu: "table"` contribution 当前只在用户对 Sidebar Tree 中的具体 table 节点打开右键菜单时生效。未声明 `action` 的旧式 contribution 会通过必需的 backend entrypoint 调用 `contextMenu/<contribution-id>`,并传递 table identity:
```json
{
"table": {
"connectionId": "connection-id",
"database": "example",
"schema": "public",
"table": "users"
}
}
```
`database` 和 `schema` 是可选字段;不可用时会省略,不会强制传递空字符串。该 context 只包含对象 identity,不包含 credential、connection string 或原始连接配置。Object Browser 后续可以复用相同 identity contract,但本次版本没有把它作为 table 菜单入口。
context-menu 也可以声明由 Host 处理的 Workbench action:
```json
{
"type": "context-menu",
"id": "vendor.example.generate",
"label": "生成测试数据",
"menu": "table",
"action": { "type": "open-workbench", "workbench": "vendor.example.main" }
}
```
`action.workbench` 必须引用同一插件 manifest 中声明的 `workbench`。Host 会直接打开该 Workbench,不调用 sidecar,并将当前 invocation context 作为 Workbench context 传入。`menu: "table"` 传入上面的 `TableContext` 对象本身(不是 backend 的 `{ "table": ... }` envelope);`menu: "connection"` 传入现有的非敏感 connection summary(`id`、`dbType`、`name`、`database`)。Host 还可能提供用于 tab 关联的标准 `connectionId`。两者都不会暴露 host、port、username、password、connection string 或原始连接配置。再次打开同一个 Workbench 时会用最新 invocation context 刷新其 context。
只有未声明 `action` 的旧式 context-menu 才要求 backend entrypoint;其返回的 `{ "message": "..." }` 仍会显示为 toast。
同一插件内通过 `id` 互相引用:Connection Provider 的 `workbench` 必须指向一个已声明的工作台,`filesystem_provider` 必须指向一个已声明的文件系统 Provider。图标优先使用 Contribution 的 `icon`,缺失时回退到插件级 `icon`。
自定义文件浏览器应使用 `workbench`:列表、虚拟滚动、预览、右键菜单和分栏由插件前端实现。只有希望使用 DBX 通用文件管理器时才选择 `filesystem_provider`;同时声明两者时默认打开工作台。未声明 `action` 的旧式 `context-menu` 调用后端 `contextMenu/<contribution-id>`;声明式 `open-workbench` action 则由 Host 直接打开同插件 Workbench。`result-view` 在工作台 context 中接收结果快照。SDK 会在初始化后通过 `dbxPlugin.contributionId` 暴露 Host 选中的 contribution id,因此单个 UI 入口可以为多个工作台和结果视图服务;具体负载见[完整贡献点协议](https://github.com/t8y2/dbx/blob/main/plugins/README.md)。
### 文件系统协议
使用 DBX 通用文件管理器时,后端实现以下方法;自定义工作台也可以自行通过 RPC 使用这些方法,但不能把声明 Contribution 当成已经实现业务逻辑。
| 方法 | 参数与返回 |
| ---------------------------- | --------------------------------------------------------------------------------------------------- |
| `filesystem/list` | `providerId`、可选 `connectionId`、`uri`、可选 `cursor`、`limit`;返回 `{ entries, nextCursor? }`。 |
| `filesystem/read` | 同上身份字段、`uri`、`maxBytes`;返回 `{ dataBase64, contentType?, truncated, etag? }`。 |
| `filesystem/write` | `uri`、`dataBase64`、`create`、`overwrite`、可选 `etag`;需声明 `write`。 |
| `filesystem/createDirectory` | `uri`;需声明 `mkdir`。 |
| `filesystem/delete` | `uri`、`recursive`;需声明 `delete`。 |
| `filesystem/rename` | `sourceUri`、`targetUri`、`overwrite`;需声明 `rename`。 |
所有方法都携带 `providerId` 和可选 `connectionId`。目录项包含单个文件名 `name`、完整 `uri`、`kind`(`file`、`directory`、`symlink`、`other`)以及可选 `size`、`modifiedAt`、`contentType`。`name` 不能包含 `/`、`\\` 或是 `.`/`..`;完整路径放在 `uri`。最后一页省略 `nextCursor`,不要返回已经使用的 cursor。图片预览应返回实际 MIME 和 base64 字节,不能把二进制当作 UTF-8 文本。
写操作返回 `{ success, message?, entry? }`;内联读写限制为 4 MiB。大文件传输应采用分块与进度/取消协议,而不是一个巨大的 JSON/base64 值。
### 国际化
`localizations` 的 key 使用 `zh-CN`、`en` 等 locale。可覆盖插件名称、说明以及每个 Contribution 的 `label`、`description`、字段和动作文案;它不会翻译插件 UI 自己的文本。
```json
{
"localizations": {
"zh-CN": {
"name": "示例文件",
"contributions": {
"com.example.files.connection": {
"label": "示例服务",
"fields": { "token": { "label": "访问令牌" } }
}
}
}
}
}
```
## Workbench 上下文契约
Workbench 上下文跨越 DBX 与插件边界时是 JSON 数据快照。宿主会递归移除 Vue 响应式包装并发送独立副本,因此插件不能依赖 Vue ref、Proxy、DOM 节点、函数、组件实例或凭据。
上下文可以包含 `null`、布尔值、有限数字、字符串、数组和普通对象。对象中的 `undefined` 字段会省略,数组中的 `undefined` 会转换为 `null`。Date、Map、Set、Symbol、BigInt、非有限数字、循环引用和自定义 class 实例会被拒绝并返回错误。上下文 UTF-8 编码后的大小上限为 2 MiB。
连接 Provider 使用 `binding: "config"` 的字段会保存到 `external_config`;使用 `binding: "secret"` 的字段会保存到 Secret Store。插件连接的 `external_config` 会在新建、编辑、保存和重新连接流程中保留。
宿主还会为每个 workbench 上下文写入三个保留字段:`workbenchId`(宿主生成的稳定实例 id)、`restored` 和 `surface`。`surface` 表示当前 UI 跑在哪种容器里——`"tab"`(主多标签工作台)、`"dock"`(底部面板)或 `"window"`(桌面浮动小组件窗口,见[浮动窗口](#浮动窗口))。同一个插件的同一个 contribution 可以同时开在多个 surface 上,各自拥有独立的上下文和生命周期;遇到未知取值应退化成 `"tab"` 的行为。
## 前端 Host API
UI 在隔离的 iframe 中运行,DBX 通过 `window.dbxPlugin` 提供桥接对象。不要导入 DBX 的 Vue/Tauri 模块,也不要假设浏览器页面能直接访问 Node.js、文件系统或网络。
多个 Workbench 或 result-view contribution 共用同一个 UI 入口时,可通过 Host 选中的 identity 路由页面:
```js
await window.dbxPlugin.ready;
const contributionId = window.dbxPlugin.contributionId;
```
`contributionId` 与 `context` 相互独立;例如从 Plugin Center 打开的 Workbench,其 context 可以是空对象 `{}`。SDK 会在 resolve `ready` 前记录 identity,因此 `await dbxPlugin.ready` 后可确定性地读取。为兼容旧插件,`dbx-plugin-init` 仍会携带完整 init payload 并作为通知派发;但该事件只派发一次,不应成为读取权威状态的唯一来源。
以下自定义 `objects/list` 和 `objects/changed` 需要由插件后端实现,资源示例假设包内存在 `ui/assets/empty-state.svg`:
```js
await window.dbxPlugin.ready;
const contributionId = window.dbxPlugin.contributionId;
const context = window.dbxPlugin.context;
const result = await window.dbxPlugin.invoke("objects/list", { prefix: "docs/" }, { timeoutMs: 30000 });
const off = window.dbxPlugin.onContext((nextContext) => render(nextContext));
await window.dbxPlugin.notify("objects/changed", { count: result.items.length });
const assetUrl = await window.dbxPlugin.readAssetUrl("assets/empty-state.svg");
```
| API | 作用 |
| ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ready` | 等待 Host 完成初始化;应用启动逻辑放在 `await dbxPlugin.ready` 之后。 |
| `contributionId` | Host 打开的 contribution 的只读 identity;`await ready` 后可读取,且与 `context` 独立。 |
| `context` / `onContext(fn)` | 读取或监听当前工作台上下文。 |
| `locale` | 读取当前语言;插件 UI 需自行完成翻译。 |
| `theme` | 读取 `appearance` 和 DBX 设计 tokens,适配明暗主题。 |
| `request(method, params)` | 调用 Host API;常用内部方法包括 `host.getContext`、`ui.readAsset`。 |
| `invoke(method, params, { timeoutMs })` | 向自己的 Sidecar 发送有响应的 RPC。 |
| `notify(method, params)` | 向 Sidecar 发送无需返回业务结果的通知。 |
| `sendBinary(channel, data)` / `onBinary(fn)` | 发送或接收二进制数据;需要 `stdio-framed` 和 `host.binary`。 |
| `readAsset(path)` / `readAssetUrl(path)` | 读取包内资源;路径不能越出插件资源根目录。 |
| `openWorkbench(id, context)` | 打开本插件的另一个工作台;需要 `host.workbench`。 |
| `setTitle(title)` | 修改当前工作台页签标题(传入 `null` 恢复默认);需要 `host.workbench`。 |
| `getTitle()` | 读取当前工作台页签标题;需要 `host.workbench`。 |
| `closeWorkbench()` | 关闭承载当前 UI 的表面——工作台标签、dock 面板条目,或本插件的浮动窗口。与 `Ctrl/Cmd+W` 快捷键走同一条路径,因此插件可以提供自己的关闭或最小化按钮。 |
| `executeCommand(commandId, context?)` | 执行本插件自己声明的命令,与菜单执行完全同径——重新检查 `enablement`、按单例复用,并遵循声明的 `presentation`(panel 停靠底部面板,tab 打开工作台标签页)。`context` 合并到命令上下文之上,并解析 `instance_key` 的 `{{path}}` 占位符(如 `instance_key: "logs:{{connectionId}}"` 即每个连接一个面板实例)。预期结果通过 `{ error }` 返回;需要 `host.workbench`。 |
| `openFilesystem(id, context)` | 打开本插件的文件系统入口;需要 `host.filesystem`。 |
| `getPlanCapabilities(connectionId)` | 读取宿主与该连接能提供什么计划能力;需要 `host.plans:read`。 |
| `explainPlan({ connectionId, database?, schema?, sql, mode, timeoutMs? })` | 返回 `sql` 的估算执行计划;需要 `host.plans:read`。 |
| `getTableMetadata({ connectionId, database?, schema?, table })` | 读取已打开连接的窄化 table schema metadata;需要 `host.schema:read`。 |
| `queryData({ connectionId, database?, schema?, sql, maxRows?, timeoutMs? })` | 在用户授权给插件的连接上执行一条只读 SQL;需要 `host.data:read`。 |
| `capabilities` | 来自 init 消息的 `{ downloadFile, planApi, schemaMetadataApi, dataApi, storage, ai, aiRecommendations, clipboardWrite, clipboardRead, floating, workbenchTitle }`。字段缺失或为 `false` 表示当前宿主不支持该组 Host API,应据此关闭功能而不是用真实请求试探。 |
| `ai.openConversation({ title, prompt, context, send?, mode? })` | 在内置 AI 面板新建插件对话;`send` 默认 `false`,`mode` 默认 `ask`。`mode: "agent"` 会在 context 包含已打开插件连接时使用实时插件工具;需要 `host.ai`,并以 `capabilities.ai` 广播。 |
| `ai.setRecommendations({ context, items })` | 覆盖当前工作台的默认推荐问题并立即刷新全局 AI 面板;每次最多 5 条,支持 `{{path.to.value}}` 占位符;需要 `host.ai` 和 `capabilities.aiRecommendations`。 |
| `ai.clearRecommendations()` | 清空当前工作台的推荐问题;需要 `host.ai` 和 `capabilities.aiRecommendations`。 |
| `onInit(fn)` / `onEvent(fn)` | 监听初始化、环境变化或后端事件;转发后端事件需要 `host.events`。 |
| `storage` | 本插件独享的持久化键值状态;需要 `host.storage`;见下文。 |
| `fileTransfer` | 本地文件流式传输、下载与(桌面端)系统拖放;见下文。 |
| `clipboard` | 沙箱内的系统剪贴板访问;见下文。 |
| `floating` | 打开并控制桌面浮动小组件窗口(仅桌面宿主);见[浮动窗口](#浮动窗口)。 |
调用失败会以 Promise rejection 返回,处理时应展示可理解的错误并允许重试。通过 `request` 调用的宿主方法、参数和返回值以当前 Host API 版本为准,不要调用未在协议中声明的 DBX 内部函数。
`readAsset` 的路径相对于 `ui.root`,所以这里的 `assets/empty-state.svg` 对应包内 `ui/assets/empty-state.svg`。资源不用时调用 `URL.revokeObjectURL(assetUrl)`;组件销毁时调用 `onContext`/`onEvent` 等返回的取消订阅函数,避免重复监听。
### 估算执行计划
插件可以在不自带驱动、连接池和凭据的前提下读取一条查询的**估算**执行计划。插件只传自己的原始 SQL 和连接引用;DBX 用自己已有的方言规则生成 `EXPLAIN` 语句、应用与自身计划视图完全相同的只读安全校验,并在宿主连接上执行。插件只拿到原始计划,由自己完成解析、归一化和可视化。
```json
{
"permissions": ["host.plans:read"]
}
```
```js
if (window.dbxPlugin.capabilities.planApi) {
const capabilities = await window.dbxPlugin.getPlanCapabilities(connectionId);
if (capabilities.supports.estimatedPlan) {
const plan = await window.dbxPlugin.explainPlan({ connectionId, database, sql, mode: "estimated", timeoutMs: 15000 });
render(plan.rawPlan, plan.format);
}
}
```
`getPlanCapabilities(connectionId)` 返回 `{ dbType, dbVersion?, supports: { estimatedPlan }, limits: { maxTimeoutMs, maxPlanBytes } }`:
- `supports.estimatedPlan` 为 `false` 表示该连接的方言在 DBX 中没有估算计划路径;应直接禁用功能,而不是调用 `explainPlan`。
- `limits.maxTimeoutMs` 已经考虑该连接自身的查询超时,请求里的 `timeoutMs` 会被夹到该上限;`limits.maxPlanBytes` 限制计划负载大小。
- `dbVersion` 只在 DBX 已经知道该连接产品版本时才存在;宿主不会为了插件去探测服务器。
- 该调用只读取已保存的连接配置,不会建立连接;但连接必须已经打开:已保存但当前已断开的连接会被拒绝,而不是替插件重新建连。
`explainPlan(request)` 返回 `{ dbType, dbVersion?, format, rawPlan, truncated, warnings }`:
- `format` 为 `json`、`xml`(SQL Server `ShowPlanXML`)或 `text`。`format` 为 `json` 时 `rawPlan` 是已解析的 JSON 文档,其余情况是计划文本。
- `warnings` 可能包含:`plan_not_json`(服务端返回的内容不是 JSON,负载会如实标记为 `text`,而不是假装可解析)、`plan_truncated`(宿主为遵守 `maxPlanBytes` 截断了计划)、`plan_rows_truncated`(驱动达到计划行数上限)。只要发生过任一截断,`truncated` 都是 `true`。
计划 API 的边界:
- **只做估算计划。** `mode` 必须是 `"estimated"`,其它取值一律拒绝。真实执行计划(`EXPLAIN ANALYZE`、`SET STATISTICS XML`)会真正执行语句,不属于本 API。
- **插件不能自己提供要执行的 SQL。** 只接受原始 `sql`,`EXPLAIN` 语句完全由宿主生成,因此插件无法传入 `EXPLAIN` 文本、驱动命令或执行模式。
- **只允许只读目标。** DBX 自身计划视图使用的同一道校验会拒绝多语句、DDL、DML 和危险关键字。Oracle 是唯一例外:DBX 也会为它规划 DML,因为 `EXPLAIN PLAN FOR` 并不会执行该语句。
- **不涉及凭据,也不返回结果集。** 返回体只有计划和元信息,密码、凭据、连接串、驱动内部结构都不会越过这条边界。
- **连接必须已经打开。** DBX 不会替插件建立连接,也不会为插件创建连接。`getPlanCapabilities` 与 `explainPlan` 对「已保存但已断开」的连接都会以 `Connection is not open` 拒绝;只有 DBX 当前已经打开的连接才能规划。
判断能力时请读 `dbxPlugin.capabilities.planApi`(旧宿主不会带这个字段),再用 `getPlanCapabilities` 确认这条连接是否支持,最后才调用 `explainPlan`。自行拼 `EXPLAIN` 文本既没必要,也不会被接受。
计划 API 属于 Host API 1.2。无法离开它工作的插件应在 manifest 里声明下限:`"engines": { "host_api": "^1.2" }`。manifest 里的版本范围是兼容下限,`capabilities.planApi` 是运行期探测,两者都保留。
### Table Schema Metadata(表结构元数据)
插件可以读取当前 DBX 已打开连接中某个 table 的窄化 schema metadata,而不需要拥有驱动、连接池、凭据或自己拼 SQL。请求复用现有的 `PluginTableContext`,只允许 table identity:
```json
{
"permissions": ["host.schema:read"]
}
```
```js
if (window.dbxPlugin.capabilities.schemaMetadataApi) {
const metadata = await window.dbxPlugin.getTableMetadata({
connectionId,
database,
schema,
table: "users",
});
for (const column of metadata.columns) {
renderColumn(column.name, column.dataType, column.nullable);
}
}
```
`getTableMetadata({ connectionId, database?, schema?, table })` 返回:
```json
{
"columns": [
{
"name": "id",
"dataType": "integer",
"nullable": false,
"precision": 32,
"default": "nextval('users_id_seq'::regclass)"
}
],
"fieldCapabilities": {
"length": "supported",
"precision": "supported",
"scale": "supported",
"default": "supported"
}
}
```
- `columns` 只包含 `name`、`dataType`、`nullable`、可选的 `length`、`precision`、`scale` 和 `default`。不会返回 comment、key/index、credential、连接串、驱动内部对象或任意 SQL 结果。
- 可选值在没有值时保持 `null` 或直接省略,绝不会被伪造为 `0` 或空字符串。`fieldCapabilities` 用 `supported`、`unsupported`、`unknown` 区分提供方支持、明确不支持和宿主没有可靠 provenance 的情况;`unknown` 不应被插件当成支持。
- `database` 和 `schema` 可选;不可用时省略,不要把空字符串当作 public identity。`connectionId` 和 `table` 必须是非空且不超过 256 个字符的 identity 值。
- 这是只读 API。DBX 只复用已打开的 Host connection/session,禁止插件隐式重连、创建连接池或执行任意 SQL。已保存但当前断开的连接会以 `Connection is not open` 拒绝;请求的 database 没有对应已打开 session 时也会拒绝,而不是切换到别的连接。
该 API 属于 Host API 1.3。无法离开它工作的插件应声明:`"engines": { "host_api": "^1.3" }`;同时仍应读取 `capabilities.schemaMetadataApi`,因为旧宿主不会提供该字段。没有 `host.schema:read` 时,宿主会在请求到达 backend 前拒绝调用。
### 只读数据查询
插件可以在用户授权给它的 DBX 连接上执行一条只读 SQL,而不需要驱动、连接池、凭据或连接串。声明权限,并按能力位决定是否调用:
```json
{
"engines": { "host_api": "^1.4" },
"permissions": ["host.data:read"]
}
```
```js
if (window.dbxPlugin.capabilities.dataApi) {
const result = await window.dbxPlugin.queryData({
connectionId,
database,
sql: "SELECT status, count(*) AS total FROM orders GROUP BY status",
maxRows: 200,
});
renderChart(result.columns, result.rows);
}
```
`queryData({ connectionId, database?, schema?, sql, maxRows?, timeoutMs? })` 返回:
```json
{
"dbType": "postgres",
"columns": [
{ "name": "status", "dataType": "text" },
{ "name": "total", "dataType": "int8" }
],
"rows": [
["paid", 1204],
["refunded", 37]
],
"truncated": false,
"elapsedMs": 12
}
```
- **按连接征求同意。** 插件第一次查询某个连接时,宿主会弹窗写明插件名和连接名。用户允许后授权会持久保存,并显示在「插件中心 → 已安装」该插件下,用户可随时撤销;拒绝会在本次工作台会话内被记住。插件无法自行授权。
- **只读且单条语句。** 宿主只接受一条被 DBX SQL 风险分类器判定为只读的语句——与 MCP 只读模式和 AI Agent 使用同一道闸门。写入、DDL、`SELECT … FOR UPDATE`、多条语句以及 `USE` 这类切换会话数据库的语句都会被拒绝;请改用 `database` 参数。
- **只用已打开的连接。** 与执行计划和表结构元数据 API 一样,DBX 不会替插件发起连接:已保存但未打开的连接会以 `Connection is not open` 拒绝。Redis、MongoDB、搜索引擎等非 SQL 连接不提供该能力。
- **有上限。** `maxRows` 默认 500、最多 5000;序列化后的行数据上限 8 MiB;`timeoutMs` 会被限制在连接自身超时和 60 秒上限之内。只要有行被截断,`truncated` 就为 `true`。
- **不含凭据。** 响应只包含列和行。授权被撤销后,调用会返回以 `PLUGIN_DATA_ACCESS_NOT_GRANTED` 开头的错误,下一次查询会重新征求用户同意。
该 API 属于 Host API 1.4。请同时保留 `engines.host_api` 下限和 `capabilities.dataApi` 运行时检查;无法弹出确认框的 Web 宿主会拒绝请求,而不会静默授权。
### 连接信息读取
需要展示连接下拉框或根据连接执行特定任务(例如通过 `host.data:read` 查询数据)的插件,可以通过 `host.connections:read` 权限枚举当前配置的所有连接列表:
```json
{
"permissions": ["host.connections:read"]
}
```
```js
await window.dbxPlugin.ready;
if (window.dbxPlugin.capabilities.connectionsApi) {
const connections = await window.dbxPlugin.getConnections();
// 也可通过 window.dbxPlugin.connections.list();
for (const conn of connections) {
console.log(conn.id, conn.name, conn.dialect, conn.connected);
}
}
```
返回的每个连接对象包含:
- `id`: 连接唯一标识,可直接传给 `queryData`、`getPlanCapabilities` 等;
- `name`: 连接名称;
- `dialect` / `dbType`: 数据库方言/类型(如 `mysql`, `postgres`, `sqlite` 等);
- `connected` / `isConnected`: 当前是否已建立物理连接;
- `readOnly`: 是否为只读连接;
- `database` / `schema`: 默认数据库或模式(如有)。
> **安全边界**:返回列表完全脱敏,不包含密码、用户名、主机、端口或连接串等敏感凭据。
### 系统剪贴板
插件沙箱是 opaque origin 且没有剪贴板权限,`navigator.clipboard` 在里面不可用。宿主代为桥接系统剪贴板:
- `window.dbxPlugin.copy(text)` / `window.dbxPlugin.clipboard.writeText(text)` 写系统剪贴板。两者走同一条无权限门控的桥接方法:写操作的输入就是用户数据本身,是低风险的一半。
- `window.dbxPlugin.clipboard.readText()` 读系统剪贴板。它需要 `host.clipboard:read` 权限——读取会把用户数据(密码、令牌)交给插件代码,且没有后续用户交互兜底——属于 Host API 1.3:离开它无法工作的插件应声明 `"engines": { "host_api": "^1.3" }`。
读剪贴板请看 init 能力而不是试探:`capabilities.clipboardRead`(写对应 `capabilities.clipboardWrite`)在旧宿主上缺失即为 falsy。读被拒绝(缺权限、Web 宿主没有原生剪贴板访问)时 Promise 会 reject——请优雅降级,例如回落到键盘粘贴路径,而不是让交互直接失败。
```js
await window.dbxPlugin.ready;
const canRead = !!window.dbxPlugin.capabilities.clipboardRead;
const canWrite = !!window.dbxPlugin.capabilities.clipboardWrite;
async function pasteFromClipboard() {
if (!canRead) return null;
try {
return await window.dbxPlugin.clipboard.readText();
} catch {
// 缺权限或读取失败:保留键盘粘贴路径。
return null;
}
}
```
### 文件传输与系统拖放
桌面端宿主通过 `window.dbxPlugin.fileTransfer` 把本地文件流式接入插件沙箱。句柄只在用户明确同意后打开:原生打开/保存对话框(`pick` / `beginSave`),或从系统拖放到本插件工作台区域内的文件(`onDrop`)。传输按分块进行,多 GB 文件也不会整体载入内存。
```js
const fileTransfer = window.dbxPlugin.fileTransfer;
if (!fileTransfer) {
// Web 宿主:回退到 <input type="file"> 和 sidecar 落盘。
}
// 上传:用户在原生对话框选择文件,插件把分块推给自己的 sidecar。
const { files } = await fileTransfer.pick({ multiple: true });
for (const file of files) {
let offset = 0;
for (;;) {
const chunk = await fileTransfer.read(file.handleId, offset, 256 * 1024);
await uploadChunk(file.name, offset, chunk.dataBase64); // 你自己的 RPC
if (chunk.eof) break;
offset += chunk.length;
}
await fileTransfer.cancel(file.handleId);
}
// 下载:打开保存目标,分块写入,最后 finish 落盘。
const target = await fileTransfer.beginSave({ name: "export.csv", size });
await fileTransfer.write(target.handleId, 0, bytes);
await fileTransfer.finish(target.handleId);
// 系统拖放(仅桌面端):被拖入的文件以已打开的句柄送达。
const offDrop = fileTransfer.onDrop((files) => uploadAll(files));
const offDrag = fileTransfer.onDragState((active) => showDropOverlay(active));
```
| API | 用途 |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `pick({ multiple })` | 原生打开对话框;返回已打开的读句柄 `{ handleId, name, size, contentType }`。 |
| `read(handleId, offset, length?)` | 从读句柄流式读取一个分块 `{ dataBase64, length, eof }`。 |
| `beginSave({ name, contentType?, size? })` | 原生保存对话框 + 写句柄;返回 `{ handleId, chunkBytes }`,用户取消时为 `null`。 |
| `write(handleId, offset, data)` | 写入一个分块(二进制 transfer 或 base64);返回 `{ written, nextOffset }`。 |
| `finish(handleId)` / `cancel(handleId)` | 冲洗并关闭写句柄 / 丢弃任意句柄。 |
| `onDrop(fn)` | 拖入本工作台的文件以读句柄送达;拖入文件夹会被展开为其中包含的文件(`.git` 等点开头文件与目录会被跳过),每个文件额外携带 `relativePath`(以 `/` 分隔、以拖入文件夹自身名字开头的路径,如 `folder/nested/a.csv`),可用于还原拖入的目录结构,也能区分不同拖入根目录下的同名文件。监听器签名为 `(files, { dropId, truncated })`:`dropId` 标识本次拖入的整组条目(取消与进度记账的分组键),`truncated` 为 `true` 表示展开因 2000 文件或 8 层深度上限被截断。 |
| `onDragState(fn)` | 是否有系统拖拽悬在本工作台上方;用它驱动拖放遮罩。 |
桌面端与网页端的行为差异:请以 init 消息里 additive 的 `capabilities.fileTransfer` 标志位(`pick`、`beginSave`、`read`、`drop`、`folderExpansion`)做能力探测,不要靠试调用。桌面端 `pick` / `beginSave` 走原生对话框、`onDrop` 接收系统拖放(句柄由宿主惰性打开,读时分块流式,因此超大文件夹与大文件都不会占满句柄;选取文件数超过共享句柄池时自动降级为惰性条目,不会静默丢弃)。网页端 `pick` 回退到浏览器文件选择器、`beginSave` 回退为内存缓冲加浏览器下载、`onDrop` / `onDragState` 不会触发(`drop`/`folderExpansion` 为 `false`)、句柄也没有 `relativePath`。`onDrop` 只在拖放落点位于本插件工作台区域内时触发;落点在宿主其他区域时保持宿主自身的行为。
### 持久化 UI 状态
沙箱是 opaque origin,`localStorage` 在里面会直接抛错。需要跨重载或跨应用重启保留状态的工作台(最近访问的目录、面板尺寸、草稿等)改用 `window.dbxPlugin.storage`。它需要 `host.storage` 权限,并以 init 消息里的 `capabilities.storage` 广播;使用前请同时探测这两者。
```js
if (window.dbxPlugin.capabilities.storage) {
const storage = window.dbxPlugin.storage;
await storage.set("layout", { sidebar: "collapsed", sort: "name" });
const layout = await storage.get("layout"); // { sidebar: "collapsed", sort: "name" },未设置时为 null
await storage.delete("layout");
}
```
| API | 用途 |
| ----------------- | --------------------------------------------------- |
| `get(key)` | 返回存储的 JSON 值;键未设置时返回 `null`。 |
| `set(key, value)` | 持久化任意 JSON 值(`undefined` 归一化为 `null`)。 |
| `delete(key)` | 删除一个键;删除不存在的键同样成功。 |
键为不超过 256 字符的字符串;单个值序列化后上限 256 KiB,整库上限 1 MiB——这是 UI 状态存储,不是数据落盘通道。数据保存在插件自己的 `plugin-data/<id>` 目录里,按插件隔离,版本升级和卸载后都保留。大批量数据、缓存以及 Sidecar 产生的任何文件请放进 Sidecar 的数据目录(见 [原生 Sidecar 和 RPC](#原生-sidecar-和-rpc))。Web 宿主上同一 API 由浏览器本地存储支撑,两端代码完全一致。
### 在 DBX AI 中分析插件数据
在 manifest 中声明 `"permissions": ["host.ai"]`。插件可以直接打开全局 AI 面板,也可以为每个 Workbench 注册快捷推荐问题。两种能力都沿用 DBX 的模型选择、历史、追问、停止和导出功能。
#### 声明 Workbench 的默认推荐问题
在 `workbench` contribution 下增加可选的 `ai.recommendations`。`label` 显示在 DBX 全局 AI 面板,`prompt` 是点击后发送给 AI 的文本;`order` 越小越靠前。
```json
{
"type": "workbench",
"id": "com.example.files.main",
"label": "Files",
"ai": {
"recommendations": [
{
"id": "health",
"label": "检查 {{resource.name}} 健康状态",
"prompt": "分析 {{resource.kind}}/{{resource.name}} 的健康状态和风险",
"order": 10
}
]
}
}
```
占位符从当前 Workbench context 读取,支持对象属性和数组下标,例如 `{{resource.name}}`、`{{items.0.status}}`。无法解析的占位符会使该条推荐被隐藏;`__proto__`、`prototype` 和 `constructor` 等路径会被拒绝。推荐问题最多显示 5 条。
#### 根据资源变化更新推荐问题
插件切换页面或资源时,可以提交运行时推荐问题。运行时 `items` 会覆盖 manifest 默认值;传入空数组表示清空当前推荐。运行时 context 通常只需要提交资源字段,Host 会保留当前 Workbench 的 `connectionId`、数据库和实例标识,确保点击推荐问题后仍绑定到正确的插件连接。
```js
function updateRecommendations(resource) {
if (!dbxPlugin.capabilities.aiRecommendations) return;
return dbxPlugin.ai.setRecommendations({
context: { resource },
items: [
{
id: "health",
label: `检查 ${resource.name} 健康状态`,
prompt: "分析 {{resource.kind}}/{{resource.name}} 的健康状态和风险",
order: 10,
},
{
id: "events",
label: "查看最近事件",
prompt: "列出这个资源最近的重要事件并给出处理建议",
order: 20,
},
],
});
}
await dbxPlugin.ai.clearRecommendations();
```
推荐问题显示在 DBX 全局 AI 对话窗口中,而不是插件页面内新增一个 AI 入口。用户点击后,DBX 会使用当前推荐文本创建新的 Agent 对话并立即发送;Agent 会通过当前已打开的插件连接调用插件工具获取实时数据。推荐列表本身只属于当前 Workbench 上下文,不会写入对话历史;用户点击后发送的实际问题会正常保存。
#### 直接打开 Ask 或 Agent 对话
`ai.openConversation` 仍适合插件按钮或其他明确动作。默认 `mode` 为 `ask`,宿主会复制插件提供的 JSON 快照;快照会随对话保存,后续资源刷新不会改变已经创建的对话。需要实时工具时显式使用 `mode: "agent"`,并在 context 中提供当前已打开插件连接的 `connectionId`:
```js
await dbxPlugin.ai.openConversation({
title: "Watchlist analysis",
prompt: "Compare the current quotes and explain risks and missing data.",
context: {
connectionId,
resource: { kind: "watchlist", name: "primary" },
dataTimestamp,
},
mode: "agent",
send: true,
});
```
`title` 最多 200 字符,`prompt` 最多 32000 字符,`context` 必须是纯 JSON 对象,上限 2 MiB。Agent 只能使用宿主为该插件连接公开的工具;插件不会收到模型回复或模型配置,也不能借此获得数据库执行能力。旧插件不声明推荐问题时仍可正常使用原有 AI API;旧宿主缺少新增 capability 字段时,插件应关闭对应入口并继续提供其他功能。
### 浮动窗口
浮动窗口把插件的某个 workbench 放进一个独立的无边框、置顶桌面窗口,而不是塞在 DBX 外壳里——这正是小型常驻小组件需要的表面(播放器胶囊、会话状态徽章、快捷选择器)。它会盖在其他应用之上,可以被拖到桌面任意位置。在屏幕中间松手就停在落点;落到或越过工作区边缘(不管怼得多用力)都会齐着那条边吸附并停靠。停靠不会在落下的那一刻就把小组件藏起来:它先保持整颗可读的胶囊,光标离开约 2.5 秒后才滑到屏外只留一条细边,光标碰到细边就滑回来。重新打开一个上次吸在边上的小组件也是这套时序(先胶囊、后收起);刚唤出的小组件即使默认落点就贴着边,也只停在胶囊态。使用前先判断 `capabilities.floating`:Web 宿主没有窗口,桌面宿主也可能不支持窗口管理。
```js
if (window.dbxPlugin.capabilities.floating) {
// 在标签或 dock 工作台里:打开小组件。
// 一个(插件, workbench contribution)只会有一个窗口,重复调用只是把已有窗口带到前台。
const { windowId } = await window.dbxPlugin.floating.open({
contributionId: "capsule",
title: "Now playing",
width: 260,
height: 60,
context: { queueId: currentQueueId },
});
}
// 在小组件内部(context.surface === "window"):控制自己所在的窗口。
grip.addEventListener("pointerdown", (event) => {
grip.setPointerCapture(event.pointerId);
dragging = false;
});
grip.addEventListener("pointermove", async (event) => {
if (dragging || Math.hypot(event.clientX - downX, event.clientY - downY) < 4) return;
dragging = true;
// 从这一刻起窗口归宿主移动:它自己读原生光标,不需要插件逐帧上报。
await window.dbxPlugin.floating.beginDrag();
});
grip.addEventListener("pointerup", async () => {
if (!dragging) return;
dragging = false;
await window.dbxPlugin.floating.endDrag(); // 吸附并记住位置
});
```
| API | 作用 |
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `floating.open(options)` | 为本插件的某个 `workbench` contribution 打开(或前置)浮动窗口。`options`:`contributionId`(必填)、`title`、逻辑像素的 `width`/`height`、物理屏幕像素的 `x`/`y`、`alwaysOnTop`(默认 `true`)、`skipTaskbar`(默认 `true`)、`resizable`(默认 `false`)、`context`。返回 `{ windowId, reused }`;需要 `host.workbench`。 |
| `floating.close(contributionId?)` | 关闭本插件的浮动窗口——指定某个 contribution,或省略 id 关闭本插件的全部浮动窗口。在小组件内部不带参数调用即关闭自己。 |
| `floating.beginDrag()` | 把窗口交给宿主的拖动循环。在 `pointermove` 里、自己的拖动阈值越过之后调用(此时按键还按住)。之后由宿主自己读原生光标,插件没有逐帧调用要做。 |
| `floating.endDrag({ snap? })` | 结束拖动。`snap`(默认 `true`)时宿主把窗口齐着它到达或越过的工作区边缘吸附并停靠在那(先保持整颗胶囊,光标离开一会儿才收起到屏外、只留一条悬停细边),落点在屏幕中间则完整夹回屏内;位置与停靠边都会记住,供下次打开使用。返回物理像素的最终矩形。实践中可不调:宿主在光标静止后也会自己收尾,收不到 pointerup 的小组件不会把窗口一直粘在光标上。 |
| `floating.setSize(width, height)` | 以逻辑像素调整所在窗口大小;宿主会夹到自己的上下限内。 |
小组件决定拖动**何时**开始(哪个元素是拖拽把手、指针要先走多远);窗口由宿主移动,吸附与位置记忆也归宿主。刻意没有逐帧的桥调用:宿主轮询原生光标,webview 忙不过来时小组件也照样贴着指针走,光标静止即收尾,pointerup 丢了也不会一直拖下去。几何类调用(`beginDrag`/`endDrag`/`setSize`)只在浮动窗口内部有效,在其他表面调用会被拒绝,因此标签页永远无法移动它不在其中的窗口。
几条规则让这个表面的行为可预期:
- **一个 contribution 只有一个窗口。** 窗口 id 由插件 id 和 contribution id 推导,所以 `floating.open` 是幂等的:第二次调用会把已开的窗口带到前台,而不是叠出更多小组件。
- **窗口是不透明的。** 宿主自己不画任何外壳,用户看到的就是小组件本身:给页面一个背景加圆角,配合宿主按内容收好的窗口尺寸,读起来就是浮在桌面上的一张卡片。刻意不提供逐像素透明(带镂空角的塑形小组件)——透明区域还收不收鼠标事件各平台不一致,而一个要给用户拖的小组件必须处处点得动。
- **外壳导航会被转发。** 浮动窗口没有标签栏、dock 和面板,所以从它发起的 `openWorkbench` 和 `openFilesystem` 会在 DBX 主窗口里打开,主窗口会自行前置。`executeCommand` 在这个表面完全不提供:每个命令动作都要打开一个外壳表面,调用会成功返回但什么都看不见。
- **位置跨重启保留**,按插件和 contribution 分别记录;当记录的位置已经不在任何已连接的显示器上时会被丢弃(拔掉显示器后退化为工作区默认位置,而不是开到屏幕外)。
- **小组件就是一个完整工作台。** 它拿到和标签页相同的桥、权限、上下文契约和 sidecar RPC;`context.surface` 为 `"window"`,`context.workbenchId` 为窗口 id。它启动的是你 UI 的另一份实例,所以共享状态必须走 sidecar(或 `storage`),不能指望模块内存。
### 主题、布局和静态资源
DBX 将主题 tokens 注入文档根元素,并更新 `data-dbx-theme`。自定义组件库(包括 Svelte、shadcn-svelte)应使用这些变量,不要依赖父页面 CSS 自动穿透 iframe:
```css
body {
margin: 0;
background: var(--color-background, #fff);
color: var(--color-foreground, #18181b);
}
button {
background: var(--color-primary, #2563eb);
color: var(--color-primary-foreground, #fff);
cursor: pointer;
}
```
使用 `window.addEventListener("dbx-plugin-env", handler)` 响应语言或主题变化。先读取 `dbxPlugin.locale`/`theme` 初始化,再在事件中刷新自己的状态。DBX 提供轻量 `dbx-btn`、`dbx-input` 等样式,但开发宿主不模拟完整组件 kit,自定义 token 样式在两者中更一致。
#### 主题负载携带什么
env 消息的 `theme` 结构为 `{ appearance, tokens, editor? }`。`appearance` 是 `"light" | "dark"`;`tokens` 是按前缀扫取的根节点设计变量(`--color-*`、`--radius-*`、`--font-*`);`editor` 是无 CSS 令牌载体的 SQL 编辑器设置结构化快照。投递矩阵:
| DBX 设置 | 投递形式 |
| ----------------------------------- | ----------------------------------------------- |
| 浅色/深色/跟随系统 | `theme.appearance` |
| 调色板 + 自定义配色 | `--color-*` tokens |
| 圆角风格 | `--radius-*` tokens |
| UI 字体 | `--font-sans` token |
| 编辑器(等宽)字体 | `--font-mono` token + `theme.editor.fontFamily` |
| 编辑器字号 | `theme.editor.fontSize` |
| SQL 编辑器语法主题 | `theme.editor.theme` |
| UI 缩放(宿主窗口级) | 不投递——插件自管缩放 |
| 数据网格字体/类型配色、编辑器背景图 | 不投递——DB 域(`--dbx-*` 不在扫取范围) |
字体"字号"类关注点归插件所有:宿主不规定你的字号;如适合你的 UI,可把 `theme.editor.fontSize` 当默认值消费。所有负载变化都经 env 通道实时推送——字体族变更走主题修订号,字号/语法主题走显式监听——无需刷新。
UI 必须构建为包内静态资源,不要把 Vite 开发服务器或 CDN 的脚本地址写入发行包。宿主把入口及资源转换成沙箱可加载内容;CSP 报错时检查构建产物是否存在、路径是否落在 `ui.root` 内,并使用匹配版本的 CLI 复现。用插件自己的对话框组件处理删除/重命名,不要依赖沙箱中的原生 `alert`/`confirm`/`prompt`。
#### 懒加载与代码分割
宿主会把入口脚本内联进沙箱文档,`ui/` 下的其余资源在运行时通过 `dbx-plugin` 协议保持可寻址:`dbx-plugin://localhost/<插件id>/<ui.root内相对路径>`(WebView2 上映射为 `http://dbx-plugin.localhost/<插件id>/…`)。宿主会把平台对应的形式注入为沙箱文档的 `<base>`,并放行资源类 CSP,因此打包后的插件可以正常动态 `import()` 代码分割的 chunk——前提是构建产物使用**相对**资源地址:Vite 设 `base: "./"`、webpack 设 `publicPath: "./"`,让 chunk 和 CSS 资源地址基于注入的 base 解析而不是宿主 origin。绝对路径 `/assets/…` 携带不了插件 id,会 404。该协议只读文件、限定在每个插件的 `ui.root` 内,且插件更新后不会沿用旧缓存。
#### 内联后的样式表
宿主会读取所有指向 `ui.root` 内部的 `<link rel="stylesheet">`,替换成带同样属性的 `<style>` 元素,因此插件运行时仍然能找到自己的样式表(`document.querySelectorAll("style[data-skin-css]")`)并切换它们。有一处差异需要注意:`disabled` 在 `<style>` 元素上不会像 `<link>` 那样从内容属性反映到 IDL,宿主重新序列化时也无法用 IDL 属性把它带过去。所以自带 disabled 样式表的插件必须在加载后自己重新设置 `sheet.disabled = true`;其余属性(id、`data-*` 标记、`media`)都会原样保留。
#### 全屏
沙箱里的 `requestFullscreen()` 取决于框架的 Permissions-Policy,宿主已经做了委派(`allow="clipboard-write; fullscreen"`)。插件无法自己授予该策略,因此应当用 `document.fullscreenEnabled` 做特性检测,为 `false` 时退化成页面内的最大化布局——否则在不委派该策略的旧宿主上,每次全屏请求都会被拒绝。
## 原生 Sidecar 和 RPC
只有浏览器沙箱无法完成的能力才需要原生后端,例如 S3/SSH 协议、系统凭据、长连接或高性能计算。Sidecar 不是操作系统沙箱,会以当前用户权限运行。
Rust 和 Go SDK 都实现 Sidecar Protocol v1:
```json
{"jsonrpc":"2.0","id":1,"method":"plugin/initialize","params":{"host":{"protocolVersions":[1]}}}
{"jsonrpc":"2.0","id":2,"method":"objects/list","params":{"prefix":"docs/"}}
```
响应必须使用相同的 `id`,成功返回 `result`,失败返回 JSON-RPC `error`。初始化响应至少包含 `protocolVersion`、`capabilities` 和 `plugin: { id, version }`;ID 和版本必须与 Manifest 一致,协议必须在双方支持范围内,否则会出现 `Sidecar identity or protocol does not match manifest`。初始化 capabilities 描述后端能力,不等于 Manifest 的 Host 权限列表。
```rust
use dbx_plugin_sdk::{PluginEmitter, PluginError, PluginHandler, PluginMetadata, PluginServer, RequestContext};
use serde_json::{json, Value};
struct Plugin;
impl PluginHandler for Plugin {
fn handle(&self, _context: RequestContext, method: &str, _params: Value, _emitter: &PluginEmitter) -> Result<Value, PluginError> {
match method {
"example/ping" => Ok(json!({ "ok": true })),
_ => Err(PluginError::method_not_found(method)),
}
}
}
fn main() -> std::io::Result<()> {
let metadata = PluginMetadata::new("com.example.files", env!("CARGO_PKG_VERSION"));
PluginServer::new(metadata, Plugin).serve()
}
```
Sidecar 约束:stdout 只能输出协议消息,日志写 stderr;请求 ID 必须支持并发关联;连接和断开应幂等;长任务应设计超时、取消和分块确认;不要在事件、context 或错误消息中泄露 Secret。
每个 Sidecar 启动时都会带上 `DBX_PLUGIN_DATA_DIR` 环境变量,指向为本插件保留的持久化目录(`plugin-data/<id>`,与安装包目录平级)。它在版本升级和卸载后都保留,插件的所有 Sidecar 运行共享;缓存、Token、后端管理的数据库等应放在这里——首次写入前需自行创建目录,宿主不会预创建。Go SDK 提供了 `dbxpluginsdk.DataDir()` / `EnsureDataDir()` 封装。工作台 UI 需要小体量持久化时走同一目录里的 `host.storage`(见 [持久化 UI 状态](#持久化-ui-状态)),不要直接读写这些文件。
上例可替换 Rust 模板的 `backend/src/main.rs`,同时让 Cargo 包版本与 Manifest 一致。Go 使用模板生成的 `dbxpluginsdk.NewServer(metadata, handler).Serve()`;当前 Go SDK 支持 JSONL,Rust SDK 还支持 framed transport。不要仅修改 Manifest transport 就认为 Go 已具备二进制帧处理能力。完整接口见 [Rust SDK](https://github.com/t8y2/dbx/tree/main/plugins/sdk/rust/dbx-plugin-sdk) 和 [Go SDK](https://github.com/t8y2/dbx/tree/main/plugins/sdk/go/dbx-plugin-sdk)。
## 为 DBX AI 助手提供工具
带原生后端的插件可以暴露工具,供 DBX 内置 AI 助手在 Agent 模式下调用。这样「订单接口为什么变慢了?」这类问题就能把数据库工具和插件提供的指标、日志或 Shell 能力结合起来。Sidecar 需要实现两个方法:
- `mcp/tools` 以 MCP 工具格式返回 `{ "tools": [{ "name", "description", "inputSchema", "annotations"? }] }`。DBX 会传入正在绑定的 `{ "connectionId" }`,插件可以据此在只读连接上隐藏写工具。
- `mcp/call` 接收 `{ "tool", "arguments", "lifecycle" }`,返回 MCP `CallToolResult`(`{ "content": [{ "type": "text", "text": "..." }], "isError": false }`)。`lifecycle` 与已打开连接的 `connection/connect` 载荷相同——凭据由宿主解析,运行端点已经过 SSH 或代理层——因此工具参数里永远不需要密钥。
宿主在此之上负责:
- **显式加入、可撤销。** 插件只有在用户于「插件中心 → 已安装 → 内置 AI 工具」中开启后才会贡献工具——仅安装不会暴露任何东西。同一面板会列出工具并标出哪些需要确认,开关可把某个插件永久关闭。manifest 中的 `mcp` 贡献声明 `ai_tools: false` 可让插件即使被用户开启也不进入该面。
- **只用已打开的连接。** 只为用户当前已打开的插件连接提供工具。连接由 DBX 绑定:模型看到的 schema 中会去掉 `connectionId` / `connectionName`;如果插件 schema 声明了 `connectionId`,宿主会在转发参数中注入绑定连接的 id。打开了多个连接时,模型通过额外的 `dbx_connection` 参数选择其一。
- **默认需要确认。** 只有声明了 `"annotations": { "readOnlyHint": true }` 的工具才会直接执行。其他调用都会暂停运行,并向用户展示将要转发的完整参数,由用户选择允许一次或拒绝;五分钟未响应视为拒绝。请为真正只读的工具加上标注,同时保留插件自身的防护(只读连接标志、两阶段确认等)——宿主确认拦截的是模型的误操作,不能替代插件自己的安全规则。
- **可移植的 schema。** 工具以 `<前缀>__<工具名>` 暴露(例如 `ssh__ssh_exec`),schema 会被收敛到所有支持的模型供应商都接受的子集:`type`、`description`、`properties`、`required`、`items`、字符串 `enum` 以及数值、长度和元素数量边界。参数名请只使用字母、数字和下划线。
- **结果有上限。** 单次调用 120 秒超时,结果在交给模型前会被压缩。长任务请作为后台任务启动并返回句柄,不要阻塞。
内置 Agent 会把工具输出当作不可信数据处理。结果里请返回事实而不是指令,也不要回显任何密钥。
同样的工具也会出现在外部 `dbx` MCP 服务器(Claude Code、Cursor 等)上,名称为 `dbx_<前缀>__<工具名>`,并受全局 MCP 设置(工具白名单 + 连接白名单)约束、绑定到插件已保存的连接。该面按作者声明加入:manifest 中的 `mcp` 贡献声明 `external_tools: true` 才会暴露;连接选择参数为 `dbx_connection`。
## 创建插件
直接安装预编译 CLI,不需要 Rust,也不需要克隆 DBX 源码:
```bash
npm install --global @dbx-app/plugin-cli
dbx-plugin --help
```
也可以直接使用 `npx`:
```bash
npx @dbx-app/plugin-cli create my-ui-plugin --template frontend
```
npm 包会自动选择当前平台的预编译二进制,并携带匹配版本的 Rust/Go 插件 SDK。纯前端插件不需要 Rust 或 Go;原生编译环境只用于构建插件自己的 Sidecar。
创建项目:
```bash
# 纯前端、跨平台
dbx-plugin create my-ui-plugin --template frontend
# Svelte + Vite、跨平台
dbx-plugin create my-svelte-plugin --template svelte
# Rust Sidecar + 前端
dbx-plugin create my-rust-plugin --template rust
# Go Sidecar + 前端
dbx-plugin create my-go-plugin --template go
```
四种模板的选择:`frontend` 适合原生 HTML 或自行接入 Vue/React 的静态 UI;`svelte` 自带 Svelte 5 + Vite;`rust` 和 `go` 额外生成原生 Sidecar。`--backend none|svelte|rust|go` 可以作为模板别名,`--language rust|go` 是原生项目兼容别名。脚本或 CI 中可以用 `--yes` 跳过交互向导:
```bash
dbx-plugin create my-plugin \
--template svelte \
--id com.example.my-plugin \
--name "My Plugin" \
--publisher example \
--description "A DBX plugin." \
--version 0.1.0 \
--yes
```
Svelte 模板构建脚本会把 Vite 输出到 `ui/`,并在成功构建后输出 `DBX_UI_BUILD_SUCCESS`:
```bash
cd my-svelte-plugin
npm install
npm run build
dbx-plugin dev --port 5190
```
生成项目后,先在自己的插件源码仓库开发、测试和维护版本记录。不要为了发布一个普通插件而把整个源码目录复制进 `t8y2/dbx`。
## 构建候选包
在插件项目根目录执行:
```bash
dbx-plugin package .
```
该命令只生成**未签名审核候选包**:
```text
dist/<plugin-id>-<version>-<target>.dbxp
dist/<plugin-id>-<version>-<target>.artifact.json
```
前端-only 项目默认使用 `universal` target;Rust/Go 项目使用当前平台 target。`--output-dir` 可以改变输出目录,`--artifact-url` 可以写入发布后的 HTTPS 地址:
```bash
dbx-plugin package . --target universal --output-dir dist --artifact-url https://downloads.example.com/my-plugin.dbxp
```
CLI 会只打包 `dbx-plugin.toml` 的 `[package].include` 声明目录,并拒绝符号链接、越界路径、`.dbx-dev`、过大的文件和不安全的输出位置。不要把 `dist/` 或开发数据再次作为输入目录。
官方插件作者不创建或持有 DBX Store 私钥。生成的 GitHub Release 工作流会按目标平台构建候选包,并合并产生 `release-candidates.json`。
本地测试未签名包时,需要在插件中心显式开启“允许安装未签名开发包”。该选项不会放宽官方商店安装校验。
## 本地调试和验证
需要实时观察前端时,在插件项目根目录运行独立开发宿主;它不会启动 DBX 桌面端:
```bash
dbx-plugin dev --path . --port 5190
```
打开终端输出的 `http://127.0.0.1:5190/`。`--port 0` 或端口被占用时会选择可用端口,`--data-dir` 可以把开发数据移出项目目录:
```bash
dbx-plugin dev --path . --data-dir /tmp/my-plugin-dev
```
`dbx-plugin dev` 不会自动安装插件依赖;前端构建由 `dbx-plugin.toml` 的 `[dev]` 命令决定:
```toml
[dev]
ui_build = ["npm", "run", "build"]
ui_watch = ["npm", "run", "build:watch"]
```
配置 `ui_watch` 时,命令只有在一次完整成功构建后输出独立一行 `DBX_UI_BUILD_SUCCESS`,开发宿主才会重载 UI。不要在失败路径或无条件的退出钩子中输出这个标记。
调试页可以切换语言、主题、自动重载和日志面板。日志只保留内存中的最近 500 条,密码、Token 和 Manifest 中的 Secret 字段会脱敏,二进制和过大的值会省略或截断;开发目录 `.dbx-dev/` 可能包含明文凭据,必须加入 `.gitignore`,不要提交或共享。
需要从脚本读取脱敏日志时:
```bash
curl -sS 'http://127.0.0.1:5190/api/diagnostics?after=0&limit=100&level=error'
```
后续请求使用响应里的 `nextAfter` 和 `instanceId`。开发宿主只模拟受支持的 Host API 子集;安装、签名、真实 Secret Store、桌面端生命周期和生产权限必须再用 DBX 验证。
### 本地验收清单
- 关闭未使用的权限,确认 Manifest 中的权限与实际调用一致。
- 用空配置、错误凭据、超时、断网和 Sidecar 重启分别测试失败路径。
- 测试 DBX 浅色/深色主题和 `zh-CN`/`en`;不要依赖宿主替你翻译插件 UI。
- 测试重新连接、关闭工作台、重复打开同一工作台以及上下文为空的情况。
- 检查包内所有资源路径、可执行文件权限和最终包是否包含不应发布的文件。
## 提交官方商店的完整流程
### 第一步:发布源码和候选 Release
在插件自己的源码仓库创建版本 Tag 和 GitHub Release。Release 中应包含:
- 每个平台的未签名 `.dbxp` 候选包;
- 每个候选包对应的 `.artifact.json`;
- 汇总后的 `release-candidates.json`;
- Release Notes 和对应源码 Tag。
候选包可以放在插件源码仓库的 GitHub Releases、CDN 或对象存储中,但不要把 `.dbxp` 提交到 Git 历史。
### 第二步:让 dbx-store 自动创建候选 PR
`dbx-store` 的同步 Workflow 会定期检查已在 `automation/plugin-sources.json` 注册且 `autoUpdate: true` 的公开插件仓库。它读取最新 Release 的 `release-candidates.json`,创建或更新唯一的:
```text
candidates/<plugin-id>.json
```
因此,已启用自动更新的插件作者**不需要每个版本手工创建 Issue 或 PR**,也不需要在插件仓库配置商店签名 Secret。插件仓库的 Release、候选 metadata 和可访问的源码 Tag 是同步所需的主要信息。首次上架或需要修改商店展示字段时,才按 [`dbx-store` 的贡献指南](https://github.com/t8y2/dbx-store/blob/main/CONTRIBUTING.md) 提供发布者和候选信息。
`.dbx-store.json` 是可选的商店元数据文件:首次登记或主动修改商店名称、标签、许可证、主页等信息时使用;仅发布新版本时不需要重复修改它。它不是插件运行时 Manifest,也不能放签名私钥。
例如:
```json
{
"name": "Example Files",
"description": "Browse files from an example service.",
"icon": "assets/plugin.svg",
"tags": ["files", "storage"],
"permissions": ["host.workbench"],
"source": "https://github.com/example/dbx-plugin-files",
"homepage": "https://github.com/example/dbx-plugin-files",
"license": "Apache-2.0",
"releaseNotes": "Initial release."
}
```
允许的商店字段是 `name`、`description`、`icon`、`tags`、`permissions`、`source`、`homepage`、`license`、`releaseNotes` 和 `localizations`;`icon` 可以是包内相对路径,自动同步时会转换成源码 Tag 下的 HTTPS 地址。`autoUpdate` 是 `dbx-store/automation/plugin-sources.json` 的注册字段,不是这个文件的字段。
自动同步只创建或更新候选 PR,**不会自动批准、签名或合并**。候选 PR 的目标仓库是 [`t8y2/dbx-store`](https://github.com/t8y2/dbx-store),目标分支是 `main`,而不是 `t8y2/dbx`。
如果插件还没有登记到自动同步列表,首次上架可以手动 Fork `dbx-store`,向 `main` 提交**一个** PR,包含发布者首次需要的 `publishers/<publisher-id>.json` 和 `candidates/<plugin-id>.json`。不需要先创建单独的 Issue;CI 会在签名前有意保持 `open candidate(s) awaiting DBX Store signing`,签名 Workflow 回写正式目录后才会变绿。插件作者可以在 `dbx-store` 提交登记自动同步源的 PR,或请维护者登记;无需在插件仓库增加 App 私钥。
候选文件的关键结构如下。它只引用插件仓库自己的未签名 Release 资产,不能填写官方 `signingKeyId`:
```json
{
"schemaVersion": 1,
"id": "com.example.files",
"publisher": "example",
"version": "0.1.0",
"name": "Example Files",
"source": "https://github.com/example/dbx-plugin-files/tree/v0.1.0",
"license": "Apache-2.0",
"targets": [
{
"target": "universal",
"url": "https://github.com/example/dbx-plugin-files/releases/download/v0.1.0/com.example.files-0.1.0-universal.dbxp",
"sha256": "<64 位十六进制 SHA-256>",
"size": 123456
}
]
}
```
新版本仅提交新增版本;生成的 `plugins/<plugin-id>.json` 和 `catalog/index.json` 不要手工编辑。
### 第三步:DBX Store 审核并签名
维护者会检查源码、Manifest、权限、候选 SHA-256、大小和原生行为。审核通过后,受保护的 DBX Store 工作流会:
1. 按已审核 SHA-256 和大小下载候选包;
2. 确认候选包没有已有签名,并核对插件 ID 和版本;
3. 使用 DBX Store 仓库密钥添加 Ed25519 签名;
4. 发布最终 `.dbxp`、最终 artifact metadata 和 signing receipt。
开发者不会接触官方仓库私钥。
### 第四步:合并候选 PR
维护者运行受保护的签名 Workflow 后,Workflow 会把最终的 `plugins/<plugin-id>.json` 和生成的 `catalog/index.json` 回写到同一个候选 PR,并将签名包、artifact metadata 和 signing receipt 发布到 `dbx-store` Release。校验通过后,维护者再审核并合并这个 PR。
上架或更新 PR 中不要包含:
- `.dbxp` 二进制;
- 插件完整源码副本;
- Ed25519 私钥、Token 或下载凭据;
- 未经 DBX Store 签名的候选包 URL;
- 自行伪造的 `verified: true`。
## 更新已有插件
每次更新都发布新语义版本,不允许覆盖旧版本的 Release 资产:
1. 修改插件源码,并递增 `manifest.json` 的 `version`;不要复用旧版本号。
2. 发布新的源码 Tag 和候选 Release;旧 Release 资产不可覆盖。
3. 等待同步 Workflow 创建或更新 `automation/plugin-release/<plugin-id>/<version>` 候选 PR。
4. 维护者审核、签名并合并该 PR;目录随后包含新版本和对应平台包。
如果仓库未启用自动同步,才需要按照 `dbx-store` 贡献指南手动提交 `candidates/<plugin-id>.json`。新版本只提交新版本候选信息,不要手工重写已生成的 `plugins/` 或 `catalog/` 文件。
插件代码问题继续在插件源码仓库修复;只有目录、审核状态、最终下载地址、哈希、大小或商店文案变更才进入 `dbx-store`。
## 签名和发布者身份
DBX 当前使用单一仓库签名:
- `publisher` 表示作者归属和商店记录;
- `signingKeyId` 表示发布最终安装包的仓库密钥;
- 官方插件由 DBX Store 审核后统一签名;
- 自定义或私有仓库由仓库运营方维护自己的密钥;
- 开发者签名或双签名不是当前上架要求。
人工审核决定插件能否展示,Ed25519 仓库签名保证审核后的安装包没有被替换。签名不能替代源码审查,也不是原生 Sidecar 的操作系统沙箱。
官方商店插件作者不要运行 `keygen`,也不要自行填写 `signingKeyId`。如果你运营自己的私有插件仓库,才使用 CLI 的高级签名工具:
```bash
dbx-plugin keygen company.plugins.release
source .dbx-repository-signing-key.env
```
该命令生成 32 字节 Ed25519 seed 的受保护环境文件(Unix 权限为 `0600`),并输出 Key ID 和 Base64 公钥;私钥文件绝不能提交 Git。自定义仓库还必须把公钥加入自己的 DBX 信任配置。官方商店的私钥只存在 `dbx-store` 受保护的 Environment Secret 中,公钥由 DBX 客户端随官方信任列表发布。
`artifact.json` 中的 `target`、`url`、`sha256` 和 `size` 绑定确切字节;官方签名后才会增加 `signingKeyId`。已发布资产不可覆盖,任何字节变化都必须递增插件版本并重新审核。
## 相关资料
- [JDBC 插件与数据库驱动](/cn/docs/plugins)
- [参与 DBX 主仓库贡献](/cn/docs/contributing)
- [`t8y2/dbx` 插件平台源码](https://github.com/t8y2/dbx/tree/main/plugins)
- [`t8y2/dbx-store` 官方商店仓库](https://github.com/t8y2/dbx-store)