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.