Skip to content

UI 插槽与 iframe 集成 ​

本文引用的文件

目录 ​

  1. 引言
  2. 项目结构与定位
  3. 核心概念:ui、页面、插槽、资源路径
  4. 服务端渲染流程
  5. 前端插件框架组件
  6. hello-ui 示例解析
  7. 管理面板插槽与玩家页面插槽的区别
  8. 安全边界与样式隔离
  9. 依赖关系分析
  10. 开发指南
  11. 故障排查
  12. 总结

引言 ​

本文面向插件开发者与维护者,说明 SkinNexus 中“插件用户界面”这一子系统的工作方式。它回答以下问题:

  • 在 plugin.json 的 ui 块中如何声明页面和插槽。
  • 插槽 id 如何与宿主页面的挂载点匹配。
  • ui.assets 指向的资源由哪个 HTTP 端点托管,以及如何注入主题与安全头。
  • 后端如何把 slot 渲染成带沙箱的 <iframe>。
  • 前端 PluginFrame.vue 与 PluginSlot.vue 如何加载远程页面并处理消息。
  • 如何通过 hello-ui 示例理解跨域通信模式。
  • 管理面板插槽与玩家页面插槽的差异、安全策略和样式隔离方案。
  • 如何在本地预览插件 UI、处理登录态、适配暗色主题以及调试 iframe 控制台错误。

本系统的设计目标是:零构建插件 UI。插件只需提供 HTML/CSS/JS 文件,宿主通过 iframe 沙箱加载,并通过受控的消息协议完成主题、用户信息和 API 调用。

项目结构与定位 ​

UI 插槽与 iframe 集成横跨三个层次:

层次主要文件职责
插件清单层server/internal/plugin/spec.go、server/internal/plugin/manifest.go、examples/plugins/hello-ui/plugin.json定义 UISpec、SlotSpec、RenderSpec,校验权限,暴露 pages、slots、i18n
服务端运行层server/internal/plugin/ui.go、server/internal/plugin/assets.go将 slot 渲染为 iframe HTML;托管 /plugin-assets/<name>/<path>
前端视图层web/src/components/plugin/PluginFrame.vue、PluginSlot.vue、PluginPage.vue、web/src/pluginUI.js拉取插件元数据、渲染 iframe、建立消息桥接

图表来源

章节来源

核心概念:ui、页面、插槽、资源路径 ​

ui 结构体 ​

后端导出的 UI 是供前端 SPA 与服务器端皮肤库使用的只读元数据。它包含:

字段含义
plugin插件名称,由运行时填充
version插件版本,用于资源缓存失效
title插件标题
pages声明式页面列表
slots插槽列表
forms可选的主机渲染表单定义
i18n插件级国际化键值

注意:UI 明确不包含密钥,仅暴露非敏感元数据。

章节来源

清单中的 ui 声明 ​

UISpec 是 plugin.json 中 ui 块的内部表示:

字段类型说明
pages数组页面声明,每个页面有 id、path、area、render.type、render.src
slots数组插槽声明,每个插槽有 id、render.type、render.src、可选 height
forms对象主机渲染表单的 schema 映射
assets字符串插件静态资源根目录,例如 ui/
i18n嵌套对象按语言代码映射到键值表

章节来源

页面与插槽的数据结构 ​

PageSpec、SlotSpec、RenderSpec 共同描述 UI 渲染契约:

