声明式界面
1.x 的插件面板是 HTML,加载进 WebView。这条路能做出任何界面,代价是插件能在 WebView 里跑任意脚本。2.0 的声明式 UI 是另一条路:插件交一份 JSON,宿主把它画成原生控件。
组件白名单
public static readonly string[] AllowedTypes =
[
"stack", "text", "button", "toggle", "input", "select",
"list", "table", "image", "progress", "badge", "divider", "link", "code",
];十四个类型,够了做一个设置页或信息面板,不够做一个游戏引擎。
显式黑名单
public static readonly string[] ForbiddenTypes =
[
"html", "iframe", "frame", "script", "style", "object", "embed", "svg", "canvas", "form",
];两个数组是分开的,这是有意的设计:白名单管「能不能画」,黑名单管「为什么不让画」。命中黑名单的组件给出明确原因:
if (ForbiddenTypes.Contains(type, StringComparer.OrdinalIgnoreCase))
{
errors.Add($"视图组件被禁止:{type}(能引入任意 HTML/脚本,只能在 ui:html 面板中使用)");
return null;
}用户写了个 <script>,报错告诉他「这个只能在 ui:html 面板里用」,比一句「未知组件」有用得多。
解析是全有全无的
PluginViewParser.TryParse 只要有一处越界,整份视图作废并给出错误列表。没有「跳过这个节点继续解析」——半张渲染出来的面板比空白面板更让人困惑。
三个数量上限
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。
值与绑定
文本要么是字面量,要么是绑定:
{ "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:
if (type == "link" && href.Length > 0 &&
!href.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
errors.Add($"link 的 href 只允许 https 地址:{href}");
return null;
}动作:不能是权限旁路
这是这一页最要紧的一条。声明式视图里的 action 有两种形态:
{ "type": "button", "label": "重置计数", "kind": "danger",
"action": { "api": "storage.set", "args": ["launches", 0] } }{ "type": "button", "label": "刷新", "action": { "handler": "onRefresh" } }{api, args} 是宿主代插件调用能力,{handler} 回调插件代码。
问题来了:如果 {api, args} 走一条「渲染器专用」的调用路径,插件就获得了一条不用经过自身权限校验的旁路——它可以在视图里写一个按钮指向任何能力,只要用户点了就执行。
所以它走的是 PluginHost.InvokeAction,和插件自己调用能力是同一个方法:
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 指明视图挂到哪:
"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 的插件,面板不会注册,只记一条警告:
logger.Warn("声明了 contributes.panels,但 ui:panel 能力未生效,面板不会注册");navItems 每个插件最多一条(一级导航位置不能被插件占满),图标名走宿主白名单,没命中就回落默认图标并记日志,不让插件提供字形或路径。
挂载点写错不会静默
contributes.slots 的 point 不在宿主支持的列表里(download.top / home.top / versions.top / version.detail.top / version.detail.sections),会跳过并记日志。声明了却静默不生效是最难排查的一类问题。