diff --git a/.github/scripts/update_version_manifest.py b/.github/scripts/update_version_manifest.py index 8550681f..5ae436e3 100644 --- a/.github/scripts/update_version_manifest.py +++ b/.github/scripts/update_version_manifest.py @@ -6,6 +6,10 @@ import datetime import json from pathlib import Path import re +import hashlib +import os +import subprocess +import tempfile DOWNLOAD_BASE = "https://downloads.crossdesk.cn/" @@ -22,6 +26,29 @@ ARTIFACTS = { "android-arm64": ("android-arm64(?:-unsigned)?", "apk"), } +def sign_update(payload, private_key, expected_public_key): + """Sign exact UTF-8 JSON bytes using a PEM Ed25519 key, never shell text.""" + if not re.fullmatch(r"[0-9a-f]{64}", expected_public_key): + raise ValueError("CROSSDESK_UPDATE_PUBLIC_KEY must contain 64 lowercase hex characters") + with tempfile.TemporaryDirectory(prefix="crossdesk-sign-") as directory: + root = Path(directory) + key = root / "key.pem" + key.write_text(private_key, encoding="utf-8") + key.chmod(0o600) + public = subprocess.run(["openssl", "pkey", "-in", str(key), "-pubout", "-outform", "DER"], + check=True, capture_output=True).stdout + # RFC 8410 SubjectPublicKeyInfo prefix for an Ed25519 public key. + if public != bytes.fromhex("302a300506032b6570032100" + expected_public_key): + raise ValueError("Update signing key does not match the public key built into clients") + data = root / "payload.json" + data.write_bytes(payload.encode("utf-8")) + signature = subprocess.run( + ["openssl", "pkeyutl", "-sign", "-rawin", "-inkey", str(key), "-in", str(data)], + check=True, capture_output=True).stdout + if len(signature) != 64: + raise ValueError("Expected an Ed25519 signature") + return {"payload": payload, "signature": signature.hex()} + def version_info(version, fallback_date=None): version = version.removeprefix("v") @@ -47,6 +74,8 @@ def version_info(version, fallback_date=None): def artifact_downloads(directory): downloads = {} + private_key = os.environ.get("CROSSDESK_UPDATE_PRIVATE_KEY", "") + public_key = os.environ.get("CROSSDESK_UPDATE_PUBLIC_KEY", "") for path in sorted(Path(directory).iterdir()): if not path.is_file(): continue @@ -65,6 +94,19 @@ def artifact_downloads(directory): continue downloads[key] = {"url": DOWNLOAD_BASE + path.name, "filename": path.name, "version": metadata["version"], "releaseDate": metadata["releaseDate"]} + with path.open("rb") as artifact: + hasher = hashlib.sha256() + for chunk in iter(lambda: artifact.read(65536), b""): + hasher.update(chunk) + digest = hasher.hexdigest() + downloads[key].update(size=path.stat().st_size, sha256=digest) + if private_key and key.startswith(("windows-", "macos-", "linux-")) and "portable" not in key: + payload = json.dumps({ + "schema": 1, "platform": key, "version": metadata["version"], + "url": DOWNLOAD_BASE + path.name, "filename": path.name, + "size": path.stat().st_size, "sha256": digest, + }, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + downloads[key]["update"] = sign_update(payload, private_key, public_key) break if not downloads: raise ValueError("No supported release artifacts found") diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3ae9d4de..40ed78b4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,6 +28,7 @@ permissions: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} XMAKE_MIN_VERSION: "3.1.1" + CROSSDESK_UPDATE_PUBLIC_KEY: ${{ vars.CROSSDESK_UPDATE_PUBLIC_KEY }} jobs: build-linux: @@ -752,6 +753,7 @@ jobs: - name: Generate version.json for all platforms env: RELEASE_TAG: ${{ steps.version.outputs.VERSION_WITH_V }} + CROSSDESK_UPDATE_PRIVATE_KEY: ${{ secrets.CROSSDESK_UPDATE_PRIVATE_KEY }} run: | python3 .github/scripts/update_version_manifest.py \ --current current-version.json --output version.json --artifacts release \ diff --git a/.github/workflows/update-version-json.yml b/.github/workflows/update-version-json.yml index 5be433b8..d2969469 100644 --- a/.github/workflows/update-version-json.yml +++ b/.github/workflows/update-version-json.yml @@ -70,6 +70,9 @@ jobs: - name: Merge release version and download URLs shell: bash + env: + CROSSDESK_UPDATE_PUBLIC_KEY: ${{ vars.CROSSDESK_UPDATE_PUBLIC_KEY }} + CROSSDESK_UPDATE_PRIVATE_KEY: ${{ secrets.CROSSDESK_UPDATE_PRIVATE_KEY }} run: | python3 .github/scripts/update_version_manifest.py \ --current current-version.json --output version.json --artifacts release \ diff --git a/apps/desktop/resources/windows/crossdesk_updater.manifest b/apps/desktop/resources/windows/crossdesk_updater.manifest new file mode 100644 index 00000000..3eb69f01 --- /dev/null +++ b/apps/desktop/resources/windows/crossdesk_updater.manifest @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/apps/desktop/resources/windows/crossdesk_updater.rc b/apps/desktop/resources/windows/crossdesk_updater.rc new file mode 100644 index 00000000..4b60a12a --- /dev/null +++ b/apps/desktop/resources/windows/crossdesk_updater.rc @@ -0,0 +1,5 @@ +1 24 "crossdesk_updater.manifest" +IDI_ICON1 ICON "crossdesk.ico" +#define CROSSDESK_FILE_DESCRIPTION "CrossDesk Updater" +#define CROSSDESK_ORIGINAL_FILENAME "crossdesk_updater.exe" +#include "version_info.rcinc" diff --git a/apps/desktop/scripts/linux/package_deb.sh b/apps/desktop/scripts/linux/package_deb.sh index 35fec013..121f277f 100755 --- a/apps/desktop/scripts/linux/package_deb.sh +++ b/apps/desktop/scripts/linux/package_deb.sh @@ -111,6 +111,7 @@ DESKTOP_DIR="$DEB_DIR/usr/share/applications" install -d "$DEBIAN_DIR" "$BIN_DIR" "$PRIVATE_DIR" "$DESKTOP_DIR" install -m 0755 "$BUILD_BINARY" "$PRIVATE_DIR/crossdesk-bin" +install -m 0755 "$(dirname "$BUILD_BINARY")/crossdesk_updater" "$PRIVATE_DIR/crossdesk_updater" # PipeWire is deliberately optional. The Wayland capturer resolves PipeWire # 0.3 with dlopen() so the same package can run on Ubuntu 20.04 without the @@ -156,7 +157,7 @@ for size in 16 24 32 48 64 96 128 256; do "$ICON_BASE_DIR/${size}x${size}/apps/${PKG_NAME}.png" done -RECOMMENDS="$PORTAL_RUNTIME_RECOMMENDS, $TRAY_RUNTIME_RECOMMENDS" +RECOMMENDS="$PORTAL_RUNTIME_RECOMMENDS, $TRAY_RUNTIME_RECOMMENDS, policykit-1" if [[ -n "$EXTRA_RECOMMENDATION" ]]; then RECOMMENDS="$RECOMMENDS, $EXTRA_RECOMMENDATION" fi diff --git a/apps/desktop/scripts/macos/pkg_arm64.sh b/apps/desktop/scripts/macos/pkg_arm64.sh index 6d018f6d..2eb6d818 100755 --- a/apps/desktop/scripts/macos/pkg_arm64.sh +++ b/apps/desktop/scripts/macos/pkg_arm64.sh @@ -51,6 +51,8 @@ mkdir -p build_pkg_temp mkdir -p "${MACOS_DIR}" "${RESOURCES_DIR}" cp "${EXECUTABLE_PATH}" "${MACOS_DIR}/${APP_NAME_UPPER}" +cp "$(dirname "${EXECUTABLE_PATH}")/crossdesk_updater" "${MACOS_DIR}/crossdesk_updater" +chmod +x "${MACOS_DIR}/crossdesk_updater" chmod +x "${MACOS_DIR}/${APP_NAME_UPPER}" if [ -f "${ICON_PATH}" ]; then diff --git a/apps/desktop/scripts/macos/pkg_x64.sh b/apps/desktop/scripts/macos/pkg_x64.sh index 313f41a7..b9647be3 100755 --- a/apps/desktop/scripts/macos/pkg_x64.sh +++ b/apps/desktop/scripts/macos/pkg_x64.sh @@ -51,6 +51,8 @@ mkdir -p build_pkg_temp mkdir -p "${MACOS_DIR}" "${RESOURCES_DIR}" cp "${EXECUTABLE_PATH}" "${MACOS_DIR}/${APP_NAME_UPPER}" +cp "$(dirname "${EXECUTABLE_PATH}")/crossdesk_updater" "${MACOS_DIR}/crossdesk_updater" +chmod +x "${MACOS_DIR}/crossdesk_updater" chmod +x "${MACOS_DIR}/${APP_NAME_UPPER}" if [ -f "${ICON_PATH}" ]; then diff --git a/apps/desktop/scripts/macos/verify_app.sh b/apps/desktop/scripts/macos/verify_app.sh index 27360097..7bf2dc08 100755 --- a/apps/desktop/scripts/macos/verify_app.sh +++ b/apps/desktop/scripts/macos/verify_app.sh @@ -71,6 +71,19 @@ verify_dependencies() { } verify_dependencies "$APP_EXECUTABLE" +UPDATER="$APP_BUNDLE/Contents/MacOS/crossdesk_updater" +if [[ ! -x "$UPDATER" ]] || ! lipo "$UPDATER" -verify_arch "$EXPECTED_ARCH" >/dev/null 2>&1; then + echo "Missing updater or incorrect updater architecture" >&2 + exit 1 +fi +# The updater runs outside the bundle during installation, so it must not rely +# on private frameworks that are being replaced. +while IFS= read -r dependency; do + case "$dependency" in + /System/Library/*|/usr/lib/*) ;; + *) echo "Updater has a non-system dynamic dependency: $dependency" >&2; exit 1 ;; + esac +done < <(otool -L "$UPDATER" | tail -n +2 | awk '{ print $1 }') SLINT_LIBRARY="$(find "$FRAMEWORKS_DIR" -maxdepth 1 -type f -name 'libslint_cpp*.dylib' -print -quit 2>/dev/null || true)" if [[ -z "$SLINT_LIBRARY" ]]; then diff --git a/apps/desktop/scripts/windows/nsis_script.nsi b/apps/desktop/scripts/windows/nsis_script.nsi index 37fe919c..95ae9fb0 100644 --- a/apps/desktop/scripts/windows/nsis_script.nsi +++ b/apps/desktop/scripts/windows/nsis_script.nsi @@ -39,6 +39,18 @@ RequestExecutionLevel admin ; Include LogicLib for process handling !include "LogicLib.nsh" +!include "FileFunc.nsh" +Var UpdateMode + +Function .onInit + ${GetParameters} $0 + ClearErrors + ${GetOptions} $0 "/CROSSDESK_UPDATE" $1 + StrCpy $UpdateMode 0 + ${IfNot} ${Errors} + StrCpy $UpdateMode 1 + ${EndIf} +FunctionEnd Name "${PRODUCT_NAME} ${PRODUCT_VERSION}" OutFile "crossdesk-win-x64-${PRODUCT_VERSION}.exe" @@ -55,6 +67,11 @@ InstallDirRegKey HKCU "Software\${PRODUCT_NAME}" "InstallDir" ShowInstDetails show Section "MainSection" + ; The updater has already coordinated shutdown. Never kill unrelated + ; instances by name during an unattended upgrade. + ${If} $UpdateMode = 1 + Goto installApp + ${EndIf} ; Check if CrossDesk is running StrCpy $1 "CrossDesk.exe" @@ -81,10 +98,12 @@ installApp: Call StopInstalledService SetOutPath "$INSTDIR" - SetOverwrite ifnewer + ; The signed release determines file versions, not filesystem timestamps. + SetOverwrite on ; Main application executable path File /oname=CrossDesk.exe "..\..\..\..\build\windows\x64\release\crossdesk.exe" + File "..\..\..\..\build\windows\x64\release\crossdesk_updater.exe" ; Bundle service-side binaries required by the Windows service flow File "..\..\..\..\build\windows\x64\release\crossdesk_service.exe" File "..\..\..\..\build\windows\x64\release\crossdesk_session_helper.exe" @@ -116,6 +135,7 @@ installApp: WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_REG_KEY}" "DisplayName" "${PRODUCT_NAME}" WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_REG_KEY}" "UninstallString" "$INSTDIR\uninstall.exe" WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_REG_KEY}" "DisplayVersion" "${PRODUCT_VERSION}" + WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_REG_KEY}" "InstallLocation" "$INSTDIR" WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_REG_KEY}" "Publisher" "${PRODUCT_PUBLISHER}" WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_REG_KEY}" "URLInfoAbout" "${PRODUCT_WEB_SITE}" WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_REG_KEY}" "DisplayIcon" "$INSTDIR\CrossDesk.exe" @@ -167,6 +187,7 @@ uninstallApp: ; Delete main executable and uninstaller Delete "$INSTDIR\CrossDesk.exe" + Delete "$INSTDIR\crossdesk_updater.exe" Delete "$INSTDIR\crossdesk_service.exe" Delete "$INSTDIR\crossdesk_session_helper.exe" Delete "$INSTDIR\crossdesk_privacy_cursor_helper.exe" @@ -196,9 +217,16 @@ SectionEnd ; ------ Functions ------ Function LaunchApp + ${If} $UpdateMode = 1 + Return + ${EndIf} Exec "$INSTDIR\CrossDesk.exe" FunctionEnd +Function .onInstFailed + SetErrorLevel 1 +FunctionEnd + Function StopInstalledService IfFileExists "$INSTDIR\CrossDesk.exe" 0 stop_with_sc IfFileExists "$INSTDIR\crossdesk_service.exe" 0 stop_with_sc diff --git a/apps/desktop/src/app/main.cpp b/apps/desktop/src/app/main.cpp index 498201d8..9c70bf50 100644 --- a/apps/desktop/src/app/main.cpp +++ b/apps/desktop/src/app/main.cpp @@ -30,6 +30,9 @@ #include "platform/single_instance.h" #include "rd_log.h" #include "render.h" +#if CROSSDESK_HAS_UPDATER +#include "updater/update_platform.h" +#endif #ifdef __linux__ #include "SimpleIni.h" @@ -472,6 +475,17 @@ int main(int argc, char* argv[]) { #endif using crossdesk::platform::InstanceResult; +#if CROSSDESK_HAS_UPDATER + // Installer CLI commands above stay available during maintenance. Ordinary + // launches (including daemon restarts) must not reopen the installation. + try { + if (crossdesk::updater::MaintenanceActive(crossdesk::updater::ExecutablePath())) + return 0; + } catch (const std::exception& error) { + std::cerr << "Cannot check update maintenance: " << error.what() << '\n'; + return 1; + } +#endif using crossdesk::platform::InstanceRole; crossdesk::platform::SingleInstanceGuard launcher_instance; crossdesk::platform::SingleInstanceGuard client_instance; diff --git a/apps/desktop/src/gui/application/gui_application.cpp b/apps/desktop/src/gui/application/gui_application.cpp index 60876d49..776b5241 100644 --- a/apps/desktop/src/gui/application/gui_application.cpp +++ b/apps/desktop/src/gui/application/gui_application.cpp @@ -86,6 +86,9 @@ #include "ui/release_notes_text.h" #include "ui/ui_localization.h" #include "update_checker.h" +#if CROSSDESK_HAS_UPDATER +#include "updater/update_controller.h" +#endif #include "window_geometry.h" namespace crossdesk { @@ -1134,6 +1137,9 @@ int GuiApplication::Run() { #endif InitializeModules(); update_checker_ = std::make_unique(CROSSDESK_VERSION); +#if CROSSDESK_HAS_UPDATER + updater_ = std::make_unique(path_manager_->GetCachePath()); +#endif InitializeUi(); #ifndef __OHOS__ ui_->activation_receiver = std::move(activation_receiver); @@ -1735,8 +1741,24 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) { ? UiText(std::to_string(signal_port)) : slint::SharedString{}); }); + main->on_cancel_update([this] { +#if CROSSDESK_HAS_UPDATER + if (updater_ && updater_->busy()) updater_->Cancel(); +#endif + }); main->on_open_download([this] { const auto& latest = update_checker_->latest(); +#if CROSSDESK_HAS_UPDATER + if (updater_ && updater_->busy()) return; + if (updater_ && latest && updater_->Available(latest->update_envelope)) { + const int language = localization::detail::ClampLanguageIndex(localization_language_index_); + if (tinyfd_messageBox("CrossDesk", localization::install_update_confirm[language].c_str(), + "okcancel", "question", 0)) { + updater_->Start(latest->update_envelope); + } + return; + } +#endif OpenUrl(latest && !latest->download_url.empty() ? latest->download_url : "https://crossdesk.cn"); }); @@ -2326,6 +2348,36 @@ void GuiApplication::Tick() { } void GuiApplication::PollUpdateCheck() { +#if CROSSDESK_HAS_UPDATER + if (updater_) { + // The event loop is running and initialization has completed. + static bool startup_confirmed = false; + if (!startup_confirmed) { + updater_->ConfirmStartup(); + startup_confirmed = true; + } + if (updater_->Poll()) { + exit_ = true; + return; + } + const auto& latest = update_checker_->latest(); + const bool can_install = latest && updater_->Available(latest->update_envelope); + const int language = localization::detail::ClampLanguageIndex(localization_language_index_); + std::string status; + if (updater_->busy()) { + status = localization::update_preparing[language]; + if (updater_->state() == updater::Controller::State::Downloading) + status += " " + std::to_string(updater_->progress()) + "%"; + } else if (updater_->state() == updater::Controller::State::Failed) { + status = localization::update_install_failed[language] + "\n" + updater_->error(); + } + for (const auto& main : {ui_->main, ui_->wall}) { + main->set_update_install_available(can_install); + main->set_update_install_busy(updater_->busy()); + main->set_update_install_status(UiText(status)); + } + } +#endif // 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() || @@ -6163,6 +6215,9 @@ void GuiApplication::Cleanup() { if (update_checker_) { update_checker_->Stop(); } +#if CROSSDESK_HAS_UPDATER + updater_.reset(); +#endif SDL_Quit(); } diff --git a/apps/desktop/src/gui/application/gui_application.h b/apps/desktop/src/gui/application/gui_application.h index bb3db1f2..0aa4bcf0 100644 --- a/apps/desktop/src/gui/application/gui_application.h +++ b/apps/desktop/src/gui/application/gui_application.h @@ -22,6 +22,7 @@ namespace crossdesk { class UpdateChecker; +namespace updater { class Controller; } // Slint application shell. GuiRuntime remains the owner of transport, media, // device, clipboard, transfer and settings behavior. @@ -126,6 +127,9 @@ private: std::unique_ptr ui_; std::unique_ptr update_checker_; +#if CROSSDESK_HAS_UPDATER + std::unique_ptr updater_; +#endif CursorStateProvider cursor_state_provider_; std::unordered_map cursor_delivery_states_; diff --git a/apps/desktop/src/gui/assets/localization/localization_data.h b/apps/desktop/src/gui/assets/localization/localization_data.h index 2749f5fc..822e50ba 100644 --- a/apps/desktop/src/gui/assets/localization/localization_data.h +++ b/apps/desktop/src/gui/assets/localization/localization_data.h @@ -317,6 +317,13 @@ struct TranslationRow { X(access_website, u8"访问官网: ", \ "Access Website: ", u8"Официальный сайт: ") \ X(update, u8"更新", "Update", u8"Обновить") \ + X(install_update, u8"安装更新", "Install update", u8"Установить обновление") \ + X(manual_download, u8"手动下载", "Download", u8"Скачать") \ + X(install_update_confirm, u8"下载完成后将关闭 CrossDesk 并安装更新,当前连接和文件传输会中断。安装需要系统授权,完成后重新启动。是否继续?", \ + "CrossDesk will close after downloading the update. Connections and file transfers will be interrupted. Installation requires system authorization, then CrossDesk will restart. Continue?", \ + u8"После загрузки CrossDesk закроется для обновления. Соединения и передача файлов будут прерваны. Потребуется разрешение системы, затем приложение перезапустится. Продолжить?") \ + X(update_preparing, u8"正在准备更新…", "Preparing update…", u8"Подготовка обновления…") \ + X(update_install_failed, u8"更新未完成,可重试或手动安装。", "Update did not complete. Retry or install manually.", u8"Обновление не завершено. Повторите или установите вручную.") \ X(connection_alias, u8"修改名称", "Edit Alias", \ u8"Изменить имя подключения") \ X(delete_connection, u8"删除连接", "Delete Connection", \ diff --git a/apps/desktop/src/gui/ui/main_window.slint b/apps/desktop/src/gui/ui/main_window.slint index 22c4bef3..ddb49688 100644 --- a/apps/desktop/src/gui/ui/main_window.slint +++ b/apps/desktop/src/gui/ui/main_window.slint @@ -99,6 +99,7 @@ export global UiStrings { in-out property view-release-notes: "Release notes"; in-out property later: "Later"; in-out property download: "Download"; + in-out property install-update: "Install update"; in-out property input-password: "Input password"; in-out property reinput-password: "Incorrect password, please try again"; in-out property remember-password: "Remember password"; @@ -408,6 +409,10 @@ export component MainWindow inherits Window { callback read-all-announcements; callback load-more-announcements; in property update-available: false; + in property update-install-available: false; + in property update-install-busy: false; + in property update-install-status: ""; + private property update-action-text: root.update-install-available ? UiStrings.install-update : UiStrings.download; in property update-check-status: idle; private property update-status-text: root.update-check-status == UpdateCheckStatus.checking ? UiStrings.checking-for-updates : @@ -460,7 +465,7 @@ export component MainWindow inherits Window { root.autostart-status == AutostartStatus.unsupported ? GalleryStrings.tr("设备不支持", "Unsupported", "Не поддерживается") : GalleryStrings.tr("读取失败", "Read failed", "Ошибка чтения"); in-out property autostart-guide-open: false; - changed autostart-guide-open => { if root.autostart-guide-open { focus-sink.focus(); } } + changed autostart-guide-open => { if root.autostart-guide-open { root.dialog-focus-pending = true; } } in-out property daemon-enabled: false; in-out property save-remote-previews: true; // Show the preference on every platform; only adapted hosts can change it. @@ -492,6 +497,7 @@ export component MainWindow inherits Window { callback cancel-self-hosted-settings; callback browse-save-path; callback open-download; + callback cancel-update; callback open-website; callback open-afdian-sponsor; callback open-github-sponsor; @@ -558,6 +564,23 @@ export component MainWindow inherits Window { private property recent-tooltip-online: false; private property recent-tooltip-pointer-x: 0px; private property recent-tooltip-pointer-y: 0px; + private property dialog-focus-pending: false; + + // A changed handler can run lazily while winit's accessibility adapter + // borrows its tree. Switching focus there reenters that adapter and panics. + // Coalesce requests and move focus on the next event-loop turn instead. + Timer { + interval: 1ms; + running: root.dialog-focus-pending; + triggered => { + root.dialog-focus-pending = false; + if root.settings-open || root.autostart-guide-open + || root.wayland-notice-open || root.messages-open || root.about-open + || (root.sponsorship-open && root.sponsorship-unobstructed) { + focus-sink.focus(); + } + } + } changed menu-open => { if root.menu-open { root.copy-feedback = CopyFeedback.idle; root.gallery-action("release", ""); } @@ -575,26 +598,26 @@ export component MainWindow inherits Window { root.gallery-bandwidth-draft = root.gallery-bandwidth-index; root.gallery-action("settings-open", ""); if root.autostart-system-managed { root.refresh-autostart-status(); } - focus-sink.focus(); + root.dialog-focus-pending = true; } else { root.autostart-guide-open = false; } } changed wayland-notice-open => { - if root.wayland-notice-open { focus-sink.focus(); } + if root.wayland-notice-open { root.dialog-focus-pending = true; } } changed messages-open => { - if root.messages-open { focus-sink.focus(); } + if root.messages-open { root.dialog-focus-pending = true; } } changed about-open => { - if root.about-open { focus-sink.focus(); } + if root.about-open { root.dialog-focus-pending = true; } } changed sponsorship-open => { - if root.sponsorship-open { focus-sink.focus(); } + if root.sponsorship-open { root.dialog-focus-pending = true; } } reset-remote-id => { @@ -2452,9 +2475,10 @@ export component MainWindow inherits Window { height: 32px; primary: true; text-size: 12px; - text: UiStrings.download; - clicked => { root.open-website(); } - about-update-button-metrics := Text { visible: false; text: UiStrings.download; font-size: 12px; } + text: root.update-action-text; + enabled: !root.update-install-busy; + clicked => { root.open-download(); root.about-open = false; root.update-open = true; } + about-update-button-metrics := Text { visible: false; text: root.update-action-text; font-size: 12px; } } SettingsButton { width: max(116px, check-button-metrics.preferred-width + 24px); @@ -2514,14 +2538,14 @@ export component MainWindow inherits Window { width: update-website-label.preferred-width; accessible-role: button; accessible-label: update-website-label.text; - accessible-action-default => { root.open-download(); } + accessible-action-default => { root.open-website(); } update-website-label := Text { text: UiStrings.access-website + "https://crossdesk.cn"; color: website-touch.has-hover ? #254cc0 : WorkbenchStyle.accent; font-size: 13px; vertical-alignment: center; } - website-touch := TouchArea { mouse-cursor: pointer; clicked => { root.open-download(); } } + website-touch := TouchArea { mouse-cursor: pointer; clicked => { root.open-website(); } } } } } @@ -2588,6 +2612,14 @@ export component MainWindow inherits Window { vertical-stretch: 0; spacing: 16px; Rectangle { height: 1px; background: WorkbenchStyle.separator; } + if root.update-install-status != "": Text { + text: root.update-install-status; + font-size: 12px; + color: WorkbenchStyle.muted; + wrap: word-wrap; + max-height: 64px; + overflow: elide; + } HorizontalLayout { alignment: end; spacing: 8px; @@ -2595,14 +2627,16 @@ export component MainWindow inherits Window { width: 88px; height: 34px; text: UiStrings.later; - clicked => { root.update-open = false; } + clicked => { root.cancel-update(); root.update-open = false; } } WorkbenchButton { - width: 88px; + width: max(110px, update-action-metrics.preferred-width + 24px); height: 34px; primary: true; - text: UiStrings.download; - clicked => { root.open-download(); root.update-open = false; } + enabled: !root.update-install-busy; + text: root.update-action-text; + clicked => { root.open-download(); } + update-action-metrics := Text { visible: false; text: root.update-action-text; font-size: 12px; } } } } diff --git a/apps/desktop/src/gui/ui/ui_localization.h b/apps/desktop/src/gui/ui/ui_localization.h index c23a15f8..cb5eb9fd 100644 --- a/apps/desktop/src/gui/ui/ui_localization.h +++ b/apps/desktop/src/gui/ui/ui_localization.h @@ -131,7 +131,8 @@ inline int ApplyMainWindowStrings( Text(localization::update_check_failed[language])); strings.set_release_notes(Text(localization::release_notes[language])); strings.set_view_release_notes(Text(localization::view_release_notes[language])); - strings.set_download(Text(localization::update[language])); + strings.set_download(Text(localization::manual_download[language])); + strings.set_install_update(Text(localization::install_update[language])); strings.set_input_password(Text(localization::input_password[language])); strings.set_reinput_password(Text(localization::reinput_password[language])); strings.set_remember_password( diff --git a/apps/desktop/src/platform/common/updater_posix.cpp b/apps/desktop/src/platform/common/updater_posix.cpp new file mode 100644 index 00000000..c1719202 --- /dev/null +++ b/apps/desktop/src/platform/common/updater_posix.cpp @@ -0,0 +1,134 @@ +#include "updater/update_platform.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace crossdesk::updater { +namespace { +std::string LockPath(const fs::path& application) { + return "/tmp/CrossDesk-update-" + Sha256(fs::weakly_canonical(application).string()) + ".lock"; +} +std::vector Argv(std::vector& args) { + std::vector values; + for (auto& argument : args) values.push_back(argument.data()); + values.push_back(nullptr); + return values; +} +int OpenLock(const std::string& path, bool create) { + constexpr int flags = O_RDONLY | O_CLOEXEC | O_NOFOLLOW; + int fd = open(path.c_str(), flags | (create ? O_CREAT | O_EXCL : 0), 0644); + if (fd < 0 && create && errno == EEXIST) fd = open(path.c_str(), flags); + if (fd >= 0) { + struct stat info{}; + if (fstat(fd, &info) || !S_ISREG(info.st_mode)) { + close(fd); errno = EINVAL; return -1; + } + // A restrictive umask must not leave a stale lock file that permanently + // prevents other users from probing whether an update is active. + if (create && info.st_uid == geteuid() && fchmod(fd, 0644)) { + const int error = errno; close(fd); errno = error; return -1; + } + } + return fd; +} +} // namespace + +bool IsPrivileged() { return geteuid() == 0; } +bool OwnedJobDirectory(const fs::path& job) { + struct stat info{}; + return lstat(job.c_str(), &info) == 0 && S_ISDIR(info.st_mode) && + info.st_uid == geteuid() && (info.st_mode & 0077) == 0; +} +fs::path PrivateDirectory() { + // Ignore TMPDIR in privileged processes. mkdtemp creates a private 0700 + // directory; installation only consumes a verified copy inside this directory. + std::string pattern = "/tmp/crossdesk-update-XXXXXX"; + if (!mkdtemp(pattern.data())) throw std::runtime_error("Cannot create private update directory"); + return fs::path(pattern); +} +fs::path HelperPath(const fs::path& application) { + return application.parent_path() / "crossdesk_updater"; +} +std::shared_ptr PinHelper(const fs::path&) { return {}; } +void RestoreServices() {} + +int Run(const fs::path& executable, const std::vector& arguments, bool elevate) { + // Platform-specific elevation builds a fixed argv and calls this with false. + if (elevate) throw std::runtime_error("Use platform elevation entry point"); + std::vector args{executable.string()}; + args.insert(args.end(), arguments.begin(), arguments.end()); + auto argv = Argv(args); + const pid_t pid = fork(); + if (pid < 0) throw std::runtime_error("Cannot start installer"); + if (pid == 0) { + execv(executable.c_str(), argv.data()); + _exit(127); + } + int status = 0; + pid_t result; + do { result = waitpid(pid, &status, 0); } while (result < 0 && errno == EINTR); + return result == pid && WIFEXITED(status) ? WEXITSTATUS(status) : 1; +} + +void Launch(const fs::path& executable, const std::vector& arguments) { + std::vector args{executable.string()}; + args.insert(args.end(), arguments.begin(), arguments.end()); + auto argv = Argv(args); + int errors[2]; + if (pipe(errors)) throw std::runtime_error("Cannot create launch pipe"); + fcntl(errors[0], F_SETFD, FD_CLOEXEC); + fcntl(errors[1], F_SETFD, FD_CLOEXEC); + const pid_t child = fork(); + if (child == 0) { + close(errors[0]); + if (setsid() < 0) _exit(127); + const pid_t detached = fork(); + if (detached < 0) _exit(127); + if (detached > 0) _exit(0); + const int null = open("/dev/null", O_RDWR); + if (null >= 0) { + dup2(null, STDIN_FILENO); dup2(null, STDOUT_FILENO); dup2(null, STDERR_FILENO); + if (null > STDERR_FILENO) close(null); + } + execv(executable.c_str(), argv.data()); + const int error = errno; + (void)write(errors[1], &error, sizeof(error)); + _exit(127); + } + close(errors[1]); + if (child < 0) { close(errors[0]); throw std::runtime_error("Cannot launch update helper"); } + int status = 0; + while (waitpid(child, &status, 0) < 0 && errno == EINTR) {} + int error = 0; + ssize_t bytes; + do { bytes = read(errors[0], &error, sizeof(error)); } while (bytes < 0 && errno == EINTR); + close(errors[0]); + if (bytes != 0 || !WIFEXITED(status) || WEXITSTATUS(status) != 0) + throw std::runtime_error("Cannot execute update helper"); +} + +MaintenanceLock::MaintenanceLock(const fs::path& application) { + const int fd = OpenLock(LockPath(application), true); + if (fd < 0) throw std::runtime_error("Cannot open update lock"); + if (flock(fd, LOCK_EX | LOCK_NB)) { close(fd); throw std::runtime_error("An update is already running"); } + handle_ = fd; +} +MaintenanceLock::~MaintenanceLock() { if (handle_ != -1) close(static_cast(handle_)); } +bool MaintenanceActive(const fs::path& application) { + const int fd = OpenLock(LockPath(application), false); + if (fd < 0) return errno != ENOENT; + const bool locked = flock(fd, LOCK_SH | LOCK_NB) != 0; + close(fd); + return locked; +} +} // namespace crossdesk::updater diff --git a/apps/desktop/src/platform/linux/updater/update_platform.cpp b/apps/desktop/src/platform/linux/updater/update_platform.cpp new file mode 100644 index 00000000..23fe77f7 --- /dev/null +++ b/apps/desktop/src/platform/linux/updater/update_platform.cpp @@ -0,0 +1,54 @@ +#include "updater/update_platform.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace crossdesk::updater { +fs::path ExecutablePath() { return fs::read_symlink("/proc/self/exe"); } +bool SupportedInstallation(const fs::path& application) { + return application == fs::path("/usr/lib/crossdesk/crossdesk-bin") && + !PlatformKey(application).empty() && fs::is_regular_file(HelperPath(application)) && + access("/usr/bin/pkexec", X_OK) == 0 && access("/usr/bin/apt-get", X_OK) == 0; +} +int RunElevated(const fs::path& executable, const fs::path& job) { + return Run("/usr/bin/pkexec", {executable.string(), "--install", job.string()}); +} +void WaitForApplicationExit(const fs::path& application) { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(45); + do { + bool running = false; + for (const auto& entry : fs::directory_iterator("/proc")) { + const auto name = entry.path().filename().string(); + if (name.empty() || name.find_first_not_of("0123456789") != std::string::npos) continue; + std::error_code error; + const auto executable = fs::read_symlink(entry.path() / "exe", error); + if (!error && executable == application) running = true; + // Fail closed when a matching process cannot be inspected. A disappeared + // process has no comm file and does not block installation. + if (error) { + std::ifstream comm(entry.path() / "comm"); + std::string process_name; + std::getline(comm, process_name); + if (process_name == "crossdesk-bin") running = true; + } + } + if (!running) return; + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + } while (std::chrono::steady_clock::now() < deadline); + throw std::runtime_error("CrossDesk is still running; stop other sessions or its supervisor and retry"); +} +void InstallPackage(const fs::path& package, const fs::path&) { + // No shell and no automatic dependency removal. apt owns dpkg locking and + // dependency resolution; a busy/broken package manager is reported as failure. + setenv("DEBIAN_FRONTEND", "noninteractive", 1); + if (Run("/usr/bin/apt-get", {"install", "--yes", "--no-remove", + "-o", "Dpkg::Options::=--force-confold", package.string()}) != 0) + throw std::runtime_error("The package manager could not install the update"); +} +void Restart(const fs::path&) { Launch("/usr/bin/crossdesk", {}); } +} // namespace crossdesk::updater diff --git a/apps/desktop/src/platform/macos/updater/update_platform.cpp b/apps/desktop/src/platform/macos/updater/update_platform.cpp new file mode 100644 index 00000000..d529f679 --- /dev/null +++ b/apps/desktop/src/platform/macos/updater/update_platform.cpp @@ -0,0 +1,72 @@ +#include "updater/update_platform.h" + +#include +#include +#include +#include +#include +#include + +namespace crossdesk::updater { +namespace { +std::string ShellQuote(const std::string& value) { + std::string result = "'"; + for (char c : value) result += c == '\'' ? "'\\''" : std::string(1, c); + return result + "'"; +} +std::string AppleScriptString(const std::string& value) { + std::string result = "\""; + for (char c : value) { + if (c == '\\' || c == '"') result += '\\'; + result += c; + } + return result + "\""; +} +} // namespace +fs::path ExecutablePath() { + uint32_t size = 0; + _NSGetExecutablePath(nullptr, &size); + std::vector buffer(size); + if (_NSGetExecutablePath(buffer.data(), &size)) throw std::runtime_error("Cannot locate executable"); + return fs::canonical(buffer.data()); +} +bool SupportedInstallation(const fs::path& application) { + return application == fs::path("/Applications/CrossDesk.app/Contents/MacOS/CrossDesk") && + fs::is_regular_file(HelperPath(application)); +} +int RunElevated(const fs::path& executable, const fs::path& job) { + const auto command = ShellQuote(executable.string()) + " --install " + ShellQuote(job.string()); + return Run("/usr/bin/osascript", {"-e", "do shell script " + + AppleScriptString(command) + " with administrator privileges"}); +} +void WaitForApplicationExit(const fs::path& application) { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(45); + do { + const int count = proc_listallpids(nullptr, 0); + if (count <= 0) throw std::runtime_error("Cannot enumerate CrossDesk processes"); + std::vector pids(static_cast(count) + 256); + const int length = proc_listallpids(pids.data(), static_cast(pids.size() * sizeof(pid_t))); + if (length <= 0 || length >= static_cast(pids.size())) + throw std::runtime_error("Cannot inspect all CrossDesk processes"); + bool running = false; + for (int i = 0; i < length; ++i) { + char path[PROC_PIDPATHINFO_MAXSIZE]{}; + if (proc_pidpath(pids[i], path, sizeof(path)) > 0) { + if (fs::path(path) == application) running = true; + } else { + char name[1024]{}; + if (proc_name(pids[i], name, sizeof(name)) > 0 && std::string(name) == "CrossDesk") + running = true; + } + } + if (!running) return; + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + } while (std::chrono::steady_clock::now() < deadline); + throw std::runtime_error("CrossDesk is still running in another session"); +} +void InstallPackage(const fs::path& package, const fs::path&) { + if (Run("/usr/sbin/installer", {"-pkg", package.string(), "-target", "/"}) != 0) + throw std::runtime_error("The macOS installer could not install the update"); +} +void Restart(const fs::path&) { Launch("/usr/bin/open", {"/Applications/CrossDesk.app"}); } +} // namespace crossdesk::updater diff --git a/apps/desktop/src/platform/windows/updater/update_platform.cpp b/apps/desktop/src/platform/windows/updater/update_platform.cpp new file mode 100644 index 00000000..f95181d5 --- /dev/null +++ b/apps/desktop/src/platform/windows/updater/update_platform.cpp @@ -0,0 +1,303 @@ +#include "updater/update_platform.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace crossdesk::updater { +namespace { +bool service_was_running = false; + +std::wstring Wide(const std::string& text) { return fs::u8path(text).wstring(); } + +std::wstring Quote(const std::wstring& value) { + std::wstring result = L"\""; + unsigned slashes = 0; + for (wchar_t c : value) { + if (c == L'\\') { ++slashes; continue; } + result.append(c == L'"' ? slashes * 2 + 1 : slashes, L'\\'); + slashes = 0; + result += c; + } + result.append(slashes * 2, L'\\'); + return result + L"\""; +} + +std::wstring Arguments(const std::vector& args) { + std::wstring result; + for (const auto& argument : args) { + if (!result.empty()) result += L' '; + result += Quote(Wide(argument)); + } + return result; +} + +std::wstring Normalized(const fs::path& path) { + auto value = fs::weakly_canonical(path).wstring(); + std::transform(value.begin(), value.end(), value.begin(), towlower); + return value; +} + +std::wstring LockName(const fs::path& app) { + return L"Global\\CrossDeskUpdate-" + Wide(Sha256(fs::path(Normalized(app)).u8string())); +} + +void StopService() { + SC_HANDLE manager = OpenSCManagerW(nullptr, nullptr, SC_MANAGER_CONNECT); + if (!manager) throw std::runtime_error("Cannot query CrossDesk service"); + SC_HANDLE service = OpenServiceW(manager, L"CrossDeskService", SERVICE_STOP | SERVICE_QUERY_STATUS); + const DWORD error = GetLastError(); + CloseServiceHandle(manager); + if (!service) { + if (error == ERROR_SERVICE_DOES_NOT_EXIST) return; + throw std::runtime_error("Cannot open CrossDesk service"); + } + SERVICE_STATUS_PROCESS status{}; + DWORD bytes = 0; + if (!QueryServiceStatusEx(service, SC_STATUS_PROCESS_INFO, + reinterpret_cast(&status), sizeof(status), &bytes)) { + CloseServiceHandle(service); + throw std::runtime_error("Cannot query CrossDesk service state"); + } + service_was_running = status.dwCurrentState == SERVICE_RUNNING || + status.dwCurrentState == SERVICE_START_PENDING; + SERVICE_STATUS ignored{}; + if (status.dwCurrentState != SERVICE_STOPPED && status.dwCurrentState != SERVICE_STOP_PENDING && + !ControlService(service, SERVICE_CONTROL_STOP, &ignored)) { + CloseServiceHandle(service); + throw std::runtime_error("Cannot stop CrossDesk service"); + } + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); + while (std::chrono::steady_clock::now() < deadline) { + if (!QueryServiceStatusEx(service, SC_STATUS_PROCESS_INFO, + reinterpret_cast(&status), sizeof(status), &bytes)) break; + if (status.dwCurrentState == SERVICE_STOPPED) { + CloseServiceHandle(service); + return; + } + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + } + CloseServiceHandle(service); + throw std::runtime_error("CrossDesk service did not stop"); +} +} // namespace + +fs::path ExecutablePath() { + wchar_t buffer[32768]; + const DWORD size = GetModuleFileNameW(nullptr, buffer, 32768); + if (!size || size >= 32768) throw std::runtime_error("Cannot locate executable"); + return fs::path(std::wstring(buffer, size)); +} + +fs::path HelperPath(const fs::path& application) { + return application.parent_path() / L"crossdesk_updater.exe"; +} + +bool SupportedInstallation(const fs::path& application) { + // Only the installer owns this value. Portable/development directories cannot + // become destinations for an elevated update. + wchar_t directory[32768]; + DWORD bytes = sizeof(directory); + if (RegGetValueW(HKEY_LOCAL_MACHINE, + L"Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\CrossDesk", + L"InstallLocation", RRF_RT_REG_SZ | RRF_SUBKEY_WOW6432KEY, nullptr, + directory, &bytes) != ERROR_SUCCESS) return false; + return Normalized(fs::path(directory) / L"CrossDesk.exe") == Normalized(application) && + fs::is_regular_file(HelperPath(application)); +} + +bool IsPrivileged() { + SID_IDENTIFIER_AUTHORITY authority = SECURITY_NT_AUTHORITY; + PSID admins = nullptr; + BOOL member = FALSE; + if (AllocateAndInitializeSid(&authority, 2, SECURITY_BUILTIN_DOMAIN_RID, + DOMAIN_ALIAS_RID_ADMINS, 0, 0, 0, 0, 0, 0, &admins)) { + CheckTokenMembership(nullptr, admins, &member); + FreeSid(admins); + } + return member != FALSE; +} + +fs::path PrivateDirectory() { + PWSTR base = nullptr; + if (FAILED(SHGetKnownFolderPath(IsPrivileged() ? FOLDERID_ProgramData : FOLDERID_LocalAppData, + 0, nullptr, &base))) throw std::runtime_error("Cannot locate update storage"); + const fs::path directory = fs::path(base) / (L"CrossDesk-update-" + Wide(RandomId())); + CoTaskMemFree(base); + std::wstring acl = IsPrivileged() ? L"O:BAG:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)" + : L"D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)"; + if (!IsPrivileged()) { + HANDLE token = nullptr; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) + throw std::runtime_error("Cannot read update owner"); + DWORD bytes = 0; + GetTokenInformation(token, TokenUser, nullptr, 0, &bytes); + std::vector data(bytes); + const BOOL ok = GetTokenInformation(token, TokenUser, data.data(), bytes, &bytes); + CloseHandle(token); + if (!ok) throw std::runtime_error("Cannot read update owner"); + PWSTR sid = nullptr; + if (!ConvertSidToStringSidW(reinterpret_cast(data.data())->User.Sid, &sid)) + throw std::runtime_error("Cannot format update owner"); + acl += L"(A;OICI;FA;;;" + std::wstring(sid) + L")"; + LocalFree(sid); + } + PSECURITY_DESCRIPTOR descriptor = nullptr; + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(acl.c_str(), SDDL_REVISION_1, + &descriptor, nullptr)) throw std::runtime_error("Cannot protect update storage"); + SECURITY_ATTRIBUTES attributes{sizeof(attributes), descriptor, FALSE}; + const BOOL created = CreateDirectoryW(directory.c_str(), &attributes); + LocalFree(descriptor); + if (!created) throw std::runtime_error("Cannot create private update directory"); + return directory; +} + +bool OwnedJobDirectory(const fs::path& job) { + if (!IsPrivileged()) return true; + // Elevated coordinators only write to a directory owned by Administrators, + // never a caller-controlled directory with reparse points or writable ACLs. + PSID owner = nullptr; + PACL acl = nullptr; + PSECURITY_DESCRIPTOR descriptor = nullptr; + if (GetNamedSecurityInfoW(job.c_str(), SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + &owner, nullptr, &acl, nullptr, &descriptor) != ERROR_SUCCESS) return false; + bool safe = owner && IsWellKnownSid(owner, WinBuiltinAdministratorsSid) && acl; + for (DWORD i = 0; safe && i < acl->AceCount; ++i) { + void* raw = nullptr; + if (!GetAce(acl, i, &raw)) { safe = false; break; } + const auto ace = static_cast(raw); + if (ace->Header.AceType != ACCESS_ALLOWED_ACE_TYPE || + (!IsWellKnownSid(&ace->SidStart, WinBuiltinAdministratorsSid) && + !IsWellKnownSid(&ace->SidStart, WinLocalSystemSid))) safe = false; + } + LocalFree(descriptor); + return safe; +} + +void Launch(const fs::path& executable, const std::vector& arguments) { + auto command = Quote(executable.wstring()) + L" " + Arguments(arguments); + STARTUPINFOW startup{sizeof(startup)}; + PROCESS_INFORMATION process{}; + const auto cwd = executable.parent_path(); + if (!CreateProcessW(executable.c_str(), command.data(), nullptr, nullptr, FALSE, + CREATE_NO_WINDOW, nullptr, cwd.c_str(), &startup, &process)) + throw std::runtime_error("Cannot start update process"); + CloseHandle(process.hThread); + CloseHandle(process.hProcess); +} + +int Run(const fs::path& executable, const std::vector& arguments, bool elevate) { + const auto parameters = Arguments(arguments); + const auto cwd = executable.parent_path(); + SHELLEXECUTEINFOW execution{sizeof(execution)}; + execution.fMask = SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NOASYNC | SEE_MASK_FLAG_NO_UI; + execution.lpVerb = elevate ? L"runas" : L"open"; + execution.lpFile = executable.c_str(); + execution.lpParameters = parameters.c_str(); + execution.lpDirectory = cwd.c_str(); + execution.nShow = SW_HIDE; + if (!ShellExecuteExW(&execution) || !execution.hProcess) + throw std::runtime_error("Installation authorization was cancelled or could not start"); + WaitForSingleObject(execution.hProcess, INFINITE); + DWORD code = 1; + GetExitCodeProcess(execution.hProcess, &code); + CloseHandle(execution.hProcess); + return static_cast(code); +} + +int RunElevated(const fs::path& executable, const fs::path& job) { + return Run(executable, {"--install", job.u8string()}, true); +} + +void WaitForApplicationExit(const fs::path& application) { + StopService(); + const auto expected = Normalized(application); + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(45); + do { + bool running = false; + HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snapshot == INVALID_HANDLE_VALUE) throw std::runtime_error("Cannot enumerate application processes"); + PROCESSENTRY32W entry{sizeof(entry)}; + if (Process32FirstW(snapshot, &entry)) do { + if (_wcsicmp(entry.szExeFile, application.filename().c_str()) != 0) continue; + HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE, + FALSE, entry.th32ProcessID); + if (!process) { running = true; continue; } + wchar_t image[32768]; + DWORD size = 32768; + if (!QueryFullProcessImageNameW(process, 0, image, &size)) running = true; + else if (Normalized(fs::path(std::wstring(image, size))) == expected && + WaitForSingleObject(process, 0) == WAIT_TIMEOUT) running = true; + CloseHandle(process); + } while (Process32NextW(snapshot, &entry)); + CloseHandle(snapshot); + if (!running) return; + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + } while (std::chrono::steady_clock::now() < deadline); + throw std::runtime_error("CrossDesk is still running in this or another session"); +} + +void InstallPackage(const fs::path& package, const fs::path& application) { + // NSIS requires /D to be the final argument, without quotes around its value. + std::wstring command = Quote(package.wstring()) + L" /S /CROSSDESK_UPDATE /D=" + + application.parent_path().wstring(); + STARTUPINFOW startup{sizeof(startup)}; + PROCESS_INFORMATION process{}; + const auto cwd = package.parent_path(); + if (!CreateProcessW(package.c_str(), command.data(), nullptr, nullptr, FALSE, + CREATE_NO_WINDOW, nullptr, cwd.c_str(), &startup, &process)) + throw std::runtime_error("Cannot start CrossDesk installer"); + WaitForSingleObject(process.hProcess, INFINITE); + DWORD code = 1; + GetExitCodeProcess(process.hProcess, &code); + CloseHandle(process.hThread); + CloseHandle(process.hProcess); + if (code != 0) throw std::runtime_error("CrossDesk installer failed: " + std::to_string(code)); +} + +void RestoreServices() { + if (!service_was_running) return; + SC_HANDLE manager = OpenSCManagerW(nullptr, nullptr, SC_MANAGER_CONNECT); + if (!manager) return; + SC_HANDLE service = OpenServiceW(manager, L"CrossDeskService", SERVICE_START); + if (service) { StartServiceW(service, 0, nullptr); CloseServiceHandle(service); } + CloseServiceHandle(manager); +} + +void Restart(const fs::path& application) { Launch(application, {}); } + +std::shared_ptr PinHelper(const fs::path& helper) { + HANDLE handle = CreateFileW(helper.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + if (handle == INVALID_HANDLE_VALUE) throw std::runtime_error("Cannot lock update helper"); + return {handle, [](void* value) { CloseHandle(value); }}; +} + +MaintenanceLock::MaintenanceLock(const fs::path& application) { + HANDLE mutex = CreateMutexExW(nullptr, LockName(application).c_str(), 0, SYNCHRONIZE); + const DWORD error = GetLastError(); + if (!mutex || error == ERROR_ALREADY_EXISTS) { + if (mutex) CloseHandle(mutex); + throw std::runtime_error("An update is already running"); + } + handle_ = reinterpret_cast(mutex); +} +MaintenanceLock::~MaintenanceLock() { + if (handle_ != -1) CloseHandle(reinterpret_cast(handle_)); +} +bool MaintenanceActive(const fs::path& application) { + HANDLE mutex = OpenMutexW(SYNCHRONIZE, FALSE, LockName(application).c_str()); + if (mutex) { CloseHandle(mutex); return true; } + return GetLastError() != ERROR_FILE_NOT_FOUND; +} +} // namespace crossdesk::updater diff --git a/apps/desktop/src/updater/main.cpp b/apps/desktop/src/updater/main.cpp new file mode 100644 index 00000000..d518c4db --- /dev/null +++ b/apps/desktop/src/updater/main.cpp @@ -0,0 +1,169 @@ +#include "updater/update_core.h" +#include "updater/update_platform.h" +#include "platform/single_instance.h" + +#include +#include +#include +#include +#ifdef _WIN32 +#include +#include +#pragma comment(linker, "/subsystem:windows /entry:mainCRTStartup") +#endif + +namespace crossdesk::updater { +namespace { +struct Request { + fs::path application; + Artifact artifact; +}; +Request ReadRequest(const fs::path& job) { + const auto request = nlohmann::json::parse(ReadText(job / "request.json")); + const auto application = fs::u8path(request.at("application").get()); + if (!SupportedInstallation(application)) throw std::runtime_error("Invalid installation destination"); + const auto artifact = VerifyEnvelope(request.at("envelope").get(), PublicKey(), + PlatformKey(application), BuildVersion()); + return {application, artifact}; +} + +int Install(const fs::path& job) { + if (!IsPrivileged()) return 2; + fs::path stage; + int result = 1; + try { + const auto request = ReadRequest(job); + stage = PrivateDirectory(); + const auto package = stage / request.artifact.filename; + ExecuteInstall({ + [&] { + // A privileged installer never executes the caller's writable file. + // Copy into private storage, then verify the exact copy being installed. + CopyPackage(job / request.artifact.filename, package, request.artifact); + }, + [&] { WaitForApplicationExit(request.application); }, + [&] { InstallPackage(package, request.application); }}); + result = 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + } + RestoreServices(); + if (!stage.empty()) { + std::error_code ignored; + fs::remove_all(stage, ignored); + } + return result; +} + +int Coordinate(const fs::path& job) { + fs::path application; + bool committed = false; + bool success = false; + bool exited = false; + std::string error; + std::unique_ptr maintenance; + try { + const auto request = ReadRequest(job); + application = request.application; + auto pinned = PinHelper(ExecutablePath()); + { + maintenance = std::make_unique(application); + VerifyPackage(job / request.artifact.filename, request.artifact); + WriteText(job / "ready", "ready\n"); + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(120); + while (!fs::exists(job / "commit")) { + if (fs::exists(job / "cancel") || std::chrono::steady_clock::now() >= deadline) + throw std::runtime_error("Update cancelled before application exit"); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + committed = true; + platform::SingleInstanceGuard client; + platform::SingleInstanceGuard launcher; + const auto exit_deadline = std::chrono::steady_clock::now() + std::chrono::seconds(45); + while (true) { + std::string lease_error; + const auto client_result = client.TryAcquire(platform::InstanceRole::kClient, lease_error); + const auto launcher_result = launcher.TryAcquire(platform::InstanceRole::kLauncher, lease_error); + if (client_result == platform::InstanceResult::kError || + launcher_result == platform::InstanceResult::kError) + throw std::runtime_error("Cannot wait for application exit: " + lease_error); + if (client_result == platform::InstanceResult::kAcquired && + launcher_result == platform::InstanceResult::kAcquired) break; + if (std::chrono::steady_clock::now() >= exit_deadline) + throw std::runtime_error("CrossDesk did not exit; no files were installed"); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + exited = true; +#ifdef _WIN32 + // This relocated copy is held read-only across UAC and installation. + const auto installer_helper = ExecutablePath(); +#else + // Use the root-owned installed helper for privileged execution. + const auto installer_helper = HelperPath(application); +#endif + const int code = RunElevated(installer_helper, job); + if (code != 0) throw std::runtime_error("Installation failed or authorization was cancelled (code " + + std::to_string(code) + ")"); + success = true; + } + } catch (const std::exception& exception) { error = exception.what(); } + try { + WriteText(job / "result.json", nlohmann::json{{"success", success}, {"error", error}}.dump(2)); + } catch (const std::exception&) {} + maintenance.reset(); + if (committed && exited) { + try { + Restart(application); + if (success) { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(90); + while (!fs::exists(job / "started") && std::chrono::steady_clock::now() < deadline) + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + if (!fs::exists(job / "started")) { + success = false; + WriteText(job / "result.json", nlohmann::json{{"success", false}, + {"error", "Installation finished but the new application did not confirm startup. Open CrossDesk or run the installer manually."}}.dump(2)); + } + } + } + catch (const std::exception& exception) { + success = false; + WriteText(job / "result.json", nlohmann::json{{"success", false}, + {"error", std::string("Could not restart CrossDesk: ") + exception.what()}}.dump(2)); + } + } + if (success) { + // Keep request/result for diagnostics, remove the large download. + std::error_code ignored; + for (const auto& entry : fs::directory_iterator(job)) { + if (entry.path().extension() == ".pkg" || entry.path().extension() == ".deb" || + (entry.path().extension() == ".exe" && entry.path() != ExecutablePath())) + fs::remove(entry.path(), ignored); + } + } + return success ? 0 : 1; +} +} // namespace +} // namespace crossdesk::updater + +int main(int argc, char** argv) { + using namespace crossdesk::updater; + try { + std::vector args; +#ifdef _WIN32 + int count = 0; + auto wide = CommandLineToArgvW(GetCommandLineW(), &count); + if (!wide) return 2; + for (int i = 0; i < count; ++i) args.push_back(fs::path(wide[i]).u8string()); + LocalFree(wide); + (void)argc; (void)argv; +#else + for (int i = 0; i < argc; ++i) args.emplace_back(argv[i]); +#endif + if (args.size() != 3) return 2; + const auto job = fs::u8path(args[2]); + if (!job.is_absolute() || fs::is_symlink(job) || !fs::is_directory(job)) return 2; + if (args[1] == "--install") return Install(job); + if (args[1] == "--coordinate" && OwnedJobDirectory(job)) return Coordinate(job); + } catch (const std::exception& error) { std::cerr << error.what() << '\n'; } + return 2; +} diff --git a/apps/desktop/src/updater/update_controller.cpp b/apps/desktop/src/updater/update_controller.cpp new file mode 100644 index 00000000..f4821d5a --- /dev/null +++ b/apps/desktop/src/updater/update_controller.cpp @@ -0,0 +1,134 @@ +#include "updater/update_controller.h" +#include "updater/update_platform.h" +#include "common/filesystem_utf8.h" + +#include +#include + +namespace crossdesk::updater { +Controller::Controller(fs::path cache) : cache_(std::move(cache)) { + // A previous result survives a restart. Incomplete jobs are never replayed. + try { + const auto previous = PathFromUtf8(ReadText(cache_ / "last-update", 32768)); + if (!fs::exists(previous / "result.json")) { + state_ = State::Failed; + error_ = "The previous update was interrupted. Download and retry.\n" + PathUtf8(previous); + return; + } + const auto result = nlohmann::json::parse(ReadText(previous / "result.json")); + if (!result.value("success", false)) { + state_ = State::Failed; + error_ = result.value("error", std::string("Update did not complete")) + + "\n" + PathUtf8(previous); + } + } catch (const std::exception&) {} +} +void Controller::ConfirmStartup() { + try { + const auto previous = PathFromUtf8(ReadText(cache_ / "last-update", 32768)); + const auto result = nlohmann::json::parse(ReadText(previous / "result.json")); + if (!result.value("success", false)) return; + const auto request = nlohmann::json::parse(ReadText(previous / "request.json")); + const auto artifact = VerifyEnvelope(request.at("envelope").get(), PublicKey(), + PlatformKey(ExecutablePath()), "0.0.0"); + auto built = BuildVersion(); + if (!built.empty() && built[0] == 'v') built.erase(0, 1); + if (artifact.version == built) WriteText(previous / "started", "started\n"); + } catch (const std::exception&) {} +} +Controller::~Controller() { + Cancel(); + if (pending_.valid()) { + try { + const auto prepared = pending_.get(); + WriteText(prepared / "cancel", "cancel\n"); + } catch (const std::exception&) {} + } +} +bool Controller::Available(const std::string& envelope) const { + if (checked_ && checked_envelope_ == envelope) return available_; + checked_ = true; + checked_envelope_ = envelope; + available_ = false; + try { + const auto application = ExecutablePath(); + if (!SupportedInstallation(application)) return false; + VerifyEnvelope(envelope, PublicKey(), PlatformKey(application), BuildVersion()); + available_ = true; + return available_; + } catch (const std::exception&) { return false; } +} +void Controller::Start(const std::string& envelope) { + if (busy() || state_ == State::Ready) return; + error_.clear(); + progress_ = 0; + cancel_ = false; + state_ = State::Downloading; + try { + pending_ = std::async(std::launch::async, [this, envelope] { + const auto application = ExecutablePath(); + if (!SupportedInstallation(application)) throw std::runtime_error("Unsupported installation"); + const auto artifact = VerifyEnvelope(envelope, PublicKey(), PlatformKey(application), BuildVersion()); + const auto job = PrivateDirectory(); + try { + WriteText(job / "request.json", nlohmann::json{ + {"application", PathUtf8(application)}, {"envelope", envelope}}.dump()); + DownloadPackage(artifact, job / artifact.filename, progress_, cancel_); + if (cancel_) throw std::runtime_error("Update cancelled"); + // Relocate the coordinator so package installation can replace the + // installed helper too. Its crypto/runtime dependencies are static. + const auto helper = job / HelperPath(application).filename(); + fs::copy_file(HelperPath(application), helper); + fs::permissions(helper, fs::perms::owner_all); + Launch(helper, {"--coordinate", PathUtf8(job)}); + return job; + } catch (...) { + // Only this freshly created directory is owned by this worker. + std::error_code ignored; + fs::remove_all(job, ignored); + throw; + } + }); + } catch (const std::exception& exception) { + error_ = exception.what(); + state_ = State::Failed; + } +} +bool Controller::Poll() { + try { + if (state_ == State::Downloading && pending_.valid() && + pending_.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { + job_ = pending_.get(); + deadline_ = std::chrono::steady_clock::now() + std::chrono::seconds(30); + state_ = State::Preparing; + } + if (state_ != State::Preparing) return false; + if (cancel_) throw std::runtime_error("Update cancelled"); + if (fs::exists(job_ / "result.json")) { + const auto result = nlohmann::json::parse(ReadText(job_ / "result.json")); + throw std::runtime_error(result.value("error", std::string("Update preparation failed"))); + } + if (fs::exists(job_ / "ready")) { + WriteText(cache_ / "last-update", PathUtf8(job_)); + WriteText(job_ / "commit", "install\n"); + state_ = State::Ready; + return true; + } + if (std::chrono::steady_clock::now() >= deadline_) + throw std::runtime_error("Update helper did not become ready"); + } catch (const std::exception& exception) { + const bool cancelled = cancel_.load(); + if (state_ == State::Preparing) Cancel(); + error_ = exception.what(); + state_ = cancelled ? State::Idle : State::Failed; + if (cancelled) error_.clear(); + } + return false; +} +void Controller::Cancel() { + cancel_ = true; + if (state_ == State::Preparing && !job_.empty()) { + try { WriteText(job_ / "cancel", "cancel\n"); } catch (const std::exception&) {} + } +} +} // namespace crossdesk::updater diff --git a/apps/desktop/src/updater/update_controller.h b/apps/desktop/src/updater/update_controller.h new file mode 100644 index 00000000..3de6b542 --- /dev/null +++ b/apps/desktop/src/updater/update_controller.h @@ -0,0 +1,43 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-10-20 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _UPDATE_CONTROLLER_H_ +#define _UPDATE_CONTROLLER_H_ + +#include "updater/update_core.h" +#include + +namespace crossdesk::updater { +class Controller { + public: + enum class State { Idle, Downloading, Preparing, Ready, Failed }; + explicit Controller(fs::path cache); + ~Controller(); + bool Available(const std::string& envelope) const; + void Start(const std::string& envelope); + // Returns true only after a live helper has acknowledged the prepared job. + bool Poll(); + void Cancel(); + void ConfirmStartup(); + State state() const { return state_; } + int progress() const { return progress_.load(); } + const std::string& error() const { return error_; } + bool busy() const { return state_ == State::Downloading || state_ == State::Preparing; } + private: + fs::path cache_, job_; + State state_ = State::Idle; + std::string error_; + mutable std::string checked_envelope_; + mutable bool checked_ = false; + mutable bool available_ = false; + std::atomic progress_{0}; + std::atomic cancel_{false}; + std::future pending_; + std::chrono::steady_clock::time_point deadline_{}; +}; +} // namespace crossdesk::updater + +#endif diff --git a/apps/desktop/src/updater/update_core.cpp b/apps/desktop/src/updater/update_core.cpp new file mode 100644 index 00000000..a9656c68 --- /dev/null +++ b/apps/desktop/src/updater/update_core.cpp @@ -0,0 +1,267 @@ +#include "updater/update_core.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#ifndef CROSSDESK_UPDATE_PUBLIC_KEY +#define CROSSDESK_UPDATE_PUBLIC_KEY "" +#endif +#ifndef CROSSDESK_VERSION +#define CROSSDESK_VERSION "0.0.0" +#endif + +namespace crossdesk::updater { +namespace { +constexpr std::uint64_t kMaxPackageSize = 2ULL * 1024 * 1024 * 1024; +constexpr const char* kDownloadOrigin = "https://downloads.crossdesk.cn"; + +std::string Hex(const unsigned char* data, std::size_t length) { + constexpr char digits[] = "0123456789abcdef"; + std::string result; + for (std::size_t i = 0; i < length; ++i) { + result += digits[data[i] >> 4]; + result += digits[data[i] & 15]; + } + return result; +} + +std::vector Unhex(const std::string& value, std::size_t size) { + if (value.size() != size * 2) throw std::runtime_error("Invalid signature/key size"); + std::vector result(size); + auto digit = [](char c) -> int { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + throw std::runtime_error("Expected lowercase hexadecimal"); + }; + for (std::size_t i = 0; i < size; ++i) + result[i] = static_cast(digit(value[2 * i]) * 16 + digit(value[2 * i + 1])); + return result; +} + +std::string ExpectedFilename(const std::string& platform, const std::string& version) { + if (version.empty() || version.find_first_not_of("0123456789.-") != std::string::npos) + throw std::runtime_error("Invalid release filename version"); + if (platform == "windows-x64") return "crossdesk-win-x64-v" + version + ".exe"; + if (platform == "windows-x64-portable") + return "crossdesk-win-x64-portable-v" + version + ".zip"; + if (platform == "macos-x64" || platform == "macos-arm64") + return "crossdesk-" + platform + "-v" + version + ".pkg"; + for (const std::string arch : {"amd64", "arm64"}) { + if (platform == "linux-" + arch) + return "crossdesk-linux-" + arch + "-v" + version + ".deb"; + if (platform == "linux-" + arch + "-virtual-desktop-xfce") + return "crossdesk-virtual-desktop-xfce-linux-" + arch + "-v" + version + ".deb"; + } + throw std::runtime_error("Unsupported update platform"); +} +} // namespace + +std::string PublicKey() { return CROSSDESK_UPDATE_PUBLIC_KEY; } +std::string BuildVersion() { return CROSSDESK_VERSION; } +std::string PathUtf8(const fs::path& path) { return path.u8string(); } + +Artifact VerifyEnvelope(const std::string& envelope, const std::string& public_key, + const std::string& platform, const std::string& current_version) { + if (envelope.size() > 65536) throw std::runtime_error("Update envelope is too large"); + const auto wrapper = nlohmann::json::parse(envelope); + const auto payload = wrapper.at("payload").get(); + const auto signature = Unhex(wrapper.at("signature").get(), 64); + const auto key_bytes = Unhex(public_key, 32); + std::unique_ptr key( + EVP_PKEY_new_raw_public_key(EVP_PKEY_ED25519, nullptr, key_bytes.data(), key_bytes.size()), + EVP_PKEY_free); + std::unique_ptr context(EVP_MD_CTX_new(), EVP_MD_CTX_free); + if (!key || !context || EVP_DigestVerifyInit(context.get(), nullptr, nullptr, nullptr, key.get()) != 1 || + EVP_DigestVerify(context.get(), signature.data(), signature.size(), + reinterpret_cast(payload.data()), payload.size()) != 1) + throw std::runtime_error("Update signature verification failed"); + const auto value = nlohmann::json::parse(payload); + if (value.at("schema") != 1) throw std::runtime_error("Unsupported update schema"); + Artifact artifact; + artifact.platform = value.at("platform").get(); + artifact.version = value.at("version").get(); + artifact.filename = value.at("filename").get(); + artifact.url = value.at("url").get(); + artifact.sha256 = value.at("sha256").get(); + if (!value.at("size").is_number_unsigned()) throw std::runtime_error("Invalid package size"); + artifact.size = value.at("size").get(); + if (artifact.platform != platform || platform.find("portable") != std::string::npos || + !IsValidAppVersion(current_version) || !IsValidAppVersion(artifact.version) || + !IsNewerVersion(current_version, artifact.version)) + throw std::runtime_error("Update platform/version does not match this installation"); + if (!artifact.size || artifact.size > kMaxPackageSize || + artifact.filename != ExpectedFilename(platform, artifact.version) || + artifact.url != std::string(kDownloadOrigin) + "/" + artifact.filename) + throw std::runtime_error("Invalid update package metadata"); + Unhex(artifact.sha256, 32); + return artifact; +} + +std::string PlatformKey(const fs::path& executable, bool portable) { +#if defined(_WIN32) && (defined(_M_X64) || defined(__x86_64__)) + (void)executable; + return portable ? "windows-x64-portable" : "windows-x64"; +#elif defined(__APPLE__) + (void)executable; (void)portable; +#if defined(__aarch64__) || defined(__arm64__) + return "macos-arm64"; +#else + return "macos-x64"; +#endif +#elif defined(__linux__) && !defined(__OHOS__) + (void)portable; +#if defined(__aarch64__) + std::string key = "linux-arm64"; +#elif defined(__x86_64__) + std::string key = "linux-amd64"; +#else + return {}; +#endif + std::ifstream marker(executable.parent_path() / "package-name"); + std::string package; + if (!std::getline(marker, package)) return {}; + if (package == "crossdesk-virtual-desktop-xfce") key += "-virtual-desktop-xfce"; + else if (package != "crossdesk") return {}; + return key; +#else + (void)executable; (void)portable; + return {}; +#endif +} + +std::string DownloadUrl(const std::string& manifest, const std::string& platform, + const std::string& version) { + try { + const auto download = nlohmann::json::parse(manifest).at("downloads").at(platform); + const auto filename = ExpectedFilename(platform, version); + const auto url = std::string(kDownloadOrigin) + "/" + filename; + if (download.at("version") == version && download.at("filename") == filename && + download.at("url") == url) return url; + } catch (const std::exception&) {} + return "https://crossdesk.cn"; +} + +std::string SignedDownload(const std::string& manifest, const std::string& platform) { + try { + return nlohmann::json::parse(manifest).at("downloads").at(platform).at("update").dump(); + } catch (const std::exception&) { return {}; } +} + +std::string Sha256(const std::string& value) { + unsigned char digest[EVP_MAX_MD_SIZE]; + unsigned int size = 0; + if (EVP_Digest(value.data(), value.size(), digest, &size, EVP_sha256(), nullptr) != 1) + throw std::runtime_error("SHA-256 failed"); + return Hex(digest, size); +} + +void VerifyPackage(const fs::path& file, const Artifact& artifact) { + std::ifstream input(file, std::ios::binary); + std::unique_ptr context(EVP_MD_CTX_new(), EVP_MD_CTX_free); + if (!input || !context || EVP_DigestInit_ex(context.get(), EVP_sha256(), nullptr) != 1) + throw std::runtime_error("Cannot read update package"); + std::array buffer{}; + std::uint64_t total = 0; + while (input.read(buffer.data(), buffer.size()) || input.gcount()) { + const auto size = static_cast(input.gcount()); + total += size; + if (total > artifact.size || EVP_DigestUpdate(context.get(), buffer.data(), size) != 1) + throw std::runtime_error("Update package size/hash failed"); + } + unsigned char digest[EVP_MAX_MD_SIZE]; + unsigned int size = 0; + if (!input.eof() || total != artifact.size || + EVP_DigestFinal_ex(context.get(), digest, &size) != 1 || Hex(digest, size) != artifact.sha256) + throw std::runtime_error("Update package verification failed"); +} + +void DownloadPackage(const Artifact& artifact, const fs::path& destination, + std::atomic& progress, const std::atomic& cancel) { + httplib::Client client(kDownloadOrigin); + client.set_connection_timeout(10); + client.set_read_timeout(30); + client.set_write_timeout(10); + client.set_max_timeout(30 * 60 * 1000); + client.set_follow_location(false); +#if defined(__linux__) + for (const char* bundle : {"/etc/ssl/certs/ca-certificates.crt", "/etc/pki/tls/certs/ca-bundle.crt"}) { + if (fs::exists(bundle)) { client.set_ca_cert_path(bundle); break; } + } +#endif + std::ofstream file(destination, std::ios::binary | std::ios::trunc); + if (!file) throw std::runtime_error("Cannot create update download"); + std::uint64_t received = 0; + const auto response = client.Get(("/" + artifact.filename).c_str(), + [&](const httplib::Response& headers) { + return headers.status == 200 && !cancel.load(); + }, + [&](const char* data, std::size_t length) { + if (cancel.load() || length > artifact.size - received) return false; + file.write(data, static_cast(length)); + received += length; + progress.store(static_cast(received * 100 / artifact.size)); + return static_cast(file); + }); + file.close(); + if (!response || response->status != 200 || !file || received != artifact.size || cancel.load()) + throw std::runtime_error(cancel.load() ? "Update cancelled" : "Update download failed"); + VerifyPackage(destination, artifact); +} + +void CopyPackage(const fs::path& source, const fs::path& destination, const Artifact& artifact) { + std::ifstream input(source, std::ios::binary); + std::ofstream output(destination, std::ios::binary | std::ios::trunc); + if (!input || !output) throw std::runtime_error("Cannot stage installation package"); + std::array buffer{}; + std::uint64_t copied = 0; + while (input.read(buffer.data(), buffer.size()) || input.gcount()) { + const auto length = static_cast(input.gcount()); + if (length > artifact.size - copied) throw std::runtime_error("Installation package is too large"); + output.write(buffer.data(), input.gcount()); + if (!output) throw std::runtime_error("Cannot write installation package"); + copied += length; + } + output.close(); + if (!input.eof() || !output || copied != artifact.size) + throw std::runtime_error("Installation package is incomplete"); + VerifyPackage(destination, artifact); +} + +std::string ReadText(const fs::path& file, std::size_t maximum) { + std::ifstream input(file, std::ios::binary); + if (!input) throw std::runtime_error("Cannot read update metadata"); + std::string value(maximum + 1, '\0'); + input.read(value.data(), static_cast(value.size())); + value.resize(static_cast(input.gcount())); + if (value.size() > maximum || input.bad()) throw std::runtime_error("Invalid update metadata size"); + return value; +} + +void WriteText(const fs::path& file, const std::string& text) { + std::ofstream output(file, std::ios::binary | std::ios::trunc); + output << text; + output.close(); + if (!output) throw std::runtime_error("Cannot write update state"); +} + +std::string RandomId() { + unsigned char bytes[16]; + if (RAND_bytes(bytes, sizeof(bytes)) != 1) throw std::runtime_error("Cannot generate update ID"); + return Hex(bytes, sizeof(bytes)); +} + +void ExecuteInstall(const InstallActions& actions) { + actions.verify(); + actions.wait_for_exit(); + actions.install(); +} +} // namespace crossdesk::updater diff --git a/apps/desktop/src/updater/update_core.h b/apps/desktop/src/updater/update_core.h new file mode 100644 index 00000000..97ef1788 --- /dev/null +++ b/apps/desktop/src/updater/update_core.h @@ -0,0 +1,61 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-10-20 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _UPDATE_CORE_H_ +#define _UPDATE_CORE_H_ + +#include +#include +#include +#include +#include + +namespace crossdesk::updater { +namespace fs = std::filesystem; + +struct Artifact { + std::string platform; + std::string version; + std::string filename; + std::string url; + std::string sha256; + std::uint64_t size = 0; +}; + +// The envelope contains the exact signed UTF-8 payload and a hex Ed25519 signature. +// Never reserialize the payload before verifying it. +Artifact VerifyEnvelope(const std::string& envelope, const std::string& public_key, + const std::string& platform, + const std::string& current_version); +std::string PublicKey(); +std::string BuildVersion(); +std::string PlatformKey(const fs::path& executable, bool portable = false); +std::string DownloadUrl(const std::string& manifest, const std::string& platform, + const std::string& version); +std::string SignedDownload(const std::string& manifest, const std::string& platform); +std::string Sha256(const std::string& value); +void VerifyPackage(const fs::path& file, const Artifact& artifact); +void CopyPackage(const fs::path& source, const fs::path& destination, const Artifact& artifact); +void DownloadPackage(const Artifact& artifact, const fs::path& destination, + std::atomic& progress, const std::atomic& cancel); +std::string ReadText(const fs::path& file, std::size_t maximum = 65536); +void WriteText(const fs::path& file, const std::string& text); +fs::path PrivateDirectory(); +std::string RandomId(); +// ABI boundary between the C++17 core and C++20 GUI (char8_t). +std::string PathUtf8(const fs::path& path); + +// Files and installation operations are injectable so failure ordering is tested +// without invoking an actual installer. +struct InstallActions { + std::function verify; + std::function wait_for_exit; + std::function install; +}; +void ExecuteInstall(const InstallActions& actions); +} // namespace crossdesk::updater + +#endif diff --git a/apps/desktop/src/updater/update_platform.h b/apps/desktop/src/updater/update_platform.h new file mode 100644 index 00000000..e8d6e343 --- /dev/null +++ b/apps/desktop/src/updater/update_platform.h @@ -0,0 +1,43 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-10-20 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _UPDATE_PLATFORM_H_ +#define _UPDATE_PLATFORM_H_ + +#include "updater/update_core.h" +#include +#include + +namespace crossdesk::updater { +fs::path ExecutablePath(); +fs::path HelperPath(const fs::path& application); +bool SupportedInstallation(const fs::path& application); +bool IsPrivileged(); +bool OwnedJobDirectory(const fs::path& job); +void Launch(const fs::path& executable, const std::vector& arguments); +int Run(const fs::path& executable, const std::vector& arguments, + bool elevate = false); +int RunElevated(const fs::path& executable, const fs::path& job); +void WaitForApplicationExit(const fs::path& application); +void InstallPackage(const fs::path& package, const fs::path& application); +void Restart(const fs::path& application); +void RestoreServices(); +// Pin relocated helper files against changes during elevation (Windows). +std::shared_ptr PinHelper(const fs::path& helper); + +class MaintenanceLock { + public: + explicit MaintenanceLock(const fs::path& application); + ~MaintenanceLock(); + MaintenanceLock(const MaintenanceLock&) = delete; + MaintenanceLock& operator=(const MaintenanceLock&) = delete; + private: + std::intptr_t handle_ = -1; +}; +bool MaintenanceActive(const fs::path& application); +} // namespace crossdesk::updater + +#endif diff --git a/apps/desktop/src/version_checker/version_checker.cpp b/apps/desktop/src/version_checker/version_checker.cpp index 179e3131..83c94158 100644 --- a/apps/desktop/src/version_checker/version_checker.cpp +++ b/apps/desktop/src/version_checker/version_checker.cpp @@ -11,6 +11,10 @@ #include "rd_log.h" #include "linux_update_download.h" +#ifndef __OHOS__ +#include "updater/update_core.h" +#include "updater/update_platform.h" +#endif #include #include @@ -138,6 +142,22 @@ std::optional CheckUpdate() { info->download_url = LinuxUpdateDownloadUrl( manifest, *info, architecture, error ? std::filesystem::path{} : executable.parent_path()); +#endif +#ifndef __OHOS__ + const auto platform = updater::PlatformKey(updater::ExecutablePath(), +#if CROSSDESK_PORTABLE + true +#else + false +#endif + ); +#if !defined(__linux__) + info->download_url = updater::DownloadUrl(manifest.dump(), platform, info->version); +#endif +#if !CROSSDESK_PORTABLE + if (updater::DownloadUrl(manifest.dump(), platform, info->version) != "https://crossdesk.cn") + info->update_envelope = updater::SignedDownload(manifest.dump(), platform); +#endif #endif LOG_INFO("Fetched version.json: latest_version={}, releaseDate={}, patch={}", info->version, info->release_date, info->patch); diff --git a/apps/desktop/tests/build_define_scope_test.lua b/apps/desktop/tests/build_define_scope_test.lua index 14d47a2b..0f624f03 100644 --- a/apps/desktop/tests/build_define_scope_test.lua +++ b/apps/desktop/tests/build_define_scope_test.lua @@ -47,11 +47,12 @@ function main(plat, portable) } local seen = {} for _, file in ipairs(gui:sourcefiles()) do - assert(not seen[file], "duplicate GUI source: " .. file) - seen[file] = true + local normalized = file:gsub("\\", "/") + assert(not seen[normalized], "duplicate GUI source: " .. file) + seen[normalized] = true local defines = (gui:fileconfig(file) or {}).defines assert(contains(defines, 'CROSSDESK_VERSION="v1.5.3-2-20260913"') == - (version_files[file] == true), "unexpected version scope: " .. file) + (version_files[normalized] == true), "unexpected version scope: " .. file) end for file in pairs(version_files) do assert(seen[file], "missing version consumer: " .. file) @@ -70,8 +71,8 @@ function main(plat, portable) wgc_plugin = "wgc_plugin" } for name, resource in pairs(resources) do - local defines = targets[name]:fileconfig( - "apps/desktop/resources/windows/" .. resource .. ".rc").defines + local defines = targets[name]:fileconfig(path.translate( + "apps/desktop/resources/windows/" .. resource .. ".rc")).defines assert(contains(defines, 'CROSSDESK_VERSION_STRING="v1.5.3-2-20260913"')) assert(contains(defines, "CROSSDESK_VERSION_NUMERIC=1,5,3,0")) assert(contains(defines, "CROSSDESK_PORTABLE=1") == diff --git a/apps/desktop/xmake/options.lua b/apps/desktop/xmake/options.lua index 10ce130a..283e9cca 100644 --- a/apps/desktop/xmake/options.lua +++ b/apps/desktop/xmake/options.lua @@ -1,4 +1,9 @@ function setup_options_and_dependencies() + option("CROSSDESK_UPDATE_PUBLIC_KEY") + set_showmenu(true) + set_description("Ed25519 public key (64 lowercase hex characters); empty disables automatic installation") + option_end() + option("CROSSDESK_VERSION") set_default("0.0.0") set_showmenu(true) diff --git a/apps/desktop/xmake/rules/slint.lua b/apps/desktop/xmake/rules/slint.lua index fef841db..fe896cc3 100644 --- a/apps/desktop/xmake/rules/slint.lua +++ b/apps/desktop/xmake/rules/slint.lua @@ -2,6 +2,10 @@ rule("slint") set_extensions(".slint") after_load(function(target) + -- Consumers inline generated C++ bindings. They must not check header + -- timestamps or compile until generation finishes, otherwise one build + -- can link the new UI layout with callers compiled against the old one. + target:set("policy", "build.fence", true) local outputdir = path.join(target:autogendir(), "rules", "slint") target:add("includedirs", outputdir, {public = true}) diff --git a/apps/desktop/xmake/targets.lua b/apps/desktop/xmake/targets.lua index 502d3c84..d9c10837 100644 --- a/apps/desktop/xmake/targets.lua +++ b/apps/desktop/xmake/targets.lua @@ -262,7 +262,7 @@ function setup_targets() set_kind("binary") set_default(false) add_packages("cpp-httplib", "nlohmann_json") - add_deps("rd_log", "crossdesk_wire") + add_deps("rd_log", "crossdesk_wire", "updater_core") add_includedirs("apps/desktop/src/version_checker") add_files("apps/desktop/tests/version_checker_test.cpp", "apps/desktop/src/version_checker/version_checker.cpp", @@ -489,11 +489,54 @@ function setup_targets() add_includedirs("apps/desktop/src/gui/assets/localization", "apps/desktop/src/gui/assets/fonts", {public = true}) + target("updater_core") + set_kind("static") + add_packages("cpp-httplib", "openssl3", "nlohmann_json") + add_deps("crossdesk_wire") + add_includedirs("apps/desktop/src", {public = true}) + local update_key = get_config("CROSSDESK_UPDATE_PUBLIC_KEY") or + os.getenv("CROSSDESK_UPDATE_PUBLIC_KEY") or "" + on_load(function () + assert(update_key == "" or (#update_key == 64 and update_key:match("^[0-9a-f]+$")), + "CROSSDESK_UPDATE_PUBLIC_KEY must be a 32-byte lowercase hex Ed25519 public key") + end) + add_files("apps/desktop/src/updater/update_core.cpp", + {defines = {version_define, "CROSSDESK_UPDATE_PUBLIC_KEY=\"" .. update_key .. "\""}}) + if is_os("windows") then + add_files("apps/desktop/src/platform/windows/updater/*.cpp") + add_syslinks("Advapi32", "Shell32", "Ole32", {public = true}) + else + add_files("apps/desktop/src/platform/common/updater_posix.cpp") + if is_os("macosx") then + add_files("apps/desktop/src/platform/macos/updater/*.cpp") + add_syslinks("proc", {public = true}) + add_defines("CPPHTTPLIB_USE_CERTS_FROM_MACOSX_KEYCHAIN") + add_frameworks("Security", "CoreFoundation", {public = true}) + else + add_files("apps/desktop/src/platform/linux/updater/*.cpp") + end + end + + if not is_config("CROSSDESK_PORTABLE", true) then + target("crossdesk_updater") + set_kind("binary") + add_deps("updater_core", "single_instance") + add_packages("nlohmann_json") + add_files("apps/desktop/src/updater/main.cpp") + if is_os("windows") then + add_files("apps/desktop/resources/windows/crossdesk_updater.rc", + {defines = windows_resource_defines}) + end + end + target("version_checker") set_kind("object") add_packages("cpp-httplib") add_packages("nlohmann_json", {public = true}) - add_deps("rd_log", "crossdesk_wire") + add_deps("rd_log", "crossdesk_wire", "updater_core") + if is_config("CROSSDESK_PORTABLE", true) then + add_defines("CROSSDESK_PORTABLE=1") + end add_files("apps/desktop/src/version_checker/*.cpp") add_includedirs("apps/desktop/src/version_checker", {public = true}) if is_os("macosx") then @@ -524,6 +567,11 @@ function setup_targets() add_defines("FMT_CONSTEVAL=") add_packages("slint", {public = true}) add_packages("libyuv", "tinyfiledialogs", "openssl3", "libsdl3", "nlohmann_json") + if not is_config("CROSSDESK_PORTABLE", true) then + add_defines("CROSSDESK_HAS_UPDATER=1", {public = true}) + add_deps("updater_core") + add_files("apps/desktop/src/updater/update_controller.cpp") + end if is_config("CROSSDESK_PORTABLE", true) then -- RuntimeState changes layout in portable builds. Consumers of GUI -- headers must see the same definition, without affecting its deps. @@ -644,6 +692,9 @@ function setup_targets() target("crossdesk") set_kind("binary") add_deps("rd_log", "desktop_common", "gui", "single_instance") + if not is_config("CROSSDESK_PORTABLE", true) then + add_deps("crossdesk_updater", {inherit = false}) + end add_files("apps/desktop/src/app/*.cpp") add_includedirs("apps/desktop/src", "apps/desktop/src/app", {public = true}) if is_os("windows") then diff --git a/docs/BUILD.md b/docs/BUILD.md index 89ed9117..89f4c0e1 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -2,6 +2,9 @@ [返回 README](../README.md) · [English](BUILD_EN.md) +安装版的升级器随主程序构建。发布公钥、清单签名和验证方法见 +[桌面端应用内升级](UPDATER.md);便携版不包含升级器。 + 克隆仓库后,构建和打包命令均在仓库根目录执行。构建依赖与参数以 [Xmake 配置](../apps/desktop/xmake/options.lua) 和 [CI 工作流](../.github/workflows/build.yml) 为准。`1.4.3` 仅为本地构建示例版本号;调整时请同步修改配置命令与打包参数,避免程序内版本号与安装包不一致。 ## 构建依赖 diff --git a/docs/UPDATER.md b/docs/UPDATER.md new file mode 100644 index 00000000..df7b15a0 --- /dev/null +++ b/docs/UPDATER.md @@ -0,0 +1,117 @@ +# 桌面端应用内升级 + +Windows 安装版、macOS PKG 安装版和 Linux DEB 安装版共用 +`apps/desktop/src/updater` 中的升级核心。各平台的安装和进程操作位于 +`apps/desktop/src/platform/{windows,macos,linux}/updater`。 + +便携版仅检查版本并提供对应 ZIP 的手动下载地址,不构建或打包 +`crossdesk_updater`,不启动安装流程。没有配置公钥、缺少有效签名、 +发布清单版本不匹配或运行位置不受支持时,安装版也显示手动下载入口。 + +## 使用流程 + +1. 用户点击“安装更新”并确认连接和文件传输将中断。 +2. 主程序异步下载,显示进度,并验证签名、大小和 SHA-256。 +3. 升级器从私有暂存目录启动,取得安装目录对应的维护锁并确认就绪。 +4. 主程序正常退出,升级器等待同一用户的 GUI 和守护进程释放实例锁。 +5. 升级器请求系统安装授权。取消授权会尝试重新打开原程序。 +6. 获得授权的安装进程再次验证签名,将包复制到只有管理员可写的 + 私有目录,再验证该副本。确认相关程序退出后,调用平台安装器。 +7. 升级器以原调用者身份重启程序。新程序进入 GUI 事件循环后确认启动; + 90 秒内未收到确认会记录失败。启动确认不依赖远程服务器连通性。 + +系统代码签名不是这里的更新来源验证机制。Windows/macOS 安全检查、 +管理员授权,以及现有 macOS 安装包的权限处理仍然适用。 + +## 发布密钥 + +客户端内置一个 Ed25519 公钥,发布端保存对应的私钥。不要把私钥提交到 +仓库,也不要在普通构建或不可信 PR 的工作流中提供私钥。 + +用 OpenSSL 在可信环境生成密钥: + +```sh +openssl genpkey -algorithm ED25519 -out crossdesk-update-private.pem +openssl pkey -in crossdesk-update-private.pem -pubout -outform DER -out crossdesk-update-public.der +python3 -c "from pathlib import Path; p=Path('crossdesk-update-public.der').read_bytes(); assert len(p)==44 and p[:12].hex()=='302a300506032b6570032100'; print(p[12:].hex())" +``` + +在 GitHub 仓库配置: + +- Actions variable `CROSSDESK_UPDATE_PUBLIC_KEY`:上述 64 位小写十六进制公钥。 +- Actions secret `CROSSDESK_UPDATE_PRIVATE_KEY`:PEM 私钥文件的完整内容。 + +桌面构建工作流将公钥传给 Xmake。只有发布清单生成步骤读取私钥。 +首次需要手动安装包含升级器、公钥及新安装元数据的版本。 + +本地构建可以设置同名环境变量,或使用: + +```sh +xmake f --CROSSDESK_UPDATE_PUBLIC_KEY=<64位小写十六进制公钥> +xmake b crossdesk +``` + +没有公钥的构建可以正常运行和检查版本,但不执行自动安装。 +私钥与公钥不匹配时,清单生成失败。当前只支持单个公钥;更换公钥前需要 +设计过渡版本,不能直接替换发布私钥而让旧客户端继续自动升级。 + +## 清单格式 + +`.github/scripts/update_version_manifest.py` 为安装包写入 `size`、`sha256`。 +配置私钥后,桌面安装包还包含: + +```json +{ + "update": { + "payload": "{\"filename\":\"...\",\"platform\":\"windows-x64\",\"schema\":1,\"sha256\":\"...\",\"size\":123,\"url\":\"...\",\"version\":\"...\"}", + "signature": "128位小写十六进制Ed25519签名" + } +} +``` + +签名覆盖 `payload` 字符串的原始 UTF-8 字节;客户端先验签,再解析。 +签名包含版本、平台、包名、下载地址、大小和哈希,避免下载记录被互换。 +仅允许 `https://downloads.crossdesk.cn/` 下与版本和平台匹配的包名, +禁止下载重定向,包大小上限为 2 GiB,禁止同版本安装及降级。 +便携包和移动端包不会获得此自动安装入口。 + +## 平台边界 + +- Windows:安装目录必须匹配 HKLM 卸载记录中的 `InstallLocation`。 + 停止 CrossDesk 服务并等待其退出;确认同一路径的主程序全部结束后, + 以 `/S /CROSSDESK_UPDATE /D=...` 调用 NSIS。升级模式不按进程名强杀 + 程序,且由升级器负责重启。原先运行的服务会尝试恢复。 +- macOS:支持 `/Applications/CrossDesk.app`,通过系统授权调用 + `/usr/sbin/installer` 安装 PKG。其他安装位置使用手动下载。 + 升级器只能依赖系统动态库,打包时验证这一点。 +- Linux:支持 `/usr/lib/crossdesk/crossdesk-bin`,根据 `package-name` + 选择普通包或虚拟桌面包。使用 `pkexec` 授权和 `apt-get install` + 安装本地 DEB;禁止包管理器自动删除依赖。需要可用的授权代理。 + 自定义 systemd/headless supervisor 必须由管理员先协调停止; + 检测到仍在运行的程序会中止升级,不猜测或停止其他服务。 + +退出等待有时间限制,跨用户仍有程序运行会报告失败。维护锁在进程退出时 +由系统释放,遗留的 POSIX 锁文件不会被当作正在升级。 + +## 失败处理与验证 + +下载、验签或准备失败时不关闭主程序。安装失败或授权取消后尝试重启。 +`last-update` 位于应用缓存目录,指向私有暂存目录;`request.json` 和 +`result.json` 保留升级版本及结果,未完成的任务不会自动重放。成功启动后 +删除大型安装包,保留少量诊断文件。失败任务保留下载包便于排查。 + +NSIS、PKG 和 DEB 的安装操作不是跨平台事务。当前没有自动回滚承诺: +安装中断或新版本启动失败时,保留错误并提供手动修复安装路径。不能通过 +只恢复 EXE 来回滚服务、依赖库或配置迁移。 + +```sh +xmake b update_checker_test version_checker_test +xmake r update_checker_test +xmake r version_checker_test +python3 -m unittest discover -s .github/scripts -p test_update_version_manifest.py +xmake lua apps/desktop/tests/build_define_scope_test.lua windows true +``` + +发布前仍须在各平台的隔离安装环境验证:授权取消、其他会话占用、服务 +退出、安装失败、升级器自身更新和新程序启动确认。不要在开发机上通过 +自动运行生产安装包来代替这些安装测试。 diff --git a/libs/wire/include/app_version.h b/libs/wire/include/app_version.h index 859cbf22..7afc1483 100644 --- a/libs/wire/include/app_version.h +++ b/libs/wire/include/app_version.h @@ -21,6 +21,8 @@ struct VersionInfo { int patch = -1; // Filled by the desktop updater for the installed package variant. std::string download_url; + // Opaque signed desktop artifact envelope; ignored by mobile controllers. + std::string update_envelope; }; // Shared release parsing and comparison for desktop and mobile controllers.