结构关键字段作用
PageSpecid、path、area、nav、render、perm描述 /ext/* 下的插件页面
SlotSpecid、render、height描述可插入宿主页面的片段
RenderSpectype、src、schema当前支持 iframe 与 schema 两种渲染方式

其中 type=iframe 对应零构建 HTML 页面;type=schema 留给未来主机渲染表单,第一阶段不会由宿主直接渲染。

章节来源

允许的插槽白名单 ​

插槽不是任意字符串。系统维护一个固定 fixedSlotIDs 白名单,包括:

  • app.topbar.after
  • auth.login.after
  • auth.register.after
  • dashboard.cards
  • texture.upload.after
  • skinlib.list.toolbar
  • skinlib.detail.sidebar
  • profile.card.footer
  • player.card.footer
  • footer.before

这意味着插件不能把内容注入宿主未审查过的页面区域。

章节来源

hello-ui 的实际声明 ​

hello-ui 的 plugin.json 展示了最小可用 UI 插件:

配置项值解释
permissions["ui:page", "ui:slot", "ui:assets"]需要页面、插槽和资源权限
ui.assets"ui/"静态资源根目录
ui.pages[0].path"/ext/hello-ui/hello"完整访问路径
ui.pages[0].area"user"玩家侧页面
ui.pages[0].render{"type":"iframe","src":"hello.html"}使用 iframe 渲染
ui.slots[0].id"app.topbar.after"挂在顶部工具栏之后
ui.slots[0].height120iframe 默认高度
ui.i18n多语言标题前端 i18n 合并时使用

章节来源

服务端渲染流程 ​

元数据聚合 ​

Manager.UI() 遍历已启用插件,收集 pages 和 slots,并为每个条目填充 plugin 名称。该接口被前端用于 /api/v1/plugins/ui。

图表来源

章节来源

插槽 HTML 渲染 ​

SlotHTMLContext 负责把 slot 渲染为安全的 iframe HTML。关键行为如下:

  1. 只处理 s.render.type === "iframe" 的插槽。
  2. 从 s.Render.Src 构造 /plugin-assets/<plugin>/<src> URL。
  3. 如果存在额外变量,拼接查询参数。
  4. 追加 ?v=<plugin.version> 以支持版本化缓存失效。
  5. 对 height 做上下界限制:最小 80,最大 600,默认 180。
  6. 输出 <div class="plugin-slot" data-skin-slot="..."><iframe ...></iframe></div>。
  7. iframe 使用 loading="lazy"、sandbox="allow-scripts allow-forms"。

图表来源

章节来源

资源托管 ​

AssetHandler 托管 /plugin-assets/<name>/<rel>,只对启用了 ui.assets 的插件开放。其安全检查包括:

检查项处理方式
方法必须是 GET 或 HEAD其他方法返回 405
路径必须包含插件名和资源相对路径否则返回 404
清理路径后禁止 ..、反斜杠、控制字符返回 404
符号链接必须落在 assets 根内返回 404
目标必须是普通文件返回 404
文件大小超过 8MB返回 404
HTML 文档注入主题 head先缓冲再写入
非 HTML 直接 http.ServeContent保持原始响应

HTML 文档会获得额外的安全响应头:

响应头值目的
Sandboxallow-scripts allow-formsiframe 二次加固,即使被直接访问也不继承父页面权限
X-Content-Type-Optionsnosniff防止 MIME 嗅探
Cache-Controlpublic, max-age=0, must-revalidate允许公共缓存但强制重新验证

章节来源

CSP 与安全头的位置 ​

需要注意:server/internal/plugin/assets.go 本身没有设置 Content-Security-Policy。CSP 通常由更上层的 Web 服务器、中间件或站点全局安全头处理。插件 iframe 的安全主要依赖:

  1. sandbox="allow-scripts allow-forms",不授予 allow-same-origin。
  2. Sandbox 响应头。
  3. 资源路径白名单。
  4. 前端消息桥接中对 origin 和 nonce 的校验。
  5. 所有 API 调用走 /api/plugins/<plugin>/...,而不是插件直接持有认证凭据。

章节来源

前端插件框架组件 ​

插件元数据加载 ​

pluginUI.js 在应用启动时拉取 /api/v1/plugins/ui,并将结果存入响应式对象:

属性含义
plugins各插件的 UI 元数据
capabilities主机能力列表
slots扁平化插槽列表
hostVersion宿主版本
i18n合并进 vue-i18n 的插件命名空间

辅助函数包括:

  • pages(area):按区域过滤页面。
  • pageByPath(path):根据 /ext/* 路径查找页面。
  • slotsFor(id):按插槽 ID 查找匹配的 iframe 插槽。
  • assetURL(plugin, src):生成 /plugin-assets/<plugin>/<src>?v=<version>。
  • pluginLabel(plugin, key, fallback):按语言回退读取插件标签。

章节来源

插槽容器:PluginSlot.vue ​

PluginSlot.vue 是一个固定插槽出口组件。它的职责很克制:

  1. 接收 slot 字符串。
  2. 调用 slotsFor(props.slot)。
  3. 过滤出 render.type === "iframe" 且 render.src 存在的条目。
  4. 对每个匹配项渲染 PluginFrame。
  5. 将 height 限制在 80 到 600 之间。

它不会接受任意 HTML,也不会绕过 manifest 白名单。

图表来源

章节来源

插件页面:PluginPage.vue ​

PluginPage.vue 处理 /ext/* 路由。当页面类型为 iframe 时,它使用固定高度 520 渲染 PluginFrame;对于 schema 类型页面,则显示“暂不支持”提示。

这体现了 P1 阶段的设计约束:iframe 页面可直接使用,schema 页面保留但不渲染。

章节来源

插件帧:PluginFrame.vue ​

PluginFrame.vue 是整个 iframe 集成的核心。它承担以下职责:

初始化流程 ​

  1. 生成随机 nonce。
  2. 获取当前会话用户(优先使用注入的 sessionUser,否则调用 /api/v1/me)。
  3. 获取 CSRF token(通过 /api/plugins/<plugin>/_csrf,同源请求)。
  4. 向 iframe 发送 init 消息,携带:
    • plugin
    • src
    • origin
    • path
    • locale
    • user
    • theme

主题载荷 ​

主题信息从宿主 CSS 变量提取,包括背景、面板、边框、文字、强调色、链接色、危险色、成功色以及是否暗色主题。

消息协议 ​

方向消息类型载荷行为
子帧 → 宿主ready无标记 iframe 就绪,触发一次 init
子帧 → 宿主resizeheight调整 iframe 高度,限制 80–2000
子帧 → 宿主requestpath、method、body宿主发起真实 API 请求
子帧 → 宿主navigatepath仅在 origin 匹配时跳转
子帧 → 宿主confirmmessage宿主弹出确认框并返回结果
宿主 → 子帧responsestatus、body 或 error转发 API 响应
宿主 → 子帧confirm-resultok返回确认对话框结果

安全要点 ​

  • iframe 使用 sandbox="allow-scripts allow-forms",没有 allow-same-origin,因此子帧拥有不透明 origin。
  • 消息监听同时校验 ev.origin 和 msg.nonce。
  • 子帧不得持有 CSRF token;CSRF 头由宿主附加。
  • 子帧不能直接打开对话框或移动顶层窗口。
  • navigate 使用 new URL(p.path, location.origin) 解析并比较 origin,拒绝非法 URL。

图表来源

章节来源

hello-ui 示例解析 ​

plugin.json ​

hello-ui 是最小的可运行 UI 插件示例:

  • 声明 ui:page、ui:slot、ui:assets 三个权限。
  • 通过 ui.assets = "ui/" 暴露静态目录。
  • 注册 /ext/hello-ui/hello 页面。
  • 在 app.topbar.after 插槽嵌入同一个 hello.html。
  • 提供中英文标题。

章节来源

hello.html ​

hello.html 的结构可以概括为:

部分内容
<!doctype html>标准 HTML5 文档
<head>字符集、标题、基础样式
<body>展示文本
<script>通知宿主 ready,监听 init 消息并标记就绪状态

它演示了最基础的跨域通信模式:

  1. 页面加载后立即向父窗口发送 { v: 1, type: 'ready' }。
  2. 监听父窗口消息,识别 { v: 1, type: 'init' }。
  3. 将 document.body.dataset.ready = '1',供后续逻辑判断。

虽然这个示例没有发起 API 请求,但实际插件应遵循 PluginFrame.vue 约定的消息协议:不要自行构造认证请求,而是通过 request 消息让宿主代为调用。

章节来源

资源访问路径 ​

当 hello-ui 启用后,浏览器实际访问的资源路径如下:

用途最终路径
插件元数据/api/v1/plugins/ui
页面 iframe 源/plugin-assets/hello-ui/hello.html?v=<version>
插槽 iframe 源同上,可能附带额外查询参数
插件 API/api/plugins/hello-ui/<payload.path>

章节来源

管理面板插槽与玩家页面插槽的区别 ​

页面 area 字段 ​

PageSpec.area 区分页面属于玩家侧还是管理侧:

area含义
user玩家页面,由 pages('user') 暴露
admin管理页面,由 pages('admin') 暴露

hello-ui 的页面使用 user,因此出现在玩家侧导航中。

章节来源

插槽位置 ​

插槽并不按 area 划分,而是按固定 id 决定挂载位置。例如:

  • app.topbar.after 通常位于顶部工具栏之后,适合轻量展示。
  • skinlib.detail.sidebar 适合放在纹理详情侧边栏。
  • auth.login.after 适合登录后的补充提示。

插槽本身只是 iframe 容器,真正的业务语义由宿主页面决定。

章节来源

安全差异 ​

维度管理面板玩家页面
访问控制由宿主管理面板路由和权限控制由宿主玩家侧路由控制
iframe 沙箱相同:allow-scripts allow-forms相同
API 调用同样走 /api/plugins/<plugin>/...同样走 /api/plugins/<plugin>/...
凭据风险插件不应持有 CSRF 或会话 cookie插件不应持有 CSRF 或会话 cookie
导航风险宿主仅允许同源跳转宿主仅允许同源跳转
内容注入风险受限于固定插槽白名单受限于固定插槽白名单

两者在 iframe 层共享同一套安全模型;差异主要体现在宿主路由和管理员权限。

章节来源

安全边界与样式隔离 ​

iframe 沙箱 ​

系统采用双层沙箱:

  1. HTTP 层:AssetHandler 对 HTML 响应设置 Sandbox: allow-scripts allow-forms。
  2. DOM 层:SlotHTMLContext 输出的 iframe 使用 sandbox="allow-scripts allow-forms"。
  3. 前端层:PluginFrame.vue 使用 postMessage(..., '*'),但只接收来自 location.origin 或 null 的消息,并校验版本号与 nonce。

重要限制:

  • 子帧没有 allow-same-origin,因此无法直接访问父页面 DOM、Cookie 或存储。
  • 子帧不能打开模态框。
  • 子帧不能直接改变顶层窗口位置。
  • 子帧不能直接发起带凭据的内部 API 请求。
  • 子帧不能通过 window.parent 直接操作宿主 Vue 组件状态。

这些限制意味着插件 UI 必须通过消息协议与宿主通信,而不能假设自己能直接调用宿主 API。

章节来源

样式隔离策略 ​

插件 iframe 拥有独立文档树,天然与宿主 DOM 隔离。但样式仍需考虑与宿主主题的一致性:

  1. CSS 变量映射:assets.go 将宿主主题变量映射到 --plugin-bg、--plugin-panel、--plugin-text 等变量。
  2. 注入时机:主题注入发生在 HTML <head> 末尾,确保插件自定义样式可以被覆盖。
  3. 前端主题载荷:PluginFrame.vue 从宿主计算样式读取 CSS 变量,并以 JSON 形式传给子帧。
  4. 暗色检测:宿主通过亮度公式判断背景是否为暗色,并把 dark 标志传给子帧。

推荐插件实现:

  • 使用 --plugin-* CSS 变量作为颜色来源。
  • 在 init 消息到达后再应用主题。
  • 同时支持亮色与暗色主题。
  • 避免硬编码与宿主冲突的颜色。

图表来源

章节来源

依赖关系分析 ​

图表来源

章节来源

开发指南 ​

如何在本地预览插件 UI ​

  1. 确保插件目录名与 plugin.json.name 一致。
  2. 在 plugin.json 中声明 ui.assets,例如 ui/。
  3. 将 HTML 文件放入该目录。
  4. 在 ui.pages 中添加 render.type=iframe 页面。
  5. 在 ui.slots 中添加一个已批准的插槽 id。
  6. 重启服务或重新加载插件。
  7. 在玩家页面或管理面板中找到对应插槽或 /ext/* 路径。
  8. 打开浏览器开发者工具,查看 Network 请求是否命中 /plugin-assets/<plugin>/<src>。

如果页面不可见,优先检查:

  • 插件是否启用。
  • permissions 是否包含 ui:page、ui:slot、ui:assets。
  • 插槽 id 是否在固定白名单中。
  • render.src 是否存在于 ui.assets 目录下。
  • 浏览器控制台是否有 CORS 或 sandbox 相关错误。

章节来源

如何处理登录态 ​

插件 iframe 不应直接读写 Cookie 或宿主令牌。正确做法:

  1. 等待宿主发送 init 消息。
  2. 从 init.payload.user 获取宿主提供的用户快照。
  3. 如果需要发起写请求,通过 request 消息让宿主调用 /api/plugins/<plugin>/<path>。
  4. 宿主负责附加 CSRF token 和会话凭据。
  5. 子帧只处理 response 消息。

如果需要用户登录状态,不要在 iframe 中直接访问 document.cookie。应通过宿主 API 或 init 消息获取必要信息。

章节来源

如何适配暗色主题 ​

插件应:

  1. 使用 --plugin-bg、--plugin-panel、--plugin-text、--plugin-accent、--plugin-link、--plugin-danger、--plugin-ok 等变量。
  2. 监听 init 消息中的 theme.dark 标志。
  3. 根据 theme.bg 与 theme.panel 判断是否需要切换配色。
  4. 避免硬编码深色背景或浅色文字。
  5. 测试宿主切换主题后插件仍可读。

若插件需要更早看到主题,可在 HTML <head> 中使用 :root 声明默认值,但要注意宿主会在 </head> 前注入主题映射,后者优先级更高。

章节来源

如何调试 iframe 内 console 错误 ​

常见调试步骤:

  1. 打开浏览器开发者工具。
  2. 切换到 iframe 对应的文档上下文。
  3. 查看 Console 中插件脚本的错误。
  4. 查看 Network 中 /plugin-assets/<plugin>/<src> 是否返回 200。
  5. 检查响应头是否包含 Sandbox: allow-scripts allow-forms。
  6. 检查是否出现 CSP 阻止脚本执行。
  7. 检查 PluginFrame.vue 是否收到 ready 消息。
  8. 检查 init 消息是否被插件脚本接收。
  9. 如果 API 失败,检查 /api/plugins/<plugin>/... 的请求路径、方法和响应。

常见问题对照:

现象可能原因处理建议
iframe 空白HTML 语法错误或 JS 立即崩溃先在浏览器直接打开 HTML 调试
看不到插件 UI插槽不在白名单或未启用检查 fixedSlotIDs 与插件状态
主题不生效插件硬编码颜色或未监听 init改用 --plugin-* 变量
API 请求失败路径错误或缺少 CSRF使用宿主消息桥接,不要直连内部 API
跳转无效navigate 路径不是同源使用绝对同源 URL
控制台报 CSP 错误全局 CSP 或 iframe Sandbox 限制检查响应头与 CSP 配置

章节来源

故障排查 ​

插槽不显示 ​

检查清单:

  • 插件是否启用。
  • permissions 是否包含 ui:slot。
  • ui.slots 是否声明。
  • render.type 是否为 iframe。
  • render.src 是否存在。
  • 插槽 id 是否在 fixedSlotIDs 中。
  • 前端是否正确调用 slotsFor(props.slot)。

章节来源

iframe 高度异常 ​

后端默认 180,最小 80,最大 600。前端 PluginSlot.vue 再次 clamp 到 80–600;PluginFrame.vue 对 resize 消息的允许范围是 80–2000。

如果插件希望动态调整高度,应发送 resize 消息,并确保高度合理。

章节来源

插件升级后资源仍为旧版本 ​

SlotHTMLContext 会为 iframe URL 追加 ?v=<plugin.version>。如果资源更新但未刷新,检查:

  • 插件版本是否变化。
  • 浏览器是否缓存了旧页面。
  • 资源响应是否设置了正确的 Cache-Control。
  • 是否直接访问了 /plugin-assets 而绕过了版本参数。

章节来源

插件能加载但不能调用 API ​

可能原因:

  • 子帧尝试直接访问内部 API。
  • 没有通过 request 消息发起请求。
  • CSRF token 缺失。
  • 路径格式不符合宿主期望。
  • 插件被禁用或权限不足。

正确路径是:子帧发送 request,宿主调用 /api/plugins/<plugin>/<path>,再把 response 返回给子帧。

章节来源

总结 ​

UI 插槽与 iframe 集成是一个围绕“零构建插件 UI”设计的安全子系统。它的核心约束是:

  • 插件 UI 以 iframe 形式加载,不共享宿主 DOM 与凭据。
  • 插件通过 plugin.json 声明页面、插槽和资源路径。
  • 插槽 id 必须落入宿主固定白名单。
  • 资源由 /plugin-assets/<plugin>/<path> 托管,并受路径、大小、符号链接和内容类型保护。
  • 前端通过 PluginFrame.vue 建立消息桥接,统一处理主题、用户、API、导航和确认对话框。
  • 管理面板与玩家页面共享 iframe 安全模型,区别在于宿主路由与权限。
  • 插件应使用 --plugin-* 变量适配主题,并通过消息协议而非直接 DOM 或 Cookie 访问宿主能力。

对插件作者而言,最重要的实践原则是:不要把插件 iframe 当作宿主页面的一部分,而要把它看作一个受沙箱保护的独立文档,通过宿主提供的消息通道完成交互。

AGPL-3.0 · SkinNexus