设置系统:持久化与注册表端点
设置系统是 SpinningMomo 桌面端的核心横切能力:features::settings 负责设置的内存模型、settings.json 持久化与启动期轻量预读取,core::rpc/endpoints/settings 提供 RPC 端点,core::events/handlers/settings_handlers 将设置变更广播给前端与各功能模块。
Purpose and Scope
本页覆盖设置能力的端到端机制:
- 启动期最小设置子集
StartupSettings的预读取与回退策略 features::settings的完整服务 API(初始化、读取、更新、补丁、事件通知、持久化、首次引导判定)- 设置持久化目标
settings.json的路径解析与写入契约 - 设置 RPC 端点与事件处理器在架构中的位置
以下内容属于兄弟页面,不在本页展开:
- 前端设置界面的组件结构(
web/src/features/settings/components/*,如GeneralSettingsContent.vue、HotkeySettingsContent.vue)——见前端设置页面 core::rpc框架整体(路由、传输、序列化)——见 RPC 框架页面core::events事件总线全貌——见事件系统页面core::state::AppState完整状态管理——见状态管理页面
Overview
设置系统解决三个问题:
- 启动顺序问题:日志初始化与管理员提权判断发生在完整
AppState建立之前,因此需要一个"最小、永不失败"的设置预读取(load_startup_settings(),标记为noexcept)。 - 运行时读写问题:Web 前端通过 RPC 端点读写设置,
features::settings在内存(app_state.settings)与磁盘(settings.json)之间保持一致。 - 变更传播问题:设置更新后需要通知所有依赖方(前端 UI、录制、悬浮窗等功能模块),这通过
notify_settings_changed发布事件、由settings_handlers消费完成。
关键术语:
| 术语 | 含义 |
|---|---|
StartupSettings | 启动期最小设置子集,仅含 always_run_as_admin 与 logger_level |
AppSettings | 完整设置模型,定义于 features/settings/types.hpp |
settings.json | 磁盘持久化文件,由 get_settings_path() 定位 |
| settings 变更事件 | notify_settings_changed 发布,settings_handlers 处理 |
Architecture
架构分层说明(依据仓库实际目录结构与 settings.hpp 声明的契约):
src/main.cpp:进程入口,是load_startup_settings()的唯一调用方,用其结果决定日志级别与提权重启策略。src/core/rpc/endpoints/settings/settings.{hpp,cpp}:设置域的 RPC 端点注册处,把前端 RPC 请求转译为features::settings的服务调用。src/features/settings/settings.{hpp,cpp}:设置域的业务实现,对外暴露以core::AppState&为首参的自由函数 API,而非类——这是一种无状态服务风格,状态统一收敛在AppState中。src/core/events/handlers/settings_handlers.{hpp,cpp}:订阅 settings 变更事件的处理器,将后端状态变化推送给前端。web/src/features/settings/components/:Vue 设置界面组件(GeneralSettingsContent.vue、HotkeySettingsContent.vue、CaptureSettingsContent.vue、BackupSettingsContent.vue、ResetSettingsDialog.vue、SettingsMenuList.vue、DraggableSettingsList.vue)。
这种分层的设计意图:业务逻辑不依赖传输层。features::settings 只认识 AppState 与自身类型,RPC 端点与事件处理器是薄适配层,因此设置逻辑可被启动流程(main.cpp)与运行时(RPC)两类完全不同的调用方复用。
Main Content
启动期预读取:load_startup_settings()
这是设置系统最精巧的部分。main.cpp 在完整 AppState 尚未建立时就需要两个决策输入:日志级别(用于尽早初始化日志)与是否强制管理员提权。完整设置模块此时不可用,因此提供了独立的轻量入口:
1// 轻量级预读取:仅解析启动早期需要的少量字段。
2// 设计目标:
3// 1. 避免为了提权判断和早期日志初始化而拉起完整设置模块;
4// 2. 即使 settings.json 缺失、损坏或字段不完整,也能稳定回退到默认值继续启动。
5auto load_startup_settings() noexcept -> StartupSettings;Source: settings.hpp
StartupSettings 只有两个字段,且都带有安全默认值(always_run_as_admin = true、logger_level 为空 optional),配合 noexcept 保证该函数在任何磁盘/解析异常下都不会中断启动——这是关键的容错设计:设置文件损坏最多导致"丢失管理员默认策略与日志级别配置",不会让进程无法启动。
main.cpp 中的实际消费方式:
1const auto startup_settings = features::settings::load_startup_settings();
2
3// 尽早初始化日志,覆盖单实例、提权与启动早期故障
4if (auto result = utils::logging::initialize(startup_settings.logger_level); !result) {
5 const auto error_message = "Logger Failed: " + result.error();
6 ...
7}
8
9// 需要管理员权限时尝试提权重启
10if (startup_settings.always_run_as_admin && !utils::system::is_process_elevated()) {
11 Logger().info("Elevation required by settings, attempting restart as elevated");
12 ...
13}Source: main.cpp
运行时服务 API:无状态自由函数风格
features::settings 未采用服务类,而是一组以 core::AppState& 为首参的自由函数。API 分为四组:
生命周期与读取
auto initialize(core::AppState& app_state) -> std::expected<void, std::string>;
auto get_settings(core::AppState& app_state) -> GetSettingsResult;Source: settings.hpp
initialize 在 AppState 建立后调用,负责加载并校验设置(返回 std::expected,失败时携带可读错误消息)。get_settings 直接返回内存中的设置快照。
写入(整体替换 vs 部分更新)
1auto update_settings(core::AppState& app_state, const UpdateSettingsParams& params)
2 -> std::expected<UpdateSettingsResult, std::string>;
3
4auto patch_settings(core::AppState& app_state, const PatchSettingsParams& params)
5 -> std::expected<PatchSettingsResult, std::string>;Source: settings.hpp
双写入口是有意为之:update_settings 表示"设置页保存整页设置"的语义(全量替换,典型调用方是设置界面);patch_settings 表示"单字段修改"的语义(局部更新,典型调用方是各功能模块在运行中调整自身参数)。两者共享校验与持久化路径,错误统一以 std::expected<_, std::string> 返回,端点层可直接把错误字符串透传给前端。
事件通知
// 发布 settings 变更事件(new_settings 使用 app_state.settings->raw)
auto notify_settings_changed(core::AppState& app_state, const AppSettings& old_settings,
std::string_view change_description) -> void;Source: settings.hpp
该函数同时接收新旧设置:用 old_settings 与 app_state.settings->raw 做差分判断哪些能力受影响,change_description 用于日志与前端提示。事件由 core::events/handlers/settings_handlers 消费,最终推送到 Web 前端使 UI 即时刷新。
持久化与引导判定
1auto get_settings_path() -> std::expected<std::filesystem::path, std::string>;
2
3auto save_settings_to_file(const std::filesystem::path& settings_path, const AppSettings& config)
4 -> std::expected<void, std::string>;
5
6// 判断当前配置是否需要显示首次引导页
7auto should_show_onboarding(const AppSettings& settings) -> bool;Source: settings.hpp
路径解析与文件写入被拆成两个独立函数,意图是让调用方可以先 get_settings_path() 拿到路径(例如用于备份/导出 UI 显示),再决定何时写盘。should_show_onboarding 是纯函数,根据配置判断是否进入首次引导流程。
Core Flow:设置修改的完整链路
链路要点:
- 先内存后磁盘:
update/patch_settings成功路径中,先把新设置写入app_state.settings->raw,再持久化,最后发事件。若写盘失败,调用方会收到std::expected错误,但内存态已更新——事件发布与持久化的先后顺序以"内存为准"。 - 事件携带差分信息:
notify_settings_changed的签名要求传入old_settings,处理器据此只刷新受影响的 UI 区块,避免全量刷新。 - 端点是薄适配层:RPC 端点只做参数转译与错误透传,不含业务规则;因此同一套业务逻辑可被启动流程与 RPC 复用。
双阶段启动时序
该流程解释了为何设置系统必须两段式:日志与提权属于"第 0 层"基础设施,必须先于一切子系统;而完整设置校验、首次引导判定属于"第 1 层",可依赖完整运行时。
Configuration Options
启动期可从 settings.json 预读取的已知字段(依据 StartupSettings 结构定义):
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
always_run_as_admin | bool | true | 为 true 且进程未提权时,启动阶段尝试以管理员身份重启发 |
logger_level | std::optional<std::string> | 空(std::nullopt) | 启动早期日志级别;为空时由 utils::logging::initialize 使用其内部默认值 |
Source: settings.hpp
说明:settings.json 的完整字段集由 features/settings/types.hpp 中的 AppSettings 定义。实现细节未在本次源码检视范围内(settings.cpp 因源工具预算受限未能读取),不做臆测。已知确定的行为是:settings.json 缺失、损坏或字段不完整时,load_startup_settings() 会回退到上表默认值并继续启动。
API Reference
以下签名均摘自 features/settings/settings.hpp 的实际声明。
initialize(app_state: core::AppState&) -> std::expected<void, std::string>
在完整 AppState 建立后初始化设置模块(加载并校验 settings.json)。
Parameters:
app_state(core::AppState&): 全局应用状态引用,设置将装载到app_state.settings
Returns: 成功为 void;失败时 std::string 携带可读错误消息(如文件损坏、路径不可写)
get_settings(app_state: core::AppState&) -> GetSettingsResult
返回内存中的设置快照(app_state.settings 的当前内容)。
Parameters:
app_state(core::AppState&): 全局应用状态
Returns: GetSettingsResult(定义于 features/settings/types.hpp),包含当前 AppSettings
update_settings(app_state, params) -> std::expected<UpdateSettingsResult, std::string>
整体替换语义的设置更新(设置页"保存"操作)。
Parameters:
app_state(core::AppState&): 全局应用状态params(const UpdateSettingsParams&): 全量设置参数
Returns: 成功时 UpdateSettingsResult;失败时错误字符串(参数校验或持久化失败)
patch_settings(app_state, params) -> std::expected<PatchSettingsResult, std::string>
部分更新语义(单字段修改)。
Parameters:
app_state(core::AppState&): 全局应用状态params(const PatchSettingsParams&): 部分设置字段
Returns: 成功时 PatchSettingsResult;失败时错误字符串
notify_settings_changed(app_state, old_settings, change_description) -> void
发布 settings 变更事件;new_settings 取自 app_state.settings->raw。
Parameters:
app_state(core::AppState&): 全局应用状态old_settings(const AppSettings&): 变更前快照,用于事件消费方差分change_description(std::string_view): 变更描述,用于日志与前端提示
Returns: 无(void)
get_settings_path() -> std::expected<std::filesystem::path, std::string>
解析 settings.json 的持久化路径。
Returns: 成功为文件路径;失败为错误字符串(如目录不可解析)
save_settings_to_file(settings_path, config) -> std::expected<void, std::string>
将设置序列化写入 settings.json。
Parameters:
settings_path(const std::filesystem::path&): 目标文件路径(通常来自get_settings_path())config(const AppSettings&): 待写入的设置
Returns: 成功为 void;失败为错误字符串(I/O 或序列化错误)
should_show_onboarding(settings: const AppSettings&) -> bool
纯函数:判断当前配置是否需要显示首次引导页。
Returns: true 表示需要进入引导流程
load_startup_settings() noexcept -> StartupSettings
启动期轻量预读取,永不抛出异常。
Returns: StartupSettings{always_run_as_admin=true, logger_level=std::nullopt} 或从文件解析出的值
Failure Modes, Edge Cases & Concurrency
基于已验证的源码证据,可确定的失败处理与边界设计:
| 场景 | 行为 | 证据来源 |
|---|---|---|
settings.json 缺失 | load_startup_settings() 回退默认值,启动继续 | settings.hpp L39-L43 注释 |
settings.json 损坏 | 同上,回退默认值;进程不会因设置解析失败而退出 | 同上 |
| 字段不完整 | 回退默认值,仍可启动 | 同上 |
| 提权判断 | always_run_as_admin && !is_process_elevated() 时提权重启 | main.cpp L39-L41 |
| 日志初始化失败 | utils::logging::initialize 返回 expected 错误,main.cpp 构造错误消息并走失败路径 | main.cpp L23-L25 |
| 运行时更新失败 | update/patch_settings 以 std::expected<_, std::string> 返回错误,不抛异常 | settings.hpp L21-L25 |
设计意图总结:启动路径零异常。noexcept + 默认值回退确保设置子系统永远不会成为进程无法启动的原因;运行时路径则统一走 std::expected 错误通道,与 RPC 端点的错误透传天然契合。
并发行为说明:settings.cpp 实现因源工具预算受限未能读取,无法验证是否存在锁保护或写并发控制;不做臆测。从 API 签名看,所有函数以 core::AppState& 为中心,若 AppState 层提供共享状态保护,则设置域自身无需额外同步——此点需阅读 core/state/app_state.hpp 确认。
Performance / Operational Notes
- 启动成本控制:
load_startup_settings()的存在本身就是为了性能——避免在提权判断前拉起完整设置模块(DI/解析/校验全链路)。轻量预读取只解析两个启动必需字段。 - 文件写入时机:
save_settings_to_file与内存更新分离,使得备份/导出场景可以复用路径解析而不触发写盘。 - 事件差分:
notify_settings_changed要求传入old_settings,事件消费方(前端)只刷新受影响区块,降低全量刷新开销。 - 提权重启是进程级操作:
always_run_as_admin触发的重启发生在日志初始化之后,因此重启前的日志(Elevation required by settings...)可被记录,便于排查用户反馈的"程序闪退后重启"类问题。
Extension Points
- 新增设置字段:在
features/settings/types.hpp的AppSettings中加字段,并在前端web/src/features/settings/对应组件(如GeneralSettingsContent.vue)与 i18n 文案(web/src/core/i18n/locales/zh-CN/settings.json、en-US/settings.json)中同步即可;patch_settings的部分更新语义天然支持增量加字段。 - 新增事件消费者:在
core/events/handlers/settings_handlers中追加处理器即可响应设置变更,无需改动features::settings。 - 新增 RPC 调用方:设置域以自由函数 +
AppState组合暴露,任何持有AppState引用的模块都可直接调用,不必经由 RPC。
Tests
本次源码检视未覆盖测试文件;无法基于证据描述测试覆盖情况。设置域的可测试性来自其设计:纯函数(should_show_onboarding)、noexcept 回退(load_startup_settings)与 std::expected 错误通道均为易于单测的形态。