插件开发文档

第三方开发者仅凭本页与仓库内的规范原文,即可写出一个包含工具栏按钮、面板、命令、读写编辑器内容并被应用成功加载的插件。

规范 Plugin Spec v1
apiVersion "1"
适用版本 ZLYNOTES v1.4.1
规范原文 docs/plugin-spec.md

1. 运行模型

ZLYNOTES 的核心原则是一切皆插件:编辑器工具栏按钮、右栏面板、左侧导航项、命令、设置分区、预览区浮动操作,全部由插件注册表驱动。插件分前端插件、后端插件与双端插件三种形态,共用同一套清单与权限规范。

底线:插件故障绝不拖垮主程序。插件抛错只会导致自身被停用,主程序、其它插件与笔记数据均不受影响。

前端插件运行在独立的不透明源 iframe 沙箱里(<iframe sandbox="allow-scripts">,没有 allow-same-origin):宿主读不到插件的 DOM,插件也读不到宿主的 DOM、localStorage 与 cookie;两者只通过 postMessage(结构化克隆,只传 JSON 数据)与 RPC 交互。

由此推出三条硬性约定,写插件时必须遵守:

  1. 函数不能跨 iframe 传递。注册命令时传给宿主的是元数据,点击后宿主用定向事件 command:invoke 回来调用插件侧的 run()。
  2. 插件不能直接操作宿主 DOM。一切读写都走 window.zly.* 的方法。
  3. 一切能力都要声明权限。未声明的接口调用会被拒绝 —— 是默认拒绝,不是默认放行。

2. 快速开始

一个插件就是一个目录,必须包含 manifest.json 与入口 HTML(默认 index.html):

my-plugin/
├── manifest.json        必填:插件清单
├── index.html           必填:入口页面(唯一被加载的 HTML)
├── panel.css            可选:本地样式(会被自动内联进沙箱)
└── panel.js             可选:本地脚本(会被自动内联进沙箱)
来源路径默认启用
内置插件 应用安装目录内 src/plugins/<插件目录>/(开发态) 是(例外:user-system 默认关闭,需用户显式启用)
用户插件 <userData>/plugins/<插件目录>/(插件管理里「打开插件目录」可直接打开) 否(用户显式开启)

3. manifest 字段表

字段类型必填说明
idstring是插件唯一标识;^[a-zA-Z0-9_-]{2,40}$(不含点)
namestring是展示名称(中文亦可)
versionstring是语义化版本,必须形如 1.0.0(三段数字)
apiVersionstring是宿主接口版本;必须是数字字符串(当前宿主支持 "1")
descriptionstring—一句话描述,展示在插件管理里(缺省空串)
iconstring—宿主内置图标名(如 puzzle / table / code-block / type);未知值回退为 puzzle
entrystring—入口 HTML 相对路径,默认 index.html;拒绝绝对路径、盘符 / 协议前缀与任何 ..
permissionsstring[]—权限声明(见第 5 节);重复项与未知权限都会导致清单校验失败
contributesobject—贡献点声明(见第 4 节),用于展示与自洽校验

后端插件清单另支持 defaultEnabled(布尔)、tables(数据库表声明)与 routes(路由声明 { method, path, auth }),见第 9 节。

清单校验失败时会一次列出全部问题(中文),插件在管理界面显示「加载失败」并给出原因,不会影响其它插件与主程序。缺省值:entry 为 index.html,permissions 为 [],contributes 各类为空数组。

4. 扩展点声明(contributes)

键元素字段说明
commandsid / title / category / shortcut命令声明;shortcut 仅是展示文案
toolbarButtonsid / title / icon / commandId编辑器工具栏按钮
panelsid / title / icon右栏插件面板
sidebarItemsid / title / icon / commandId左侧导航项
previewActionsid / title / icon / commandId / shortcut预览区选中文字的浮动操作;shortcut 在这里是真实按键
settingsSectionsid / title / description设置面板中的分区

声明与运行时注册是两件事。contributes 只用于界面展示与冻结前的自洽校验;真正把按钮 / 面板挂到界面上的是 ready 回调里的 window.zly.register.* 调用。两者应保持一致,否则界面上看到的能力与声明不符。

5. 权限清单(默认拒绝)

