Skip to content

feat(dashboard): 为插件页面 iframe 传递 AstrBot 主题信息以消除深色模式首次加载闪白(FOUC)#8515

Open
Sisyphbaous-DT-Project wants to merge 1 commit into
AstrBotDevs:masterfrom
Sisyphbaous-DT-Project:feat/plugin-page-theme-injection
Open

feat(dashboard): 为插件页面 iframe 传递 AstrBot 主题信息以消除深色模式首次加载闪白(FOUC)#8515
Sisyphbaous-DT-Project wants to merge 1 commit into
AstrBotDevs:masterfrom
Sisyphbaous-DT-Project:feat/plugin-page-theme-injection

Conversation

@Sisyphbaous-DT-Project
Copy link
Copy Markdown
Contributor

@Sisyphbaous-DT-Project Sisyphbaous-DT-Project commented Jun 2, 2026

问题描述

AstrBot 使用 iframe 加载插件页面,iframe 的 sandbox 属性缺少 allow-same-origin,导致 iframe 被分配 opaque origin。插件页面内的 localStorage 与 AstrBot 主应用完全隔离

当用户在 AstrBot 中设置为深色模式(PurpleThemeDark)后打开插件页面时:

  1. 插件 iframe 加载 HTML,其内部 localStorage 为空白
  2. CSS 加载并应用 :root 定义的浅色 token
  3. 浏览器第一帧渲染为浅色页面(白底黑字)
  4. 数十毫秒后插件 JS 初始化、bridge 传递 context 消息
  5. 插件根据后端 API / bridge context 切换到深色

第 3 步到第 5 步之间存在一个明显的闪白(Flash of Unstyled Content,FOUC),在系统浅色 + AstrBot 深色组合下尤为突出。

如果仅靠 bridge context 的 postMessage 通道,即使 context 中包含 isDark 字段,由于 bridge SDK 是异步外部加载的(<script src>),消息到达时间晚于 CSS 首帧绘制,无法从根本上阻止 FOUC。

解决方案

新增一个同步主题传递通道——通过 iframe URL 参数 + HTML <head> 内联注入,让插件页面在 CSS 加载前即可获知 AstrBot 当前主题:

Vue 前端: localStorage.uiTheme → iframe URL 参数 ?ui_theme=PurpleThemeDark
    ↓
Python 后端: 读取 URL 参数 → 在 <head> 最前面注入:
    <script>window.__ASTRBOT_UI_THEME__="PurpleThemeDark"</script>
    ↓
插件页面: 内联预读脚本在 CSS <link> 前同步执行 → 设置 data-theme 属性 → 第一帧即深色

改动内容

文件 改动 行数
dashboard/src/views/PluginPagePage.vue loadPluginPage() 中给 iframeSrc 追加 ui_theme URL 参数 +3
astrbot/dashboard/routes/plugin.py _serve_plugin_page_html_asset() 读取 URL 参数并在 <head> 首部注入 <script> +11

设计决策

  • 不修改 sandbox 属性allow-same-origin 会使 iframe 获得主应用的所有 cookie/localStorage 访问权,安全风险较大,且不在此改动范围内
  • 不在 bridge context 中新增字段:即使添加 isDark,其异步传递特性无法阻止 FOUC(bridge SDK 是外部 <script src> 异步加载)。当前 URL 同步注入是阻止 FOUC 的必要手段
  • 使用 json.dumps 转义:防止 URL 参数中的特殊字符破坏 HTML 结构或 JS 语法
  • 仅在 HTML 资源中注入:不影响 CSS、JS、图片等非 HTML 资源的处理流程
  • 变量名 __ASTRBOT_UI_THEME__:遵循 AstrBot 已有命名惯例(参考 __setInitialContext),前缀双下划线表示这是一个内部传递变量

兼容性

  • ui_theme 参数时(旧版 AstrBot 或用户未配置):Python 后端跳过注入,HTML 输出与原版一致
  • 旧插件不读取 __ASTRBOT_UI_THEME__:变量仅为 undefined,不影响任何现有逻辑
  • 不引入新依赖:仅使用已有的 request.argsjson.dumpslocalStorage
  • 不改变 iframe sandbox 策略:安全模型不变
  • 非 breaking change:所有现有功能不受影响

扩展价值

