diff --git a/.github/workflows/update-version-json.yml b/.github/workflows/update-version-json.yml index d92bd950..909496cb 100644 --- a/.github/workflows/update-version-json.yml +++ b/.github/workflows/update-version-json.yml @@ -92,8 +92,9 @@ jobs: - name: Generate or update version.json run: | - # If version.json exists, try to preserve downloads section - if [ -f version.json ] && jq -e '.downloads' version.json > /dev/null 2>&1; then + # Preserve download URLs only for this release, never an older version. + if [ -f version.json ] && jq -e --arg tag "${{ steps.version.outputs.TAG_NAME }}" \ + '.tagName == $tag and (.downloads | type == "object")' version.json > /dev/null 2>&1; then EXISTING_DOWNLOADS=$(jq -c '.downloads' version.json) if [ "$EXISTING_DOWNLOADS" != "null" ] && [ "$EXISTING_DOWNLOADS" != "{}" ]; then DOWNLOADS_JSON="$EXISTING_DOWNLOADS" diff --git a/apps/desktop/src/gui/application/gui_application.cpp b/apps/desktop/src/gui/application/gui_application.cpp index 48bad82e..16f9d614 100644 --- a/apps/desktop/src/gui/application/gui_application.cpp +++ b/apps/desktop/src/gui/application/gui_application.cpp @@ -3286,9 +3286,12 @@ void GuiApplication::SyncWindowsUpdate() { } 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_downloading(downloading || state == State::Checking || + state == State::Launching); + ui_->main->set_update_security_blocked(state == State::SecurityBlocked); ui_->main->set_update_failed(state == State::Failed || - state == State::LaunchFailed); + state == State::LaunchFailed || + state == State::SecurityBlocked); const int language = localization_language_index_; std::string status; if (downloading) { @@ -3307,6 +3310,11 @@ void GuiApplication::SyncWindowsUpdate() { status = localization::update_download_failed[language]; } else if (state == State::LaunchFailed) { status = localization::update_launch_failed[language]; + } else if (state == State::Checking) { + status = localization::update_checking[language]; + ui_->main->set_update_progress(1.0f); + } else if (state == State::SecurityBlocked) { + status = localization::update_security_blocked[language]; } else if (state == State::Launching) { status = localization::update_launching[language]; } else if (state == State::Launched) { diff --git a/apps/desktop/src/gui/assets/localization/localization_data.h b/apps/desktop/src/gui/assets/localization/localization_data.h index 76cd066a..f5d3b7ad 100644 --- a/apps/desktop/src/gui/assets/localization/localization_data.h +++ b/apps/desktop/src/gui/assets/localization/localization_data.h @@ -186,6 +186,12 @@ struct TranslationRow { u8"Доступна новая версия") \ X(update_downloading, u8"正在下载安装包…", "Downloading installer…", \ u8"Загрузка установщика…") \ + X(update_checking, u8"正在进行 Windows 安全检查…", \ + "Running Windows security checks…", u8"Проверка безопасности Windows…") \ + X(update_security_blocked, \ + u8"Windows 安全检查未通过,请查看安全软件日志并核实发布者。", \ + "Windows security checks failed. Review security logs and verify the publisher.", \ + u8"Проверка Windows не пройдена. Проверьте журнал защиты и издателя.") \ X(update_launching, u8"正在打开安装包,请确认管理员权限请求…", \ "Opening installer. Please confirm the administrator permission request…", \ u8"Запуск установщика. Подтвердите запрос прав администратора…") \ diff --git a/apps/desktop/src/gui/ui/main_window.slint b/apps/desktop/src/gui/ui/main_window.slint index 5b98593a..5acbeb98 100644 --- a/apps/desktop/src/gui/ui/main_window.slint +++ b/apps/desktop/src/gui/ui/main_window.slint @@ -212,6 +212,7 @@ export component MainWindow inherits Window { in property update-available: false; in property update-downloading: false; in property update-failed: false; + in property update-security-blocked: false; in property update-progress: 0; in property update-status: ""; in property current-version: ""; @@ -1696,7 +1697,7 @@ export component MainWindow inherits Window { 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 ? 163px : 130px; y: 309px; width: root.compact-language ? 34px : 70px; primary: true; text: UiStrings.download; enabled: !root.update-downloading && !root.update-security-blocked; 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_installer.cpp b/apps/desktop/src/platform/windows/windows_installer.cpp new file mode 100644 index 00000000..c94b176c --- /dev/null +++ b/apps/desktop/src/platform/windows/windows_installer.cpp @@ -0,0 +1,224 @@ +#include "windows_installer.h" + +#include + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "rd_log.h" + +namespace crossdesk { +namespace { + +constexpr uint64_t kMaxInstallerSize = 1024ull * 1024 * 1024; +// Stable identity used only for Windows Attachment Services policy/UI. +constexpr GUID kAttachmentClient = { + 0x72d17a6b, + 0xeac1, + 0x4816, + {0x91, 0x40, 0x33, 0x1b, 0xa7, 0x76, 0x22, 0xcd}}; + +void CheckWin32(BOOL success, const char* operation) { + if (!success) { + throw std::system_error(static_cast(GetLastError()), + std::system_category(), operation); + } +} + +struct InternetCloser { + void operator()(HINTERNET handle) const { WinHttpCloseHandle(handle); } +}; +using InternetHandle = std::unique_ptr; + +struct PartialFile { + std::filesystem::path path; + ~PartialFile() { + std::error_code error; + std::filesystem::remove(path, error); + } +}; + +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}; +} + +uint64_t ContentLength(HINTERNET request) { + std::array value{}; + DWORD bytes = static_cast(sizeof(value)); + if (!WinHttpQueryHeaders(request, WINHTTP_QUERY_CONTENT_LENGTH, + WINHTTP_HEADER_NAME_BY_INDEX, value.data(), &bytes, + WINHTTP_NO_HEADER_INDEX)) { + if (GetLastError() == ERROR_WINHTTP_HEADER_NOT_FOUND) return 0; + CheckWin32(FALSE, "WinHttpQueryHeaders(Content-Length)"); + } + const std::wstring length(value.data()); + if (length.empty() || + length.find_first_not_of(L"0123456789") != std::wstring::npos) { + throw std::runtime_error("Invalid installer content length"); + } + return std::stoull(length); +} + +class ComApartment { + public: + ComApartment() : status_(CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED)) {} + ~ComApartment() { + if (SUCCEEDED(status_)) CoUninitialize(); + } + bool Ready() const { + return SUCCEEDED(status_) || status_ == RPC_E_CHANGED_MODE; + } + + private: + HRESULT status_; +}; + +bool HandleAttachment(const std::filesystem::path& installer, + const std::string& source, bool execute) { + ComApartment apartment; + if (!apartment.Ready()) { + LOG_WARN("Cannot initialize Windows Attachment Services"); + return false; + } + Microsoft::WRL::ComPtr attachment; + HRESULT result = + CoCreateInstance(CLSID_AttachmentServices, nullptr, CLSCTX_INPROC_SERVER, + IID_PPV_ARGS(&attachment)); + const std::wstring source_url(source.begin(), source.end()); + if (SUCCEEDED(result)) result = attachment->SetClientGuid(kAttachmentClient); + if (SUCCEEDED(result)) + result = attachment->SetClientTitle(L"CrossDesk Update"); + if (SUCCEEDED(result)) result = attachment->SetLocalPath(installer.c_str()); + if (SUCCEEDED(result)) result = attachment->SetSource(source_url.c_str()); + if (SUCCEEDED(result)) { + if (execute) { + HANDLE process = nullptr; + // Execute applies attachment policy, scanning, and any required prompts. + // The installer's manifest, rather than this process, requests elevation. + result = attachment->Execute(GetActiveWindow(), L"open", &process); + if (process) CloseHandle(process); + } else { + // Save records the source zone and invokes installed trust/AV services. + result = attachment->Save(); + } + } + if (FAILED(result)) { + LOG_WARN("Windows Attachment Services {} failed/blocked, HRESULT=0x{:08X}", + execute ? "execute" : "save", static_cast(result)); + return false; + } + return true; +} + +} // namespace + +bool DownloadWindowsInstaller( + const InstallerDownload& download, const std::filesystem::path& destination, + const std::function& progress) { + auto partial_path = destination; + partial_path += ".part"; + PartialFile partial{partial_path}; + try { + if (!progress(0, 0)) return false; + const std::string agent = "CrossDesk/" CROSSDESK_VERSION " WindowsUpdater"; + const std::wstring user_agent(agent.begin(), agent.end()); + InternetHandle session( + WinHttpOpen(user_agent.c_str(), WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, + WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0)); + CheckWin32(session != nullptr, "WinHttpOpen"); + CheckWin32(WinHttpSetTimeouts(session.get(), 5000, 5000, 5000, 5000), + "WinHttpSetTimeouts"); + InternetHandle connection(WinHttpConnect(session.get(), + L"downloads.crossdesk.cn", + INTERNET_DEFAULT_HTTPS_PORT, 0)); + CheckWin32(connection != nullptr, "WinHttpConnect"); + const std::wstring path(download.path.begin(), download.path.end()); + InternetHandle request(WinHttpOpenRequest( + connection.get(), L"GET", path.c_str(), nullptr, WINHTTP_NO_REFERER, + WINHTTP_DEFAULT_ACCEPT_TYPES, WINHTTP_FLAG_SECURE)); + CheckWin32(request != nullptr, "WinHttpOpenRequest"); + DWORD disabled = WINHTTP_DISABLE_REDIRECTS; + CheckWin32(WinHttpSetOption(request.get(), WINHTTP_OPTION_DISABLE_FEATURE, + &disabled, sizeof(disabled)), + "Disable redirects"); + // Use Windows certificate validation and configured proxies as-is. Do not + // ignore TLS errors, spoof a browser, or retry through alternative hosts. + CheckWin32(WinHttpSendRequest(request.get(), WINHTTP_NO_ADDITIONAL_HEADERS, + 0, WINHTTP_NO_REQUEST_DATA, 0, 0, 0), + "WinHttpSendRequest"); + CheckWin32(WinHttpReceiveResponse(request.get(), nullptr), + "WinHttpReceiveResponse"); + DWORD status = 0; + DWORD status_size = sizeof(status); + CheckWin32(WinHttpQueryHeaders( + request.get(), + WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + WINHTTP_HEADER_NAME_BY_INDEX, &status, &status_size, + WINHTTP_NO_HEADER_INDEX), + "WinHttpQueryHeaders(status)"); + if (status != 200) { + LOG_WARN("Windows installer download returned HTTP {}", status); + return false; + } + const uint64_t total = ContentLength(request.get()); + if (total > kMaxInstallerSize || !progress(0, total)) return false; + std::ofstream output(partial.path, std::ios::binary | std::ios::trunc); + if (!output) + throw std::runtime_error("Cannot create installer download file"); + uint64_t received = 0; + std::array buffer{}; + while (progress(received, total)) { + DWORD count = 0; + CheckWin32(WinHttpReadData(request.get(), buffer.data(), + static_cast(buffer.size()), &count), + "WinHttpReadData"); + if (count == 0) break; + received += count; + if (received > kMaxInstallerSize || (total > 0 && received > total)) + return false; + output.write(reinterpret_cast(buffer.data()), count); + if (!output) + throw std::runtime_error("Cannot write installer download file"); + } + output.close(); + if (output.fail() || received == 0 || (total > 0 && received != total) || + !progress(received, total) || !IsWindowsExecutable(partial.path)) + return false; + LOG_INFO("Windows installer downloaded, bytes={}", received); + std::filesystem::rename(partial.path, destination); + return true; + } catch (const std::exception& error) { + LOG_WARN("Windows installer download failed: {}", error.what()); + return false; + } +} + +bool CheckWindowsInstaller(const std::filesystem::path& installer, + const std::string& source) { + return HandleAttachment(installer, source, false); +} + +bool LaunchWindowsInstaller(const std::filesystem::path& installer, + const std::string& source) { + return HandleAttachment(installer, source, true); +} + +} // namespace crossdesk diff --git a/apps/desktop/src/platform/windows/windows_installer.h b/apps/desktop/src/platform/windows/windows_installer.h new file mode 100644 index 00000000..345db529 --- /dev/null +++ b/apps/desktop/src/platform/windows/windows_installer.h @@ -0,0 +1,33 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-09-09 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _WINDOWS_INSTALLER_H_ +#define _WINDOWS_INSTALLER_H_ + +#include +#include +#include +#include + +#include "installer_download.h" + +namespace crossdesk { + +bool DownloadWindowsInstaller( + const InstallerDownload& download, const std::filesystem::path& destination, + const std::function& progress); + +// Attachment Services records the Internet source and applies Windows policy +// and installed antivirus checks. Failure must never fall back to direct +// launch. +bool CheckWindowsInstaller(const std::filesystem::path& installer, + const std::string& source); +bool LaunchWindowsInstaller(const std::filesystem::path& installer, + const std::string& source); + +} // namespace crossdesk + +#endif diff --git a/apps/desktop/src/platform/windows/windows_updater.cpp b/apps/desktop/src/platform/windows/windows_updater.cpp index 97c64595..8f7ba2e9 100644 --- a/apps/desktop/src/platform/windows/windows_updater.cpp +++ b/apps/desktop/src/platform/windows/windows_updater.cpp @@ -1,7 +1,6 @@ #include "windows_updater.h" -#include -#include +#include #include #include @@ -9,6 +8,7 @@ #include "installer_download.h" #include "rd_log.h" +#include "windows_installer.h" namespace crossdesk { @@ -20,7 +20,7 @@ WindowsUpdater::~WindowsUpdater() { 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. + // the user's update cache; clean up cancelled/failed attempts. if (installer_.empty() || installer_launched_) return; std::error_code error; std::filesystem::remove(installer_, error); @@ -29,7 +29,9 @@ void WindowsUpdater::RemoveDownload() { void WindowsUpdater::Start(const nlohmann::json& metadata) { const State state = GetState(); - if (state == State::Downloading || state == State::Launching) return; + if (state == State::Downloading || state == State::Checking || + state == State::Launching || state == State::SecurityBlocked) + return; if (state == State::Ready || state == State::LaunchFailed || state == State::Launched) { cancelled_ = false; @@ -51,11 +53,18 @@ void WindowsUpdater::Start(const nlohmann::json& metadata) { } try { std::random_device random; - const auto temp = std::filesystem::temp_directory_path(); + PWSTR local_app_data = nullptr; + const HRESULT path_result = SHGetKnownFolderPath( + FOLDERID_LocalAppData, KF_FLAG_CREATE, nullptr, &local_app_data); + if (FAILED(path_result)) + throw std::runtime_error("Cannot locate LocalAppData"); + const std::filesystem::path data_path(local_app_data); + CoTaskMemFree(local_app_data); + const auto updates = data_path / L"CrossDesk" / L"Updates"; + std::filesystem::create_directories(updates); for (int attempt = 0; attempt < 10; ++attempt) { - const auto directory = - temp / ("CrossDesk-update-" + std::to_string(random()) + "-" + - std::to_string(random())); + const auto directory = updates / ("download-" + std::to_string(random()) + + "-" + std::to_string(random())); if (std::filesystem::create_directory(directory)) { installer_ = directory / download->filename; break; @@ -63,20 +72,23 @@ void WindowsUpdater::Start(const nlohmann::json& metadata) { } 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); + source_url_ = download->origin + download->path; 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; + worker_ = std::thread([this, download = *download] { + const bool success = DownloadWindowsInstaller( + download, installer_, [this](uint64_t received, uint64_t total) { + downloaded_ = received; + total_ = total; + return !cancelled_.load(); + }); + if (success && !cancelled_) { + state_ = State::Checking; + state_ = CheckWindowsInstaller(installer_, source_url_) + ? State::Ready + : State::SecurityBlocked; + } else { + state_ = State::Failed; + } if (cancelled_) state_ = State::Idle; }); } catch (const std::exception& error) { @@ -87,7 +99,6 @@ void WindowsUpdater::Start(const nlohmann::json& metadata) { void WindowsUpdater::Cancel() { cancelled_ = true; - if (client_) client_->stop(); if (GetState() == State::Ready) state_ = State::Idle; } @@ -98,22 +109,11 @@ void WindowsUpdater::Launch() { 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()); + if (!LaunchWindowsInstaller(installer_, source_url_)) { 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. diff --git a/apps/desktop/src/platform/windows/windows_updater.h b/apps/desktop/src/platform/windows/windows_updater.h index 9c699563..859bc17e 100644 --- a/apps/desktop/src/platform/windows/windows_updater.h +++ b/apps/desktop/src/platform/windows/windows_updater.h @@ -9,14 +9,10 @@ #include #include -#include #include +#include #include -namespace httplib { -class Client; -} - namespace crossdesk { class WindowsUpdater { @@ -24,9 +20,11 @@ class WindowsUpdater { enum class State { Idle, Downloading, + Checking, Ready, Launching, Failed, + SecurityBlocked, LaunchFailed, Launched }; @@ -46,12 +44,12 @@ class WindowsUpdater { std::atomic cancelled_{false}; std::atomic downloaded_{0}; std::atomic total_{0}; - std::shared_ptr client_; std::thread worker_; std::filesystem::path installer_; + std::string source_url_; bool installer_launched_ = false; }; } // namespace crossdesk -#endif \ No newline at end of file +#endif diff --git a/apps/desktop/src/version_checker/installer_download.cpp b/apps/desktop/src/version_checker/installer_download.cpp index c0655c27..3e36a860 100644 --- a/apps/desktop/src/version_checker/installer_download.cpp +++ b/apps/desktop/src/version_checker/installer_download.cpp @@ -1,42 +1,6 @@ #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; @@ -74,55 +38,4 @@ std::optional GetWindowsInstallerDownload( 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 index 7561b3e9..a60bf27d 100644 --- a/apps/desktop/src/version_checker/installer_download.h +++ b/apps/desktop/src/version_checker/installer_download.h @@ -7,17 +7,10 @@ #ifndef _INSTALLER_DOWNLOAD_H_ #define _INSTALLER_DOWNLOAD_H_ -#include -#include -#include #include #include #include -namespace httplib { -class Client; -} - namespace crossdesk { struct InstallerDownload { @@ -30,12 +23,6 @@ struct InstallerDownload { 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 +#endif diff --git a/apps/desktop/xmake/targets.lua b/apps/desktop/xmake/targets.lua index 92941b57..af8be993 100644 --- a/apps/desktop/xmake/targets.lua +++ b/apps/desktop/xmake/targets.lua @@ -286,7 +286,9 @@ function setup_targets() 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") + add_files("apps/desktop/src/platform/windows/windows_updater.cpp", + "apps/desktop/src/platform/windows/windows_installer.cpp") + add_links("winhttp", "ole32", "uuid") end add_includedirs("apps/desktop/src/version_checker", {public = true}) if is_os("macosx") then diff --git a/docs/windows-updates.md b/docs/windows-updates.md new file mode 100644 index 00000000..62d174e4 --- /dev/null +++ b/docs/windows-updates.md @@ -0,0 +1,48 @@ +# Windows 更新与安全软件兼容性 + +Windows 的更新按钮在后台下载发布信息中指定版本的安装包,完成校验及 +Windows 附件安全检查后打开安装向导。客户端使用 WinHTTP,保留系统的 +HTTPS 证书检查和代理配置,并用 `CrossDesk/ WindowsUpdater` +标识请求。 + +安装包保存在 `%LOCALAPPDATA%\CrossDesk\Updates` 下的独立目录中。 +下载过程写入 `.part` 文件;HTTP 响应、文件长度、Windows 可执行文件头 +均通过检查后才生成最终安装包。 + +客户端通过 Windows Attachment Services 的 `Save` 记录下载来源并请求 +系统安全检查,再通过 `Execute` 打开安装包。这些 API 会按 Windows 策略 +调用扫描、信任检查和必要的提示。安装程序由自身的管理员权限清单请求 +UAC 授权。安全检查失败时停止流程,不回退到直接启动,也不删除来源标记。 + +## 发行版签名 + +应使用受信任的正式代码签名证书签署发布的主程序、服务、辅助程序、DLL +和最终安装包。只签安装包不能建立已安装主程序的发布者身份。 +SignPath 的 `test-signing` 用于测试,不能代替公开发行所需的受信任签名。 +当前流水线中的测试签名配置仍保持为可选项;正式策略需在取得证书及 +SignPath 授权后配置。 + +检查实际分发文件的签名: + +```powershell +Get-AuthenticodeSignature .\crossdesk.exe +Get-AuthenticodeSignature .\crossdesk-win-x64-.exe +``` + +## 网络防护告警的处理 + +2026-09-09 的天擎日志记录了在域名解析阶段对 +`downloads.crossdesk.cn` 和 `101.132.143.203` 的阻断。 +该记录不能单独证明 HTTP 库、提权方式或文件内容是命中原因。 +切换到标准 Windows API 也不能保证域名/IP 信誉告警消失。 + +出现同类告警时,保留告警时间、策略名称、域名/IP、程序版本及签名状态, +由安全管理员向厂商核查命中原因,同时检查服务器是否存在异常活动。 +确认误报后按厂商流程纠正信誉或企业策略。应在策略允许的测试环境中 +验证下载、取消、不完整文件、安全检查失败及安装向导启动流程。 + +相关 API: + +- [WinHttpOpen](https://learn.microsoft.com/windows/win32/api/winhttp/nf-winhttp-winhttpopen) +- [IAttachmentExecute::Save](https://learn.microsoft.com/windows/win32/api/shobjidl_core/nf-shobjidl_core-iattachmentexecute-save) +- [IAttachmentExecute::Execute](https://learn.microsoft.com/windows/win32/api/shobjidl_core/nf-shobjidl_core-iattachmentexecute-execute)