未在 permissions 里声明的接口,调用时一律返回 PERMISSION_DENIED。无需权限的接口:events.on / events.off / log。

权限覆盖的接口
editor:readeditor.getContent / editor.getSelection / editor.getCursor / preview.getSelection
editor:writeeditor.insertText / editor.replaceSelection / editor.setContent / editor.wrapSelection / editor.setSelection / editor.focus / preview.wrapSelection / preview.replaceSelection / preview.insertBlock
notes:readnotes.getCurrent / notes.getMetadata / notes.list
notes:writenotes.updateMetadata / notes.save
storagestorage.get / storage.set / storage.remove / storage.keys
uiui.showToast / ui.confirm / ui.openPanel / ui.getTheme
ui:toolbarregister.toolbarButton
ui:panelregister.panel
ui:sidebarregister.sidebarItem
ui:previewregister.previewAction
commandsregister.command
settingsregister.settingsSection
accountaccount.status / account.login / account.register / account.captcha / account.logout / account.openLoginDialog / account.syncNow
balancebalance.status / balance.add / balance.remove / balance.test / balance.activate / balance.deactivate
announcementsannouncements.status / announcements.list / announcements.refresh / announcements.markRead / announcements.markAllRead
net:<host>放行网络访问;同时决定沙箱 CSP 的 connect-src

net: 权限不只是声明,而是真实的网络访问控制:

声明生成的 CSP connect-src
无 net:*'none'(插件完全无法发起网络请求)
net:api.example.comhttps://api.example.com + 本地回环(localhost / 127.0.0.1)
net:*https: + http: + 本地回环

6. 能力域与接口

所有方法返回 Promise:成功时 resolve 返回值,失败时 reject 一个 Error,其 .code 为第 12 节的错误码、.message 为中文说明。

能力域说明
zly.editor读写正文、选区、光标:getContent / getSelection / getCursor / insertText / replaceSelection / setContent / wrapSelection / setSelection / focus
zly.notes当前笔记与元数据:getCurrent / getMetadata / list / updateMetadata / save
zly.storage插件私有数据(写在当前笔记 pluginData[pluginId]):get / set / remove / keys
zly.uishowToast / confirm(异步,需 await) / openPanel / getTheme
zly.preview预览区选中 → 正文源码:getSelection / wrapSelection / replaceSelection / insertBlock
zly.register注册扩展点:command / toolbarButton / panel / sidebarItem / previewAction / settingsSection
zly.account账号与云同步(双端门禁):status / login / register / captcha / logout / openLoginDialog / syncNow
zly.balance后端节点负载均衡:status / add / remove / test / activate / deactivate
zly.announcements双端插件「公告」:status / list / refresh / markRead / markAllRead

接口速查:

window.zly = {
  plugin: { id, manifest, apiVersion, raw(method, params) },
  editor: {
    getContent(), getSelection(), getCursor(),
    insertText(text), replaceSelection(text), setContent(text),
    wrapSelection(before, after), setSelection(from, to?), focus(),
  },
  preview: { getSelection(), wrapSelection(before, after), replaceSelection(text), insertBlock(markdown) },
  notes: { getCurrent(), getMetadata(), list(), updateMetadata(patch), save() },
  storage: { get(key?), set(key, value), remove(key), keys() },
  ui: { showToast(message, type?), confirm(message), openPanel(panelId?), getTheme() },
  account: { status(), login(username, password, captcha?), register(username, password, captcha?), captcha(), logout(), openLoginDialog(), syncNow() },
  balance: { status(), add(url), remove(url), test(), activate(), deactivate() },
  register: {
    command(def), toolbarButton(def), panel(def),
    sidebarItem(def), previewAction(def), settingsSection(def),
  },
  events: { on(event, handler), off(event, handler) },
  log: { info(...a), warn(...a), error(...a) },
  ready(callback?),
}

双端门禁:account / announcements 类能力要求「客户端插件启用」且「服务端同名插件状态为 ready」。任一端未满足则不请求、不展示,接口 reject FEATURE_DISABLED + 中文原因。

主题令牌由宿主统一以 --zly- 前缀注入沙箱 :root(如 --zly-color-text、--zly-space-3、--zly-font-sans)。插件界面应引用令牌而非硬编码颜色,以自动适配双主题;主题切换时宿主还会推送 theme:change 事件。