此改动为通用基础设施,所有插件均受益:

  • 插件开发者可在 index.html<head> 内联脚本中读取 window.__ASTRBOT_UI_THEME__,实现零闪白的深色模式响应
  • 不需要插件做任何适配的情况下,变量仅为 undefined,无副作用
  • 为 AstrBot 的插件主题 API(未来可能的 data-astrbot-theme HTML 属性标注等)提供基础数据通道

验证

代码审查清单

  • ruff format .ruff check . 通过
  • 无新增依赖
  • 不引入安全漏洞(URL 参数经 json.dumps 转义;需有效 asset_token JWT 才能访问注入路径)
  • 向后兼容(不加参数时行为不变)

逻辑验证(独立测试脚本)

编写并运行了独立测试脚本,覆盖以下场景:

场景 预期行为 结果
Vue 前端读取 localStorage.uiTheme 并追加入 URL iframeSrc 包含 &ui_theme=PurpleThemeDark
Python 后端读取 ui_theme 并注入 <head> HTML 包含 window.__ASTRBOT_UI_THEME__="PurpleThemeDark"
预读脚本优先级:localStorage > __ASTRBOT_UI_THEME__ > matchMedia 回退链完整
ui_theme 参数时跳过注入 HTML 不变
ui_theme 为空字符串时跳过注入 不注入
json.dumps 对特殊字符的转义 安全
_userThemeSet 守护 bridge context 不覆盖用户选择 逻辑正确

此外,除本地运行环境与联动插件测试外,本改动还通过了独立测试脚本的自动化验证,并由 Kimi K2.6 Thinking 进行了独立脚本覆盖测试、由 DeepSeek V4 Pro Max 完成了全链路逻辑推演,确保数据流无竞争条件、无回退断裂。


Checklist / 检查清单

  • 😊 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。

  • 👀 我的更改经过了良好的测试,并已在上方提供了"验证步骤"

  • 🤓 我确保没有引入新依赖库。

  • 😮 我的更改没有引入恶意代码。

Summary by Sourcery

Propagate AstrBot UI theme from the dashboard to plugin iframes via URL and inline script injection to eliminate dark-mode FOUC in plugin pages.

New Features:

  • Pass the current AstrBot UI theme as a ui_theme query parameter when loading plugin page iframes.
  • Inject a window.ASTRBOT_UI_THEME inline script into served HTML plugin pages when a ui_theme parameter is present so plugins can apply the correct theme on first render.

@dosubot dosubot Bot added size:XS This PR changes 0-9 lines, ignoring generated files. area:webui The bug / feature is about webui(dashboard) of astrbot. feature:plugin The bug / feature is about AstrBot plugin system. labels Jun 2, 2026
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements theme injection for plugin pages to prevent Flash of Unstyled Content (FOUC) by passing the user's UI theme as a query parameter to the iframe and injecting it into the page's HTML head. However, a critical Cross-Site Scripting (XSS) vulnerability was identified in the backend routing, where the unvalidated ui_theme parameter is directly injected into an inline script block. To resolve this, the parameter must be strictly validated using a regular expression before injection.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread astrbot/dashboard/routes/plugin.py Outdated
Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • The HTML injection currently only matches the literal string <head>; consider handling <head> tags with attributes or different casing (e.g. <head lang="en">) so the theme script is reliably injected across more plugin templates.
  • When building the iframe URL, you only check for ? to decide the separator; if content_path can contain a hash fragment (#), it may be safer to insert the ui_theme query before the fragment to avoid breaking URLs.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The HTML injection currently only matches the literal string `<head>`; consider handling `<head>` tags with attributes or different casing (e.g. `<head lang="en">`) so the theme script is reliably injected across more plugin templates.
- When building the iframe URL, you only check for `?` to decide the separator; if `content_path` can contain a hash fragment (`#`), it may be safer to insert the `ui_theme` query before the fragment to avoid breaking URLs.

## Individual Comments

### Comment 1
<location path="astrbot/dashboard/routes/plugin.py" line_range="812-815" />
<code_context>
     ):
         html_text = await self._read_plugin_page_text(file_path)
