Repository Wiki
ChanIok/SpinningMomo

原生 UI 层:托盘、通知、悬浮窗与共享渲染资源

原生 UI 层(src/ui/)是 SpinningMomo 原生 Win32 C++ 后端中独立于 WebView2 前端的桌面 UI 子系统,负责托盘图标、右键上下文菜单、悬浮窗以及跨窗口共享的渲染/动画资源,并通过 core::commands 命令注册表与 core::events 事件总线与业务功能层联动。

Purpose and Scope

本页覆盖以下内容:

  • src/ui/ 下四大原生 Win32 UI 模块的职责划分:floating_window、tray_icon、context_menu、webview_window(模块清单见 AGENTS.md)。
  • 共享渲染与动画资源:src/ui/composition_animation/ 与 src/ui/context_menu/ 内的 render_context / painter 绘制基础设施。
  • 命令注册表(core::commands)如何驱动托盘图标与上下文菜单,以及事件总线如何把后台事件唤醒到 Win32 消息循环。
  • 与 features::notifications、features::overlay、features::preview 等业务模块的协作边界,以及"预览窗 / 叠加层互斥"这类 UI 层冲突行为。
  • 相关本地化键(src/locales/zh-CN.json、src/locales/en-US.json)与安装器对托盘常驻进程的处理约定。

以下相关主题有意留给兄弟页面,本页只做交叉指引:

  • WebView2 前端(Vue 3 应用)页面与前端 RPC 客户端:属于 Web 前端架构页,不在本页范围(见 AGENTS.md)。
  • RPC 端点注册与 JSON-RPC 传输细节:属于 RPC/通信层页(见 AGENTS.md)。
  • overlay / preview / recording 等业务内部实现:属于各 feature 页;本页只描述它们与原生 UI 的交互面。
  • core::events 事件总线自身实现:属于 core 基础设施页;本页仅从消费方视角描述 post() 唤醒消息循环的行为。

Overview

SpinningMomo(旋转吧大喵)是一个仅面向 Windows 的桌面工具,服务于游戏《无限暖暖》的摄影、截图、录制等围绕游戏窗口的工作流(见 AGENTS.md)。整体采用"原生 Win32 C++ 后端 + 内嵌 WebView2 前端"的两进程形态,二者通过 JSON-RPC 2.0 通信(WebView bridge 用于生产,HTTP + SSE 用于浏览器内开发调试)。

在 WebView 前端之外,应用还需要一组必须以原生 Win32 形态存在的桌面 UI:

  1. 托盘图标(tray icon)——应用常驻系统托盘。安装器在升级/卸载时明确要求用户"退出应用(包括托盘图标)",证明托盘是应用的常驻宿主形态(见 Package.en-us.wxl)。
  2. 上下文菜单(context menu)——托盘与悬浮窗上的原生右键菜单,其条目不是硬编码,而是由 core::commands 注册表动态生成。
  3. 悬浮窗(floating window)——叠加层(Overlay)等覆盖在游戏窗口之上的轻量原生窗口。
  4. WebView 宿主窗口(webview window)——承载 Vue 前端的 WebView2 容器窗口。

设计哲学上,整个后端不使用 OOP 类继承层级,而是"POD 结构体 + 自由函数":所有状态集中存放在 core::AppState(以 std::unique_ptr 成员持有各子系统状态),函数是接受 AppState& 的自由函数(见 AGENTS.md)。原生 UI 层同样遵循这一约定。

字符串编码约定:内部处理统一 UTF-8(std::string),调用 Win32 API 时转 UTF-16(std::wstring),通过 utils::string 转换(见 AGENTS.md)。这直接影响原生 UI 层:所有菜单文本、窗口标题在进入 Win32 调用前都要经过一次编码转换。

Architecture

下图展示原生 UI 层在整体架构中的位置与依赖关系。实线箭头表示 AGENTS.md 中明文声明的驱动/通信关系;虚线表示基于目录结构与本地化键推断的协作面(推断点在正文逐一说明)。

Loading diagram...

各组成部分的职责与依据:

组件职责依据
ui::tray_icon系统托盘图标,常驻后台的入口AGENTS.md、Package.en-us.wxl
ui::context_menu原生右键菜单,由命令注册表生成条目AGENTS.md
ui::floating_window悬浮窗(叠加层宿主),含 events.hpp 与 layout.cpp目录清单(见下文模块构成表)
ui::webview_windowWebView2 容器窗口,承载 Vue 前端AGENTS.md
ui::composition_animationUI 动画支持目录清单:src/ui/composition_animation/animation.{hpp,cpp}
core::commands绑定动作、开关态、i18n 键与可选热键的注册表;"托盘图标与上下文菜单由该注册表驱动"AGENTS.md
core::events类型擦除事件总线,post() 异步投递并经 PostMessageW 唤醒 Win32 消息循环AGENTS.md

