Repository Wiki
ChanIok/SpinningMomo

应用内更新机制

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 明确说明:仅在用户主动"检查更新/下载更新"或启用"自动检查更新"(启动时)才会访问更新源。

架构

Loading diagram...

分层意图说明:

  • 入口有两个:启动时由 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 个函数,签名摘自源码:

cpp
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::update

Source: update.hpp

设计意图:错误统一用 std::expected<T, std::string>(同步接口)或 asio::awaitable<std::expected<...>>(协程接口)表达,不依赖异常穿透层间边界;start_download_update_task 的 prepare_install_on_exit 参数是"退出时自动更新"链路的核心开关。

响应/参数类型(types.hpp)

cpp
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:

cpp
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

Loading diagram...

关键实现片段(含状态清理与"过期下载清理"):

cpp
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 状态码即失败:

cpp
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

cpp
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

Loading diagram...

完整源码(体现全部守卫条件):

cpp
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(省略号处为原文中重复的状态就绪守卫,逻辑与上文一致)

设计意图:

  1. 不阻塞启动:初始化器只调用一次本函数即返回(见 initializer.cpp),协程 co_spawn 后先 asio::post 让出,避免抢占启动事件循环。
  2. 双重设置分支:auto_check 控制是否检查;auto_update_on_exit 控制发现更新后的行为——关闭时降级为系统通知(带最新版本号,i18n key 为 message.update_available_about_prefix),开启时静默进入下载准备。
  3. 幂等保护:pending_update 已存在时不再重复准备,避免覆盖一份已就绪的待安装上下文。
  4. 通知降级:i18n 文案缺失时只记日志不发通知(Skip update available notification: i18n text is missing),绝不弹出未本地化/空文案。

更新通知的构造:post_update_notification

cpp
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 窗口,把"发现更新"引导到用户可操作的页面。

后台下载与单实例去重

cpp
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(头文件已引入)。

退出时执行待安装更新

cpp
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(省略号处为日志参数的多行拼接,语义不变)

脚本参数契约(更新脚本侧需支持的开关):

参数值说明
-PidToWaitGetCurrentProcessId()脚本先等待主进程退出,再执行文件替换
-Modeinstalled / 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)中的更新相关块:

cpp
struct Update { bool auto_check = true; // 是否自动检查更新 bool auto_update_on_exit = false; // 是否在退出时自动更新

Source: types.hpp (settings)

选项类型默认值作用点说明
update.auto_checkbooltrueschedule_startup_auto_update_check 入口启动时是否发起自动检查;关闭后仅用户主动检查(隐私声明对应的开关)
update.auto_update_on_exitboolfalse启动流程分支发现更新后是否静默下载并准备"退出时安装";关闭时降级为系统通知
update.version_urlstring(运行时配置)check_for_update更新源 URL(Cloudflare Pages 版本信息端点)
(下载 URL 模板)string(运行时配置)format_download_url以版本号与文件名填充得到下载地址

前端 TypeScript 侧的镜像类型(web/src/features/settings/types.ts):

typescript
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)

cpp
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_messageupdate.cpp L102-L109
settings 未初始化复位 is_checking 后返回 "Settings not initialized"update.cpp L685-L688
协程内抛异常catch 中复位 is_checking、写入 e.what() 并返回 unexpectedupdate.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),只需替换该函数的四段解析/比较逻辑。

相关链接