Skip to content

声明式界面 ​

1.x 的插件面板是 HTML,加载进 WebView。这条路能做出任何界面,代价是插件能在 WebView 里跑任意脚本。2.0 的声明式 UI 是另一条路:插件交一份 JSON,宿主把它画成原生控件。

组件白名单 ​

csharp
public static readonly string[] AllowedTypes =
[
    "stack", "text", "button", "toggle", "input", "select",
    "list", "table", "image", "progress", "badge", "divider", "link", "code",
];

十四个类型,够了做一个设置页或信息面板,不够做一个游戏引擎。

显式黑名单 ​

csharp
public static readonly string[] ForbiddenTypes =
[
    "html", "iframe", "frame", "script", "style", "object", "embed", "svg", "canvas", "form",
];

两个数组是分开的,这是有意的设计:白名单管「能不能画」,黑名单管「为什么不让画」。命中黑名单的组件给出明确原因:

csharp
if (ForbiddenTypes.Contains(type, StringComparer.OrdinalIgnoreCase))
{
    errors.Add($"视图组件被禁止:{type}(能引入任意 HTML/脚本,只能在 ui:html 面板中使用)");
    return null;
}

用户写了个 <script>,报错告诉他「这个只能在 ui:html 面板里用」,比一句「未知组件」有用得多。

解析是全有全无的

PluginViewParser.TryParse 只要有一处越界,整份视图作废并给出错误列表。没有「跳过这个节点继续解析」——半张渲染出来的面板比空白面板更让人困惑。

三个数量上限 ​

csharp
public const int MaxDepth = 12;      // 嵌套深度
public const int MaxNodes = 500;     // 节点总数
public const int MaxItemDepth = 3;   // list 逐项模板里再套 list 的层数

MaxDepth 防的是递归构造的视图模型把渲染器压垮;MaxNodes 防的是一份「节点海」占满内存;MaxItemDepth 防的是 itemView 模板自引用导致无限展开。渲染器那边还有一层:table 最多 8 列 × 50 行,list 最多 40 项,image 最高 320。

值与绑定 ​

文本要么是字面量,要么是绑定:

json
{ "type": "text", "value": { "bind": "storage.launches" }, "style": "title" }

绑定作用域只有四个:storage(插件私有存储,需要 storage:kv 能力)、state(视图内本地状态)、host(宿主字段)、item(仅 list 逐项模板内,支持 item.a.b 下钻)。绑定键不允许路径分隔符,防止落到插件 data 目录之外。

图片与链接 ​

image 的 src 只允许两种:https 绝对地址,或包内相对路径(http:// 不行——明文传输在插件场景里没必要)。判定走的是 IsAllowedImageSource,它直接复用清单的路径安全检查。

link 的 href 只允许 https:

csharp
if (type == "link" && href.Length > 0 &&
    !href.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
    errors.Add($"link 的 href 只允许 https 地址:{href}");
    return null;
}

动作:不能是权限旁路 ​

这是这一页最要紧的一条。声明式视图里的 action 有两种形态:

json
{ "type": "button", "label": "重置计数", "kind": "danger",
  "action": { "api": "storage.set", "args": ["launches", 0] } }
json
{ "type": "button", "label": "刷新", "action": { "handler": "onRefresh" } }

{api, args} 是宿主代插件调用能力,{handler} 回调插件代码。

问题来了:如果 {api, args} 走一条「渲染器专用」的调用路径,插件就获得了一条不用经过自身权限校验的旁路——它可以在视图里写一个按钮指向任何能力,只要用户点了就执行。

所以它走的是 PluginHost.InvokeAction,和插件自己调用能力是同一个方法:

csharp
public PluginActionResult InvokeAction(string pluginId, string api, string argsJson)
{
    Loaded? plugin;
    lock (_gate) plugin = _loaded.FirstOrDefault(p => p.Manifest.Id == pluginId);
    if (plugin is null)
        return new PluginActionResult(null, "插件未启用:面板动作需要插件处于启用状态");
    var ctx = BuildContext(plugin.Manifest, plugin.Dir, plugin.Logger, args: null);
    var result = InvokeCapability(ctx, api, string.IsNullOrWhiteSpace(argsJson) ? "{}" : argsJson);
    return new PluginActionResult(PluginApiCore.Json(result), null);
}

BuildContext 按权限装配能力,InvokeCapability 走能力分发——权限校验、网络主机白名单、存储配额全在链路上,一样不少。而且 BuildContext 是即时装配的:插件被停用了,残留的按钮点了只会得到「插件未启用」。

一句话概括

声明式 UI 不是权限的旁路,是权限的又一个入口。

{handler} 那一支目前未实现——按钮会提示。需要复杂逻辑时,2.0 插件得用 JS/Python 运行时,声明式视图只负责画。

扩展点 ​

清单里的 contributes 指明视图挂到哪:

json
"contributes": {
  "panels": [ { "id": "main", "title": "我的面板", "view": "panels/main.json" } ],
  "slots": [ { "point": "download.top", "view": "views/banner.json", "height": 120 } ],
  "navItems": [ { "id": "servers", "title": "服务器浏览", "icon": "server", "view": "views/servers.json" } ]
}

(还有 versionSections 和 homeCards,结构相同。)

这些都是声明式注册——不需要插件代码执行,插件一启用就挂上去。但有个前提:必须有 ui:panel 能力。声明了 contributes.panels 却没拿到 ui:panel 的插件,面板不会注册,只记一条警告:

csharp
logger.Warn("声明了 contributes.panels,但 ui:panel 能力未生效,面板不会注册");

navItems 每个插件最多一条(一级导航位置不能被插件占满),图标名走宿主白名单,没命中就回落默认图标并记日志,不让插件提供字形或路径。

挂载点写错不会静默

contributes.slots 的 point 不在宿主支持的列表里(download.top / home.top / versions.top / version.detail.top / version.detail.sections),会跳过并记日志。声明了却静默不生效是最难排查的一类问题。

相关链接:权限模型 · 插件清单格式 · 插件与内核

基于 MIT 许可证(有修改)开源