初始化顺序上,原生 UI 层位于核心基础设施之后、业务功能服务之前:main.cpp → Application::Initialize() → core::initializer::initialize_application(),大致顺序为"先核心基础设施,再原生 UI,再功能服务,最后扩展与启动任务"(见 AGENTS.md)。这个顺序是必然的:命令注册表、事件总线、i18n 都是 UI 的前置依赖,而托盘图标一旦创建就要能响应由业务层注册的命令。

模块构成:src/ui/ 的实际目录结构

以下文件清单通过目录列表工具核实,是原生 UI 层的真实物理边界:

模块文件说明
composition_animationanimation.hpp / animation.cppUI 动画(共享渲染资源的一部分)
context_menustate.hpp / types.hpp菜单状态(POD 结构体)与类型定义
context_menuinteraction.hpp / interaction.cpp用户交互处理
context_menulayout.hpp / layout.cpp菜单布局计算
context_menumessage_handler.hpp / message_handler.cppWin32 消息处理(承接 PostMessageW 唤醒)
context_menupainter.hpp / painter.cpp菜单绘制
context_menurender_context.hpp / render_context.cpp渲染上下文(与 painter 配合的共享渲染资源)
floating_windowfloating_window.hpp / floating_window.cpp悬浮窗本体
floating_windowevents.hpp / layout.cpp悬浮窗事件与布局

context_menu 是本层中拆分最细的模块(9 个源文件),floating_window 次之。这种拆分方式符合项目"POD 结构体 + 自由函数、状态集中在 AppState"的哲学:每个 .hpp/.cpp 对应一个自由函数簇或一个 POD 状态片段,而不是一个继承体系中的类。

说明:ui::* 的完整模块清单(floating_window、tray_icon、context_menu、webview_window)声明于仓库级指南,且本页源探索预算有限,未能逐一读取每个 .cpp 的内部实现。上述目录清单来自实际的 src/ui/**/* 文件列表,tray_icon 与 webview_window 的具体源文件名在本页采证范围内未逐一列出;相关实现细节以仓库源码为准,本页不虚构。

核心机制

1. 命令注册表驱动的菜单与托盘

core::commands 注册表把"动作 + 开关状态 + i18n 键 + 可选热键"绑定为一条命令,托盘图标与上下文菜单的条目由该注册表驱动生成(见 AGENTS.md)。这一设计的关键意图:

  • 单一事实来源:菜单条目、托盘交互、热键共享同一份命令定义。新增功能时只需向注册表注册命令,原生 UI 层自动获得菜单项,无需分别修改托盘与菜单代码。
  • i18n 天然内建:命令绑定的是 i18n 键而非字面文本。本地化文件中可见对应键位,例如叠加层开关:
json
"menu.overlay_toggle": "叠加层",

Source: zh-CN.json

json
"menu.overlay_toggle": "Overlay",

Source: en-US.json

