[feat] add in-app Windows updates with automatic download and setup launch

This commit is contained in:
dijunkun
2026-09-09 18:13:21 +08:00
parent 4f200095f4
commit c2c74913e3
9 changed files with 466 additions and 4 deletions
@@ -1413,6 +1413,21 @@ void GuiApplication::BindMainCallbacks() {
: slint::SharedString{});
});
main->on_open_download([this] { OpenUrl("https://crossdesk.cn"); });
main->on_start_update([this] {
#if _WIN32
windows_updater_.Start(latest_version_info_);
SyncWindowsUpdate();
#else
OpenUrl("https://crossdesk.cn");
ui_->main->set_update_open(false);
#endif
});
main->on_cancel_update([this] {
#if _WIN32
windows_updater_.Cancel();
#endif
ui_->main->set_update_open(false);
});
main->on_connection_cancel([this] {
const auto props = FindRemoteSession(ui_->connection_dialog_remote_id);
if (!props) {
@@ -2100,6 +2115,9 @@ void GuiApplication::SyncMainWindow() {
ui_->main->set_signal_tls_error(signal_status_ ==
SignalStatus::SignalTlsCertError);
ui_->main->set_update_available(update_available_);
#if _WIN32
SyncWindowsUpdate();
#endif
ui_->main->set_current_version(CROSSDESK_VERSION);
ui_->main->set_latest_version(UiText(latest_version_));
ui_->main->set_release_name(UiText(release_name_));
@@ -3257,7 +3275,51 @@ bool GuiApplication::OpenUrl(const std::string& url) {
return SDL_OpenURL(url.c_str());
}
#if _WIN32
void GuiApplication::SyncWindowsUpdate() {
using State = WindowsUpdater::State;
if (windows_updater_.GetState() == State::Ready) {
windows_updater_.Launch();
if (windows_updater_.GetState() == State::Launched) {
ui_->main->set_update_open(false);
}
}
const State state = windows_updater_.GetState();
const bool downloading = state == State::Downloading;
ui_->main->set_update_downloading(downloading || state == State::Launching);
ui_->main->set_update_failed(state == State::Failed ||
state == State::LaunchFailed);
const int language = localization_language_index_;
std::string status;
if (downloading) {
status = localization::update_downloading[language];
const uint64_t total = windows_updater_.Total();
const uint64_t received = windows_updater_.Downloaded();
if (total > 0) {
const int percent = static_cast<int>(std::min(100.0, 100.0 * received / total));
status += " " + std::to_string(percent) + "%";
ui_->main->set_update_progress(static_cast<float>(percent) / 100.0f);
} else {
status += " " + std::to_string(received / (1024 * 1024)) + " MB";
ui_->main->set_update_progress(-1.0f);
}
} else if (state == State::Failed) {
status = localization::update_download_failed[language];
} else if (state == State::LaunchFailed) {
status = localization::update_launch_failed[language];
} else if (state == State::Launching) {
status = localization::update_launching[language];
} else if (state == State::Launched) {
status = localization::update_installer_opened[language];
}
ui_->main->set_update_status(UiText(status));
}
#endif
void GuiApplication::Cleanup() {
#if _WIN32
windows_updater_.Cancel();
#endif
if (!ui_) {
SDL_Quit();
return;
@@ -16,6 +16,9 @@
#include "runtime/cursor_state_provider.h"
#include "runtime/gui_runtime.h"
#if _WIN32
#include "platform/windows/windows_updater.h"
#endif
namespace crossdesk {
@@ -52,6 +55,9 @@ private:
void HandlePasswordChangeResult();
void HandleCredentialRecovery();
void SyncMainWindow();
#if _WIN32
void SyncWindowsUpdate();
#endif
void SyncConnectionDialog();
void SyncPlatformDialogs();
void SyncStreamWindow();
@@ -87,6 +93,9 @@ private:
bool OpenUrl(const std::string &url);
std::unique_ptr<SlintUi> ui_;
#if _WIN32
WindowsUpdater windows_updater_;
#endif
CursorStateProvider cursor_state_provider_;
std::unordered_map<std::string, CursorDeliveryState>
cursor_delivery_states_;
@@ -100,4 +109,4 @@ private:
} // namespace crossdesk
#endif
#endif
@@ -184,6 +184,20 @@ struct TranslationRow {
X(notification, u8"通知", "Notification", u8"Уведомление") \
X(new_version_available, u8"新版本可用", "New Version Available", \
u8"Доступна новая версия") \
X(update_downloading, u8"正在下载安装包…", "Downloading installer…", \
u8"Загрузка установщика…") \
X(update_launching, u8"正在打开安装包,请确认管理员权限请求…", \
"Opening installer. Please confirm the administrator permission request…", \
u8"Запуск установщика. Подтвердите запрос прав администратора…") \
X(update_download_failed, u8"下载失败,请点击更新重试,或访问官网。", \
"Download failed. Click Update to retry, or visit the website.", \
u8"Ошибка загрузки. Повторите обновление или откройте сайт.") \
X(update_launch_failed, u8"安装包未能启动或授权已取消,请点击更新重试。", \
"Installer launch failed or permission was cancelled. Click Update to retry.", \
u8"Установщик не запущен или доступ отменён. Повторите обновление.") \
X(update_installer_opened, u8"安装包已打开,请在安装向导中完成更新。", \
"Installer opened. Follow the setup wizard to finish updating.", \
u8"Установщик открыт. Следуйте указаниям мастера установки.") \
X(release_notes, u8"更新内容", "Release Notes", u8"Содержание обновления") \
X(version, u8"版本", "Version", u8"Версия") \
X(release_date, u8"发布日期: ", "Release Date: ", u8"Дата релиза: ") \
+28 -3
View File
@@ -210,6 +210,10 @@ 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 <bool> update-downloading: false;
in property <bool> update-failed: false;
in property <float> update-progress: 0;
in property <string> update-status: "";
in property <string> current-version: "";
in property <string> latest-version: "";
in property <string> release-name: "";
@@ -268,6 +272,8 @@ export component MainWindow inherits Window {
callback cancel-self-hosted-settings;
callback browse-save-path;
callback open-download;
callback start-update;
callback cancel-update;
callback connection-cancel;
callback connection-acknowledge;
callback connection-submit-password(string, bool);
@@ -1644,7 +1650,7 @@ export component MainWindow inherits Window {
x: 30px;
y: 105px;
width: 360px;
height: 190px;
height: root.update-status != "" ? 136px : 190px;
background: white;
border-width: 1px;
border-color: #b9bdc3;
@@ -1671,8 +1677,27 @@ export component MainWindow inherits Window {
}
}
}
CompactButton { x: root.compact-language ? 163px : 130px; y: 309px; width: root.compact-language ? 34px : 70px; primary: true; text: UiStrings.download; clicked => { root.open-download(); root.update-open = false; } }
CompactButton { x: root.compact-language ? 204px : 207px; y: 309px; width: root.compact-language ? 34px : 56px; text: UiStrings.later; clicked => { root.update-open = false; } }
if root.update-status != "": Text {
x: 30px; y: 247px; width: 340px; height: 40px;
text: root.update-status;
color: root.update-failed ? #b23b3b : #3d434d;
font-size: ImGuiFontStyle.body;
wrap: word-wrap;
vertical-alignment: center;
}
if root.update-downloading: Rectangle {
x: 30px; y: 291px; width: 340px; height: 4px;
background: #e2e4e7;
clip: true;
Rectangle {
x: root.update-progress < 0 ? (parent.width + self.width) * (mod(animation-tick(), 1500ms) / 1500ms) - self.width : 0px;
width: parent.width * (root.update-progress < 0 ? 0.25 : root.update-progress);
height: parent.height;
background: #334fd1;
}
}
CompactButton { x: root.compact-language ? 163px : 130px; y: 309px; width: root.compact-language ? 34px : 70px; primary: true; text: UiStrings.download; enabled: !root.update-downloading; clicked => { root.start-update(); } }
CompactButton { x: root.compact-language ? 204px : 207px; y: 309px; width: root.compact-language ? 34px : 56px; text: UiStrings.later; clicked => { root.cancel-update(); } }
}
}
@@ -0,0 +1,123 @@
#include "windows_updater.h"
#include <httplib.h>
#include <shellapi.h>
#include <windows.h>
#include <random>
#include <stdexcept>
#include "installer_download.h"
#include "rd_log.h"
namespace crossdesk {
WindowsUpdater::~WindowsUpdater() {
Cancel();
if (worker_.joinable()) worker_.join();
RemoveDownload();
}
void WindowsUpdater::RemoveDownload() {
// A running NSIS installer still needs its source file. Leave that one in
// the user's temporary directory; clean up cancelled/failed attempts.
if (installer_.empty() || installer_launched_) return;
std::error_code error;
std::filesystem::remove(installer_, error);
std::filesystem::remove(installer_.parent_path(), error);
}
void WindowsUpdater::Start(const nlohmann::json& metadata) {
const State state = GetState();
if (state == State::Downloading || state == State::Launching) return;
if (state == State::Ready || state == State::LaunchFailed ||
state == State::Launched) {
cancelled_ = false;
Launch();
return;
}
if (worker_.joinable()) worker_.join();
RemoveDownload();
installer_.clear();
installer_launched_ = false;
cancelled_ = false;
downloaded_ = 0;
total_ = 0;
const auto download = GetWindowsInstallerDownload(metadata);
if (!download) {
LOG_WARN("No valid Windows installer for the advertised version");
state_ = State::Failed;
return;
}
try {
std::random_device random;
const auto temp = std::filesystem::temp_directory_path();
for (int attempt = 0; attempt < 10; ++attempt) {
const auto directory =
temp / ("CrossDesk-update-" + std::to_string(random()) + "-" +
std::to_string(random()));
if (std::filesystem::create_directory(directory)) {
installer_ = directory / download->filename;
break;
}
}
if (installer_.empty())
throw std::runtime_error("Cannot create update directory");
client_ = std::make_shared<httplib::Client>(download->origin);
client_->set_connection_timeout(5);
client_->set_read_timeout(5);
client_->set_write_timeout(5);
state_ = State::Downloading;
worker_ = std::thread([this, path = download->path] {
const bool success =
DownloadInstaller(*client_, path, installer_,
[this](uint64_t received, uint64_t total) {
downloaded_ = received;
total_ = total;
return !cancelled_.load();
});
state_ = success ? State::Ready : State::Failed;
if (cancelled_) state_ = State::Idle;
});
} catch (const std::exception& error) {
LOG_WARN("Cannot start Windows update: {}", error.what());
state_ = State::Failed;
}
}
void WindowsUpdater::Cancel() {
cancelled_ = true;
if (client_) client_->stop();
if (GetState() == State::Ready) state_ = State::Idle;
}
void WindowsUpdater::Launch() {
if (cancelled_) return;
const State state = GetState();
if (state != State::Ready && state != State::LaunchFailed &&
state != State::Launched)
return;
if (worker_.joinable()) worker_.join();
const std::wstring executable = installer_.wstring();
const std::wstring directory = installer_.parent_path().wstring();
state_ = State::Launching;
SHELLEXECUTEINFOW info{};
info.cbSize = sizeof(info);
info.fMask = SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NOASYNC | SEE_MASK_FLAG_NO_UI;
info.lpVerb = L"runas";
info.lpFile = executable.c_str();
info.lpDirectory = directory.c_str();
info.nShow = SW_SHOWNORMAL;
if (!ShellExecuteExW(&info)) {
LOG_WARN("Cannot launch Windows installer, error={}", GetLastError());
state_ = State::LaunchFailed;
return;
}
if (info.hProcess) CloseHandle(info.hProcess);
installer_launched_ = true;
state_ = State::Launched;
// NSIS already prompts to close CrossDesk and stops the installed service.
// Keep this process alive if the user subsequently cancels installation.
}
} // namespace crossdesk
@@ -0,0 +1,57 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-09
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _WINDOWS_UPDATER_H_
#define _WINDOWS_UPDATER_H_
#include <atomic>
#include <filesystem>
#include <memory>
#include <nlohmann/json.hpp>
#include <thread>
namespace httplib {
class Client;
}
namespace crossdesk {
class WindowsUpdater {
public:
enum class State {
Idle,
Downloading,
Ready,
Launching,
Failed,
LaunchFailed,
Launched
};
~WindowsUpdater();
void Start(const nlohmann::json& metadata);
void Cancel();
void Launch();
State GetState() const { return state_.load(); }
uint64_t Downloaded() const { return downloaded_.load(); }
uint64_t Total() const { return total_.load(); }
private:
void RemoveDownload();
std::atomic<State> state_{State::Idle};
std::atomic<bool> cancelled_{false};
std::atomic<uint64_t> downloaded_{0};
std::atomic<uint64_t> total_{0};
std::shared_ptr<httplib::Client> client_;
std::thread worker_;
std::filesystem::path installer_;
bool installer_launched_ = false;
};
} // namespace crossdesk
#endif
@@ -0,0 +1,128 @@
#include "installer_download.h"
#include <httplib.h>
#include <array>
#include <fstream>
#include "rd_log.h"
namespace crossdesk {
namespace {
// Reject error pages and files without Windows executable headers before
// invoking the loader. The download uses the verified official HTTPS URL.
bool IsWindowsExecutable(const std::filesystem::path& path) {
std::ifstream input(path, std::ios::binary);
std::array<unsigned char, 64> header{};
input.read(reinterpret_cast<char*>(header.data()), header.size());
if (!input || header[0] != 'M' || header[1] != 'Z') return false;
const uint32_t pe_offset =
uint32_t(header[60]) | (uint32_t(header[61]) << 8) |
(uint32_t(header[62]) << 16) | (uint32_t(header[63]) << 24);
if (pe_offset < header.size()) return false;
input.seekg(pe_offset);
std::array<char, 4> signature{};
input.read(signature.data(), signature.size());
return input && signature == std::array<char, 4>{'P', 'E', 0, 0};
}
struct PartialFile {
std::filesystem::path path;
~PartialFile() {
std::error_code error;
std::filesystem::remove(path, error);
}
};
} // namespace
std::optional<InstallerDownload> GetWindowsInstallerDownload(
const nlohmann::json& metadata) {
if (!metadata.is_object()) return std::nullopt;
const auto version_it = metadata.find(
metadata.contains("latest_version") ? "latest_version" : "version");
if (version_it == metadata.end() || !version_it->is_string()) {
return std::nullopt;
}
std::string version = version_it->get<std::string>();
if (!version.empty() && version.front() == 'v') version.erase(0, 1);
if (version.empty() ||
version.find_first_not_of(
"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.-") !=
std::string::npos) {
return std::nullopt;
}
const auto downloads = metadata.find("downloads");
if (downloads == metadata.end() || !downloads->is_object())
return std::nullopt;
const auto installer = downloads->find("windows-x64");
if (installer == downloads->end() || !installer->is_object())
return std::nullopt;
const auto url = installer->find("url");
const auto filename = installer->find("filename");
InstallerDownload result{"https://downloads.crossdesk.cn", "",
"crossdesk-win-x64-v" + version + ".exe"};
result.path = "/" + result.filename;
if (url == installer->end() || !url->is_string() ||
url->get<std::string>() != result.origin + result.path ||
(filename != installer->end() &&
(!filename->is_string() ||
filename->get<std::string>() != result.filename))) {
return std::nullopt;
}
return result;
}
bool DownloadInstaller(
httplib::Client& client, const std::string& path,
const std::filesystem::path& destination,
const std::function<bool(uint64_t, uint64_t)>& progress) {
auto partial_path = destination;
partial_path += ".part";
PartialFile partial{partial_path};
try {
std::ofstream output(partial.path, std::ios::binary | std::ios::trunc);
if (!output) return false;
uint64_t received = 0;
uint64_t total = 0;
bool accepted = false;
// Do not follow a redirect to an unverified origin or downgrade to HTTP.
client.set_follow_location(false);
client.set_decompress(false);
const auto response = client.Get(
path,
[&](const httplib::Response& headers) {
if (headers.status != 200) return false;
if (headers.has_header("Content-Length")) {
total = headers.get_header_value_u64("Content-Length");
}
accepted = progress(0, total);
return accepted;
},
[&](const char* data, size_t size) {
if (!accepted || !progress(received, total)) return false;
output.write(data, static_cast<std::streamsize>(size));
received += size;
return output.good() && progress(received, total);
});
output.close();
if (!response || response->status != 200 || !accepted || output.fail() ||
received == 0 || (total != 0 && received != total) ||
!progress(received, total) || !IsWindowsExecutable(partial.path)) {
LOG_WARN(
"Installer download failed or cancelled: HTTP={}, error={}, "
"received={}, expected={}",
response ? response->status : 0, httplib::to_string(response.error()),
received, total);
return false;
}
std::filesystem::rename(partial.path, destination);
return true;
} catch (const std::exception& error) {
LOG_WARN("Installer download failed: {}", error.what());
return false;
}
}
} // namespace crossdesk
@@ -0,0 +1,41 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-09
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _INSTALLER_DOWNLOAD_H_
#define _INSTALLER_DOWNLOAD_H_
#include <cstdint>
#include <filesystem>
#include <functional>
#include <nlohmann/json.hpp>
#include <optional>
#include <string>
namespace httplib {
class Client;
}
namespace crossdesk {
struct InstallerDownload {
std::string origin;
std::string path;
std::string filename;
};
// Only accept the official Windows installer for the advertised version.
std::optional<InstallerDownload> GetWindowsInstallerDownload(
const nlohmann::json& metadata);
// Streams to a .part file and publishes the installer only after a complete,
// successful response. Returning false from progress cancels the transfer.
bool DownloadInstaller(httplib::Client& client, const std::string& path,
const std::filesystem::path& destination,
const std::function<bool(uint64_t, uint64_t)>& progress);
} // namespace crossdesk
#endif
+3
View File
@@ -285,6 +285,9 @@ function setup_targets()
add_defines("CROSSDESK_VERSION=\"" .. (get_config("CROSSDESK_VERSION") or "Unknown") .. "\"")
add_deps("rd_log")
add_files("apps/desktop/src/version_checker/*.cpp")
if is_os("windows") then
add_files("apps/desktop/src/platform/windows/windows_updater.cpp")
end
add_includedirs("apps/desktop/src/version_checker", {public = true})
if is_os("macosx") then
add_defines("CPPHTTPLIB_USE_CERTS_FROM_MACOSX_KEYCHAIN")