7. 事件清单

事件触发时机载荷
app:ready插件宿主完成一次「发现 → 校验 → 启动」{ pluginCount, loaded }
note:open当前笔记切换{ filePath, noteId }
note:save当前笔记保存成功{ filePath, noteId }
note:change当前笔记正文变化(300ms 节流){ filePath, noteId, length }
editor:selection编辑器选区变化(200ms 节流,文本截断到 200 字){ from, to, text }
theme:change主题切换{ theme, tokens }
command:invoke宿主执行了你注册的命令(定向事件,只发给你的插件){ commandId, registeredId }
toolbar:invoke点击了你注册的工具栏按钮(定向事件;仅该按钮没有 commandId 时才走){ id }
preview:invoke点击了你注册的预览区操作(定向事件;仅该操作没有 commandId 时才走){ id }

三个 *:invoke 已由 bootstrap 自动处理:你传给 register.* 的 run / onClick 会被自动调用,通常无需自己订阅。事件处理函数抛错会被捕获并计入崩溃自愈额度。

8. 前端插件最小示例

manifest.json:

{
  "id": "my-first-plugin",
  "name": "我的第一个插件",
  "version": "1.0.0",
  "apiVersion": "1",
  "description": "演示工具栏按钮 + 面板 + 读写编辑器内容",
  "icon": "puzzle",
  "entry": "index.html",
  "permissions": ["editor:read", "editor:write", "storage", "ui", "commands", "ui:toolbar", "ui:panel"],
  "contributes": {
    "commands": [{ "id": "wrap-quote", "title": "把选中文字变成引用", "category": "我的插件" }],
    "toolbarButtons": [
      { "id": "quote", "title": "变成引用", "icon": "quote", "commandId": "wrap-quote" }
    ],
    "panels": [{ "id": "main", "title": "我的插件", "icon": "puzzle" }]
  }
}

index.html:

