From c6613198907174fc82a861c2b68b7d8b8105fe0f Mon Sep 17 00:00:00 2001 From: dijunkun Date: Thu, 24 Sep 2026 17:36:41 +0800 Subject: [PATCH] [feat] add background and manual update checks --- .../src/gui/application/application_state.h | 2 - .../src/gui/application/gui_application.cpp | 86 ++++++---- .../src/gui/application/gui_application.h | 4 + .../assets/localization/localization_data.h | 9 + .../features/settings/settings_manager.cpp | 1 + .../src/gui/runtime/peer_data_callbacks.cpp | 1 + .../src/gui/runtime/peer_event_handler.cpp | 1 + apps/desktop/src/gui/ui/crossdesk_ui.slint | 2 +- apps/desktop/src/gui/ui/main_window.slint | 108 ++++++++++-- apps/desktop/src/gui/ui/ui_localization.h | 6 + .../src/version_checker/update_checker.cpp | 84 +++++++++ .../src/version_checker/update_checker.h | 62 +++++++ .../src/version_checker/version_checker.cpp | 93 ++++++---- .../src/version_checker/version_checker.h | 12 +- apps/desktop/tests/slint_ui_smoke_test.cpp | 34 +++- apps/desktop/tests/update_checker_test.cpp | 162 ++++++++++++++++++ apps/desktop/tests/version_checker_test.cpp | 46 +++++ apps/desktop/xmake/targets.lua | 6 + 18 files changed, 629 insertions(+), 90 deletions(-) create mode 100644 apps/desktop/src/version_checker/update_checker.cpp create mode 100644 apps/desktop/src/version_checker/update_checker.h create mode 100644 apps/desktop/tests/update_checker_test.cpp diff --git a/apps/desktop/src/gui/application/application_state.h b/apps/desktop/src/gui/application/application_state.h index f5cd3ee9..2df96342 100644 --- a/apps/desktop/src/gui/application/application_state.h +++ b/apps/desktop/src/gui/application/application_state.h @@ -10,7 +10,6 @@ #include #include #include -#include #include namespace crossdesk::gui_detail { @@ -47,7 +46,6 @@ struct InteractionState { }; struct UpdateState { - nlohmann::json latest_version_info_ = nlohmann::json{}; bool update_available_ = false; std::string latest_version_; std::string release_name_; diff --git a/apps/desktop/src/gui/application/gui_application.cpp b/apps/desktop/src/gui/application/gui_application.cpp index f29b29de..615b069c 100644 --- a/apps/desktop/src/gui/application/gui_application.cpp +++ b/apps/desktop/src/gui/application/gui_application.cpp @@ -58,7 +58,7 @@ #include "rd_log.h" #include "server_window_state.h" #include "ui/ui_localization.h" -#include "version_checker.h" +#include "update_checker.h" #include "window_geometry.h" namespace crossdesk { @@ -989,33 +989,6 @@ int GuiApplication::Run() { "%d", signal_port); } - latest_version_info_ = CheckUpdate(); - if (!latest_version_info_.empty()) { - std::string version; - if (latest_version_info_.contains("latest_version") && - latest_version_info_["latest_version"].is_string()) { - version = latest_version_info_["latest_version"].get(); - } else if (latest_version_info_.contains("version") && - latest_version_info_["version"].is_string()) { - version = latest_version_info_["version"].get(); - } - latest_version_ = version.empty() ? std::string{} : "v" + version; - if (latest_version_info_.contains("releaseNotes") && - latest_version_info_["releaseNotes"].is_string()) { - release_notes_ = latest_version_info_["releaseNotes"].get(); - } - if (latest_version_info_.contains("releaseName") && - latest_version_info_["releaseName"].is_string()) { - release_name_ = latest_version_info_["releaseName"].get(); - } - if (latest_version_info_.contains("releaseDate") && - latest_version_info_["releaseDate"].is_string()) { - release_date_ = latest_version_info_["releaseDate"].get(); - } - update_available_ = - !version.empty() && IsNewerVersion(CROSSDESK_VERSION, latest_version_); - } - InitializeSettings(); if (!InitializeSDL()) { return -1; @@ -1030,6 +1003,7 @@ int GuiApplication::Run() { LOG_INFO("Slint backend: {}; {}", backend.backend, backend.diagnostic); #endif InitializeModules(); + update_checker_ = std::make_unique(CROSSDESK_VERSION); InitializeUi(); ui_->timer.start(slint::TimerMode::Repeated, 16ms, [this] { Tick(); }); @@ -1155,14 +1129,6 @@ void GuiApplication::InitializeUi() { ui_->main->set_custom_titlebar(use_x11_custom_titlebar_); ui_->main->set_wayland_titlebar(use_xwayland_gui_); #endif - std::vector release_note_blocks; - for (const auto& parsed : ParseReleaseNotesMarkdownForSlint(release_notes_)) { - ui::ReleaseNoteBlock block; - block.content = parsed.content; - block.section_gap = parsed.section_gap; - release_note_blocks.push_back(std::move(block)); - } - ui_->release_note_blocks_model->set_vector(std::move(release_note_blocks)); ui_->main->set_release_note_blocks(ui_->release_note_blocks_model); RegisterFontAwesome(ui_->main->window()); ui_->main->set_recent_connections(ui_->recent_model); @@ -1398,6 +1364,10 @@ void GuiApplication::BindMainCallbacks() { : slint::SharedString{}); }); main->on_open_download([this] { OpenUrl("https://crossdesk.cn"); }); + main->on_check_for_updates([this] { + update_checker_->RequestCheck(); + SyncMainWindow(); + }); main->on_connection_cancel([this] { const auto props = FindRemoteSession(ui_->connection_dialog_remote_id); if (!props) { @@ -1783,6 +1753,7 @@ void GuiApplication::Tick() { slint::quit_event_loop(); return; } + PollUpdateCheck(); HandleSessionCleanup(); HandlePasswordChangeResult(); HandleCredentialRecovery(); @@ -1857,6 +1828,29 @@ void GuiApplication::Tick() { SyncServerWindow(); } +void GuiApplication::PollUpdateCheck() { + // The first tick runs after show()/event-loop startup. Poll never waits for + // networking and all application/model changes remain on this UI thread. + if (!update_checker_->Poll() || + update_checker_->status() == UpdateChecker::Status::Failed) { + return; + } + const auto& info = *update_checker_->latest(); + latest_version_ = "v" + info.version; + release_name_ = info.release_name; + release_notes_ = info.release_notes; + release_date_ = info.release_date; + update_available_ = update_checker_->update_available(); + std::vector blocks; + for (const auto& parsed : ParseReleaseNotesMarkdownForSlint(release_notes_)) { + ui::ReleaseNoteBlock block; + block.content = parsed.content; + block.section_gap = parsed.section_gap; + blocks.push_back(std::move(block)); + } + ui_->release_note_blocks_model->set_vector(std::move(blocks)); +} + void GuiApplication::ShareLocalCursorState() { constexpr auto kCursorEchoSuppressionInterval = 300ms; constexpr auto kCursorStateHeartbeatInterval = 500ms; @@ -2253,6 +2247,23 @@ void GuiApplication::SyncMainWindow() { ui_->main->set_signal_tls_error(signal_status_ == SignalStatus::SignalTlsCertError); ui_->main->set_update_available(update_available_); + switch (update_checker_->status()) { + case UpdateChecker::Status::Idle: + ui_->main->set_update_check_status(ui::UpdateCheckStatus::Idle); + break; + case UpdateChecker::Status::Checking: + ui_->main->set_update_check_status(ui::UpdateCheckStatus::Checking); + break; + case UpdateChecker::Status::UpToDate: + ui_->main->set_update_check_status(ui::UpdateCheckStatus::UpToDate); + break; + case UpdateChecker::Status::Available: + ui_->main->set_update_check_status(ui::UpdateCheckStatus::Available); + break; + case UpdateChecker::Status::Failed: + ui_->main->set_update_check_status(ui::UpdateCheckStatus::Failed); + break; + } ui_->main->set_current_version(CROSSDESK_VERSION); ui_->main->set_latest_version(UiText(latest_version_)); ui_->main->set_release_name(UiText(release_name_)); @@ -3495,6 +3506,9 @@ void GuiApplication::Cleanup() { } #endif ui_.reset(); + if (update_checker_) { + update_checker_->Stop(); + } SDL_Quit(); } diff --git a/apps/desktop/src/gui/application/gui_application.h b/apps/desktop/src/gui/application/gui_application.h index cacd867c..fe2f6027 100644 --- a/apps/desktop/src/gui/application/gui_application.h +++ b/apps/desktop/src/gui/application/gui_application.h @@ -19,6 +19,8 @@ namespace crossdesk { +class UpdateChecker; + // Slint application shell. GuiRuntime remains the owner of transport, media, // device, clipboard, transfer and settings behavior. class GuiApplication final : private GuiRuntime { @@ -48,6 +50,7 @@ private: void BindStreamCallbacks(); void BindServerCallbacks(); void Tick(); + void PollUpdateCheck(); void ShareLocalCursorState(); bool RequestPasswordChange(const std::string &password); void HandlePasswordChangeResult(); @@ -88,6 +91,7 @@ private: bool OpenUrl(const std::string &url); std::unique_ptr ui_; + std::unique_ptr update_checker_; CursorStateProvider cursor_state_provider_; std::unordered_map cursor_delivery_states_; diff --git a/apps/desktop/src/gui/assets/localization/localization_data.h b/apps/desktop/src/gui/assets/localization/localization_data.h index 52e12d8b..307ed89d 100644 --- a/apps/desktop/src/gui/assets/localization/localization_data.h +++ b/apps/desktop/src/gui/assets/localization/localization_data.h @@ -199,6 +199,15 @@ struct TranslationRow { X(notification, u8"通知", "Notification", u8"Уведомление") \ X(new_version_available, u8"新版本可用", "New Version Available", \ u8"Доступна новая версия") \ + X(check_for_updates, u8"检查更新", "Check for updates", \ + u8"Проверить обновления") \ + X(checking_for_updates, u8"正在检查更新…", "Checking for updates…", \ + u8"Проверка обновлений…") \ + X(up_to_date, u8"已是最新版本", "You're up to date", \ + u8"Установлена последняя версия") \ + X(update_check_failed, u8"检查失败,请稍后重试", \ + "Check failed. Please try again later.", \ + u8"Не удалось проверить обновления. Повторите позже.") \ X(release_notes, u8"更新内容", "Release Notes", u8"Содержание обновления") \ X(version, u8"版本", "Version", u8"Версия") \ X(release_date, u8"发布日期: ", "Release Date: ", u8"Дата релиза: ") \ diff --git a/apps/desktop/src/gui/features/settings/settings_manager.cpp b/apps/desktop/src/gui/features/settings/settings_manager.cpp index 100446dc..612d4fb8 100644 --- a/apps/desktop/src/gui/features/settings/settings_manager.cpp +++ b/apps/desktop/src/gui/features/settings/settings_manager.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include diff --git a/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp b/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp index c2d18c5a..c99233a2 100644 --- a/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp +++ b/apps/desktop/src/gui/runtime/peer_data_callbacks.cpp @@ -1,6 +1,7 @@ #include "runtime/peer_event_handler.h" #include +#include #include #include diff --git a/apps/desktop/src/gui/runtime/peer_event_handler.cpp b/apps/desktop/src/gui/runtime/peer_event_handler.cpp index f19f7b41..7547343f 100644 --- a/apps/desktop/src/gui/runtime/peer_event_handler.cpp +++ b/apps/desktop/src/gui/runtime/peer_event_handler.cpp @@ -1,6 +1,7 @@ #include "runtime/peer_event_handler.h" #include +#include #include #include diff --git a/apps/desktop/src/gui/ui/crossdesk_ui.slint b/apps/desktop/src/gui/ui/crossdesk_ui.slint index 891db8f8..f1169df1 100644 --- a/apps/desktop/src/gui/ui/crossdesk_ui.slint +++ b/apps/desktop/src/gui/ui/crossdesk_ui.slint @@ -1,5 +1,5 @@ // Single AOT entry point. Generating one header for the entire UI avoids // duplicate imported-component definitions when C++ uses all three windows. -export { MainWindow, RecentConnection, UiStrings } from "main_window.slint"; +export { MainWindow, RecentConnection, UpdateCheckStatus, UiStrings } from "main_window.slint"; export { StreamWindow, StreamTab, FileTransferEntry, NetworkStatsRow, StreamStrings } from "stream_window.slint"; export { ServerWindow, ControllerEntry } from "server_window.slint"; diff --git a/apps/desktop/src/gui/ui/main_window.slint b/apps/desktop/src/gui/ui/main_window.slint index 89fc0688..477f4009 100644 --- a/apps/desktop/src/gui/ui/main_window.slint +++ b/apps/desktop/src/gui/ui/main_window.slint @@ -14,6 +14,8 @@ export struct ReleaseNoteBlock { section-gap: bool, } +export enum UpdateCheckStatus { idle, checking, up-to-date, available, failed } + export global UiStrings { in-out property local-desktop: "Local Desktop"; in-out property remote-desktop: "Remote Desktop"; @@ -59,6 +61,10 @@ export global UiStrings { in-out property signal-disconnected: "Signal server disconnected"; in-out property tls-error: "Signal server TLS certificate error"; in-out property update-available: "A new version is available"; + in-out property check-for-updates: "Check for updates"; + in-out property checking-for-updates: "Checking for updates…"; + in-out property up-to-date: "You’re up to date"; + in-out property update-check-failed: "Check failed. Please try again later."; in-out property release-notes: "Release notes"; in-out property later: "Later"; in-out property download: "Download"; @@ -257,6 +263,11 @@ export component MainWindow inherits Window { in property signal-tls-error: false; in property <[RecentConnection]> recent-connections; in property update-available: false; + in property update-check-status: idle; + private property update-status-text: + root.update-check-status == UpdateCheckStatus.checking ? UiStrings.checking-for-updates : + root.update-check-status == UpdateCheckStatus.up-to-date ? UiStrings.up-to-date : + root.update-check-status == UpdateCheckStatus.failed ? UiStrings.update-check-failed : ""; in property current-version: ""; in property latest-version: ""; in property release-name: ""; @@ -321,6 +332,7 @@ export component MainWindow inherits Window { callback cancel-self-hosted-settings; callback browse-save-path; callback open-download; + callback check-for-updates; callback connection-cancel; callback connection-acknowledge; callback connection-submit-password(string, bool); @@ -340,7 +352,7 @@ export component MainWindow inherits Window { private property settings-scroll-y: 0px; in-out property self-host-settings-open: false; in-out property about-open: false; - in-out property update-open: root.update-available; + in-out property update-open: false; in-out property reset-password-open: false; in-out property reset-password-invalid: false; in-out property new-password-input: ""; @@ -1594,10 +1606,10 @@ export component MainWindow inherits Window { z: 46; TouchArea { clicked => { focus-sink.focus(); } } DialogSurface { - x: 170px; - y: 127px; - width: 300px; - height: 196px; + x: (root.width - self.width) / 2; + y: (root.height - self.height) / 2; + width: 320px; + height: 216px; border-radius: 7px; Rectangle { @@ -1611,19 +1623,81 @@ export component MainWindow inherits Window { Rectangle { y: parent.height - 7px; height: 7px; background: #d3d3d3; } Text { x: 6px; text: UiStrings.about; font-size: ImGuiFontStyle.base; font-weight: 600; color: #202124; vertical-alignment: center; } } - Text { x: 30px; y: 40px; width: 240px; height: 20px; text: UiStrings.version + ": CrossDesk " + root.current-version; color: #202124; font-size: ImGuiFontStyle.body; vertical-alignment: center; } - if root.update-available: Text { x: 30px; y: 60px; width: 240px; height: 20px; text: UiStrings.update-available + ":"; color: #202124; font-size: ImGuiFontStyle.body; vertical-alignment: center; } - if root.update-available: Rectangle { - x: 30px; - y: 60px; - width: 240px; - height: 20px; - version-link := Text { text: root.latest-version; color: version-touch.has-hover ? #174ea6 : #334fd1; font-size: ImGuiFontStyle.body; horizontal-alignment: center; vertical-alignment: center; } - version-touch := TouchArea { mouse-cursor: pointer; clicked => { root.open-download(); } } + // Keep the dialog/footer stable while visible text rows determine + // each group's size and the layout redistributes the space between. + about-content := VerticalLayout { + padding: 16px; + padding-top: 35px; + spacing: 12px; + alignment: space-between; + + VerticalLayout { + vertical-stretch: 0; + spacing: 4px; + Text { + text: "CrossDesk " + root.current-version; + color: #202124; + font-size: ImGuiFontStyle.base; + font-weight: 600; + min-height: 18px; + overflow: elide; + } + if root.update-available: Rectangle { + height: max(18px, version-link.preferred-height); + accessible-role: button; + accessible-label: UiStrings.release-notes + " " + root.latest-version; + accessible-action-default => { version-touch.clicked(); } + version-link := Text { + x: 0px; + y: 0px; + width: parent.width; + height: parent.height; + text: UiStrings.update-available + ": " + root.latest-version; + color: version-touch.has-hover ? #174ea6 : #334fd1; + font-size: ImGuiFontStyle.body; + vertical-alignment: center; + horizontal-alignment: left; + wrap: word-wrap; + } + version-touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.about-open = false; root.update-open = true; } + } + } + if root.update-status-text != "": Text { + text: root.update-status-text; + color: root.update-check-status == UpdateCheckStatus.failed ? #b3261e : #61656c; + font-size: ImGuiFontStyle.body; + min-height: 18px; + wrap: word-wrap; + } + } + + VerticalLayout { + vertical-stretch: 0; + spacing: 3px; + Rectangle { height: 1px; background: #e6e8eb; } + Text { height: 16px; text: UiStrings.copyright; color: #61656c; font-size: ImGuiFontStyle.body; vertical-alignment: center; } + Text { height: 16px; text: UiStrings.license; color: #61656c; font-size: ImGuiFontStyle.body; vertical-alignment: center; } + } + + HorizontalLayout { + vertical-stretch: 0; + alignment: end; + spacing: 8px; + CompactButton { + width: max(76px, check-button-metrics.preferred-width + 20px); + text: UiStrings.check-for-updates; + enabled: root.update-check-status != UpdateCheckStatus.checking; + accessible-role: button; + accessible-label: self.text; + accessible-action-default => { if self.enabled { root.check-for-updates(); } } + clicked => { root.check-for-updates(); } + check-button-metrics := Text { visible: false; text: UiStrings.check-for-updates; font-size: ImGuiFontStyle.body; } + } + CompactButton { width: 48px; primary: true; text: UiStrings.ok; clicked => { root.about-open = false; } } + } } - Text { x: 30px; y: 85px; width: 240px; height: 20px; text: UiStrings.copyright; color: #202124; font-size: ImGuiFontStyle.body; vertical-alignment: center; } - Text { x: 30px; y: 105px; width: 240px; height: 20px; text: UiStrings.license; color: #202124; font-size: ImGuiFontStyle.body; vertical-alignment: center; } - CompactButton { x: 133px; y: 145px; primary: true; text: UiStrings.ok; clicked => { root.about-open = false; } } } } diff --git a/apps/desktop/src/gui/ui/ui_localization.h b/apps/desktop/src/gui/ui/ui_localization.h index 5401e96c..a61a52d4 100644 --- a/apps/desktop/src/gui/ui/ui_localization.h +++ b/apps/desktop/src/gui/ui/ui_localization.h @@ -80,6 +80,12 @@ inline int ApplyMainWindowStrings( strings.set_tls_error(Text(localization::signal_tls_cert_error[language])); strings.set_update_available( Text(localization::new_version_available[language])); + strings.set_check_for_updates(Text(localization::check_for_updates[language])); + strings.set_checking_for_updates( + Text(localization::checking_for_updates[language])); + strings.set_up_to_date(Text(localization::up_to_date[language])); + strings.set_update_check_failed( + Text(localization::update_check_failed[language])); strings.set_release_notes(Text(localization::release_notes[language])); strings.set_download(Text(localization::update[language])); strings.set_input_password(Text(localization::input_password[language])); diff --git a/apps/desktop/src/version_checker/update_checker.cpp b/apps/desktop/src/version_checker/update_checker.cpp new file mode 100644 index 00000000..71b6af38 --- /dev/null +++ b/apps/desktop/src/version_checker/update_checker.cpp @@ -0,0 +1,84 @@ +#include "update_checker.h" + +#include +#include + +#include "rd_log.h" + +namespace crossdesk { +using namespace std::chrono_literals; + +UpdateChecker::UpdateChecker(std::string current_version, Fetch fetch) + : current_version_(std::move(current_version)), fetch_(std::move(fetch)) {} + +UpdateChecker::~UpdateChecker() { Stop(); } + +bool UpdateChecker::RequestCheck(TimePoint now) { + if (stopped_ || pending_.valid()) { + return false; + } + try { + pending_ = std::async(std::launch::async, fetch_); + status_ = Status::Checking; + return true; + } catch (const std::exception& error) { + LOG_WARN("Could not start update check: {}", error.what()); + ScheduleRetry(now); + return false; + } +} + +bool UpdateChecker::Poll(TimePoint now) { + if (stopped_) { + return false; + } + if (!pending_.valid()) { + if (now >= next_check_) { + RequestCheck(now); + } + return false; + } + if (pending_.wait_for(0ms) != std::future_status::ready) { + return false; + } + + std::optional result; + try { + result = pending_.get(); + } catch (const std::exception& error) { + LOG_WARN("Update check failed: {}", error.what()); + } catch (...) { + LOG_WARN("Update check failed with an unknown error"); + } + if (!result) { + ScheduleRetry(now); + return true; + } + latest_ = std::move(result); + update_available_ = + IsNewerVersionWithMetadata(current_version_, latest_->version, + latest_->release_date, latest_->patch); + status_ = update_available_ ? Status::Available : Status::UpToDate; + failures_ = 0; + next_check_ = now + 6h; + return true; +} + +void UpdateChecker::ScheduleRetry(TimePoint now) { + constexpr std::chrono::minutes delays[] = {5min, 30min, 60min}; + next_check_ = now + delays[std::min(failures_, 2u)]; + failures_ = std::min(failures_ + 1, 3u); + status_ = Status::Failed; +} + +void UpdateChecker::Stop() { + stopped_ = true; + // Join before application/logging teardown. The HTTP client uses finite + // connection/read/write and overall request timeouts; no worker is detached. + if (pending_.valid()) { + pending_.wait(); + pending_ = {}; + } +} + +} // namespace crossdesk diff --git a/apps/desktop/src/version_checker/update_checker.h b/apps/desktop/src/version_checker/update_checker.h new file mode 100644 index 00000000..09527c76 --- /dev/null +++ b/apps/desktop/src/version_checker/update_checker.h @@ -0,0 +1,62 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-24 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _UPDATE_CHECKER_H_ +#define _UPDATE_CHECKER_H_ + +#include +#include +#include +#include +#include + +#include "version_checker.h" + +namespace crossdesk { + +// Owned and polled by the UI thread. Only the fetch function runs on a worker; +// it returns a value and never accesses application or UI state. +class UpdateChecker { + public: + // Wall time includes suspend time, so an overdue check runs on the first UI + // tick after wake, including on platforms whose steady clock pauses in sleep. + using Clock = std::chrono::system_clock; + using TimePoint = Clock::time_point; + using Fetch = std::function()>; + enum class Status { Idle, Checking, UpToDate, Available, Failed }; + + explicit UpdateChecker(std::string current_version, + Fetch fetch = CheckUpdate); + ~UpdateChecker(); + + // Manual requests bypass the schedule, but share an already running check. + bool RequestCheck(TimePoint now = Clock::now()); + // Starts due checks and consumes ready results without blocking. Returns true + // when a result was consumed; failure leaves the last successful info intact. + bool Poll(TimePoint now = Clock::now()); + void Stop(); + + Status status() const { return status_; } + bool update_available() const { return update_available_; } + const std::optional& latest() const { return latest_; } + + private: + void ScheduleRetry(TimePoint now); + + std::string current_version_; + Fetch fetch_; + std::future> pending_; + std::optional latest_; + TimePoint next_check_{}; + Status status_ = Status::Idle; + unsigned int failures_ = 0; + bool update_available_ = false; + bool stopped_ = false; +}; + +} // namespace crossdesk + +#endif \ No newline at end of file diff --git a/apps/desktop/src/version_checker/version_checker.cpp b/apps/desktop/src/version_checker/version_checker.cpp index d8edf382..a8e35775 100644 --- a/apps/desktop/src/version_checker/version_checker.cpp +++ b/apps/desktop/src/version_checker/version_checker.cpp @@ -23,10 +23,6 @@ namespace crossdesk { -static std::string latest_release_date_ = ""; -static bool latest_patch_available_ = false; -static int latest_patch_ = 0; - std::vector SplitVersion(const std::string& ver); namespace { @@ -202,18 +198,20 @@ int CompareNumericVersion(const std::vector& current, return 0; } -void ResetLatestMetadata() { - latest_release_date_ = ""; - latest_patch_available_ = false; - latest_patch_ = 0; -} - bool ReadPatchField(const nlohmann::json& json, int* patch) { if (!json.contains("patch")) { return false; } const auto& patch_value = json["patch"]; + if (patch_value.is_number_unsigned()) { + const auto parsed = patch_value.get(); + if (parsed > static_cast(std::numeric_limits::max())) { + return false; + } + *patch = static_cast(parsed); + return true; + } if (patch_value.is_number_integer()) { const long long parsed = patch_value.get(); if (parsed < 0 || parsed > std::numeric_limits::max()) { @@ -353,9 +351,7 @@ bool IsNewerDate(const std::string& date1, const std::string& date2) { } bool IsNewerVersion(const std::string& current, const std::string& latest) { - return IsNewerVersionWithMetadata( - current, latest, latest_release_date_, - latest_patch_available_ ? latest_patch_ : -1); + return IsNewerVersionWithMetadata(current, latest, "", -1); } bool IsNewerVersionWithMetadata(const std::string& current, @@ -391,11 +387,54 @@ bool IsNewerVersionWithMetadata(const std::string& current, return false; } -nlohmann::json CheckUpdate() { +std::optional ParseVersionInfo(const nlohmann::json& json) { + if (!json.is_object()) { + return std::nullopt; + } + const auto read_string = [&](const char* key) -> std::string { + const auto it = json.find(key); + return it != json.end() && it->is_string() ? it->get() + : std::string{}; + }; + VersionInfo info; + info.version = read_string("latest_version"); + if (info.version.empty()) { + info.version = read_string("version"); + } + if (!info.version.empty() && + (info.version.front() == 'v' || info.version.front() == 'V')) { + info.version.erase(0, 1); + } + // Invalid/missing version data is a failed check, never "up to date". + const auto numeric_end = FindNumericEnd(info.version, 0); + if (numeric_end == 0 || info.version[numeric_end - 1] == '.' || + (numeric_end < info.version.size() && + (numeric_end + 1 == info.version.size() || + (info.version[numeric_end] != '-' && info.version[numeric_end] != '+')))) { + return std::nullopt; + } + std::istringstream parts(info.version.substr(0, numeric_end)); + std::string part; + int number = 0; + while (std::getline(parts, part, '.')) { + if (!TryParseNonNegativeInt(part, &number)) { + return std::nullopt; + } + } + info.release_name = read_string("releaseName"); + info.release_notes = read_string("releaseNotes"); + info.release_date = read_string("releaseDate"); + ReadPatchField(json, &info.patch); + return info; +} + +std::optional CheckUpdate() { httplib::Client cli("https://version.crossdesk.cn"); cli.set_connection_timeout(5); cli.set_read_timeout(5); + cli.set_write_timeout(5); + cli.set_max_timeout(15000); cli.set_follow_location(true); #if defined(CPPHTTPLIB_OPENSSL_SUPPORT) && defined(__linux__) @@ -406,33 +445,25 @@ nlohmann::json CheckUpdate() { if (res) { if (res->status == 200) { try { - auto j = nlohmann::json::parse(res->body); - if (j.contains("releaseDate") && j["releaseDate"].is_string()) { - latest_release_date_ = j["releaseDate"]; + auto info = ParseVersionInfo(nlohmann::json::parse(res->body)); + if (info) { + LOG_INFO("Fetched version.json: latest_version={}, releaseDate={}, patch={}", + info->version, info->release_date, info->patch); } else { - latest_release_date_ = ""; + LOG_WARN("version.json does not contain a valid version"); } - latest_patch_ = 0; - latest_patch_available_ = ReadPatchField(j, &latest_patch_); - LOG_INFO("Fetched version.json: latest_version={}, releaseDate={}, patch={}", - j.value("latest_version", j.value("version", "")), - j.value("releaseDate", ""), - latest_patch_available_ ? latest_patch_ : -1); - return j; + return info; } catch (const std::exception& e) { LOG_WARN("Failed to parse version.json: {}", e.what()); - ResetLatestMetadata(); - return nlohmann::json{}; + return std::nullopt; } } else { LOG_WARN("Failed to fetch version.json: HTTP status={}", res->status); - ResetLatestMetadata(); - return nlohmann::json{}; + return std::nullopt; } } else { LogHttpError(res); - ResetLatestMetadata(); - return nlohmann::json{}; + return std::nullopt; } } diff --git a/apps/desktop/src/version_checker/version_checker.h b/apps/desktop/src/version_checker/version_checker.h index d6382edb..ba99e76c 100644 --- a/apps/desktop/src/version_checker/version_checker.h +++ b/apps/desktop/src/version_checker/version_checker.h @@ -8,11 +8,21 @@ #define _VERSION_CHECKER_H_ #include +#include #include namespace crossdesk { -nlohmann::json CheckUpdate(); +struct VersionInfo { + std::string version; + std::string release_name; + std::string release_notes; + std::string release_date; + int patch = -1; +}; + +std::optional ParseVersionInfo(const nlohmann::json& json); +std::optional CheckUpdate(); bool IsNewerVersion(const std::string& current, const std::string& latest); diff --git a/apps/desktop/tests/slint_ui_smoke_test.cpp b/apps/desktop/tests/slint_ui_smoke_test.cpp index 2c861e41..b0e52a65 100644 --- a/apps/desktop/tests/slint_ui_smoke_test.cpp +++ b/apps/desktop/tests/slint_ui_smoke_test.cpp @@ -161,6 +161,7 @@ void ResetMainCaptureState( window->set_about_open(false); window->set_update_open(false); window->set_update_available(false); + window->set_update_check_status(crossdesk::ui::UpdateCheckStatus::Idle); window->set_reset_password_open(false); window->set_reset_password_invalid(false); window->set_new_password_input(""); @@ -213,9 +214,20 @@ void ConfigureMainCapturePage( } else if (options.page == "self-hosted") { window->set_settings_open(true); window->set_self_host_settings_open(true); - } else if (options.page == "about") { - window->set_current_version("0.0.0"); + } else if (options.page == "about" || options.page == "about-checking" || + options.page == "about-failed" || options.page == "about-update") { + window->set_current_version("v1.3.5-1-20260924"); window->set_about_open(true); + window->set_update_check_status(crossdesk::ui::UpdateCheckStatus::UpToDate); + if (options.page == "about-checking") { + window->set_update_check_status(crossdesk::ui::UpdateCheckStatus::Checking); + } else if (options.page == "about-failed" || options.page == "about-update") { + window->set_update_available(true); + window->set_latest_version("v1.3.6-1-20260924"); + window->set_update_check_status(options.page == "about-failed" + ? crossdesk::ui::UpdateCheckStatus::Failed + : crossdesk::ui::UpdateCheckStatus::Available); + } } else if (options.page == "update") { window->set_update_available(true); window->set_latest_version("v0.0.1"); @@ -444,6 +456,24 @@ int main() { window->set_about_open(true); assert(window->get_settings_open()); assert(window->get_about_open()); + window->set_update_available(true); + if (window->get_update_open()) { + std::cerr << "Automatic update checks must not open the update dialog\n"; + return 8; + } + bool update_check_requested = false; + window->on_check_for_updates([&] { + update_check_requested = true; + window->set_update_check_status(crossdesk::ui::UpdateCheckStatus::Checking); + }); + window->invoke_check_for_updates(); + if (!update_check_requested || window->get_update_check_status() != + crossdesk::ui::UpdateCheckStatus::Checking) { + std::cerr << "Manual update check must expose the checking state\n"; + return 8; + } + window->set_update_available(false); + window->set_update_check_status(crossdesk::ui::UpdateCheckStatus::Idle); if (const char *snapshot_path = std::getenv("CROSSDESK_SETTINGS_UI_SNAPSHOT")) { window->set_about_open(false); diff --git a/apps/desktop/tests/update_checker_test.cpp b/apps/desktop/tests/update_checker_test.cpp new file mode 100644 index 00000000..dddf83a2 --- /dev/null +++ b/apps/desktop/tests/update_checker_test.cpp @@ -0,0 +1,162 @@ +#include "update_checker.h" + +#include +#include +#include +#include + +namespace { +using namespace std::chrono_literals; +using crossdesk::UpdateChecker; +using crossdesk::VersionInfo; +using Status = UpdateChecker::Status; +using TimePoint = UpdateChecker::TimePoint; + +bool Expect(const char* name, bool condition) { + if (!condition) { + std::cerr << "Failed: " << name << '\n'; + } + return condition; +} + +bool Complete(UpdateChecker& checker, TimePoint now) { + const auto deadline = std::chrono::steady_clock::now() + 5s; + while (std::chrono::steady_clock::now() < deadline) { + if (checker.Poll(now)) { + return true; + } + std::this_thread::sleep_for(1ms); + } + return Expect("worker finishes without blocking UI polling", false); +} + +bool TestAsyncAndSchedule() { + std::promise release; + const auto gate = release.get_future().share(); + std::promise started; + auto started_future = started.get_future(); + std::atomic calls{0}; + UpdateChecker checker("1.0.0", [&]() -> std::optional { + if (++calls == 1) { + started.set_value(); + gate.wait(); + } + return VersionInfo{"1.0.0"}; + }); + const TimePoint now{}; + bool ok = Expect("initial state has no result", !checker.latest()); + checker.Poll(now); + ok &= Expect("startup check starts asynchronously", + checker.status() == Status::Checking && + started_future.wait_for(5s) == std::future_status::ready); + ok &= Expect("manual request shares running startup check", + !checker.RequestCheck(now)); + checker.Poll(now + 8h); + ok &= Expect("overdue timer cannot overlap active request", calls == 1); + release.set_value(); + ok &= Complete(checker, now); + ok &= Expect( + "successful current version result", + checker.status() == Status::UpToDate && !checker.update_available()); + checker.Poll(now + 6h - 1ms); + ok &= + Expect("no check before six hours", checker.status() == Status::UpToDate); + checker.Poll(now + 6h); + ok &= Complete(checker, now + 6h); + ok &= Expect("periodic check runs once", calls == 2); + // Simulate the process receiving no ticks while the machine is asleep. + checker.Poll(now + 20h); + ok &= Complete(checker, now + 20h); + ok &= Expect("wake starts one overdue check without replaying missed checks", + calls == 3); + ok &= Expect("manual check bypasses six-hour interval", + checker.RequestCheck(now + 20h + 1min)); + ok &= Complete(checker, now + 20h + 1min); + checker.Stop(); + checker.Poll(now + 48h); + ok &= Expect("stopped checker cannot restart", + !checker.RequestCheck(now + 48h) && calls == 4); + return ok; +} + +bool TestRetryAndCachedResult() { + int calls = 0; + UpdateChecker checker( + "1.3.5-1-20260529", [&]() -> std::optional { + ++calls; + if (calls <= 4 || calls == 7) { + return std::nullopt; + } + if (calls == 5) { + return VersionInfo{"1.3.5", "Patch release", "Fixes", "2026-05-29", + 2}; + } + if (calls == 6) { + throw std::runtime_error("simulated network failure"); + } + return VersionInfo{"1.3.5", "", "", "", 1}; + }); + TimePoint now{}; + checker.Poll(now); + bool ok = Complete(checker, now); + ok &= Expect("startup failure is not up to date", + checker.status() == Status::Failed && !checker.latest()); + for (const auto delay : {5min, 30min, 60min, 60min}) { + checker.Poll(now + delay - 1ms); + ok &= Expect("failure does not retry early", + checker.status() == Status::Failed); + now += delay; + checker.Poll(now); + ok &= Complete(checker, now); + } + ok &= Expect("metadata patch detects update after retries", + checker.status() == Status::Available && + checker.update_available() && calls == 5); + checker.RequestCheck(now + 1min); + now += 1min; + ok &= Complete(checker, now); + ok &= Expect("exception is a failure and preserves previous update details", + checker.status() == Status::Failed && + checker.update_available() && checker.latest() && + checker.latest()->release_name == "Patch release" && + checker.latest()->release_notes == "Fixes" && + checker.latest()->patch == 2); + checker.Poll(now + 5min - 1ms); + ok &= Expect("successful check resets backoff", + checker.status() == Status::Failed); + now += 5min; + checker.Poll(now); + ok &= Complete(checker, now); + ok &= Expect("retry is due five minutes after failure", calls == 7); + checker.RequestCheck(now + 1min); + ok &= Complete(checker, now + 1min); + ok &= Expect("new successful result replaces stale metadata", + checker.status() == Status::UpToDate && + !checker.update_available() && + checker.latest()->release_notes.empty() && + checker.latest()->patch == 1); + return ok; +} + +bool TestStopDuringFetch() { + std::atomic finished{false}; + UpdateChecker checker("1.0.0", [&]() -> std::optional { + std::this_thread::sleep_for(10ms); + finished = true; + return VersionInfo{"2.0.0"}; + }); + checker.RequestCheck(); + checker.Stop(); + return Expect("shutdown joins the worker and discards pending UI results", + finished && !checker.Poll() && !checker.latest() && + !checker.RequestCheck()); +} + +} // namespace + +int main() { + bool ok = TestAsyncAndSchedule(); + ok &= TestRetryAndCachedResult(); + ok &= TestStopDuringFetch(); + return ok ? 0 : 1; +} diff --git a/apps/desktop/tests/version_checker_test.cpp b/apps/desktop/tests/version_checker_test.cpp index 1ff14c05..f827ad84 100644 --- a/apps/desktop/tests/version_checker_test.cpp +++ b/apps/desktop/tests/version_checker_test.cpp @@ -1,7 +1,9 @@ #include "version_checker.h" #include +#include #include +#include namespace { @@ -50,5 +52,49 @@ int main() { "v1.3.5-9-20260529", "v1.3.6-1-20260529", "", -1), true); + const auto info = + crossdesk::ParseVersionInfo({{"latest_version", "v1.3.5-20260529"}, + {"patch", "10"}, + {"releaseName", "Release"}, + {"releaseNotes", "Changes"}, + {"releaseDate", "2026-05-29"}}); + ok &= ExpectEqual("valid metadata is normalized and retained", + info && info->version == "1.3.5-20260529" && + info->patch == 10 && info->release_name == "Release" && + info->release_notes == "Changes" && + info->release_date == "2026-05-29", + true); + const auto legacy = crossdesk::ParseVersionInfo({{"version", "1.3.6"}}); + ok &= ExpectEqual("legacy version field is supported", + legacy && legacy->version == "1.3.6" && legacy->patch == -1, + true); + for (const auto& json : + std::vector{nullptr, + nlohmann::json::array(), + nlohmann::json::object(), + {{"version", false}}, + {{"version", ""}}, + {{"version", "unknown"}}, + {{"version", "1..2"}}, + {{"version", ".1.2"}}, + {{"version", "1.2."}}, + {{"version", "1.2-"}}, + {{"version", "999999999999999999999.2"}}}) { + ok &= ExpectEqual("invalid response is a failed check: " + json.dump(), + crossdesk::ParseVersionInfo(json).has_value(), false); + } + const auto malformed_optional = crossdesk::ParseVersionInfo( + {{"version", "1.3.5"}, + {"patch", std::numeric_limits::max()}, + {"releaseName", nullptr}, + {"releaseNotes", 123}}); + ok &= ExpectEqual("bad optional metadata does not leak into later checks", + malformed_optional && malformed_optional->patch == -1 && + malformed_optional->release_name.empty() && + malformed_optional->release_notes.empty(), + true); + ok &= ExpectEqual("plain comparison has no hidden metadata from prior checks", + crossdesk::IsNewerVersion("1.3.5", "1.3.5"), false); + return ok ? 0 : 1; } diff --git a/apps/desktop/xmake/targets.lua b/apps/desktop/xmake/targets.lua index a72b559b..5ca9dbda 100644 --- a/apps/desktop/xmake/targets.lua +++ b/apps/desktop/xmake/targets.lua @@ -197,6 +197,12 @@ function setup_targets() add_frameworks("Security", "CoreFoundation") end + target("update_checker_test") + set_kind("binary") + set_default(false) + add_deps("version_checker") + add_files("apps/desktop/tests/update_checker_test.cpp") + target("privacy") set_kind("object") add_deps("rd_log", "crossdesk_wire")