应用内更新机制
SpinningMomo 的应用内更新机制由 features::update 模块实现,负责"检查更新 → 后台下载 → 退出时安装"的完整生命周期,并通过 core::rpc 端点与 core::tasks 后台任务体系向前端 UI 暴露能力。
目的与范围
本页覆盖应用内更新机制的端到端实现:
- 更新模块的公共 API 与响应类型(
features::update命名空间) - 检查更新的网络请求、版本号比较算法与状态写回
- 启动时自动更新流程(含"退出时自动更新"与通知降级路径)
- 后台下载任务的创建、单实例去重与异步调度
- 退出时执行待安装更新的脚本参数构造
- 相关设置项(
settings.update)与前端类型映射
以下内容有意留给兄弟页面:
- 设置模块的整体持久化与加载流程:见设置(settings)相关页面
- 后台任务系统的通用实现(进度、取消、事件广播):见后台任务相关页面
- 安装器(WiX bundle)自身在安装过程中的"检查更新"界面文案:那是安装引导器的独立机制,与应用内更新不同
- HTTP 客户端基础设施本身:见网络请求相关页面
概述
应用内更新机制解决的问题是:Windows 桌面程序运行期间无法替换自身可执行文件,因此更新被拆成两个阶段——运行期间完成版本检查与安装包下载,进程退出后由独立脚本执行文件替换。
关键概念:
| 概念 | 说明 |
|---|---|
| 更新源 | 部署在 Cloudflare Pages 上的版本信息端点,由 settings.update.version_url 指定 |
| 版本比较 | x.y.z.w 四段式版本号逐段整数比较(is_update_needed) |
| 后台下载任务 | 通过 core::tasks 创建的任务,同一时刻仅允许一个,以 kUpdateDownloadTaskType 标识 |
| 待安装更新(pending_update) | 下载完成且用户同意/设置为"退出时更新"后记录的安装上下文(包路径、目标目录、安装日志、是否便携版) |
| 退出时执行 | execute_pending_update 以 -PidToWait 等参数启动更新脚本,脚本等待当前进程退出后再替换文件 |
该机制支持两种运行形态:安装版(installed)与便携版(portable),通过待安装更新上下文中的 is_portable 标志区分,最终作为 -Mode 参数传给更新脚本。
隐私方面,docs/about/legal.md 明确说明:仅在用户主动"检查更新/下载更新"或启用"自动检查更新"(启动时)才会访问更新源。
架构
分层意图说明:
- 入口有两个:启动时由 initializer.cpp 调度
schedule_startup_auto_update_check(异步、不阻塞启动);运行期由 RPC 端点 endpoints/update/update.cpp 包装check_for_update响应用户在"关于"页的主动检查。 - features::update 不直接持有线程:所有协程都
co_spawn到core::async提供的共享io_context上,网络访问统一走core::http_client,长耗时下载以core::tasks任务形式暴露给任务系统。 - UI 解耦:更新结果通过
core::notifications弹出系统通知,通知动作回调直接调用ui::webview_window::activate_window(state, L"/about")打开关于页,而不是耦合具体窗口实现。
公共 API 与数据结构
features::update 命名空间(update.hpp)
完整公共接口共 6 个函数,签名摘自源码:
1namespace features::update {
2
3// 初始化Update模块
4auto initialize(core::AppState& app_state) -> std::expected<void, std::string>;
5
6// 启动时自动更新流程(按 settings 决定是否检查/下载/准备退出更新)
7auto schedule_startup_auto_update_check(core::AppState& app_state) -> void;
8
9// 检查更新
10auto check_for_update(core::AppState& app_state)
11 -> asio::awaitable<std::expected<CheckUpdateResult, std::string>>;
12
13// 启动后台下载更新任务
14auto start_download_update_task(core::AppState& app_state, bool prepare_install_on_exit = false)
15 -> asio::awaitable<std::expected<StartDownloadUpdateResult, std::string>>;
16
17// 安装更新
18auto install_update(core::AppState& app_state, const InstallUpdateParams& params)
19 -> std::expected<InstallUpdateResult, std::string>;
20
21// 执行待处理的更新
22auto execute_pending_update(core::AppState& app_state) -> void;
23
24} // namespace features::updateSource: update.hpp
设计意图:错误统一用 std::expected<T, std::string>(同步接口)或 asio::awaitable<std::expected<...>>(协程接口)表达,不依赖异常穿透层间边界;start_download_update_task 的 prepare_install_on_exit 参数是"退出时自动更新"链路的核心开关。
响应/参数类型(types.hpp)
1// 检查更新响应结果
2struct CheckUpdateResult {
3 bool has_update; // 是否有可用更新
4 std::string latest_version; // 最新版本
5 std::string current_version; // 当前版本
6};
7
8// 启动后台下载更新任务响应结果
9struct StartDownloadUpdateResult {
10 std::string task_id; // 后台任务ID
11 std::string status; // started | already_running
12};
13
14// 安装更新请求参数
15struct InstallUpdateParams {
16 bool restart = true; // 安装后是否重启程序
17 bool quiet_install = false; // 是否静默安装(仅安装版生效)
18};
19
20// 安装更新响应结果
21struct InstallUpdateResult {
22 std::string message; // 结果消息
23};Source: types.hpp
status 只有两种取值:started 表示新建任务成功,already_running 表示复用已有任务——这是 UI 判断"重复点击检查下载"的依据。运行时状态(state.hpp)另持有 is_checking、update_available、latest_version、downloaded_version、pending_update、update_script_path 等字段(见 state.hpp)。
RPC 端点
RPC 层是薄包装,把 features::update 的结果包装为 core::rpc::RpcResult:
auto handle_check_for_update(core::AppState& app_state, [[maybe_unused]] const rfl::Generic& params)
-> asio::awaitable<core::rpc::RpcResult<features::update::CheckUpdateResult>> {
auto result = co_await features::update::check_for_update(app_state);Source: update.cpp (RPC)
核心流程
检查更新:check_for_update
关键实现片段(含状态清理与"过期下载清理"):
1 app_state.update->is_checking = true;
2 app_state.update->error_message.clear();
3 ...
4 // 从Cloudflare Pages获取最新版本号
5 const auto& version_url = app_state.settings->raw.update.version_url;
6 auto latest = co_await fetch_latest_version(app_state, version_url);
7 ...
8 auto current_version = core::version::get_app_version();
9
10 CheckUpdateResult result;
11 result.latest_version = latest.value();
12 result.current_version = current_version;
13 result.has_update = is_update_needed(current_version, result.latest_version);
14
15 // 更新状态
16 app_state.update->is_checking = false;
17 app_state.update->update_available = result.has_update;
18 app_state.update->latest_version = result.latest_version;
19 // 已下载的版本与最新版本不符时清除,避免安装过期文件
20 if (result.has_update && !app_state.update->downloaded_version.empty() &&
21 app_state.update->downloaded_version != result.latest_version) {
22 app_state.update->downloaded_version.clear();
23 }Source: update.cpp
设计意图:is_checking/error_message 在每条失败路径上都被复位(包括 catch (const std::exception&) 分支,见 update.cpp L721-L727),保证 UI 轮询状态机不会卡在"检查中";downloaded_version 失配即清除,防止把旧版本安装包当作最新版安装。
HTTP 错误被统一收敛为字符串错误,非 200 状态码即失败:
1auto http_get(core::AppState& app_state, const std::string& url)
2 -> asio::awaitable<std::expected<std::string, std::string>> {
3 core::http_client::Request request{
4 .method = "GET",
5 .url = url,
6 };
7
8 auto response_result = co_await core::http_client::fetch(app_state, request);
9 if (!response_result) {
10 co_return std::unexpected("Failed to send HTTP request: " + response_result.error());
11 }
12
13 if (response_result->status_code != 200) {
14 co_return std::unexpected("HTTP error: " + std::to_string(response_result->status_code));
15 }
16
17 co_return response_result->body;
18}Source: update.cpp
版本比较算法:is_update_needed
1auto is_update_needed(const std::string& current_version, const std::string& latest_version)
2 -> bool {
3 // 简单的版本号比较,格式为 "x.y.z.w"
4 auto split_version = [](const std::string& version) -> std::vector<int> {
5 std::vector<int> parts;
6 std::stringstream ss(version);
7 std::string part;
8
9 while (std::getline(ss, part, '.')) {
10 try {
11 parts.push_back(std::stoi(part));
12 } catch (...) {
13 parts.push_back(0);
14 }
15 }
16
17 // 确保有4个部分
18 while (parts.size() < 4) {
19 parts.push_back(0);
20 }
21
22 return parts;
23 };
24
25 auto v1_parts = split_version(latest_version);
26 auto v2_parts = split_version(current_version);
27
28 for (size_t i = 0; i < 4; ++i) {
29 if (v1_parts[i] > v2_parts[i]) {
30 return true;
31 } else if (v1_parts[i] < v2_parts[i]) {
32 return false;
33 }
34 }
35
36 return false; // 版本相同
37}Source: update.cpp
设计意图与边界:
- 四段固定长度(Windows 四段版本号风格),不足补 0,因此
1.2与1.2.0.0视为相等。 - 段内解析失败(非数字,如
1.2.0-beta)被catch (...)吞掉并以0参与比较——这是一个刻意的容错决策,代价是带预发布后缀的版本会被低估。 - 与
latest_version逐段比较,任何一段更大即判定需要更新;完全相同返回false。
启动时自动更新流程:schedule_startup_auto_update_check
完整源码(体现全部守卫条件):
1auto schedule_startup_auto_update_check(core::AppState& app_state) -> void {
2 if (!app_state.settings || !app_state.update || !app_state.async) {
3 Logger().warn("Skip startup auto update check: state is not ready");
4 return;
5 }
6
7 if (!app_state.settings->raw.update.auto_check) {
8 Logger().info("Skip startup auto update check: auto_check is disabled");
9 return;
10 }
11
12 auto* io_context = core::async::get_io_context(app_state);
13 if (!io_context) {
14 Logger().warn("Skip startup auto update check: async runtime is not ready");
15 return;
16 }
17
18 asio::co_spawn(
19 *io_context,
20 [&app_state]() -> asio::awaitable<void> {
21 co_await asio::post(asio::use_awaitable);
22 Logger().info("Startup auto update check started");
23
24 auto check_result = co_await check_for_update(app_state);
25 if (!check_result) {
26 Logger().warn("Startup auto update check failed: {}", check_result.error());
27 co_return;
28 }
29
30 if (check_result->has_update) {
31 Logger().info("Startup auto update check found update: current={}, latest={}",
32 check_result->current_version, check_result->latest_version);
33 ...
34 if (!app_state.settings->raw.update.auto_update_on_exit) {
35 Logger().info("Skip startup auto update prepare: auto_update_on_exit is disabled");
36 if (app_state.i18n) {
37 auto text_it = app_state.i18n->texts.find("message.update_available_about_prefix");
38 if (text_it != app_state.i18n->texts.end()) {
39 post_update_notification(
40 app_state, std::vformat(text_it->second,
41 std::make_format_args(check_result->latest_version)));
42 } else {
43 Logger().warn("Skip update available notification: i18n text is missing");
44 }
45 }
46 co_return;
47 }
48
49 if (app_state.update->pending_update) {
50 Logger().info("Skip startup auto update prepare: pending update already exists");
51 co_return;
52 }
53
54 auto download_task_result = co_await start_download_update_task(app_state, true);
55 if (!download_task_result) {
56 Logger().warn("Startup auto update download task failed: {}",
57 download_task_result.error());
58 co_return;
59 }
60
61 Logger().info("Startup auto update background download {}: task_id={}",
62 download_task_result->status, download_task_result->task_id);
63 co_return;
64 }
65
66 Logger().info("Startup auto update check completed: current version is up-to-date ({})",
67 check_result->current_version);
68 },
69 core::async::log_completion("Startup update check"));
70}Source: update.cpp(省略号处为原文中重复的状态就绪守卫,逻辑与上文一致)
设计意图:
- 不阻塞启动:初始化器只调用一次本函数即返回(见 initializer.cpp),协程
co_spawn后先asio::post让出,避免抢占启动事件循环。 - 双重设置分支:
auto_check控制是否检查;auto_update_on_exit控制发现更新后的行为——关闭时降级为系统通知(带最新版本号,i18n key 为message.update_available_about_prefix),开启时静默进入下载准备。 - 幂等保护:
pending_update已存在时不再重复准备,避免覆盖一份已就绪的待安装上下文。 - 通知降级:i18n 文案缺失时只记日志不发通知(
Skip update available notification: i18n text is missing),绝不弹出未本地化/空文案。
更新通知的构造:post_update_notification
1auto post_update_notification(core::AppState& app_state, const std::string& message) -> void {
2 auto app_name_it = app_state.i18n->texts.find("label.app_name");
3 if (app_name_it == app_state.i18n->texts.end()) {
4 Logger().warn("Skip update notification: app name text is missing");
5 return;
6 }
7
8 core::notifications::NotificationOptions options;
9 options.title = utils::string::FromUtf8(app_name_it->second);
10 options.message = utils::string::FromUtf8(message);
11
12 auto action_label_it = app_state.i18n->texts.find("notification.action.view");
13 if (action_label_it != app_state.i18n->texts.end()) {
14 options.action = core::notifications::NotificationAction{
15 .label = utils::string::FromUtf8(action_label_it->second),
16 .callback =
17 [](core::AppState& state) { ui::webview_window::activate_window(state, L"/about"); },
18 };
19 } else {
20 Logger().warn("Skip update notification action: view action text is missing");
21 }
22
23 core::notifications::post_notification_request(app_state, std::move(options));
24}Source: update.cpp
utils::string::FromUtf8 做 UTF-8 到宽字符转换以适配 Windows 通知 API;通知动作("查看")通过回调直接激活 /about 路由的 WebView 窗口,把"发现更新"引导到用户可操作的页面。
后台下载与单实例去重
1 // 同一时刻只允许一个下载任务,重复调用直接返回已有任务 ID
2 if (auto active_task = core::tasks::find_active_task_of_type(app_state, kUpdateDownloadTaskType);
3 active_task.has_value()) {
4 co_return StartDownloadUpdateResult{
5 .task_id = active_task->task_id,
6 .status = "already_running",
7 };
8 }
9
10 auto* io_context = core::async::get_io_context(app_state);
11 if (!io_context) {
12 co_return std::unexpected("Async runtime is not available");
13 }
14
15 auto version = app_state.update->latest_version;
16 auto task_id = core::tasks::create_task(app_state, kUpdateDownloadTaskType, version);
17 if (task_id.empty()) {
18 co_return std::unexpected("Failed to create update download task");
19 }
20
21 // co_await asio::post 将实际下载推迟到下一个事件循环周期,使本函数先返回给调用方
22 asio::co_spawn(
23 *io_context,
24 [&app_state, task_id, version, prepare_install_on_exit]() -> asio::awaitable<void> {
25 co_await asio::post(asio::use_awaitable);
26 co_await run_download_update_task(app_state, task_id, version, prepare_install_on_exit);
27 },
28 core::async::log_completion("Update download task"));
29
30 co_return StartDownloadUpdateResult{
31 .task_id = task_id,
32 .status = "started",
33 };Source: update.cpp
设计意图:
- 单实例语义:以
kUpdateDownloadTaskType在任务注册表中查找活动任务,命中即返回already_running+ 原task_id,调用方无需自行加锁。任务以目标版本号作为创建载荷(create_task(app_state, kUpdateDownloadTaskType, version))。 - 先返回再下载:注释明确说明
asio::post的作用——让start_download_update_task先把task_id返回给 RPC 调用方,实际下载(run_download_update_task,同文件内部协程)推迟到下一轮事件循环,前端可立即拿到任务 ID 去订阅进度。 - 完成日志:
core::async::log_completion("Update download task")统一记录协程完成/异常。 - 下载缓存落在应用数据子目录(
utils::path::GetAppDataSubdirectory("temp"),见 update.cpp L114-L116),下载 URL 由format_download_url(url_template, version, filename)从模板格式化(见 update.cpp L118-L120),文件完整性依赖utils/crypto(头文件已引入)。
退出时执行待安装更新
1auto execute_pending_update(core::AppState& app_state) -> void {
2 if (!app_state.update || !app_state.update->pending_update.has_value()) {
3 return;
4 }
5
6 const auto script_path = app_state.update->update_script_path;
7 const auto& pending_update = app_state.update->pending_update.value();
8 const auto& package_path = pending_update.package_path;
9 const auto& target_install_directory = pending_update.target_install_directory;
10 const auto& install_log_path = pending_update.install_log_path;
11
12 if (script_path.empty() || package_path.empty() || target_install_directory.empty()) {
13 Logger().error("Pending update context is incomplete: script={}, package={}, target={}", ...);
14 app_state.update->pending_update.reset();
15 return;
16 }
17
18 ...
19 const auto update_mode =
20 pending_update.is_portable ? std::wstring(L"portable") : std::wstring(L"installed");
21
22 // 所有业务路径都作为独立参数交给统一执行器,避免手工拼接命令行。
23 std::vector<std::wstring> arguments{
24 L"-PidToWait",
25 std::to_wstring(GetCurrentProcessId()),
26 L"-Mode",
27 update_mode,
28 L"-PackagePath",
29 package_path.wstring(),
30 L"-TargetInstallDirectory",
31 target_install_directory.wstring(),
32 };
33
34 if (!install_log_path.empty()) {
35 arguments.emplace_back(L"-InstallLogPath");
36 arguments.emplace_back(install_log_path.wstring());
37 }
38
39 if (pending_update.restart) {Source: update.cpp(省略号处为日志参数的多行拼接,语义不变)
脚本参数契约(更新脚本侧需支持的开关):
| 参数 | 值 | 说明 |
|---|---|---|
-PidToWait | GetCurrentProcessId() | 脚本先等待主进程退出,再执行文件替换 |
-Mode | installed / portable | 由 pending_update.is_portable 决定安装形态 |
-PackagePath | 下载完成的安装包路径 | 从 temp 目录取包 |
-TargetInstallDirectory | 目标安装目录 | 文件替换目标 |
-InstallLogPath | 可选 | 仅当非空时附加 |
(-Restart 相关分支) | 由 pending_update.restart 决定 | 见 update.cpp L773 起的后续分支 |
设计意图:
- 进程外执行:以独立参数向量(而非拼接命令字符串)通过
utils::powershell统一执行器启动脚本,避免路径含空格/特殊字符时的注入与转义问题——这正是源码注释"避免手工拼接命令行"的含义。 - 上下文不完整即丢弃:
script/package/target任一为空就pending_update.reset()并记error日志,宁可放弃更新也不执行半残脚本。 - PID 等待:把退出时机交给脚本(
-PidToWait),主进程自身无需在退出路径上做额外同步。
配置选项
后端设置(features/settings/types.hpp)中的更新相关块:
struct Update {
bool auto_check = true; // 是否自动检查更新
bool auto_update_on_exit = false; // 是否在退出时自动更新Source: types.hpp (settings)
| 选项 | 类型 | 默认值 | 作用点 | 说明 |
|---|---|---|---|---|
update.auto_check | bool | true | schedule_startup_auto_update_check 入口 | 启动时是否发起自动检查;关闭后仅用户主动检查(隐私声明对应的开关) |
update.auto_update_on_exit | bool | false | 启动流程分支 | 发现更新后是否静默下载并准备"退出时安装";关闭时降级为系统通知 |
update.version_url | string | (运行时配置) | check_for_update | 更新源 URL(Cloudflare Pages 版本信息端点) |
| (下载 URL 模板) | string | (运行时配置) | format_download_url | 以版本号与文件名填充得到下载地址 |
前端 TypeScript 侧的镜像类型(web/src/features/settings/types.ts):
update: {
autoCheck: boolean // 是否自动检查更新
autoUpdateOnExit: boolean // 是否在退出时自动更新Source: types.ts
两侧字段一一对应(snake_case ↔ camelCase),设置页的修改会即时影响下一次启动的自动更新行为。
API 参考
schedule_startup_auto_update_check(app_state: core::AppState&) -> void
启动时自动更新调度入口。内部做三层守卫(settings/update/async 就绪、auto_check、io_context 可用),随后 co_spawn 一个"检查 → (可选)下载准备"协程,log_completion("Startup update check") 兜底记录完成。
参数: app_state — 全局应用状态引用。
返回: 无。结果只反映在运行时状态与日志/通知中。
check_for_update(app_state) -> asio::awaitable<std::expected<CheckUpdateResult, std::string>>
拉取 version_url,与 core::version::get_app_version() 比较,并写回 update 运行时状态。
返回: CheckUpdateResult{has_update, latest_version, current_version};失败返回错误字符串(网络失败、非 200、状态未初始化、异常文本)。
start_download_update_task(app_state, prepare_install_on_exit = false) -> asio::awaitable<std::expected<StartDownloadUpdateResult, std::string>>
创建(或复用)更新下载后台任务。
参数: prepare_install_on_exit — 为 true 时下载完成后填充 pending_update,供退出时安装。
返回: {task_id, status},status ∈ {started, already_running}。
失败条件: async 状态未初始化 / io_context 不可用 / 任务创建失败。
install_update(app_state, params) -> std::expected<InstallUpdateResult, std::string>
参数: InstallUpdateParams{restart=true, quiet_install=false};quiet_install 仅安装版生效。
返回: InstallUpdateResult{message}。
execute_pending_update(app_state) -> void
读取 pending_update 上下文,以参数向量启动更新脚本;上下文不完整时重置 pending_update 并记 error 日志。同步函数,在退出路径上调用。
RPC:update.check_for_update(端点处理器 handle_check_for_update)
auto handle_check_for_update(core::AppState& app_state, [[maybe_unused]] const rfl::Generic& params)
-> asio::awaitable<core::rpc::RpcResult<features::update::CheckUpdateResult>> {
auto result = co_await features::update::check_for_update(app_state);Source: update.cpp (RPC)
失败模式、边界与并发
| 场景 | 行为 | 代码依据 |
|---|---|---|
| HTTP 请求失败 / 状态码非 200 | 返回 "Failed to send HTTP request: ..." / "HTTP error: <code>",写入 error_message | update.cpp L102-L109 |
| settings 未初始化 | 复位 is_checking 后返回 "Settings not initialized" | update.cpp L685-L688 |
| 协程内抛异常 | catch 中复位 is_checking、写入 e.what() 并返回 unexpected | update.cpp L721-L727 |
| 重复发起下载 | 返回已有 task_id,status = "already_running",不重复建任务 | update.cpp L564-L571 |
| 已下载版本与最新版不符 | 清空 downloaded_version,避免安装过期文件 | update.cpp L711-L714 |
已存在 pending_update | 启动准备流程直接跳过(幂等) | update.cpp L652-L655 |
| 版本段解析失败 | 按 0 处理(catch (...)),预发布后缀被低估 | update.cpp L66-L70 |
| i18n 文案缺失 | 只记 warn 日志,不发通知/不带动作 | update.cpp L34-L37, L50-L52, L645-L647 |
| 待安装上下文不完整 | pending_update.reset() + error 日志,不执行脚本 | update.cpp L741-L748 |
并发与一致性要点:
- 单写者原则:所有协程运行在共享
io_context上(单线程事件循环语义),对app_state.update的读写不需要额外互斥量;跨线程隔离由core::async层保证。 - 下载去重靠任务注册表而非布尔标志,任务创建与查找是原子操作序列,重复 RPC 只会得到
already_running。 - 退出时执行是纯同步调用,
execute_pending_update不做网络/等待,只启动脚本(脚本自身通过-PidToWait等待主进程退出)。
性能与运维要点
- 启动路径上的开销仅是一次
co_spawn + post,网络检查完全异步,不拖慢应用启动(initializer 注释明确"异步,不阻塞启动")。 - 下载包写入应用数据
temp子目录;安装日志可选写入install_log_path,便于排查更新失败。 - 所有关键分支都有结构化日志(
Logger().info/warn/error),可通过日志串起"启动检查 → 发现更新 → 任务创建 → 退出执行"整条链路。 - 隐私合规:更新源访问仅发生在用户主动操作或
auto_check = true的启动时刻(见 docs/about/legal.md)。
扩展点
- 更换更新源:
version_url来自设置,改配置即可切换到任何返回纯版本号的 HTTP 端点(当前实现要求 200 + 版本串)。 - 新增安装形态:
-Mode目前只有installed/portable两个值,由pending_update.is_portable派生;新增形态需扩展update_mode的取值并让更新脚本理解新参数。 - 通知行为定制:
post_update_notification中的动作回调目前固定打开/about路由,可通过修改NotificationAction.callback引导到其它页面。 - 版本策略:
is_update_needed是独立的纯函数,若要支持预发布通道(如1.2.0-beta),只需替换该函数的四段解析/比较逻辑。
相关链接
- 更新模块头文件与公共 API:update.hpp
- 更新模块实现:update.cpp
- 响应/参数类型:types.hpp
- RPC 更新端点:endpoints/update/update.cpp
- 启动接线:initializer.cpp
- 设置类型(后端/前端):settings/types.hpp、web/src/features/settings/types.ts
- 隐私声明中的更新源访问说明:docs/about/legal.md