Repository Wiki
ChanIok/SpinningMomo

设置系统:持久化与注册表端点

设置系统是 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

设置系统解决三个问题:

  1. 启动顺序问题:日志初始化与管理员提权判断发生在完整 AppState 建立之前,因此需要一个"最小、永不失败"的设置预读取(load_startup_settings(),标记为 noexcept)。
  2. 运行时读写问题:Web 前端通过 RPC 端点读写设置,features::settings 在内存(app_state.settings)与磁盘(settings.json)之间保持一致。
  3. 变更传播问题:设置更新后需要通知所有依赖方(前端 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

Loading diagram...

架构分层说明(依据仓库实际目录结构与 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 尚未建立时就需要两个决策输入:日志级别(用于尽早初始化日志)与是否强制管理员提权。完整设置模块此时不可用,因此提供了独立的轻量入口:

cpp
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 中的实际消费方式:

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 分为四组:

生命周期与读取

cpp
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 部分更新)

cpp
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> 返回,端点层可直接把错误字符串透传给前端。

事件通知

cpp
// 发布 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 即时刷新。

持久化与引导判定

cpp
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:设置修改的完整链路

Loading diagram...

链路要点:

  1. 先内存后磁盘:update/patch_settings 成功路径中,先把新设置写入 app_state.settings->raw,再持久化,最后发事件。若写盘失败,调用方会收到 std::expected 错误,但内存态已更新——事件发布与持久化的先后顺序以"内存为准"。
  2. 事件携带差分信息:notify_settings_changed 的签名要求传入 old_settings,处理器据此只刷新受影响的 UI 区块,避免全量刷新。
  3. 端点是薄适配层:RPC 端点只做参数转译与错误透传,不含业务规则;因此同一套业务逻辑可被启动流程与 RPC 复用。

双阶段启动时序

Loading diagram...

该流程解释了为何设置系统必须两段式:日志与提权属于"第 0 层"基础设施,必须先于一切子系统;而完整设置校验、首次引导判定属于"第 1 层",可依赖完整运行时。

Configuration Options

启动期可从 settings.json 预读取的已知字段(依据 StartupSettings 结构定义):

选项类型默认值说明
always_run_as_adminbooltrue为 true 且进程未提权时,启动阶段尝试以管理员身份重启发
logger_levelstd::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 错误通道均为易于单测的形态。

Sources

(1 files)