本地化以 menu.* 为命名空间承载菜单命令文本,notification.action.* 承载通知按钮文本(如 notification.action.view = "查看"、notification.action.retry = "重试",见 zh-CN.json)。注意 src/locales/*.json 修改后必须重跑 node scripts/generate-embedded-locales.js(见 AGENTS.md)——原生 UI 在运行时读取的是内嵌后的语言资源。

  • 开关态直读:注册表条目带 toggle 状态,菜单可据此渲染选中/未选中(例如 menu.overlay_toggle 对应叠加层开/关)。

2. 事件总线唤醒 Win32 消息循环

core::events 是类型擦除的事件总线,提供同步 send() 与异步 post();post() 通过 PostMessageW 唤醒 Win32 消息循环(见 AGENTS.md)。这是原生 UI 层能响应后台线程事件的关键桥梁:

  • 后台工作(截图完成、录制状态变化、更新检查等)运行在 Asio 协程/线程池上;
  • 这些工作不能直接触碰 UI,于是通过 post() 把事件安全地转回消息循环所在线程;
  • ui::context_menu/message_handler.cpp 这类消息处理器随后在 UI 线程上消费事件并更新菜单/托盘/悬浮窗状态。

这解释了为什么 context_menu 拥有独立的 message_handler 文件:它是事件进入 UI 线程后的落点。

3. 共享渲染资源:render_context 与 painter

context_menu 自带 render_context 与 painter 两个渲染相关文件,composition_animation 提供动画能力。二者共同构成原生 UI 层的共享绘制基础设施:

  • painter 负责把布局结果(layout.cpp 产出)绘制到设备上下文;
  • render_context 封装绘制所需的状态/资源,使菜单绘制不与布局、交互耦合;
  • composition_animation/animation 为菜单出现/消失等过渡提供动画。

这种"布局 / 绘制 / 交互 / 消息处理"四分离的拆分,让 context_menu 的每个关注点都可独立演进,也符合项目"自由函数簇"组织方式。

Core Flow:托盘右键到命令执行的端到端路径

Loading diagram...

流程要点:

  1. 菜单内容是按需从注册表拉取的,因此业务层启动后新增的命令可以即时出现在菜单里。
  2. 用户点击产生的 Win32 消息在 message_handler 内处理,这是唯一允许触碰 UI 状态的线程边界。
  3. 业务执行结果经事件总线 post() 回到消息循环,刷新菜单开关态;刷新过程可经 composition_animation 做过渡动画。

UI 层冲突处理:预览窗与叠加层互斥

原生 UI 窗口之间存在资源互斥约束,本地化文件中保留了面向用户的提示文案:

json
"message.preview_overlay_conflict": "预览窗和叠加层功能冲突,已自动关闭另一功能", "message.preview_start_failed": "预览窗启动失败: ", "message.overlay_start_failed": "叠加层启动失败: ",

Source: zh-CN.json

对应英文文案见 en-US.json。行为语义是:当用户同时开启预览窗(features::preview)与叠加层(features::overlay)时,系统自动关闭其中一个并给出提示。这类冲突仲裁按项目规范属于 usecase 编排层(usecase.hpp/.cpp 是顶层编排层,可跨模块协调 core::*、UI 与扩展,见 AGENTS.md),原生 UI 层只负责呈现仲裁结果与失败提示。

头文件与依赖纪律

原生 UI 层必须遵守仓库级的头文件纪律(见 AGENTS.md):

  • 项目内每个头文件在不依赖 PCH 的情况下必须自包含:显式 include vendor/std.hpp 与所需 vendor 门面;src/pch.hpp 仅加速这些相同依赖。
  • 尖括号外部 include 只允许出现在 src/vendor/ 内;src/vendor/windows/ 下的 SDK 门面与物理 SDK 头一一对应,不建领域聚合门面。
  • 对原生 UI 层的含义:src/ui/** 中直接出现的应当是项目头与已存在的 vendor 门面;新引入的低频 Win32 SDK 依赖应留在调用点本地(例如 floating_window.cpp 里需要的窗口类注册头),只有稳定高频依赖才进 PCH。

Configuration Options

原生 UI 层自身没有独立的配置文件;其可配置面通过命令注册表与本地化体系间接暴露。与页面相关的可配置项:

配置面形式默认/取值说明
菜单条目core::commands 注册表项无静态默认动作 + toggle 态 + i18n 键 + 可选热键(见 AGENTS.md)
菜单/通知文案src/locales/*.jsonzh-CN / en-US 双语修改后需重跑 node scripts/generate-embedded-locales.js(见 AGENTS.md)
热键命令注册表的可选绑定未绑定与命令一一关联,随命令生效
叠加层/预览窗开关menu.overlay_toggle 等命令 toggle关两者互斥,同时开启自动关闭其一

本页采证范围内未发现原生 UI 层专属的运行时配置结构体(如托盘图标路径、悬浮窗位置持久化字段)。依据仓库规则不做臆测;如需确认请查阅 src/ui/floating_window/state 与 core::AppState 定义。

Professional Notes

失败模式与边界(基于已核实证据):

  • 预览/叠加冲突:见上文 Core Flow 后的"UI 层冲突处理",UI 层以提示消息反馈自动仲裁结果。
  • 窗口启动失败:message.preview_start_failed / message.overlay_start_failed 携带错误详情后缀(冒号 + 空格),说明异常路径会把底层错误文本拼给用户。
  • 卸载/升级时的托盘常驻:安装器本地化明确要求用户先退出应用(包括托盘图标),否则 MSI 报 CloseAppError(见 Package.en-us.wxl)。这是运维层面需要注意的原生 UI 生命周期约束。

并发模型:

  • 依据项目模式(见 AGENTS.md):错误处理统一 std::expected<T, std::string>,无异常控制流;异步基于 Asio 协程,RPC 处理器返回 asio::awaitable<RpcResult<T>>。
  • 事件总线 post() 是后台线程 → UI 线程的唯一安全通道(PostMessageW 跨线程投递)。任何业务线程直接改 UI 状态都会破坏该纪律。

性能与运维:

  • 后端经 xmake 构建,Release 输出位于 build\windows\x64\release\(见 AGENTS.md)。
  • 端到端场景测试通过 tests/scenarios/ 以 JSON-RPC 驱动编译后的 SpinningMomo.exe,会在隔离便携沙箱中运行(见 AGENTS.md)——测试前需关闭运行中的应用实例,再次印证托盘常驻形态。

扩展点:

  • 新增托盘/菜单能力:向 core::commands 注册命令并补 menu.* i18n 键,原生 UI 层自动获得入口;随后按需在 feature 层实现动作本体。
  • 新增通知动作按钮:补 notification.action.* 键(现有 view / retry 模式,见 zh-CN.json)。
  • 新增原生窗口类型:在 src/ui/<name>/ 下按 state.hpp(POD)+ 自由函数簇组织,状态注册进 core::AppState。

测试覆盖说明:本页采证范围内未读取 src/ui/** 专属测试文件,无法核实托盘/菜单的单测覆盖情况;仅能确认场景级测试通过 JSON-RPC 驱动整体进程。此为明确的证据缺口,不做虚构。

Sources

(1 files)