mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-21 03:40:40 +08:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ea462764d | ||
|
|
ef02ef2e6c | ||
|
|
e45350319c | ||
|
|
cf98775754 | ||
|
|
1aa23126d2 | ||
|
|
cb2ae207c4 | ||
|
|
a625890b28 |
@@ -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")
|
||||
|
||||
@@ -11,6 +11,10 @@ on:
|
||||
- "!android-v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
desktop_version:
|
||||
description: "Desktop version for branch builds, e.g. 1.7.2. Leave blank for 0.0.0; tags use their own version."
|
||||
required: false
|
||||
type: string
|
||||
patch:
|
||||
description: "Hotfix patch number, for example 1 or 2. Use 0 for a normal build."
|
||||
required: false
|
||||
@@ -28,6 +32,8 @@ permissions:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
XMAKE_MIN_VERSION: "3.1.1"
|
||||
CROSSDESK_UPDATE_PUBLIC_KEY: ${{ vars.CROSSDESK_UPDATE_PUBLIC_KEY }}
|
||||
CROSSDESK_DESKTOP_VERSION: ${{ inputs.desktop_version }}
|
||||
|
||||
jobs:
|
||||
build-linux:
|
||||
@@ -56,6 +62,16 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION_REF="${GITHUB_REF##*/}"
|
||||
if [[ "${GITHUB_REF}" == refs/heads/* ]]; then
|
||||
VERSION_REF="${CROSSDESK_DESKTOP_VERSION:-0.0.0}"
|
||||
if [[ ! "${VERSION_REF}" =~ ^v?[0-9]+(\.[0-9]+){1,3}$ ]]; then
|
||||
echo "::error::desktop_version must be a numeric version such as 1.7.2"
|
||||
exit 1
|
||||
fi
|
||||
elif [[ -n "${CROSSDESK_DESKTOP_VERSION}" ]]; then
|
||||
echo "::error::desktop_version is only supported for branch builds; tags determine release versions"
|
||||
exit 1
|
||||
fi
|
||||
VERSION_BASE="${VERSION_REF#v}"
|
||||
PATCH_NUMBER="${{ github.event.inputs.patch }}"
|
||||
BUILD_DATE_OVERRIDE=""
|
||||
@@ -228,6 +244,16 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION_REF="${GITHUB_REF##*/}"
|
||||
if [[ "${GITHUB_REF}" == refs/heads/* ]]; then
|
||||
VERSION_REF="${CROSSDESK_DESKTOP_VERSION:-0.0.0}"
|
||||
if [[ ! "${VERSION_REF}" =~ ^v?[0-9]+(\.[0-9]+){1,3}$ ]]; then
|
||||
echo "::error::desktop_version must be a numeric version such as 1.7.2"
|
||||
exit 1
|
||||
fi
|
||||
elif [[ -n "${CROSSDESK_DESKTOP_VERSION}" ]]; then
|
||||
echo "::error::desktop_version is only supported for branch builds; tags determine release versions"
|
||||
exit 1
|
||||
fi
|
||||
VERSION_BASE="${VERSION_REF#v}"
|
||||
BUILD_DATE=$(TZ=Asia/Shanghai date +%Y%m%d)
|
||||
PATCH_NUMBER="${{ github.event.inputs.patch }}"
|
||||
@@ -363,6 +389,18 @@ jobs:
|
||||
run: |
|
||||
$ref = $env:GITHUB_REF
|
||||
$version = $ref -replace '^refs/(tags|heads)/', ''
|
||||
if ($ref.StartsWith("refs/heads/")) {
|
||||
$version = if ([string]::IsNullOrEmpty($env:CROSSDESK_DESKTOP_VERSION)) {
|
||||
"0.0.0"
|
||||
} else {
|
||||
$env:CROSSDESK_DESKTOP_VERSION
|
||||
}
|
||||
if ($version -notmatch '^v?[0-9]+(\.[0-9]+){1,3}\z') {
|
||||
throw "desktop_version must be a numeric version such as 1.7.2"
|
||||
}
|
||||
} elseif (![string]::IsNullOrEmpty($env:CROSSDESK_DESKTOP_VERSION)) {
|
||||
throw "desktop_version is only supported for branch builds; tags determine release versions"
|
||||
}
|
||||
$version = $version -replace '^v', ''
|
||||
$version = $version -replace '/', '-'
|
||||
$BUILD_DATE = ([System.TimeZoneInfo]::ConvertTimeBySystemTimeZoneId((Get-Date), "China Standard Time")).ToString("yyyyMMdd")
|
||||
@@ -752,6 +790,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 \
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<assemblyIdentity version="1.0.0.0" name="CrossDesk.Updater" type="win32"/>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges>
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
</assembly>
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <system_error>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <utility>
|
||||
@@ -86,6 +87,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 {
|
||||
@@ -1110,7 +1114,17 @@ int GuiApplication::Run() {
|
||||
"%d", signal_port);
|
||||
}
|
||||
|
||||
#if defined(__linux__) && !defined(__OHOS__)
|
||||
try {
|
||||
InitializeSettings();
|
||||
} catch (const std::system_error&) {
|
||||
// Credential I/O errors are logged by SettingsManager. Exit cleanly rather
|
||||
// than initializing a new identity or crashing with an uncaught exception.
|
||||
return -1;
|
||||
}
|
||||
#else
|
||||
InitializeSettings();
|
||||
#endif
|
||||
#if defined(__linux__) && !defined(__OHOS__)
|
||||
HeadlessConsole::Instance().BindSettings(
|
||||
config_center_.get(), [this] {
|
||||
@@ -1134,6 +1148,9 @@ int GuiApplication::Run() {
|
||||
#endif
|
||||
InitializeModules();
|
||||
update_checker_ = std::make_unique<UpdateChecker>(CROSSDESK_VERSION);
|
||||
#if CROSSDESK_HAS_UPDATER
|
||||
updater_ = std::make_unique<updater::Controller>(path_manager_->GetCachePath());
|
||||
#endif
|
||||
InitializeUi();
|
||||
#ifndef __OHOS__
|
||||
ui_->activation_receiver = std::move(activation_receiver);
|
||||
@@ -1735,11 +1752,39 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) {
|
||||
? UiText(std::to_string(signal_port))
|
||||
: slint::SharedString{});
|
||||
});
|
||||
main->on_open_download([this] {
|
||||
main->on_cancel_update([this] {
|
||||
#if CROSSDESK_HAS_UPDATER
|
||||
if (updater_ && updater_->busy()) updater_->Cancel();
|
||||
#endif
|
||||
});
|
||||
main->on_open_download([this, main] {
|
||||
const auto& latest = update_checker_->latest();
|
||||
#if CROSSDESK_HAS_UPDATER
|
||||
if (updater_ && updater_->busy()) return;
|
||||
if (updater_ && latest && updater_->Available(latest->update_envelope)) {
|
||||
main->set_about_open(false);
|
||||
main->set_update_open(true);
|
||||
main->set_update_confirm_open(true);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
OpenUrl(latest && !latest->download_url.empty() ? latest->download_url
|
||||
: "https://crossdesk.cn");
|
||||
});
|
||||
main->on_confirm_update([this, main] {
|
||||
if (!main->get_update_confirm_open()) return;
|
||||
for (const auto& window : {ui_->main, ui_->wall})
|
||||
window->set_update_confirm_open(false);
|
||||
#if CROSSDESK_HAS_UPDATER
|
||||
const auto& latest = update_checker_->latest();
|
||||
if (!updater_ || updater_->busy() || !latest ||
|
||||
!updater_->Available(latest->update_envelope)) return;
|
||||
updater_->Start(latest->update_envelope);
|
||||
// Disable both windows immediately, before the next polling tick.
|
||||
for (const auto& window : {ui_->main, ui_->wall})
|
||||
window->set_update_install_busy(updater_->busy());
|
||||
#endif
|
||||
});
|
||||
main->on_open_website([this] { OpenUrl("https://crossdesk.cn"); });
|
||||
main->on_open_afdian_sponsor([this] {
|
||||
OpenUrl("https://afdian.com/a/kunkundi");
|
||||
@@ -2326,6 +2371,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 +6238,9 @@ void GuiApplication::Cleanup() {
|
||||
if (update_checker_) {
|
||||
update_checker_->Stop();
|
||||
}
|
||||
#if CROSSDESK_HAS_UPDATER
|
||||
updater_.reset();
|
||||
#endif
|
||||
SDL_Quit();
|
||||
}
|
||||
|
||||
|
||||
@@ -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<SlintUi> ui_;
|
||||
std::unique_ptr<UpdateChecker> update_checker_;
|
||||
#if CROSSDESK_HAS_UPDATER
|
||||
std::unique_ptr<updater::Controller> updater_;
|
||||
#endif
|
||||
CursorStateProvider cursor_state_provider_;
|
||||
std::unordered_map<std::string, CursorDeliveryState>
|
||||
cursor_delivery_states_;
|
||||
|
||||
@@ -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", \
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "features/settings/settings_manager.h"
|
||||
|
||||
#include <cerrno>
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <cstdio>
|
||||
@@ -11,6 +12,7 @@
|
||||
#include <memory>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
#include <thread>
|
||||
|
||||
#if defined(_WIN32)
|
||||
@@ -20,6 +22,9 @@
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
#if defined(__linux__) && !defined(__OHOS__)
|
||||
#include <sys/stat.h>
|
||||
#endif
|
||||
|
||||
#include "localization.h"
|
||||
#include "rd_log.h"
|
||||
@@ -31,6 +36,92 @@ namespace {
|
||||
constexpr uint32_t kCacheV3Magic = 0x33444358; // "XCD3"
|
||||
constexpr uint32_t kCacheV3Version = 3;
|
||||
|
||||
#if defined(__linux__) && !defined(__OHOS__)
|
||||
constexpr const char *kCredentialCacheNames[] = {
|
||||
"secure_cache_v3.enc", "secure_cache_v2.enc", "secure_cache.enc"};
|
||||
|
||||
bool RestrictCachePermissions(const std::filesystem::path &path,
|
||||
bool directory = false) {
|
||||
const int flags = O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK |
|
||||
(directory ? O_DIRECTORY : 0);
|
||||
const int fd = open(path.c_str(), flags);
|
||||
if (fd < 0 && errno == ENOENT) return true;
|
||||
int error = fd < 0 ? errno : 0;
|
||||
if (fd >= 0) {
|
||||
struct stat info {};
|
||||
if (fstat(fd, &info) != 0) {
|
||||
error = errno;
|
||||
} else if (info.st_uid != geteuid() ||
|
||||
(directory ? !S_ISDIR(info.st_mode) : !S_ISREG(info.st_mode))) {
|
||||
error = EPERM;
|
||||
} else {
|
||||
const mode_t mode = directory ? 0700 : 0600;
|
||||
if ((info.st_mode & 07777) != mode && fchmod(fd, mode) != 0)
|
||||
error = errno;
|
||||
}
|
||||
close(fd);
|
||||
}
|
||||
if (error) {
|
||||
LOG_WARN("Cannot restrict credential cache permissions for {}: {}",
|
||||
path.string(),
|
||||
std::error_code(error, std::generic_category()).message());
|
||||
}
|
||||
return error == 0;
|
||||
}
|
||||
|
||||
[[noreturn]] void CacheReadError(const std::filesystem::path &path, int error) {
|
||||
const std::system_error failure(
|
||||
error, std::generic_category(),
|
||||
"Cannot read credential cache; existing data was kept: " + path.string());
|
||||
LOG_ERROR("{}", failure.what());
|
||||
throw failure;
|
||||
}
|
||||
|
||||
bool ReadCredentialCache(const std::filesystem::path &path, void *data,
|
||||
size_t size) {
|
||||
const int fd = open(path.c_str(), O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK);
|
||||
if (fd < 0) {
|
||||
if (errno == ENOENT) return false;
|
||||
CacheReadError(path, errno);
|
||||
}
|
||||
struct stat info {};
|
||||
if (fstat(fd, &info) != 0) {
|
||||
const int error = errno;
|
||||
close(fd);
|
||||
CacheReadError(path, error);
|
||||
}
|
||||
if (!S_ISREG(info.st_mode)) {
|
||||
close(fd);
|
||||
CacheReadError(path, EINVAL);
|
||||
}
|
||||
size_t received = 0;
|
||||
while (received < size) {
|
||||
const auto count =
|
||||
read(fd, static_cast<char *>(data) + received, size - received);
|
||||
if (count < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
const int error = errno;
|
||||
close(fd);
|
||||
CacheReadError(path, error);
|
||||
}
|
||||
if (count == 0) break;
|
||||
received += static_cast<size_t>(count);
|
||||
}
|
||||
close(fd);
|
||||
return received == size;
|
||||
}
|
||||
|
||||
bool HasExistingCredentialCache(const std::filesystem::path &directory) {
|
||||
for (const char *name : kCredentialCacheNames) {
|
||||
const auto path = directory / name;
|
||||
struct stat info {};
|
||||
if (lstat(path.c_str(), &info) == 0) return true;
|
||||
if (errno != ENOENT) CacheReadError(path, errno);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
|
||||
template <size_t Size>
|
||||
void CopyString(char (&destination)[Size], const char *source) {
|
||||
static_assert(Size > 0);
|
||||
@@ -110,11 +201,41 @@ bool WriteFileAtomically(const std::filesystem::path &target,
|
||||
if (ec) {
|
||||
return false;
|
||||
}
|
||||
#if defined(__linux__) && !defined(__OHOS__)
|
||||
if (!RestrictCachePermissions(parent, true)) return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
#if defined(__linux__) && !defined(__OHOS__)
|
||||
// Never adopt another user's file or replace a link while saving credentials.
|
||||
struct stat previous {};
|
||||
if (lstat(target.c_str(), &previous) == 0) {
|
||||
if (!S_ISREG(previous.st_mode) || previous.st_uid != geteuid()) return false;
|
||||
} else if (errno != ENOENT) {
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
|
||||
const std::filesystem::path temporary = TemporaryPathFor(target);
|
||||
#if defined(_WIN32)
|
||||
FILE *file = _wfopen(temporary.c_str(), L"wb");
|
||||
#elif defined(__linux__) && !defined(__OHOS__)
|
||||
// Set permissions before writing any secret, including under umask(0000).
|
||||
// Exclusive creation also prevents following/reusing a pre-existing temp file.
|
||||
const int fd = open(temporary.c_str(),
|
||||
O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0600);
|
||||
if (fd < 0) return false;
|
||||
if (fchmod(fd, 0600) != 0) {
|
||||
close(fd);
|
||||
std::filesystem::remove(temporary, ec);
|
||||
return false;
|
||||
}
|
||||
FILE *file = fdopen(fd, "wb");
|
||||
if (!file) {
|
||||
close(fd);
|
||||
std::filesystem::remove(temporary, ec);
|
||||
return false;
|
||||
}
|
||||
#else
|
||||
FILE *file = std::fopen(temporary.c_str(), "wb");
|
||||
#endif
|
||||
@@ -184,16 +305,26 @@ int SettingsManager::SaveLocked() {
|
||||
}
|
||||
|
||||
bool SettingsManager::ReadV3Locked() {
|
||||
CacheV3 loaded{};
|
||||
#if defined(__linux__) && !defined(__OHOS__)
|
||||
if (!ReadCredentialCache(owner_.cache_path_ + "/secure_cache_v3.enc",
|
||||
&loaded, sizeof(loaded))) {
|
||||
return false;
|
||||
}
|
||||
#else
|
||||
std::ifstream cache_file(owner_.cache_path_ + "/secure_cache_v3.enc",
|
||||
std::ios::binary);
|
||||
if (!cache_file) {
|
||||
return false;
|
||||
}
|
||||
|
||||
CacheV3 loaded{};
|
||||
cache_file.read(reinterpret_cast<char *>(&loaded), sizeof(loaded));
|
||||
if (cache_file.gcount() != static_cast<std::streamsize>(sizeof(loaded)) ||
|
||||
loaded.magic != kCacheV3Magic ||
|
||||
if (cache_file.gcount() != static_cast<std::streamsize>(sizeof(loaded))) {
|
||||
LOG_WARN("Ignore invalid v3 credential cache");
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
if (loaded.magic != kCacheV3Magic ||
|
||||
loaded.version != kCacheV3Version ||
|
||||
loaded.checksum != CacheChecksum(&loaded, offsetof(CacheV3, checksum))) {
|
||||
LOG_WARN("Ignore invalid v3 credential cache");
|
||||
@@ -212,6 +343,12 @@ bool SettingsManager::ReadV3Locked() {
|
||||
}
|
||||
|
||||
bool SettingsManager::ReadV2Locked() {
|
||||
#if defined(__linux__) && !defined(__OHOS__)
|
||||
if (!ReadCredentialCache(owner_.cache_path_ + "/secure_cache_v2.enc",
|
||||
&cache_v2_, sizeof(cache_v2_))) {
|
||||
return false;
|
||||
}
|
||||
#else
|
||||
std::ifstream cache_v2_file(owner_.cache_path_ + "/secure_cache_v2.enc",
|
||||
std::ios::binary);
|
||||
if (!cache_v2_file) {
|
||||
@@ -222,6 +359,7 @@ bool SettingsManager::ReadV2Locked() {
|
||||
static_cast<std::streamsize>(sizeof(cache_v2_))) {
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
cache_v2_
|
||||
.client_id_with_password[sizeof(cache_v2_.client_id_with_password) - 1] =
|
||||
'\0';
|
||||
@@ -239,6 +377,14 @@ std::string SettingsManager::ActiveIdentity() const {
|
||||
int SettingsManager::Load() {
|
||||
std::unique_lock<std::mutex> lock(cache_mutex_);
|
||||
|
||||
#if defined(__linux__) && !defined(__OHOS__)
|
||||
// Migrate all generations in place, even when only v3 is used. A chmod
|
||||
// failure must not turn a readable identity into a missing/invalid cache.
|
||||
RestrictCachePermissions(owner_.cache_path_, true);
|
||||
for (const char *name : kCredentialCacheNames)
|
||||
RestrictCachePermissions(std::filesystem::path(owner_.cache_path_) / name);
|
||||
#endif
|
||||
|
||||
const bool loaded_v3 = ReadV3Locked();
|
||||
if (loaded_v3 || ReadV2Locked()) {
|
||||
CopyString(owner_.client_id_with_password_,
|
||||
@@ -254,9 +400,19 @@ int SettingsManager::Load() {
|
||||
LOG_INFO("Migrated settings from v2 to v3 cache file");
|
||||
}
|
||||
} else {
|
||||
#if defined(__linux__) && !defined(__OHOS__)
|
||||
const bool loaded_v1 = ReadCredentialCache(
|
||||
owner_.cache_path_ + "/secure_cache.enc", &cache_v1_, sizeof(cache_v1_));
|
||||
if (!loaded_v1) {
|
||||
// Permission/I/O errors throw above. Invalid existing files also must
|
||||
// never trigger first-run identity generation or thumbnail deletion.
|
||||
if (HasExistingCredentialCache(owner_.cache_path_))
|
||||
CacheReadError(owner_.cache_path_, EINVAL);
|
||||
#else
|
||||
std::ifstream cache_v1_file(owner_.cache_path_ + "/secure_cache.enc",
|
||||
std::ios::binary);
|
||||
if (!cache_v1_file) {
|
||||
#endif
|
||||
lock.unlock();
|
||||
|
||||
std::memset(owner_.password_saved_, 0, sizeof(owner_.password_saved_));
|
||||
@@ -273,7 +429,9 @@ int SettingsManager::Load() {
|
||||
return -1;
|
||||
}
|
||||
|
||||
#if !defined(__linux__) || defined(__OHOS__)
|
||||
cache_v1_file.read(reinterpret_cast<char *>(&cache_v1_), sizeof(cache_v1_));
|
||||
#endif
|
||||
cache_v1_
|
||||
.client_id_with_password[sizeof(cache_v1_.client_id_with_password) -
|
||||
1] = '\0';
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <mutex>
|
||||
#include <shared_mutex>
|
||||
#include <string>
|
||||
#include <system_error>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
@@ -96,7 +97,18 @@ int GuiRuntime::CreateConnectionPeer() {
|
||||
signal_server_port = config_center_->GetSignalServerPort();
|
||||
|
||||
std::string current_self_hosted_ip = config_center_->GetSignalServerHost();
|
||||
const bool use_cached_id = settings_.LoadCachedSelfHostedIdentity();
|
||||
bool use_cached_id = false;
|
||||
#if defined(__linux__) && !defined(__OHOS__)
|
||||
try {
|
||||
use_cached_id = settings_.LoadCachedSelfHostedIdentity();
|
||||
} catch (const std::system_error&) {
|
||||
// Keep the cached identity and avoid requesting a replacement identity
|
||||
// when its file is temporarily inaccessible. SettingsManager logs why.
|
||||
return -1;
|
||||
}
|
||||
#else
|
||||
use_cached_id = settings_.LoadCachedSelfHostedIdentity();
|
||||
#endif
|
||||
if (!use_cached_id) {
|
||||
LOG_INFO(
|
||||
"secure_cache_v2.enc not found, will use empty id to get new id from "
|
||||
|
||||
@@ -99,6 +99,8 @@ export global UiStrings {
|
||||
in-out property <string> view-release-notes: "Release notes";
|
||||
in-out property <string> later: "Later";
|
||||
in-out property <string> download: "Download";
|
||||
in-out property <string> install-update: "Install update";
|
||||
in-out property <string> install-update-confirm;
|
||||
in-out property <string> input-password: "Input password";
|
||||
in-out property <string> reinput-password: "Incorrect password, please try again";
|
||||
in-out property <string> remember-password: "Remember password";
|
||||
@@ -408,6 +410,16 @@ export component MainWindow inherits Window {
|
||||
callback read-all-announcements;
|
||||
callback load-more-announcements;
|
||||
in property <bool> update-available: false;
|
||||
in property <bool> update-install-available: false;
|
||||
in property <bool> update-install-busy: false;
|
||||
changed update-install-available => {
|
||||
if !root.update-install-available { root.update-confirm-open = false; }
|
||||
}
|
||||
changed update-install-busy => {
|
||||
if root.update-install-busy { root.update-confirm-open = false; }
|
||||
}
|
||||
in property <string> update-install-status: "";
|
||||
private property <string> update-action-text: root.update-install-available ? UiStrings.install-update : UiStrings.download;
|
||||
in property <UpdateCheckStatus> update-check-status: idle;
|
||||
private property <string> update-status-text:
|
||||
root.update-check-status == UpdateCheckStatus.checking ? UiStrings.checking-for-updates :
|
||||
@@ -460,7 +472,7 @@ export component MainWindow inherits Window {
|
||||
root.autostart-status == AutostartStatus.unsupported ? GalleryStrings.tr("设备不支持", "Unsupported", "Не поддерживается") :
|
||||
GalleryStrings.tr("读取失败", "Read failed", "Ошибка чтения");
|
||||
in-out property <bool> 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 <bool> daemon-enabled: false;
|
||||
in-out property <bool> save-remote-previews: true;
|
||||
// Show the preference on every platform; only adapted hosts can change it.
|
||||
@@ -492,6 +504,8 @@ export component MainWindow inherits Window {
|
||||
callback cancel-self-hosted-settings;
|
||||
callback browse-save-path;
|
||||
callback open-download;
|
||||
callback confirm-update;
|
||||
callback cancel-update;
|
||||
callback open-website;
|
||||
callback open-afdian-sponsor;
|
||||
callback open-github-sponsor;
|
||||
@@ -528,6 +542,7 @@ export component MainWindow inherits Window {
|
||||
&& !root.menu-open && !root.settings-open
|
||||
&& !root.self-host-settings-open && !root.about-open
|
||||
&& !(root.update-open && root.update-available) && !root.messages-open
|
||||
&& !root.update-confirm-open
|
||||
&& !root.reset-password-open && !root.alias-open && !root.delete-open
|
||||
&& !root.device-group-open && !root.connection-dialog-open
|
||||
&& !root.permission-dialog-open && !root.wayland-notice-open
|
||||
@@ -539,6 +554,10 @@ export component MainWindow inherits Window {
|
||||
// Follow the first available update. Dismissing the dialog replaces this
|
||||
// binding, so background checks do not reopen it during this launch.
|
||||
in-out property <bool> update-open: root.update-available;
|
||||
changed update-open => {
|
||||
if !root.update-open { root.update-confirm-open = false; }
|
||||
}
|
||||
in-out property <bool> update-confirm-open: false;
|
||||
in-out property <bool> reset-password-open: false;
|
||||
in-out property <bool> reset-password-invalid: false;
|
||||
in-out property <string> new-password-input: "";
|
||||
@@ -558,6 +577,23 @@ export component MainWindow inherits Window {
|
||||
private property <bool> recent-tooltip-online: false;
|
||||
private property <length> recent-tooltip-pointer-x: 0px;
|
||||
private property <length> recent-tooltip-pointer-y: 0px;
|
||||
private property <bool> 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 +611,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 +2488,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 +2551,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 +2625,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 +2640,99 @@ 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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep confirmation inside the app instead of relying on external native
|
||||
// dialogs and their platform-specific button labels.
|
||||
if root.update-confirm-open: Rectangle {
|
||||
background: #14233b66;
|
||||
z: 48;
|
||||
TouchArea {}
|
||||
FocusScope {
|
||||
width: parent.width;
|
||||
height: parent.height;
|
||||
key-pressed(event) => {
|
||||
if event.text == Key.Escape {
|
||||
root.update-confirm-open = false;
|
||||
return accept;
|
||||
}
|
||||
if event.text == Key.Tab {
|
||||
if update-confirm-cancel.has-focus {
|
||||
update-confirm-install.focus();
|
||||
} else {
|
||||
update-confirm-cancel.focus();
|
||||
}
|
||||
return accept;
|
||||
}
|
||||
return reject;
|
||||
}
|
||||
Timer {
|
||||
interval: 1ms;
|
||||
running: true;
|
||||
triggered => {
|
||||
self.running = false;
|
||||
// Defer focus until after Slint's accessibility update.
|
||||
// Return defaults to cancelling this disruptive action.
|
||||
update-confirm-cancel.focus();
|
||||
}
|
||||
}
|
||||
DialogSurface {
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
width: min(440px, parent.width - 40px);
|
||||
height: update-confirm-content.preferred-height;
|
||||
border-radius: UiRadius.surface;
|
||||
border-color: WorkbenchStyle.border;
|
||||
TouchArea {}
|
||||
update-confirm-content := VerticalLayout {
|
||||
padding: 24px;
|
||||
spacing: 20px;
|
||||
Text {
|
||||
text: UiStrings.install-update;
|
||||
color: WorkbenchStyle.text;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
Text {
|
||||
text: UiStrings.install-update-confirm;
|
||||
color: WorkbenchStyle.text;
|
||||
font-size: 13px;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
HorizontalLayout {
|
||||
alignment: end;
|
||||
spacing: 8px;
|
||||
update-confirm-cancel := WorkbenchButton {
|
||||
text: UiStrings.cancel;
|
||||
clicked => { root.update-confirm-open = false; }
|
||||
}
|
||||
update-confirm-install := WorkbenchButton {
|
||||
width: max(112px, confirm-label-metrics.preferred-width + 24px);
|
||||
text: UiStrings.install-update;
|
||||
primary: true;
|
||||
enabled: root.update-install-available && !root.update-install-busy;
|
||||
clicked => { root.confirm-update(); }
|
||||
confirm-label-metrics := Text {
|
||||
visible: false;
|
||||
text: UiStrings.install-update;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +131,10 @@ 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_install_update_confirm(
|
||||
Text(localization::install_update_confirm[language]));
|
||||
strings.set_input_password(Text(localization::input_password[language]));
|
||||
strings.set_reinput_password(Text(localization::reinput_password[language]));
|
||||
strings.set_remember_password(
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
#include "updater/update_platform.h"
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <signal.h>
|
||||
#include <sys/file.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cerrno>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <stdexcept>
|
||||
#include <thread>
|
||||
|
||||
namespace crossdesk::updater {
|
||||
namespace {
|
||||
std::string LockPath(const fs::path& application) {
|
||||
return "/tmp/CrossDesk-update-" + Sha256(fs::weakly_canonical(application).string()) + ".lock";
|
||||
}
|
||||
std::vector<char*> Argv(std::vector<std::string>& args) {
|
||||
std::vector<char*> 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<void> PinHelper(const fs::path&) { return {}; }
|
||||
void RestoreServices() {}
|
||||
|
||||
int Run(const fs::path& executable, const std::vector<std::string>& 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<std::string> 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<std::string>& arguments) {
|
||||
std::vector<std::string> 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<int>(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
|
||||
@@ -0,0 +1,88 @@
|
||||
#include "updater/update_platform.h"
|
||||
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
#include <thread>
|
||||
|
||||
namespace crossdesk::updater {
|
||||
namespace {
|
||||
std::vector<std::string> HeadlessArguments(const std::vector<std::string>& arguments) {
|
||||
std::vector<std::string> result;
|
||||
for (std::size_t i = 0; i < arguments.size(); ++i) {
|
||||
const auto& argument = arguments[i];
|
||||
if (argument == "--headless" || argument == "--no-headless") {
|
||||
result.push_back(argument);
|
||||
} else if (argument == "--headless-display" || argument == "--headless-size" ||
|
||||
argument == "--headless-session") {
|
||||
if (i + 1 == arguments.size() || arguments[i + 1].empty() ||
|
||||
arguments[i + 1].rfind("--", 0) == 0)
|
||||
throw std::runtime_error("Missing headless restart argument");
|
||||
result.push_back(argument);
|
||||
result.push_back(arguments[++i]);
|
||||
}
|
||||
// Internal modes such as --child must never replace a normal restart.
|
||||
}
|
||||
return result;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
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");
|
||||
}
|
||||
std::vector<std::string> RestartArguments() {
|
||||
// The built-in headless supervisor forks the GUI without exec, so procfs
|
||||
// still contains its original display/session options in the GUI process.
|
||||
std::istringstream command_line(ReadText("/proc/self/cmdline", 32768));
|
||||
std::string argument;
|
||||
std::getline(command_line, argument, '\0'); // Executable name.
|
||||
std::vector<std::string> arguments;
|
||||
while (std::getline(command_line, argument, '\0')) arguments.push_back(argument);
|
||||
return HeadlessArguments(arguments);
|
||||
}
|
||||
void Restart(const fs::path&, const std::vector<std::string>& arguments) {
|
||||
Launch("/usr/bin/crossdesk", HeadlessArguments(arguments));
|
||||
}
|
||||
} // namespace crossdesk::updater
|
||||
@@ -0,0 +1,75 @@
|
||||
#include "updater/update_platform.h"
|
||||
|
||||
#include <libproc.h>
|
||||
#include <mach-o/dyld.h>
|
||||
#include <unistd.h>
|
||||
#include <chrono>
|
||||
#include <stdexcept>
|
||||
#include <thread>
|
||||
|
||||
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<char> 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<pid_t> pids(static_cast<std::size_t>(count) + 256);
|
||||
const int length = proc_listallpids(pids.data(), static_cast<int>(pids.size() * sizeof(pid_t)));
|
||||
if (length <= 0 || length >= static_cast<int>(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");
|
||||
}
|
||||
std::vector<std::string> RestartArguments() { return {}; }
|
||||
void Restart(const fs::path&, const std::vector<std::string>&) {
|
||||
Launch("/usr/bin/open", {"/Applications/CrossDesk.app"});
|
||||
}
|
||||
} // namespace crossdesk::updater
|
||||
@@ -0,0 +1,306 @@
|
||||
#include "updater/update_platform.h"
|
||||
|
||||
#include <windows.h>
|
||||
#include <shellapi.h>
|
||||
#include <shlobj.h>
|
||||
#include <sddl.h>
|
||||
#include <aclapi.h>
|
||||
#include <tlhelp32.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cwctype>
|
||||
#include <stdexcept>
|
||||
#include <thread>
|
||||
|
||||
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<std::string>& 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<BYTE*>(&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<BYTE*>(&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<unsigned char> 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<TOKEN_USER*>(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<ACCESS_ALLOWED_ACE*>(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<std::string>& 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<std::string>& 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<int>(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);
|
||||
}
|
||||
|
||||
std::vector<std::string> RestartArguments() { return {}; }
|
||||
void Restart(const fs::path& application, const std::vector<std::string>&) {
|
||||
Launch(application, {});
|
||||
}
|
||||
|
||||
std::shared_ptr<void> 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<std::intptr_t>(mutex);
|
||||
}
|
||||
MaintenanceLock::~MaintenanceLock() {
|
||||
if (handle_ != -1) CloseHandle(reinterpret_cast<HANDLE>(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
|
||||
@@ -0,0 +1,199 @@
|
||||
#include "updater/update_core.h"
|
||||
#include "updater/update_platform.h"
|
||||
#include "platform/single_instance.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <chrono>
|
||||
#include <iostream>
|
||||
#include <thread>
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#include <shellapi.h>
|
||||
#pragma comment(linker, "/subsystem:windows /entry:mainCRTStartup")
|
||||
#endif
|
||||
|
||||
namespace crossdesk::updater {
|
||||
namespace {
|
||||
struct Request {
|
||||
fs::path application;
|
||||
Artifact artifact;
|
||||
std::vector<std::string> restart_arguments;
|
||||
};
|
||||
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<std::string>());
|
||||
if (!SupportedInstallation(application)) throw std::runtime_error("Invalid installation destination");
|
||||
const auto artifact = VerifyEnvelope(request.at("envelope").get<std::string>(), PublicKey(),
|
||||
PlatformKey(application), BuildVersion());
|
||||
return {application, artifact,
|
||||
request.value("restart_arguments", std::vector<std::string>{})};
|
||||
}
|
||||
|
||||
void WaitForCommit(const fs::path& job) {
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(120);
|
||||
while (true) {
|
||||
if (fs::exists(job / "cancel"))
|
||||
throw std::runtime_error("Update cancelled before application exit");
|
||||
if (fs::exists(job / "commit")) return;
|
||||
if (std::chrono::steady_clock::now() >= deadline)
|
||||
throw std::runtime_error("Application did not confirm update installation");
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
},
|
||||
[&] {
|
||||
#ifndef _WIN32
|
||||
// Authorization and staging finish while the GUI can still service
|
||||
// the remote connection. An empty directory publishes readiness
|
||||
// atomically without writing through a caller-controlled file.
|
||||
if (fs::exists(job / "cancel"))
|
||||
throw std::runtime_error("Update cancelled before application exit");
|
||||
if (!fs::create_directory(job / "ready"))
|
||||
throw std::runtime_error("Update readiness was already published");
|
||||
WaitForCommit(job);
|
||||
#endif
|
||||
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;
|
||||
std::vector<std::string> restart_arguments;
|
||||
bool committed = false;
|
||||
bool success = false;
|
||||
bool exited = false;
|
||||
std::string error;
|
||||
std::unique_ptr<MaintenanceLock> maintenance;
|
||||
try {
|
||||
const auto request = ReadRequest(job);
|
||||
application = request.application;
|
||||
restart_arguments = request.restart_arguments;
|
||||
auto pinned = PinHelper(ExecutablePath());
|
||||
{
|
||||
maintenance = std::make_unique<MaintenanceLock>(application);
|
||||
VerifyPackage(job / request.artifact.filename, request.artifact);
|
||||
#ifdef _WIN32
|
||||
WriteText(job / "ready", "ready\n");
|
||||
WaitForCommit(job);
|
||||
committed = true;
|
||||
#else
|
||||
// The root-owned helper publishes ready only after authorization and
|
||||
// verification of its private package copy, then waits for the GUI's
|
||||
// commit before touching the installation.
|
||||
const int code = RunElevated(HelperPath(application), job);
|
||||
committed = fs::exists(job / "commit");
|
||||
if (!committed)
|
||||
throw std::runtime_error("Update preparation failed or authorization was cancelled (code " +
|
||||
std::to_string(code) + ")");
|
||||
#endif
|
||||
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 int code = RunElevated(ExecutablePath(), job);
|
||||
#endif
|
||||
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, restart_arguments);
|
||||
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<std::string> 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;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
#include "updater/update_controller.h"
|
||||
#include "updater/update_platform.h"
|
||||
#include "common/filesystem_utf8.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <stdexcept>
|
||||
|
||||
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);
|
||||
} else if (fs::exists(previous / "started")) {
|
||||
// Retire receipts confirmed by older clients too, before temporary
|
||||
// storage is cleaned by the OS and a successful update looks interrupted.
|
||||
fs::remove(cache_ / "last-update");
|
||||
}
|
||||
} 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<std::string>(), 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");
|
||||
// Acknowledge the coordinator first. Successful startup no longer needs
|
||||
// a persistent pointer into a temporary job directory.
|
||||
fs::remove(cache_ / "last-update");
|
||||
}
|
||||
} 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},
|
||||
{"restart_arguments", RestartArguments()}}.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();
|
||||
// POSIX readiness includes the system authorization dialog and staging
|
||||
// the verified package; keep the GUI available while the user approves.
|
||||
deadline_ = std::chrono::steady_clock::now() + std::chrono::minutes(5);
|
||||
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
|
||||
@@ -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 <future>
|
||||
|
||||
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<int> progress_{0};
|
||||
std::atomic<bool> cancel_{false};
|
||||
std::future<fs::path> pending_;
|
||||
std::chrono::steady_clock::time_point deadline_{};
|
||||
};
|
||||
} // namespace crossdesk::updater
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,267 @@
|
||||
#include "updater/update_core.h"
|
||||
|
||||
#include <app_version.h>
|
||||
#include <httplib.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/rand.h>
|
||||
|
||||
#include <array>
|
||||
#include <fstream>
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
#include <vector>
|
||||
|
||||
#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<unsigned char> Unhex(const std::string& value, std::size_t size) {
|
||||
if (value.size() != size * 2) throw std::runtime_error("Invalid signature/key size");
|
||||
std::vector<unsigned char> 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<unsigned char>(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<std::string>();
|
||||
const auto signature = Unhex(wrapper.at("signature").get<std::string>(), 64);
|
||||
const auto key_bytes = Unhex(public_key, 32);
|
||||
std::unique_ptr<EVP_PKEY, decltype(&EVP_PKEY_free)> key(
|
||||
EVP_PKEY_new_raw_public_key(EVP_PKEY_ED25519, nullptr, key_bytes.data(), key_bytes.size()),
|
||||
EVP_PKEY_free);
|
||||
std::unique_ptr<EVP_MD_CTX, decltype(&EVP_MD_CTX_free)> 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<const unsigned char*>(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<std::string>();
|
||||
artifact.version = value.at("version").get<std::string>();
|
||||
artifact.filename = value.at("filename").get<std::string>();
|
||||
artifact.url = value.at("url").get<std::string>();
|
||||
artifact.sha256 = value.at("sha256").get<std::string>();
|
||||
if (!value.at("size").is_number_unsigned()) throw std::runtime_error("Invalid package size");
|
||||
artifact.size = value.at("size").get<std::uint64_t>();
|
||||
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<EVP_MD_CTX, decltype(&EVP_MD_CTX_free)> 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<char, 65536> buffer{};
|
||||
std::uint64_t total = 0;
|
||||
while (input.read(buffer.data(), buffer.size()) || input.gcount()) {
|
||||
const auto size = static_cast<std::size_t>(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<int>& progress, const std::atomic<bool>& 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<std::streamsize>(length));
|
||||
received += length;
|
||||
progress.store(static_cast<int>(received * 100 / artifact.size));
|
||||
return static_cast<bool>(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<char, 65536> buffer{};
|
||||
std::uint64_t copied = 0;
|
||||
while (input.read(buffer.data(), buffer.size()) || input.gcount()) {
|
||||
const auto length = static_cast<std::uint64_t>(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<std::streamsize>(value.size()));
|
||||
value.resize(static_cast<std::size_t>(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
|
||||
@@ -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 <atomic>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
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<int>& progress, const std::atomic<bool>& 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<void()> verify;
|
||||
std::function<void()> wait_for_exit;
|
||||
std::function<void()> install;
|
||||
};
|
||||
void ExecuteInstall(const InstallActions& actions);
|
||||
} // namespace crossdesk::updater
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* @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 <memory>
|
||||
#include <vector>
|
||||
|
||||
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<std::string>& arguments);
|
||||
int Run(const fs::path& executable, const std::vector<std::string>& 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);
|
||||
std::vector<std::string> RestartArguments();
|
||||
void Restart(const fs::path& application, const std::vector<std::string>& arguments);
|
||||
void RestoreServices();
|
||||
// Pin relocated helper files against changes during elevation (Windows).
|
||||
std::shared_ptr<void> 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
|
||||
@@ -54,6 +54,15 @@ bool UpdateChecker::Poll(TimePoint now) {
|
||||
ScheduleRetry(now);
|
||||
return true;
|
||||
}
|
||||
// Branch names followed by a date are not application versions. Comparing
|
||||
// their first digit sequence would incorrectly treat the date as a major
|
||||
// version and report "up to date" for every ordinary release.
|
||||
if (!IsValidAppVersion(current_version_) || !IsValidAppVersion(result->version)) {
|
||||
LOG_WARN("Cannot compare invalid application versions: current={}, latest={}",
|
||||
current_version_, result->version);
|
||||
ScheduleRetry(now);
|
||||
return true;
|
||||
}
|
||||
latest_ = std::move(result);
|
||||
update_available_ =
|
||||
IsNewerVersionWithMetadata(current_version_, latest_->version,
|
||||
|
||||
@@ -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 <cstdlib>
|
||||
#include <filesystem>
|
||||
@@ -138,6 +142,22 @@ std::optional<VersionInfo> 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);
|
||||
|
||||
@@ -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") ==
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` 仅为本地构建示例版本号;调整时请同步修改配置命令与打包参数,避免程序内版本号与安装包不一致。
|
||||
|
||||
## 构建依赖
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
# 桌面端应用内升级
|
||||
|
||||
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. macOS/Linux 先请求系统授权,再由提权后的安装进程再次验证签名、
|
||||
将包复制到只有管理员可写的私有目录并验证副本。完成这些步骤后才
|
||||
通知主程序退出;等待授权期间远程连接继续运行,取消授权不会关闭
|
||||
主程序。准备阶段最多等待五分钟。
|
||||
5. 主程序收到就绪通知后提交安装并正常退出。Windows 保持原有流程:
|
||||
先等待同一用户的 GUI 和守护进程退出,再启动提权安装进程验证副本。
|
||||
6. 安装进程确认相关程序退出后调用平台安装器。macOS/Linux 未收到
|
||||
主程序的安装提交、收到取消通知或等待提交超时时,不执行安装。
|
||||
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。只有发布清单生成步骤读取私钥。
|
||||
首次需要手动安装包含升级器、公钥及新安装元数据的版本。
|
||||
|
||||
GitHub Actions 从分支手动打包时,在 `Build and Release` 的
|
||||
`Run workflow` 中填写 `desktop_version`(例如 `1.7.2`),`patch` 保持
|
||||
`0`。三个桌面平台会使用同一个数字版本并追加构建日期。空着不填的分支
|
||||
构建使用 `0.0.0-日期`;版本标签构建继续使用标签中的版本号。
|
||||
`desktop_version` 只影响桌面安装包,不改变移动端版本。
|
||||
|
||||
不要使用 `vauto-update-20261020` 这类分支名作为版本号验证升级。
|
||||
新版本必须增加数字版本或 patch;只改变日期不视为升级。例如先手动安装
|
||||
`v1.7.2-20261020`,再验证到 `v1.7.3-20261020` 的升级。
|
||||
分支手动构建只上传 Actions 构建产物,不会自动发布版本清单。
|
||||
|
||||
本地构建可以设置同名环境变量,或使用:
|
||||
|
||||
```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;禁止包管理器自动删除依赖。需要可用的授权代理。
|
||||
重启时恢复原来的 `--headless`、`--no-headless`、`--headless-display`、
|
||||
`--headless-size` 和 `--headless-session` 参数,包括安装失败后的重启;
|
||||
内部运行模式参数不会重放。
|
||||
自定义 systemd/headless supervisor 必须由管理员先协调停止;
|
||||
检测到仍在运行的程序会中止升级,不猜测或停止其他服务。
|
||||
|
||||
退出等待有时间限制,跨用户仍有程序运行会报告失败。维护锁在进程退出时
|
||||
由系统释放,遗留的 POSIX 锁文件不会被当作正在升级。
|
||||
|
||||
## 失败处理与验证
|
||||
|
||||
下载、验签或准备失败时不关闭主程序。主程序退出后的安装失败会尝试
|
||||
重启;macOS/Linux 在退出前取消授权时保留原程序和连接。
|
||||
`last-update` 位于应用缓存目录,指向私有暂存目录;`request.json` 和
|
||||
`result.json` 保留升级版本及结果,未完成的任务不会自动重放。新版本确认
|
||||
启动后先通知升级器,再清除 `last-update`,避免临时目录被系统清理后
|
||||
误报升级中断;旧客户端已经确认成功的记录也会在下次启动时清除。
|
||||
升级器随后删除大型安装包,保留少量诊断文件。失败任务保留记录和下载包
|
||||
便于排查。
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
发布前仍须在各平台的隔离安装环境验证:授权取消、其他会话占用、服务
|
||||
退出、安装失败、升级器自身更新和新程序启动确认。不要在开发机上通过
|
||||
自动运行生产安装包来代替这些安装测试。
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user