插件清单格式
每个插件包的根目录必须有 manifest.json。没有清单 = 拒绝加载,不做兜底猜测——插件包是要被第三方分发的东西,字段含糊就意味着行为含糊。
一个 1.x 最小清单
仓库里的示例插件(examples/plugins/hello-panel/manifest.json)长这样:
{
"id": "com.example.hello",
"name": "Hello 面板",
"version": "1.0.0",
"apiVersion": "1.0",
"author": "Starcloud 示例",
"description": "示例插件:注册面板 + 启动参数追加(仅规范允许的能力)",
"permissions": [
"launcher:game_arguments",
"launcher:notification",
"launcher:ui_panel"
],
"optionalPermissions": [],
"compatibility": { "minLauncherVersion": "0.2.4" }
}必填的只有五项:id、name、version、apiVersion、author。permissions 缺省等于零权限,插件照样能加载——只是什么也干不了。
没有 main 字段
1.x/2.0 的清单不声明入口文件。入口是推断出来的:包里有 main.js 就走 JS 运行时,有 main.py 就走 Python,都没有但声明了 contributes,就当作纯声明式插件(无可执行代码,钩子全是空操作);都没有也没 contributes 直接报「插件缺少入口脚本」。
id 的命名规则
id 是反域名格式,不是域名格式:
public static bool IsValidPluginId(string id) =>
!string.IsNullOrWhiteSpace(id) && id.Length <= 64 &&
Regex.IsMatch(id, @"^[a-zA-Z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)+$");也就是必须至少有一个点,且不能以点开头、不能是 com.example 这样的纯域名形式,≤64 字符。
为什么不是域名?因为 com.example.hello 里的 example 不是域名所有者,是命名空间。用域名形式会让人误以为 id 全局唯一且可反查注册商——实际上唯一性由宿主保证,跟域名注册无关。
id 一旦发布就不能改:它决定插件的私有目录名(plugin-data/<id>、plugin-config/<id>.json)、导航项 tag(plugin:<id>:<navId>)、存储键前缀。改 id 等于换了一个插件。
路径安全
清单里所有指向包内文件的字段(contributes.*.view、contributes.settings.schema、itemView)都必须过这一关:
public static bool IsSafeRelativePath(string path)
{
var norm = path.Replace('\\', '/');
if (Path.IsPathRooted(norm) || norm.Contains(':')) return false;
return !norm.Split('/').Contains("..");
}三样东西被拒:绝对路径(/etc/passwd)、盘符(C:\...,靠含 : 判定)、.. 穿越。校验失败是拒绝整份清单,不是跳过这个字段——留一半生效的清单比全盘拒绝更难排查。
2.0 的清单
apiVersion: "2.0" 走另一套校验分支,字段也换了一批:
{
"id": "com.example.myplugin",
"name": "My Plugin",
"version": "2.1.0",
"apiVersion": "2.0",
"author": "Example",
"engines": { "starcloud": ">=0.4.0 <1.0.0" },
"runtime": "js",
"permissions": ["instances:read", "network:host=api.example.com", "ui:panel"],
"dependencies": { "com.example.lib": "^1.2.0" },
"conflicts": { "com.example.rival": "*" },
"contributes": {
"panels": [ { "id": "main", "title": "我的面板", "view": "panels/main.json" } ],
"navItems": [ { "id": "servers", "title": "服务器浏览", "icon": "server", "view": "views/servers.json" } ]
}
}几个容易踩的点:
- 2.0 的
version必须是严格语义化版本(主.次.修订),1.x 不强制。校验用的是正则^\d+\.\d+\.\d+([-+][0-9A-Za-z.\-]+)?$。 engines.starcloud是区间,取代 1.x 的compatibility.minLauncherVersion。后者仍被接受,当等价下限用。runtime只能是js/wasm/py,但wasm运行时目前没有实现——写了也只是清单能过,实际加载不到。- 权限语法在 2.0 换了(
launcher:network→network:host=...)。在 2.0 清单里写 1.x 写法会被明确拒绝并告诉你语法已变更。
版本区间
engines.starcloud、dependencies、conflicts 用的是 npm 风格区间,解析器在 PluginVersionRange:
| 写法 | 含义 |
|---|---|
1.2.3 | 精确等于 |
^1.2.3 | >=1.2.3 <2.0.0 |
~1.2.3 | >=1.2.3 <1.3.0 |
>=0.4.0 <1.0.0 | 空格分隔的比较符组合(逗号也行) |
* / x / latest | 任意版本 |
^ 和 ~ 的上界按 npm 语义算:^0.2.3 是 <0.3.0,^0.0.3 是 <0.0.4——0 开头的版本号锁得比 1.x 更紧。
比较只按数字段走,预发布和构建元数据尾缀(-beta.1、+build)被剥掉不比。1.0.0-beta 和 1.0.0 在区间判断里是同一个版本,想要区分预发布目前做不到。
依赖与冲突
dependencies 声明「我需要谁」,conflicts 声明「我和谁不能共存」,两者用同一套区间语法。
冲突的判定规则有个不对称:声明 conflicts 的那一方被阻断,不会自动停用对方;双向都声明则双方都停。这样规则是确定的——不会出现「谁先加载谁赢」这种依赖加载顺序的结果。
依赖缺失、未启用、版本不满足、自身不可用——每种阻断都有独立文案,直接显示在插件页上,不用猜。