<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8" />
    <title>我的第一个插件</title>
    <style>
      body {
        margin: 0;
        padding: var(--zly-space-3, 12px);      /* 主题令牌已注入,前缀是 --zly- */
        color: var(--zly-color-text, #e8eefc);
        background: var(--zly-color-bg-panel, #121c2f);
        font-family: var(--zly-font-sans, system-ui, sans-serif);
        font-size: var(--zly-font-size-sm, 13px);
      }
    </style>
  </head>
  <body>
    <p id="status">正在等待宿主初始化…</p>
    <button id="quote" type="button">把选中文字变成引用</button>

    <script>
      // 插件脚本运行在沙箱里,window.zly 已由宿主注入,可放心使用
      ;(function () {
        'use strict'
        var statusEl = document.getElementById('status')

        /** 读编辑器选区 → 每行前加 "> " → 写回 */
        function wrapQuote() {
          return window.zly.editor
            .getSelection()
            .then(function (selection) {
              var text = selection.text || '在这里输入引用内容'
              var quoted = text
                .split('\n')
                .map(function (line) { return '> ' + line })
                .join('\n')
              return window.zly.editor.replaceSelection(quoted)
            })
            .then(function () {
              statusEl.textContent = '已插入引用'
              return window.zly.storage.set('lastAction', 'quote')  // 写进当前笔记的 pluginData
            })
            .catch(function (error) {
              // 失败时 error.code 是结构化错误码,error.message 是中文说明
              statusEl.textContent = '失败 [' + error.code + '] ' + error.message
            })
        }

        document.getElementById('quote').addEventListener('click', wrapQuote)

        // ① 通知宿主「我准备好了」——这一步必须做,否则宿主 5 秒后会把插件标记为加载失败
        window.zly.ready(function (init) {
          statusEl.textContent = '已加载 · API v' + init.apiVersion + ' · 主题 ' + init.theme

          // ② 注册命令(元数据 + run 函数;run 只能活在插件侧)
          window.zly.register.command({
            id: 'wrap-quote',
            title: '把选中文字变成引用',
            category: '我的插件',
            run: wrapQuote,
          })

          // ③ 注册工具栏按钮:优先用 commandId,宿主点击时执行对应命令
          window.zly.register.toolbarButton({
            id: 'quote',
            title: '变成引用',
            icon: 'quote',
            commandId: 'wrap-quote',
          })

          // ④ 注册面板:注册后右栏「插件面板」会出现可切换的标签
          window.zly.register.panel({ id: 'main', title: '我的插件', icon: 'puzzle' })
        })
      })()
    </script>
  </body>
</html>

把该目录放进 <userData>/plugins/ 后,在「插件管理」里启用它:工具栏会出现「变成引用」按钮,命令面板(Ctrl+Shift+P)能搜到命令,右栏能打开插件面板。

9. 后端插件

后端插件跑在服务端子工程的 worker 软沙箱里:能力白名单 + SQL 白名单 + 超时与崩溃隔离。清单除公共字段外支持 defaultEnabled、tables 与 routes({ method, path, auth })。

宿主注入的 ctx:

能力说明
ctx.manifest插件清单(只读)
ctx.log(level, message)写宿主日志(中文)
ctx.config.get(key)读取宿主配置(如 SMTP 配置)
ctx.db.query(sql, params)受 SQL 白名单限制的数据库访问(仅本插件声明的表)
ctx.storage.*插件私有键值存储
ctx.resources.read(name)只读资源:读本插件目录内 resources/<name> 的文本,返回 { ok: true, text };名字禁 .. / 绝对路径 / 分隔符,单文件上限 256KB,按 mtime 缓存

manifest.json:

{
  "id": "hello-backend",
  "name": "示例后端插件",
  "version": "1.0.0",
  "apiVersion": "1",
  "entry": "index.cjs",
  "defaultEnabled": true,
  "tables": [],
  "routes": [{ "method": "GET", "path": "/api/hello", "auth": false }]
}

index.cjs:

'use strict'

// 读取插件目录内 resources/greeting.txt(宿主做名字白名单 + 256KB 上限 + mtime 缓存)
async function greeting(ctx) {
  try {
    const read = await ctx.resources.read('greeting.txt')
    return { status: 200, body: { ok: true, text: read.text } }
  } catch (error) {
    // 任何失败都收敛为中文原因,绝不让异常逸出(否则宿主会把插件标记 error)
    return { status: 200, body: { ok: true, text: '', reason: '资源不可用:' + error.message } }
  }
}

module.exports = {
  async handleRequest(req, ctx) {
    const method = String(req.method || '').toUpperCase()
    const path = String(req.path || '').split('?')[0]
    if (method === 'GET' && path === '/api/hello') return greeting(ctx)
    return { status: 404, body: { ok: false, code: 'NOT_FOUND', message: '接口不存在' } }
  },
}

边界:这是软沙箱的一部分,不是 OS 级隔离;只应加载可信来源的插件。

10. .zlymodel 打包与分发

.zlymodel 是与 .zly 同理念的 JSON 明文单文件包格式:可 diff、可手改、零新依赖(不需要解压库)。它与「目录形式插件」完全并存,加载链路完全统一。

字段类型必填说明
formatstring是必须严格等于 zlymodel(大小写敏感)
schemaVersionnumber是正整数,当前宿主支持 1;更高版本直接拒绝,不猜测结构
manifestobject是与 manifest.json 完全同构,复用同一套校验器(问题带 manifest. 前缀)
filesarray是非空数组,条目数 ≤ 64;必须包含 manifest.entry 指向的文件
files[].pathstring是相对插件目录的路径;拒绝绝对路径、盘符 / 协议前缀与任何 ..;包内去重(Windows 语义下大小写不敏感)
files[].contentstring是非空字符串;utf8 时是文本本身,base64 时是 base64 文本
files[].encodingstring—utf8(缺省)或 base64;二进制资源用 base64
metaobject—仅用于展示,不参与运行;支持 author / homepage / description
体积与条目限制上限
files 条目数64
单文件解码后字节数2 MB
全部文件解码后总量16 MB
base64 校验严格:先校验 → 再解码 → 后按解码后字节数判体积

所有安全检查都发生在写盘之前:校验失败时插件目录不会有任何残留(既无临时目录,也无半装目录)。

导入方式操作结果
拖入窗口把 .zlymodel 拖到应用窗口任意位置,出现全屏高亮遮罩后松手成功 toast 显示「插件名 + 版本 + 文件数」;插件列表出现该插件,状态已停用
手动添加插件管理 → 顶部「添加插件」→ 文件选择框(过滤器只有 zlymodel,可多选)同上;多个包给出汇总提示
情形结果说明
目标 <plugins>/<id>/ 已存在ALREADY_EXISTS界面弹二次确认;确认后带 overwrite: true 重试,只替换插件目录,不触碰该插件的 pluginData
manifest.id 与内置插件同名BUILTIN_ID_TAKEN直接拒绝、不写盘(内置插件 id:md-enhance / table-tools / code-tools / user-system / announcements / load-balancer)
包内容非法(路径穿越 / 缺入口 / 超限 / 版本过高 / 清单非法)PARSE_FAILED中文原因一次列全,插件目录无残留

导入通道与「手工把目录放进 <userData>/plugins/」是两条刻意不同的路径:前者拒绝任何与内置插件同名的包,后者允许同名用户插件优先(本地调试用)。要分发就用 .zlymodel,并把 id 改成本身独有的名字。

11. 超时、配额与稳定性约束

项值说明
握手超时5 秒iframe load 后 5 秒内没收到 plugin:ready → 标记「加载失败」并自动禁用
普通 RPC 超时5 秒超时后 reject TIMEOUT,迟到的结果被丢弃
register.* 超时3 秒注册是本地操作,不该慢
单次返回值体积512KB超出 QUOTA_EXCEEDED;别把整篇大文档一次性取回
storage.set 单值256KB超出 QUOTA_EXCEEDED
资源内联20 个文件 / 2MB超出部分替换为错误提示脚本
崩溃自愈连续 3 次未捕获异常自动禁用插件:销毁沙箱、注销其全部命令与扩展点并提示;主程序与其它插件、笔记数据不受影响
插件日志缓冲最近 200 条超出后丢弃最旧的

12. 错误码

12.1 RPC 错误码(error.code)

错误码含义常见原因
PERMISSION_DENIED权限不足未在 permissions 声明;或调用了未知接口(未登记的方法一律拒绝)
UNKNOWN_METHOD方法不存在拼写错误;使用了宿主未提供的方法
INVALID_PARAMS参数不合法类型错误、必填缺失、id 含非法字符等(message 会指出具体字段)
TIMEOUT调用超时普通 RPC 超过 5 秒;register.* 超过 3 秒
NOT_AVAILABLE依赖未就绪未打开笔记、预览区未展开、预览选区无法映射回源码
INTERNAL_ERROR宿主内部异常宿主实现抛错,请附日志反馈
MANIFEST_INVALID清单不可用清单校验未通过,或插件未成功加载却仍在发起调用
API_VERSION_UNSUPPORTED接口版本不支持插件 apiVersion 高于宿主支持值
PLUGIN_DISABLED插件已停用插件被禁用,或崩溃自愈停用后仍在发起调用
QUOTA_EXCEEDED超出配额storage.set 单值超过 256KB;或单次 RPC 返回值超过 512KB
FEATURE_DISABLED双端门禁未满足客户端插件未启用,或服务端同名插件非 ready
try {
  await window.zly.editor.getContent()
} catch (error) {
  if (error.code === 'NOT_AVAILABLE') {
    // 优雅降级:提示用户先打开一篇笔记
    await window.zly.ui.showToast('请先打开一篇笔记', 'warning')
  } else {
    await window.zly.log.error(error.code, error.message)
  }
}

12.2 .zlymodel 导入错误码(install 结果里的 code)

错误码含义
PARSE_FAILED包不是合法 JSON、format 不等于 zlymodel、schemaVersion 过高、清单非法、路径穿越、缺入口文件或超出体积上限
ALREADY_EXISTS目标 <plugins>/<id>/ 已存在
BUILTIN_ID_TAKEN包的 manifest.id 与内置插件同名
IO_ERROR读源文件失败、插件目录不可写、rename 失败

这四个码只用于程序分支判断;展示给用户的中文文案一律取 reasons 数组(界面只显示首条,全部写入插件运行日志)。

13. 常见坑与排查

现象原因处理
插件 5 秒后被自动禁用,提示「加载超时」没有调用 zly.ready(),或脚本在 ready 之前就抛错确保 ready() 在脚本加载后立刻调用
状态显示「不兼容」apiVersion 高于宿主,或不是数字字符串改为 "1"
状态显示「加载失败」并列出中文原因清单校验未通过(id 非法、version 非语义化、权限未知、entry 越界…)按提示逐条修正,校验会一次列全
按钮 / 面板不出现只写了 contributes 声明,没有在 ready 回调里真正调用 register.*补齐运行时注册,并保持两者一致
点击按钮没反应只给了 onClick 却忘了权限;或 commandId 拼错(应为局部 id)检查 permissions 与 commandId
调用报 PERMISSION_DENIED未声明权限,或方法名未登记(未知接口同样拒绝)补 permissions;用 zly.plugin.raw 复查方法名
调 editor.* 报 NOT_AVAILABLE当前没有打开笔记先提示用户打开 / 新建笔记,再重试
预览浮条不出现没有可映射的预览选区(未展开预览 / 选区在编辑器里 / 选区无法定位到源码)这是预期行为:映不到源码时宁可拒绝也不写错位置
插进正文的 HTML 没有样式用了 class 或非白名单 style 属性改用内联 style 的 color / background-color / font-size 等
<script type="module"> 不执行srcdoc 不支持 ES module 相对导入打包成单文件,或去掉 type="module"
插件改完没生效沙箱已缓存插件管理里点「重载插件」
数据没保存只写了 storage.set 但笔记未触发保存写入会标记笔记为脏,等自动保存或主动调 notes.save()
插件被禁用后数据丢了吗?不会禁用只销毁沙箱,pluginData 保留在笔记文件里

14. 验证脚本与探针

本项目采用三层验证口径,提交前请至少跑通与你改动相关的部分:

约定:

npm run build                        # 端到端探针前置:先构建
node scripts/run-checks.mjs          # 批量跑自检与探针
node scripts/verify-open-source.mjs  # 提交前的开源审查(许可 / 忽略规则 / 待入库清单)

15. 目录结构

zlynotes/
├── src/                     渲染进程(React + TypeScript)
│   ├── core/                内核:笔记 / 设置 / 插件宿主 / 网络 / API
│   ├── plugins/             内置插件(每个插件一个目录)
│   │   ├── md-enhance/      Markdown 增强
│   │   ├── table-tools/     表格工具
│   │   ├── code-tools/      代码块工具
│   │   ├── user-system/     用户系统(默认关闭)
│   │   ├── announcements/   双端插件「公告」的客户端侧
│   │   └── load-balancer/   负载均衡
│   └── components/          界面组件
├── electron/                主进程(窗口 / 原生能力 / 插件包安装 / 自动更新)
├── server/                  后端子工程(Express + better-sqlite3)
│   ├── plugins/             后端插件(manifest.json + index.cjs)
│   │   ├── user-system/     账号 / 邮箱验证码 / 图片验证码
│   │   └── announcements/   公告(含 resources/announcements.json)
│   └── src/                 宿主:路由 / 插件软沙箱 / 配置
├── scripts/                 纯函数自检(verify-*.mjs)与构建脚本
├── test/                    端到端探针(probe-*.mjs)与公共库
├── docs/                    规范 / 架构 / 每轮交付记录 / 决策
├── web/                     本地站点(不随仓库分发)
└── release/                 打包产物(不入库)

16. 贡献流程

  1. 阅读 docs/plugin-spec.md 与仓库 CONTRIBUTING.md。
  2. 开发插件或改动代码后,补上对应的 scripts/verify-*.mjs 断言或 test/probe-*.mjs 探针。
  3. 本地跑通:npm run build、node scripts/verify-code-health.mjs(0 违规)、相关探针逐个串行运行。
  4. 提交前运行 node scripts/verify-open-source.mjs,确认没有把密钥 / 数据库 / 构建产物纳入版本库。
  5. 提交信息用「类型前缀 + 中文简述」(如 fix: 修复左下角账号下拉被遮挡)。

17. 许可

本项目(含源码、安装包、文档与插件规范)以 PolyForm Noncommercial License 1.0.0 发布。许可全文随源码仓库分发(仓库根目录 LICENSE), 官方文本见 polyformproject.org/licenses/noncommercial/1.0.0; 许可以英文原文为准。

向本项目提交的贡献(代码、文档、示例、测试等)按同一许可分发。

返回首页 · GitHub 仓库 · 规范原文 docs/plugin-spec.md