+        # Inject AstrBot theme for plugin pages to prevent FOUC
+        ui_theme = request.args.get("ui_theme", "").strip()
+        if ui_theme and "<head>" in html_text:
+            theme_script = f'<script>window.__ASTRBOT_UI_THEME__={json.dumps(ui_theme)}</script>'
+            html_text = html_text.replace("<head>", f"<head>{theme_script}", 1)
         rewritten_html = self._rewrite_plugin_page_html(
             html_text,
</code_context>
<issue_to_address>
**suggestion:** Theme injection relies on an exact, lowercase `<head>` tag, which may miss valid HTML variations.

Because the injection checks only for the literal `<head>` string, it won’t run when the tag has attributes, different capitalization, or spacing, which can cause the theme not to apply on some pages. It would be more robust to match the opening head tag generically (e.g., `<head` with optional attributes) or inject before the closing `</head>` so all valid head variants are handled consistently.

Suggested implementation:

```python
        html_text = await self._read_plugin_page_text(file_path)
        # Inject AstrBot theme for plugin pages to prevent FOUC
        ui_theme = request.args.get("ui_theme", "").strip()
        if ui_theme:
            theme_script = f'<script>window.__ASTRBOT_UI_THEME__={json.dumps(ui_theme)}</script>'

            # Prefer injecting before the closing </head> tag (any capitalization / spacing)
            head_close_pattern = re.compile(r"</head\s*>", re.IGNORECASE)
            if head_close_pattern.search(html_text):
                html_text = head_close_pattern.sub(theme_script + "</head>", html_text, count=1)
            else:
                # Fallback: inject immediately after an opening <head> tag with optional attributes
                head_open_pattern = re.compile(r"<head\b[^>]*>", re.IGNORECASE)
                match = head_open_pattern.search(html_text)
                if match:
                    start, end = match.span()
                    html_text = html_text[:end] + theme_script + html_text[end:]
        rewritten_html = self._rewrite_plugin_page_html(
            html_text,
            plugin_name,

```

You’ll also need to ensure `re` is imported in this module, e.g. near the top of `astrbot/dashboard/routes/plugin.py`:

- Add `import re` alongside the existing imports (for example, next to `import json` if present).
</issue_to_address>

### Comment 2
<location path="dashboard/src/views/PluginPagePage.vue" line_range="433-438" />
<code_context>
     plugin.value = pluginData;
     page.value = pageEntry;
-    iframeSrc.value = pageEntry.content_path;
+    const uiTheme = localStorage.getItem("uiTheme") || "PurpleTheme";
+    const sep = pageEntry.content_path.includes('?') ? '&' : '?';
+    iframeSrc.value = pageEntry.content_path + sep + 'ui_theme=' + encodeURIComponent(uiTheme);
   } catch (error) {
     errorMessage.value =
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Building the iframe URL via string concatenation can mishandle existing query params or hash fragments.

This assumes `content_path` has no hash fragment and no existing `ui_theme` parameter; if either is present, the resulting URL can be malformed or duplicate parameters. Consider using `URL`/`URLSearchParams` (e.g. `new URL(pageEntry.content_path, window.location.origin)`) to set or overwrite `ui_theme` and then use the final `href` for `iframeSrc.value`.

```suggestion
    plugin.value = pluginData;
    page.value = pageEntry;
    const uiTheme = localStorage.getItem("uiTheme") || "PurpleTheme";
    const url = new URL(pageEntry.content_path, window.location.origin);
    url.searchParams.set("ui_theme", uiTheme);
    iframeSrc.value = url.href;
  } catch (error) {
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread astrbot/dashboard/routes/plugin.py Outdated
Comment thread dashboard/src/views/PluginPagePage.vue
@Sisyphbaous-DT-Project Sisyphbaous-DT-Project force-pushed the feat/plugin-page-theme-injection branch from 44d6bd7 to 2e37e7a Compare June 2, 2026 11:01
@dosubot dosubot Bot added size:S This PR changes 10-29 lines, ignoring generated files. and removed size:XS This PR changes 0-9 lines, ignoring generated files. labels Jun 2, 2026
@Sisyphbaous-DT-Project
Copy link
Copy Markdown
Contributor Author

Sisyphbaous-DT-Project commented Jun 2, 2026

补充:此改动无需插件适配即可安全共存,已适配的插件可在 内联脚本中读取 ASTRBOT_UI_THEME 实现首帧深色,消除 FOUC
如果各位大佬觉得方向ok,将来可以推进集成到sdk里

EBB112434303F88483068E1ADBF3E527

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:webui The bug / feature is about webui(dashboard) of astrbot. feature:plugin The bug / feature is about AstrBot plugin system. size:S This PR changes 10-29 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant