卷 II · 框CH 07深度 07/24

清单:插件的身份证

清单看起来只是一个无聊的元数据文件。但它是整个插件系统里唯一在「零信任、零执行」状态下工作的东西 —— 宿主一行插件代码都没跑,就已经知道它是谁、要什么权限、提供什么功能。这一个性质,同时解决了安全、性能、分发三个完全不同的问题。

manifest零执行校验五家对照

先想清楚清单到底在解决什么

上一章那台宿主有个根本缺陷:它必须先执行插件的代码,才知道这个插件是什么。

把这个缺陷展开,你会发现它同时是三个不同领域的问题:

安全问题:宿主想拒绝一个可疑插件,但它得先把它跑起来才能看出可疑 —— 那时候已经晚了

性能问题:用户打开命令面板,宿主为了列出所有命令,必须把一千个插件全部加载执行一遍。启动要十二秒(第 21 章会算这笔账)。

分发问题:商店页面想告诉用户「这个插件会提供 3 条命令、需要访问网络」—— 这些信息只存在于代码运行之后,商店没法在不运行代码的情况下知道,只能靠作者手写描述(然后作者可以随便写)。

◆ 清单的一句话定义

清单是插件在「代码还没跑」的时候,唯一说得上话的地方。

它把一部分「只有运行时才知道的事实」,前移成了「静态可读的数据」。而这个前移,让宿主第一次有了在执行之前做决策的能力 —— 决策,正是控制的全部含义。

五家的清单,摆在一起看

抽掉各家的具体字段名,你会发现它们在说同样的几件事:

要说的事ChromeVS CodeObsidianMihonIntelliJ
我是谁 name name/publisher id/name 包名 <id>/<name>
我什么版本 version version version versionName <version>
我要什么宿主 minimum_chrome_version engines.vscode minAppVersion tachiyomix.extensionLib since-build/until-build
代码入口在哪 background/content_scripts main 约定 main.js tachiyomi.extension.class implementationClass
我要什么权限 permissions/host_permissions
我提供什么功能 action/commands/menus contributes <extensions>
什么时候加载我 matches(注入时机) activationEvents

那几个空格是这张表最有信息量的部分。看第五行:只有 Chrome 有权限声明 —— 因为只有它选了「控制拉满」那一端。看最后两行:只有 VS Code 和 IntelliJ 能在不跑代码时知道插件提供什么、什么时候该加载它 —— 这不是巧合,这正是第 5 章说的「声明派」的红利。

而 Obsidian 那一列几乎全空,也完全一致于它的定位:它的清单只回答「我是谁、我要哪个版本的 Obsidian」,其余全靠 onload() 跑起来才知道。

一份好清单该有的六段

综合五家的做法,给自己设计清单时,按这六段来想:

{
  // ── ① 身份:稳定、唯一、机器可读
  "id": "word-count",
  "name": "字数统计",
  "version": "1.0.0",

  // ── ② 兼容:我要什么样的宿主
  "engine": "^1.0.0",

  // ── ③ 入口:代码在哪
  "main": "./main.js",

  // ── ④ 申报:我要什么能力(用户看得懂的粒度)
  "permissions": ["notes.read", "ui"],

  // ── ⑤ 贡献:我提供什么(宿主不跑代码就能显示的部分)
  "contributes": {
    "commands": [{ "id": "wc.count", "title": "统计字数" }]
  },

  // ── ⑥ 激活时机:什么时候才需要真的加载我
  "activation": ["onCommand:wc.count"]
}

逐段说要点。

① 身份:id 必须机器友好,name 才是给人看的

把这两个分开是必须的。id 用来做目录名、做键、做去重、做依赖引用,所以它必须限制字符集、不能改(改了就是另一个插件)、要能一眼看出不是给人读的。name 是显示名,可以有中文、有 emoji、随便改。

id 的格式要在校验器里强制,本书那台宿主用的规则是:

/^[a-z][a-z0-9-]{1,38}$/    // 小写字母开头,只含小写字母数字和连字符

为什么这么严?因为 id 会变成文件路径、URL 片段、JSON 键。允许大写字母,你迟早会在大小写不敏感的文件系统上撞车;允许点号或斜杠,你就送了别人一个路径穿越漏洞。

② 兼容:写范围,不写单个版本

"engine": "^1.0.0" 而不是 "engine": "1.0.0"。前者说「1.x 我都行」,后者说「只有 1.0.0 我才行」—— 后者会导致宿主每发一个补丁版本,全生态集体失效。

第 19 章会告诉你一个反直觉的结论:换大版本时,先死的恰恰是这批老老实实写了范围的人。

③ 入口:显式声明,别靠约定

Obsidian 约定入口一定叫 main.js,Mihon 则在 meta-data 里显式写出类名。显式更好:约定省下的那点字数,换来的是「为什么我的插件加载不了」这类支持工单。

④ 申报:权限的粒度要用户能看懂

这一段的难点不是技术,是产品

你的权限名最终会被翻译成一句话弹给用户看。所以 fs.read 这种名字是不合格的 —— 用户不知道这意味着什么。Chrome 的做法是把技术权限映射成人话:"tabs" 会显示成「读取你的浏览历史」,host_permissions: ["<all_urls>"] 会显示成「读取和更改你在所有网站上的所有数据」。

如果你想不出怎么把一条权限翻译成用户能懂的一句话,那这条权限的粒度就是错的。

⑤ 贡献:把「用户看得见的东西」全放进来

判断标准很简单:凡是需要显示在界面上的,都应该能从清单读出来 —— 命令的标题、菜单的位置、配置项的类型和默认值、图标、支持的文件类型。

这一段写得越全,你能推迟的执行就越多,启动就越快。

⑥ 激活时机:告诉宿主「什么时候才需要我」

VS Code 的 activationEvents 是这个思路做得最彻底的:"onCommand:foo.bar""onLanguage:python""workspaceContains:**/.editorconfig"

顺带一提,VS Code 后来又往前走了一步:从 1.74 开始,命令、语言、视图这些贡献点会自动生成对应的激活事件,插件作者连写都不用写 —— 因为宿主已经从 contributes 里知道了这些信息,何必让人再抄一遍。而 "*"(启动就激活)在官方文档里被明确劝退。

写一个真的校验器

清单必须能被严格校验,否则它就只是一堆没人当真的字符串。下面这个校验器是真的 —— 改文本框里的任何一个字符,结果立刻变:

试试第三个样例(「权限拼错了」)。它声明了一个叫 "network" 的权限 —— 看起来非常合理,但宿主认识的那个叫 "net"

如果校验器不把未知权限当错误,会发生什么?这个插件会装上、会启用、一切正常,直到某个用户点了那个需要联网的按钮 —— 然后抛出一个 PermissionDenied。用户看到一个莫名其妙的报错,插件作者收到一个「在我这儿是好的」的 issue。

◆ 校验的三条原则

① 未知即错误,不是「忽略」。看不懂的字段、不认识的权限名、拼错的贡献点 —— 全部报错。宽容地忽略未知内容,等于把作者的笔误变成了用户的运行时故障。

(唯一的例外:为了向前兼容,你可能需要允许未知的顶层字段,好让新版清单能被老宿主读懂。但即使这样,也要打印警告。)

② 尽早拒绝,而且要在正确的层次拒绝。注意 demo 里的一个细节:JSON 本身解析失败时,报错和字段校验失败是分开的两类。「你的 JSON 语法就不对」和「你的 JSON 是对的但字段有问题」,对作者来说是完全不同的指引。

③ 错误信息是写给插件作者的,不是写给用户的。对比一下:

✗ 清单无效 —— 毫无用处。
✗ 未知权限 "network"(可用:notes.read / notes.write / ui / net / fs / clipboard) —— 作者三秒钟就能修好。

把合法取值列出来,这一个小动作能消灭掉你未来一半的支持工单。

清单最大的坑:它会和代码说的不一样

清单是「插件说自己是什么」,代码是「插件实际干什么」。这两者可以不一致,而且一定会不一致。

三种不一致,后果完全不同:

类型例子后果怎么办
说了没做 清单里声明了 wc.count 命令,代码里没注册 用户在面板里点了,什么都没发生 激活后校验一遍,发现缺失就报警告
做了没说 代码注册了清单里没有的命令 命令能用,但懒加载失效(宿主不知道该在什么时候加载它) 允许但记日志;或者干脆拒绝注册
要了没声明 清单里只有 notes.read,代码却调 fs.read 这是安全问题 运行时闸门拦截(第 17 章)

第三种是唯一必须靠运行时兜底的。这引出一个非常重要的观念:

⚠ 清单是申报,不是约束

一个新手很容易产生的错觉:「我在清单里写了 permissions: ["notes.read"],那插件就只能读笔记了。」

完全不是。清单只是一张作者自己填的申报表。插件的代码里想调什么就能调什么 —— 除非宿主在运行时真的去检查

清单负责「让宿主提前知道」和「让用户提前看到」,闸门负责「真的拦住」。两者缺一不可:只有清单没有闸门 = 君子协定;只有闸门没有清单 = 用户在装的时候不知道自己在同意什么。

第 17 章会把闸门装上,第 23 章你会看到这两件东西合起来的效果:一个申报了 notes.read 却偷偷调 fs.read 的插件,被当场抓住。

装到自己身上

◆ Kotlin:用数据类 + kotlinx.serialization 做清单
@Serializable
data class PluginManifest(
    val id: String,
    val name: String,
    val version: String,
    val engine: String,                              // 版本范围,如 "^1.0.0"
    val main: String,
    val permissions: List<String> = emptyList(),
    val contributes: Contributes = Contributes(),
    val activation: List<String> = emptyList(),
)

@Serializable
data class Contributes(
    val commands: List<CommandDecl> = emptyList(),
)

@Serializable
data class CommandDecl(val id: String, val title: String)

// 校验:解析失败和字段非法要分开报
sealed interface LoadResult {
    data class Ok(val manifest: PluginManifest) : LoadResult
    data class BadJson(val reason: String) : LoadResult      // JSON 本身就不对
    data class Invalid(val errors: List<String>) : LoadResult // JSON 对,字段不对
}

fun parseManifest(text: String): LoadResult {
    val m = try {
        Json { ignoreUnknownKeys = true }.decodeFromString<PluginManifest>(text)
    } catch (e: SerializationException) {
        return LoadResult.BadJson(e.message ?: "无法解析")
    }
    val errors = buildList {
        if (!Regex("^[a-z][a-z0-9-]{1,38}$").matches(m.id))
            add("\"id\" 必须是小写字母开头、只含小写字母数字和连字符:得到 \"${m.id}\"")
        if (parseVersion(m.version) == null)
            add("\"version\" 不是合法的三段式版本号:得到 \"${m.version}\"")
        m.permissions.filterNot { it in KNOWN_PERMISSIONS }.forEach {
            add("未知权限 \"$it\"(可用:${KNOWN_PERMISSIONS.joinToString(" / ")})")
        }
    }
    return if (errors.isEmpty()) LoadResult.Ok(m) else LoadResult.Invalid(errors)
}

注意 ignoreUnknownKeys = true 这个选择。它是为了向前兼容(新版清单能被老宿主读),但代价是拼错的字段名会被静默忽略 —— 和上面「未知即错误」的原则冲突。

务实的折中:对顶层字段宽容(忽略但打日志),对枚举型的值(权限名、贡献点名、激活事件名)严格。因为前者是「未来的新特性」,后者是「作者打错字」。

这一章的一句话

清单是插件在代码跑起来之前唯一说得上话的地方;它同时买下了安全(执行前决策)、性能(懒加载)和分发(诚实的商店信息)—— 但它只是申报表,真正的拦截必须靠运行时闸门。

下一章把清单接到宿主上,让它第一次学会说不:坏清单拒绝、版本对不上拒绝,而且整个过程一行插件代码都不执行