diff --git a/apps/desktop/src/gui/application/gui_application.cpp b/apps/desktop/src/gui/application/gui_application.cpp index 48b6633a..48bad82e 100644 --- a/apps/desktop/src/gui/application/gui_application.cpp +++ b/apps/desktop/src/gui/application/gui_application.cpp @@ -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(std::min(100.0, 100.0 * received / total)); + status += " " + std::to_string(percent) + "%"; + ui_->main->set_update_progress(static_cast(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; diff --git a/apps/desktop/src/gui/application/gui_application.h b/apps/desktop/src/gui/application/gui_application.h index f05d757d..4bdea7e5 100644 --- a/apps/desktop/src/gui/application/gui_application.h +++ b/apps/desktop/src/gui/application/gui_application.h @@ -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 ui_; +#if _WIN32 + WindowsUpdater windows_updater_; +#endif CursorStateProvider cursor_state_provider_; std::unordered_map cursor_delivery_states_; @@ -100,4 +109,4 @@ private: } // namespace crossdesk -#endif \ No newline at end of file +#endif diff --git a/apps/desktop/src/gui/assets/localization/localization_data.h b/apps/desktop/src/gui/assets/localization/localization_data.h index 03726883..76cd066a 100644 --- a/apps/desktop/src/gui/assets/localization/localization_data.h +++ b/apps/desktop/src/gui/assets/localization/localization_data.h @@ -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"Дата релиза: ") \ diff --git a/apps/desktop/src/gui/ui/main_window.slint b/apps/desktop/src/gui/ui/main_window.slint index 8de436a1..5b98593a 100644 --- a/apps/desktop/src/gui/ui/main_window.slint +++ b/apps/desktop/src/gui/ui/main_window.slint @@ -210,6 +210,10 @@ export component MainWindow inherits Window { in property signal-tls-error: false; in property <[RecentConnection]> recent-connections; in property update-available: false; + in property update-downloading: false; + in property update-failed: false; + in property update-progress: 0; + in property update-status: ""; in property current-version: ""; in property latest-version: ""; in property 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(); } } } } diff --git a/apps/desktop/src/platform/windows/windows_updater.cpp b/apps/desktop/src/platform/windows/windows_updater.cpp new file mode 100644 index 00000000..97c64595 --- /dev/null +++ b/apps/desktop/src/platform/windows/windows_updater.cpp @@ -0,0 +1,123 @@ +#include "windows_updater.h" + +#include +#include +#include + +#include +#include + +#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(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 diff --git a/apps/desktop/src/platform/windows/windows_updater.h b/apps/desktop/src/platform/windows/windows_updater.h new file mode 100644 index 00000000..9c699563 --- /dev/null +++ b/apps/desktop/src/platform/windows/windows_updater.h @@ -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 +#include +#include +#include +#include + +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::Idle}; + std::atomic cancelled_{false}; + std::atomic downloaded_{0}; + std::atomic total_{0}; + std::shared_ptr client_; + std::thread worker_; + std::filesystem::path installer_; + bool installer_launched_ = false; +}; + +} // namespace crossdesk + +#endif \ No newline at end of file diff --git a/apps/desktop/src/version_checker/installer_download.cpp b/apps/desktop/src/version_checker/installer_download.cpp new file mode 100644 index 00000000..c0655c27 --- /dev/null +++ b/apps/desktop/src/version_checker/installer_download.cpp @@ -0,0 +1,128 @@ +#include "installer_download.h" + +#include + +#include +#include + +#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 header{}; + input.read(reinterpret_cast(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 signature{}; + input.read(signature.data(), signature.size()); + return input && signature == std::array{'P', 'E', 0, 0}; +} + +struct PartialFile { + std::filesystem::path path; + ~PartialFile() { + std::error_code error; + std::filesystem::remove(path, error); + } +}; + +} // namespace + +std::optional 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(); + 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() != result.origin + result.path || + (filename != installer->end() && + (!filename->is_string() || + filename->get() != result.filename))) { + return std::nullopt; + } + return result; +} + +bool DownloadInstaller( + httplib::Client& client, const std::string& path, + const std::filesystem::path& destination, + const std::function& 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(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 diff --git a/apps/desktop/src/version_checker/installer_download.h b/apps/desktop/src/version_checker/installer_download.h new file mode 100644 index 00000000..7561b3e9 --- /dev/null +++ b/apps/desktop/src/version_checker/installer_download.h @@ -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 +#include +#include +#include +#include +#include + +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 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& progress); + +} // namespace crossdesk + +#endif \ No newline at end of file diff --git a/apps/desktop/xmake/targets.lua b/apps/desktop/xmake/targets.lua index 05a7b0ce..92941b57 100644 --- a/apps/desktop/xmake/targets.lua +++ b/apps/desktop/xmake/targets.lua @@ -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")