[feat] add background and manual update checks

This commit is contained in:
dijunkun
2026-09-24 17:36:41 +08:00
parent e0cd4c0567
commit c661319890
18 changed files with 629 additions and 90 deletions
@@ -10,7 +10,6 @@
#include <atomic>
#include <chrono>
#include <cstdint>
#include <nlohmann/json.hpp>
#include <string>
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_;
@@ -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<std::string>();
} else if (latest_version_info_.contains("version") &&
latest_version_info_["version"].is_string()) {
version = latest_version_info_["version"].get<std::string>();
}
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<std::string>();
}
if (latest_version_info_.contains("releaseName") &&
latest_version_info_["releaseName"].is_string()) {
release_name_ = latest_version_info_["releaseName"].get<std::string>();
}
if (latest_version_info_.contains("releaseDate") &&
latest_version_info_["releaseDate"].is_string()) {
release_date_ = latest_version_info_["releaseDate"].get<std::string>();
}
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<UpdateChecker>(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<ui::ReleaseNoteBlock> 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<ui::ReleaseNoteBlock> 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();
}
@@ -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<SlintUi> ui_;
std::unique_ptr<UpdateChecker> update_checker_;
CursorStateProvider cursor_state_provider_;
std::unordered_map<std::string, CursorDeliveryState>
cursor_delivery_states_;
@@ -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"Дата релиза: ") \
@@ -9,6 +9,7 @@
#include <fstream>
#include <functional>
#include <memory>
#include <nlohmann/json.hpp>
#include <string>
#include <thread>
@@ -1,6 +1,7 @@
#include "runtime/peer_event_handler.h"
#include <remote_action.h>
#include <nlohmann/json.hpp>
#include <algorithm>
#include <chrono>
@@ -1,6 +1,7 @@
#include "runtime/peer_event_handler.h"
#include <remote_action.h>
#include <nlohmann/json.hpp>
#include <algorithm>
#include <chrono>
+1 -1
View File
@@ -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";
+91 -17
View File
@@ -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 <string> local-desktop: "Local Desktop";
in-out property <string> remote-desktop: "Remote Desktop";
@@ -59,6 +61,10 @@ export global UiStrings {
in-out property <string> signal-disconnected: "Signal server disconnected";
in-out property <string> tls-error: "Signal server TLS certificate error";
in-out property <string> update-available: "A new version is available";
in-out property <string> check-for-updates: "Check for updates";
in-out property <string> checking-for-updates: "Checking for updates…";
in-out property <string> up-to-date: "You’re up to date";
in-out property <string> update-check-failed: "Check failed. Please try again later.";
in-out property <string> release-notes: "Release notes";
in-out property <string> later: "Later";
in-out property <string> download: "Download";
@@ -257,6 +263,11 @@ export component MainWindow inherits Window {
in property <bool> signal-tls-error: false;
in property <[RecentConnection]> recent-connections;
in property <bool> update-available: false;
in property <UpdateCheckStatus> update-check-status: idle;
private property <string> 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 <string> current-version: "";
in property <string> latest-version: "";
in property <string> 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 <length> settings-scroll-y: 0px;
in-out property <bool> self-host-settings-open: false;
in-out property <bool> about-open: false;
in-out property <bool> update-open: root.update-available;
in-out property <bool> update-open: false;
in-out property <bool> reset-password-open: false;
in-out property <bool> reset-password-invalid: false;
in-out property <string> 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; } }
}
}
@@ -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]));
@@ -0,0 +1,84 @@
#include "update_checker.h"
#include <algorithm>
#include <utility>
#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<VersionInfo> 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
@@ -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 <chrono>
#include <functional>
#include <future>
#include <optional>
#include <string>
#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<std::optional<VersionInfo>()>;
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<VersionInfo>& latest() const { return latest_; }
private:
void ScheduleRetry(TimePoint now);
std::string current_version_;
Fetch fetch_;
std::future<std::optional<VersionInfo>> pending_;
std::optional<VersionInfo> latest_;
TimePoint next_check_{};
Status status_ = Status::Idle;
unsigned int failures_ = 0;
bool update_available_ = false;
bool stopped_ = false;
};
} // namespace crossdesk
#endif
@@ -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<int> SplitVersion(const std::string& ver);
namespace {
@@ -202,18 +198,20 @@ int CompareNumericVersion(const std::vector<int>& 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<unsigned long long>();
if (parsed > static_cast<unsigned long long>(std::numeric_limits<int>::max())) {
return false;
}
*patch = static_cast<int>(parsed);
return true;
}
if (patch_value.is_number_integer()) {
const long long parsed = patch_value.get<long long>();
if (parsed < 0 || parsed > std::numeric_limits<int>::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<VersionInfo> 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>()
: 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<VersionInfo> 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;
}
}
@@ -8,11 +8,21 @@
#define _VERSION_CHECKER_H_
#include <nlohmann/json.hpp>
#include <optional>
#include <string>
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<VersionInfo> ParseVersionInfo(const nlohmann::json& json);
std::optional<VersionInfo> CheckUpdate();
bool IsNewerVersion(const std::string& current, const std::string& latest);
+32 -2
View File
@@ -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);
+162
View File
@@ -0,0 +1,162 @@
#include "update_checker.h"
#include <atomic>
#include <iostream>
#include <stdexcept>
#include <thread>
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<void> release;
const auto gate = release.get_future().share();
std::promise<void> started;
auto started_future = started.get_future();
std::atomic<int> calls{0};
UpdateChecker checker("1.0.0", [&]() -> std::optional<VersionInfo> {
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<VersionInfo> {
++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<bool> finished{false};
UpdateChecker checker("1.0.0", [&]() -> std::optional<VersionInfo> {
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;
}
@@ -1,7 +1,9 @@
#include "version_checker.h"
#include <iostream>
#include <limits>
#include <string>
#include <vector>
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<nlohmann::json>{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<uint64_t>::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;
}
+6
View File
@@ -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")