Compare commits

..
Author SHA1 Message Date
dijunkun 33d4199f7c [ui] turn the reconnect action into a button 2026-10-21 23:38:32 +08:00
dijunkun a6f4df405b [fix] update MiniRTC for signaling disconnect detection 2026-10-21 23:34:53 +08:00
dijunkun 97960f3a6b [ui] remove icons from about navigation rows 2026-10-21 23:34:44 +08:00
dijunkun 53c657aa55 [fix] share desktop build version across workflow jobs 2026-10-21 23:18:23 +08:00
dijunkun 941de898fb [fix] validate desktop artifact versions against release tags 2026-10-21 23:10:14 +08:00
dijunkun 6abcafc205 [fix] prevent older releases from overwriting update metadata 2026-10-21 23:03:32 +08:00
dijunkun 171e559b0d [feat] add desktop license and source information pages 2026-10-21 11:45:27 +08:00
dijunkun 41f1e29583 [fix] pin desktop and Python dependencies to OpenSSL 3.3.2 2026-10-21 11:07:16 +08:00
dijunkun 2ea462764d [fix] clear confirmed update receipts before temporary cleanup 2026-10-21 03:17:20 +08:00
dijunkun ef02ef2e6c [fix] preserve Linux desktop options when restarting after updates 2026-10-21 03:16:23 +08:00
dijunkun e45350319c [fix] authorize POSIX updates before closing CrossDesk 2026-10-21 03:10:40 +08:00
dijunkun cf98775754 [fix] secure Linux credential cache permissions 2026-10-21 02:08:31 +08:00
dijunkun 1aa23126d2 [fix] use in-app confirmation for update installation 2026-10-21 00:21:59 +08:00
dijunkun cb2ae207c4 [fix] use numeric versions for desktop branch builds 2026-10-20 21:14:38 +08:00
dijunkun a625890b28 [feat] add in-app updates for desktop installations 2026-10-20 20:11:12 +08:00
dijunkun 62ba3693af [fix] restore clipboard sync after focus returns, refs #121 2026-10-20 11:34:24 +08:00
dijunkun 4e5bf66734 [fix] support Unicode text input on Windows hosts, refs #120 2026-10-20 11:22:14 +08:00
56 changed files with 3142 additions and 288 deletions
+108 -14
View File
@@ -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,8 +26,31 @@ 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):
def version_fields(version):
version = version.removeprefix("v")
base = r"[0-9]+(?:\.[0-9]+){1,3}"
for pattern in (
@@ -33,20 +60,45 @@ def version_info(version, fallback_date=None):
):
match = re.fullmatch(pattern, version)
if match:
fields = match.groupdict()
date = fields.get("date")
release_date = (datetime.datetime.strptime(date, "%Y%m%d").date().isoformat()
if date else fallback_date)
if not release_date:
raise ValueError(f"Release date is missing for {version}")
datetime.date.fromisoformat(release_date)
return {"latest_version": version, "version": version,
"releaseDate": release_date, "patch": int(fields.get("patch") or 0)}
return match.groupdict()
raise ValueError(f"Invalid release version: {version}")
def version_info(version, fallback_date=None):
fields = version_fields(version)
version = version.removeprefix("v")
date = fields.get("date")
release_date = (datetime.datetime.strptime(date, "%Y%m%d").date().isoformat()
if date else fallback_date)
if not release_date:
raise ValueError(f"Release date is missing for {version}")
datetime.date.fromisoformat(release_date)
return {"latest_version": version, "version": version,
"releaseDate": release_date, "patch": int(fields.get("patch") or 0)}
def release_order(metadata):
"""Match client ordering: zero-padded numeric components, then patch, never date."""
version = metadata.get("latest_version") or metadata.get("version")
if not version:
return None
fields = version_fields(version)
numbers = tuple(int(part) for part in fields["base"].split("."))
inline_patch = fields.get("patch") or "0"
patch = int(inline_patch) if len(inline_patch) <= 4 else 0
# Legacy manifests may store the effective patch outside the version string.
explicit_patch = metadata.get("patch")
if isinstance(explicit_patch, str) and re.fullmatch(r"[0-9]+", explicit_patch):
explicit_patch = int(explicit_patch)
if type(explicit_patch) is int and 0 <= explicit_patch <= 2147483647:
patch = explicit_patch
return numbers + (0,) * (4 - len(numbers)) + (patch,)
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 +117,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")
@@ -87,10 +152,34 @@ def merge_release(current, tag, name, notes, downloads, published_date=None):
if not tag.startswith("v"):
raise ValueError(f"Expected a version tag, got {tag!r}")
metadata = version_info(tag, published_date)
# Clients require the full artifact version to match the advertised
# desktop version, including its date and patch spelling.
for platform, download in downloads.items():
if (platform.startswith(("windows-", "macos-", "linux-"))
and download["version"] != metadata["version"]):
raise ValueError(
f"Desktop release {tag} expects version {metadata['version']}, "
f"but {platform} artifact {download['filename']} "
f"has version {download['version']}")
current_order = release_order(current)
if current_order is not None and release_order(metadata) < current_order:
current_version = current.get("latest_version") or current.get("version")
print(f"Skipping {tag}: older than current desktop release {current_version}")
return dict(current)
accepted_downloads = {}
for platform, download in downloads.items():
previous_order = release_order(current["downloads"].get(platform, {}))
if previous_order is not None and release_order(download) < previous_order:
print(f"Skipping {tag} download for {platform}: a newer version is already published")
continue
accepted_downloads[platform] = download
if mobile and not accepted_downloads:
return dict(current)
result = dict(current)
result.pop("platforms", None)
result["downloads"] = {**current["downloads"], **downloads}
result["downloads"] = {**current["downloads"], **accepted_downloads}
if not mobile:
result.update({**metadata, "releaseName": name, "releaseNotes": notes, "tagName": tag})
return result
@@ -117,11 +206,16 @@ def main():
parser.error("Supply --release-json or --tag, --name and --notes")
tag, name, notes = args.tag, args.name, args.notes.read_text()
published_date = None
current = json.loads(args.current.read_text())
current_text = args.current.read_text()
current = json.loads(current_text)
downloads = artifact_downloads(args.artifacts)
result = merge_release(current, tag, name, notes, downloads, published_date)
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n")
print(f"Updated {tag}: {', '.join(downloads)}")
if result == current:
args.output.write_text(current_text)
print(f"No changes to version.json for {tag}")
else:
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n")
print(f"Updated version.json for {tag}")
if __name__ == "__main__":
+80 -187
View File
@@ -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,11 +32,72 @@ 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:
prepare-version:
name: Resolve desktop version
runs-on: ubuntu-24.04
outputs:
version: ${{ steps.version.outputs.version }}
# Resolve the date once for every desktop build and the release job.
steps:
- name: Resolve desktop version
id: version
shell: bash
env:
PATCH_NUMBER: ${{ inputs.patch || '0' }}
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)
if [[ ! "${PATCH_NUMBER}" =~ ^[0-9]+$ ]]; then
PATCH_NUMBER=0
fi
if [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)-([0-9]{8})$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
PATCH_NUMBER="${BASH_REMATCH[3]}"
BUILD_DATE="${BASH_REMATCH[4]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})-([0-9]+)$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
BUILD_DATE="${BASH_REMATCH[3]}"
PATCH_NUMBER="${BASH_REMATCH[4]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
BUILD_DATE="${BASH_REMATCH[3]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)$ && "${PATCH_NUMBER}" == "0" ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
PATCH_NUMBER="${BASH_REMATCH[3]}"
fi
if [[ "${PATCH_NUMBER}" != "0" ]]; then
VERSION_NUM="v${VERSION_BASE}-${PATCH_NUMBER}-${BUILD_DATE}"
else
VERSION_NUM="v${VERSION_BASE}-${BUILD_DATE}"
fi
echo "version=${VERSION_NUM}" >> "$GITHUB_OUTPUT"
echo "Desktop version: ${VERSION_NUM}"
build-linux:
name: Build Linux (${{ matrix.arch }})
needs: prepare-version
runs-on: ${{ matrix.runner }}
env:
LEGAL_VERSION: ${{ needs.prepare-version.outputs.version }}
strategy:
matrix:
include:
@@ -52,59 +117,6 @@ jobs:
options: --user root
steps:
- name: Extract version number
shell: bash
run: |
VERSION_REF="${GITHUB_REF##*/}"
VERSION_BASE="${VERSION_REF#v}"
PATCH_NUMBER="${{ github.event.inputs.patch }}"
BUILD_DATE_OVERRIDE=""
if [[ ! "${PATCH_NUMBER}" =~ ^[0-9]+$ ]]; then
PATCH_NUMBER=0
fi
if [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)-([0-9]{8})$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
PATCH_NUMBER="${BASH_REMATCH[3]}"
BUILD_DATE_OVERRIDE="${BASH_REMATCH[4]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})-([0-9]+)$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
BUILD_DATE_OVERRIDE="${BASH_REMATCH[3]}"
PATCH_NUMBER="${BASH_REMATCH[4]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
BUILD_DATE_OVERRIDE="${BASH_REMATCH[3]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)$ && "${PATCH_NUMBER}" == "0" ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
PATCH_NUMBER="${BASH_REMATCH[3]}"
fi
echo "VERSION_BASE=${VERSION_BASE}" >> $GITHUB_ENV
echo "PATCH_NUMBER=${PATCH_NUMBER}" >> $GITHUB_ENV
echo "BUILD_DATE_OVERRIDE=${BUILD_DATE_OVERRIDE}" >> $GITHUB_ENV
- name: Set legal Debian version
shell: bash
run: |
BUILD_DATE="${BUILD_DATE_OVERRIDE}"
if [[ -z "${BUILD_DATE}" ]]; then
BUILD_DATE=$(TZ=Asia/Shanghai date +%Y%m%d)
fi
if [[ ! "${VERSION_BASE}" =~ ^[0-9] ]]; then
VERSION_BASE="0.0.0-${VERSION_BASE}"
fi
if [[ "${PATCH_NUMBER}" != "0" ]]; then
LEGAL_VERSION="v${VERSION_BASE}-${PATCH_NUMBER}-${BUILD_DATE}"
else
LEGAL_VERSION="v${VERSION_BASE}-${BUILD_DATE}"
fi
echo "LEGAL_VERSION=${LEGAL_VERSION}" >> $GITHUB_ENV
echo "BUILD_DATE=${BUILD_DATE}" >> $GITHUB_ENV
- name: Checkout code
uses: actions/checkout@v5
with:
@@ -202,8 +214,10 @@ jobs:
# macOS
build-macos:
name: Build on macOS
needs: prepare-version
runs-on: ${{ matrix.runner }}
env:
VERSION_NUM: ${{ needs.prepare-version.outputs.version }}
MACOSX_DEPLOYMENT_TARGET: "14.0"
EXPECTED_MACOS_SDK: "26.5"
strategy:
@@ -223,46 +237,6 @@ jobs:
package_script: ./apps/desktop/scripts/macos/pkg_arm64.sh
steps:
- name: Extract version number
id: version
shell: bash
run: |
VERSION_REF="${GITHUB_REF##*/}"
VERSION_BASE="${VERSION_REF#v}"
BUILD_DATE=$(TZ=Asia/Shanghai date +%Y%m%d)
PATCH_NUMBER="${{ github.event.inputs.patch }}"
if [[ ! "${PATCH_NUMBER}" =~ ^[0-9]+$ ]]; then
PATCH_NUMBER=0
fi
if [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)-([0-9]{8})$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
PATCH_NUMBER="${BASH_REMATCH[3]}"
BUILD_DATE="${BASH_REMATCH[4]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})-([0-9]+)$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
BUILD_DATE="${BASH_REMATCH[3]}"
PATCH_NUMBER="${BASH_REMATCH[4]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
BUILD_DATE="${BASH_REMATCH[3]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)$ && "${PATCH_NUMBER}" == "0" ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
PATCH_NUMBER="${BASH_REMATCH[3]}"
fi
if [[ "${PATCH_NUMBER}" != "0" ]]; then
VERSION_NUM="v${VERSION_BASE}-${PATCH_NUMBER}-${BUILD_DATE}"
else
VERSION_NUM="v${VERSION_BASE}-${BUILD_DATE}"
fi
echo "VERSION_NUM=${VERSION_NUM}" >> $GITHUB_ENV
echo "VERSION_NUM=${VERSION_NUM}"
echo "BUILD_DATE=${BUILD_DATE}" >> $GITHUB_ENV
echo "PATCH_NUMBER=${PATCH_NUMBER}" >> $GITHUB_ENV
- name: Select Xcode 26.6
shell: bash
run: |
@@ -354,50 +328,12 @@ jobs:
# Windows
build-windows-x64:
name: Build on Windows x64
needs: prepare-version
runs-on: windows-2022
env:
VERSION_NUM: ${{ needs.prepare-version.outputs.version }}
XMAKE_GLOBALDIR: D:\xmake_global
steps:
- name: Extract version number
shell: pwsh
run: |
$ref = $env:GITHUB_REF
$version = $ref -replace '^refs/(tags|heads)/', ''
$version = $version -replace '^v', ''
$version = $version -replace '/', '-'
$BUILD_DATE = ([System.TimeZoneInfo]::ConvertTimeBySystemTimeZoneId((Get-Date), "China Standard Time")).ToString("yyyyMMdd")
$PATCH_NUMBER = "${{ github.event.inputs.patch }}"
if ($PATCH_NUMBER -notmatch '^[0-9]+$') {
$PATCH_NUMBER = "0"
}
if ($version -match '^([0-9]+(\.[0-9]+){1,3})-([0-9]+)-([0-9]{8})$') {
$version = $Matches[1]
$PATCH_NUMBER = $Matches[3]
$BUILD_DATE = $Matches[4]
} elseif ($version -match '^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})-([0-9]+)$') {
$version = $Matches[1]
$BUILD_DATE = $Matches[3]
$PATCH_NUMBER = $Matches[4]
} elseif ($version -match '^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})$') {
$version = $Matches[1]
$BUILD_DATE = $Matches[3]
} elseif ($version -match '^([0-9]+(\.[0-9]+){1,3})-([0-9]+)$' -and $PATCH_NUMBER -eq "0") {
$version = $Matches[1]
$PATCH_NUMBER = $Matches[3]
}
if ($PATCH_NUMBER -ne "0") {
$VERSION_NUM = "v$version-$PATCH_NUMBER-$BUILD_DATE"
} else {
$VERSION_NUM = "v$version-$BUILD_DATE"
}
echo "VERSION_NUM=$VERSION_NUM" >> $env:GITHUB_ENV
echo "BUILD_DATE=$BUILD_DATE" >> $env:GITHUB_ENV
echo "PATCH_NUMBER=$PATCH_NUMBER" >> $env:GITHUB_ENV
- name: Cache xmake dependencies
uses: actions/cache@v5
with:
@@ -598,7 +534,7 @@ jobs:
release:
name: Publish Release
if: startsWith(github.ref, 'refs/tags/v')
needs: [build-linux, build-macos, build-ios, build-android, sign-android, build-windows-x64]
needs: [prepare-version, build-linux, build-macos, build-ios, build-android, sign-android, build-windows-x64]
runs-on: ubuntu-latest
concurrency:
group: publish-release-metadata
@@ -613,50 +549,6 @@ jobs:
with:
path: artifacts
- name: Extract version number
id: version
shell: bash
run: |
VERSION_REF="${GITHUB_REF##*/}"
VERSION_BASE="${VERSION_REF#v}"
BUILD_DATE=$(TZ=Asia/Shanghai date +%Y%m%d)
PATCH_NUMBER="${{ github.event.inputs.patch }}"
if [[ ! "${PATCH_NUMBER}" =~ ^[0-9]+$ ]]; then
PATCH_NUMBER=0
fi
if [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)-([0-9]{8})$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
PATCH_NUMBER="${BASH_REMATCH[3]}"
BUILD_DATE="${BASH_REMATCH[4]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})-([0-9]+)$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
BUILD_DATE="${BASH_REMATCH[3]}"
PATCH_NUMBER="${BASH_REMATCH[4]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
BUILD_DATE="${BASH_REMATCH[3]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)$ && "${PATCH_NUMBER}" == "0" ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
PATCH_NUMBER="${BASH_REMATCH[3]}"
fi
BUILD_DATE_ISO="${BUILD_DATE:0:4}-${BUILD_DATE:4:2}-${BUILD_DATE:6:2}"
if [[ "${PATCH_NUMBER}" != "0" ]]; then
VERSION_NUM="${VERSION_BASE}-${PATCH_NUMBER}-${BUILD_DATE}"
else
VERSION_NUM="${VERSION_BASE}-${BUILD_DATE}"
fi
VERSION_WITH_V="v${VERSION_NUM}"
echo "VERSION_NUM=${VERSION_NUM}" >> $GITHUB_OUTPUT
echo "VERSION_WITH_V=${VERSION_WITH_V}" >> $GITHUB_OUTPUT
echo "VERSION_BASE=${VERSION_BASE}" >> $GITHUB_OUTPUT
echo "BUILD_DATE=${BUILD_DATE}" >> $GITHUB_OUTPUT
echo "BUILD_DATE_ISO=${BUILD_DATE_ISO}" >> $GITHUB_OUTPUT
echo "PATCH_NUMBER=${PATCH_NUMBER}" >> $GITHUB_OUTPUT
- name: Prepare release notes
run: |
cat > "$RUNNER_TEMP/release-notes.md" << EOF
@@ -686,18 +578,18 @@ jobs:
- name: Rename artifacts
run: |
mkdir -p release
cp artifacts/crossdesk-macos-x64-${{ steps.version.outputs.VERSION_WITH_V }}/* release/crossdesk-macos-x64-${{ steps.version.outputs.VERSION_WITH_V }}.pkg
cp artifacts/crossdesk-macos-arm64-${{ steps.version.outputs.VERSION_WITH_V }}/* release/crossdesk-macos-arm64-${{ steps.version.outputs.VERSION_WITH_V }}.pkg
cp artifacts/crossdesk-macos-x64-${{ needs.prepare-version.outputs.version }}/* release/crossdesk-macos-x64-${{ needs.prepare-version.outputs.version }}.pkg
cp artifacts/crossdesk-macos-arm64-${{ needs.prepare-version.outputs.version }}/* release/crossdesk-macos-arm64-${{ needs.prepare-version.outputs.version }}.pkg
for ARCH in amd64 arm64; do
for PACKAGE in crossdesk crossdesk-virtual-desktop-xfce; do
FILE="${PACKAGE}-linux-${ARCH}-${{ steps.version.outputs.VERSION_WITH_V }}.deb"
cp "artifacts/crossdesk-linux-${ARCH}-${{ steps.version.outputs.VERSION_WITH_V }}/${FILE}" "release/${FILE}"
FILE="${PACKAGE}-linux-${ARCH}-${{ needs.prepare-version.outputs.version }}.deb"
cp "artifacts/crossdesk-linux-${ARCH}-${{ needs.prepare-version.outputs.version }}/${FILE}" "release/${FILE}"
done
done
cp artifacts/crossdesk-ios-arm64-unsigned-${{ needs.build-ios.outputs.version }}/* release/crossdesk-ios-arm64-unsigned-${{ needs.build-ios.outputs.version }}.zip
cp artifacts/crossdesk-android-arm64-${{ needs.build-android.outputs.version }}/*.apk release/crossdesk-android-arm64-${{ needs.build-android.outputs.version }}.apk
cp artifacts/crossdesk-win-x64-${{ steps.version.outputs.VERSION_WITH_V }}/* release/crossdesk-win-x64-${{ steps.version.outputs.VERSION_WITH_V }}.exe
cp artifacts/crossdesk-win-x64-portable-${{ steps.version.outputs.VERSION_WITH_V }}/* release/crossdesk-win-x64-portable-${{ steps.version.outputs.VERSION_WITH_V }}.zip
cp artifacts/crossdesk-win-x64-${{ needs.prepare-version.outputs.version }}/* release/crossdesk-win-x64-${{ needs.prepare-version.outputs.version }}.exe
cp artifacts/crossdesk-win-x64-portable-${{ needs.prepare-version.outputs.version }}/* release/crossdesk-win-x64-portable-${{ needs.prepare-version.outputs.version }}.zip
- name: List release files
run: ls -lh release/
@@ -705,10 +597,10 @@ jobs:
- name: Upload to Versioned GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.VERSION_WITH_V }}
tag_name: ${{ needs.prepare-version.outputs.version }}
# The pushed tag already determines the release source commit.
target_commitish: ${{ github.event.repository.default_branch }}
name: Release ${{ steps.version.outputs.VERSION_WITH_V }}
name: Release ${{ needs.prepare-version.outputs.version }}
draft: false
prerelease: false
files: release/*
@@ -751,7 +643,8 @@ jobs:
- name: Generate version.json for all platforms
env:
RELEASE_TAG: ${{ steps.version.outputs.VERSION_WITH_V }}
RELEASE_TAG: ${{ needs.prepare-version.outputs.version }}
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 \
+1 -1
View File
@@ -151,7 +151,7 @@ gh secret set ANDROID_KEY_PASSWORD --repo kunkundi/crossdesk
身份按主机名和端口分开保存,备份与设备迁移不包含应用数据。密码不写入连接记录,仅在明确开启“保存密码”后写入独立加密存储;未开启时仅用于当前会话。连接成功通知只处理一次,重复的传输就绪回调不会再次保存密码或重建会话页面;密码存储拒绝空值覆盖。旧版本已被空值覆盖的密码无法恢复,遇到此记录时会提示重新输入一次,并保留“保存密码”开启状态。由于 MiniRTC 的诊断日志包含登录身份,Android 桥接层关闭其会话日志。身份存储损坏会显示错误,不会静默重新注册。
会话中的“剪贴板”发送用户输入的文本;收到的远端文本先留在会话内,点击“复制远端文本”才写入手机剪贴板。退出会话后清除会话内容。每次发送最多 128 KB。电脑键盘的 Ctrl / Win / Alt / Shift 可锁定组合键;macOS 的 Command 对应 Win。系统键盘通过 `supports_text_input` 检测远端能力,支持 UTF-8 的 macOS 被控端可直接输入中文、表情和听写文本;其他端或旧版使用 ASCII 按键回退,不支持的文本会提示使用剪贴板,不会先删除远端内容。输入法尚未确认的组字不会发送,自动纠错仅替换已发送文本的变化后缀;点击远端、收起、切换键盘或进入后台会清除本地输入上下文。
会话中的“剪贴板”发送用户输入的文本;收到的远端文本先留在会话内,点击“复制远端文本”才写入手机剪贴板。退出会话后清除会话内容。每次发送最多 128 KB。电脑键盘的 Ctrl / Win / Alt / Shift 可锁定组合键;macOS 的 Command 对应 Win。系统键盘通过 `supports_text_input` 检测远端能力,支持 UTF-8 的 Windows 和 macOS 被控端可直接输入中文、表情和听写文本;其他端或旧版使用 ASCII 按键回退,不支持的文本会提示使用剪贴板,不会先删除远端内容。Windows 通过当前已解锁用户桌面的 Unicode 键盘事件输入文本,无需切换电脑输入法或覆盖剪贴板;锁屏和 UAC 安全桌面不接受此文本通道。Windows 被控端也必须更新到包含文本输入支持的版本,仅更新 Android 不会启用该能力。输入法尚未确认的组字不会发送,自动纠错仅替换已发送文本的变化后缀;点击远端、收起、切换键盘或进入后台会清除本地输入上下文。
“发送文件”调用系统文件选择器,后台读取所选文件后使用桌面端相同的 `file` / `file_feedback` 协议传输,支持空文件和中文文件名。每次发送一个文件,以 64 KiB 分块、最多 1 MiB 未确认数据限制发送队列;进度来自接收端确认,30 秒没有有效进展则报告失败。接收时校验大小并追踪完整字节范围,允许跨线程回调乱序,未完成的文件不会提供保存入口。同名文件分开存储,不覆盖已有文件;可通过“保存收到的文件”导出最近收到的文件,连续接收多个文件时应逐个保存。
@@ -477,7 +477,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
private boolean sendTextEdit(int count,String value){
if(session==null)return false;
if(session.textEdit(count,value))return true;
toast("当前远端不支持此文本,中文或表情需更新 macOS 被控端,或通过剪贴板发送");return false;
toast("当前远端不支持直接输入此文本,请使用支持文本输入的被控端版本,或通过剪贴板发送");return false;
}
private void tapKey(int code){if(session!=null){session.key(code,true);session.key(code,false);}}
private void shortcut(int code){if(session!=null){int modifier=hostPlatform.equals("macos")?0x5B:0x11;session.key(modifier,true);tapKey(code);session.key(modifier,false);}}
@@ -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"
+2 -1
View File
@@ -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
+2
View File
@@ -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
+2
View File
@@ -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
+13
View File
@@ -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
+29 -1
View File
@@ -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
+14
View File
@@ -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>
@@ -35,6 +36,7 @@
#include "crossdesk_ui.h"
#include "fa_solid_900.h"
#include "features/about/about_catalog.h"
#include "features/file_transfer/transfer_notification_layout.h"
#include "features/gallery/gallery_fullscreen_transition.h"
#include "features/gallery/gallery_window_state.h"
@@ -86,6 +88,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 +1115,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 +1149,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 +1753,44 @@ 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
});
about::Populate(ui_->Window(gallery_window));
main->on_open_about_link([this](slint::SharedString url) {
const std::string link(url);
if (link.starts_with("https://")) OpenUrl(link);
});
main->on_open_website([this] { OpenUrl("https://crossdesk.cn"); });
main->on_open_afdian_sponsor([this] {
OpenUrl("https://afdian.com/a/kunkundi");
@@ -2326,6 +2377,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() ||
@@ -4382,15 +4463,20 @@ void GuiApplication::SelectStreamTab(int index) {
(*ui_->stream)->set_has_frame(false);
}
std::shared_ptr<RemoteSession> selected_session;
bool clipboard_resumed = false;
{
std::lock_guard delivery_lock(audio_delivery_mutex_);
std::shared_lock lock(remote_sessions_mutex_);
for (auto& [id, props] : remote_sessions_) {
if (props) {
props->tab_selected_ = id == focused_remote_id_;
props->clipboard_allowed_ = props->tab_selected_ &&
props->control_mouse_ &&
focus_on_stream_window_;
const bool clipboard_allowed = props->tab_selected_ &&
props->control_mouse_ &&
focus_on_stream_window_;
if (!props->clipboard_allowed_.exchange(clipboard_allowed) &&
clipboard_allowed) {
clipboard_resumed = true;
}
}
}
const auto selected = remote_sessions_.find(selected_remote_id);
@@ -4402,6 +4488,11 @@ void GuiApplication::SelectStreamTab(int index) {
selected_session->connection_status_.load() ==
ConnectionStatus::Connected;
}
if (clipboard_resumed) {
// Flush a copy made in another local app before the next paste shortcut,
// instead of waiting for the periodic clipboard poll.
clipboard_.HandleLocalUpdate();
}
SyncAudioPlayback();
}
@@ -6153,6 +6244,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", \
@@ -0,0 +1,115 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-21
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#include "about_catalog.h"
#include <nlohmann/json.hpp>
#include <iterator>
#include <memory>
#include <string>
#include <string_view>
#include <vector>
#include "about_resources.h"
namespace crossdesk::about {
namespace {
slint::SharedString Text(const nlohmann::json& object, const char* key) {
return slint::SharedString(object.value(key, std::string{}));
}
std::string_view TrimLayoutWhitespace(std::string_view line) {
const auto first = line.find_first_not_of(" \t\r");
if (first == std::string_view::npos) return {};
return line.substr(first, line.find_last_not_of(" \t\r") - first + 1);
}
slint::SharedString DocumentText(const nlohmann::json& document) {
const auto title = document.value("title", std::string{});
const auto text = document.value("text", std::string{});
if (title != "GNU GPL v3" && title != "GNU LGPL v3") {
return slint::SharedString(text);
}
// GNU's plain-text copies use fixed-width wrapping and space-aligned headings.
// Reflow prose for the reader, retaining paragraph boundaries, notice examples,
// and every non-whitespace character. The embedded source stays untouched.
std::string formatted;
std::vector<std::string_view> paragraph;
auto append_paragraph = [&] {
if (paragraph.empty()) return;
if (!formatted.empty()) formatted += "\n\n";
const bool preserve_lines = paragraph.front().starts_with("GNU ") ||
paragraph.front().starts_with("<");
for (size_t index = 0; index < paragraph.size(); ++index) {
if (index > 0) {
const bool after_copyright =
paragraph[index - 1].starts_with("Copyright (C)");
formatted += preserve_lines || after_copyright ? '\n' : ' ';
}
formatted += paragraph[index];
}
paragraph.clear();
};
std::string_view remaining(text);
while (!remaining.empty()) {
const auto end = remaining.find('\n');
const auto line = TrimLayoutWhitespace(remaining.substr(0, end));
if (line.empty()) {
append_paragraph();
} else {
paragraph.push_back(line);
}
if (end == std::string_view::npos) break;
remaining.remove_prefix(end + 1);
}
append_paragraph();
return slint::SharedString(formatted);
}
} // namespace
void Populate(const slint::ComponentHandle<ui::MainWindow>& window) {
// Both the main window and DeskHub share the immutable, embedded catalog.
static const auto catalog = nlohmann::json::parse(
std::begin(kAboutResources), std::end(kAboutResources));
static const auto model = [] {
std::vector<ui::AboutComponent> components;
for (const auto& item : catalog.at("components")) {
ui::AboutComponent component;
component.id = Text(item, "id");
component.name = Text(item, "name");
component.version = Text(item, "version");
component.license = Text(item, "license");
component.source_url = Text(item, "sourceURL");
std::vector<ui::AboutDocument> documents;
for (const auto& document : item.at("documents")) {
ui::AboutDocument entry;
entry.title = Text(document, "title");
entry.text = DocumentText(document);
documents.push_back(std::move(entry));
}
component.documents =
std::make_shared<slint::VectorModel<ui::AboutDocument>>(documents);
components.push_back(std::move(component));
}
return std::make_shared<slint::VectorModel<ui::AboutComponent>>(components);
}();
window->set_about_components(model);
for (size_t index = 0; index < model->row_count(); ++index) {
if (model->row_data(index)->id == "openfec") {
window->set_about_openfec_index(static_cast<int>(index));
break;
}
}
window->set_about_revision(Text(catalog, "revision"));
window->set_about_source_url(Text(catalog, "sourceURL"));
window->set_about_build_url(Text(catalog, "buildURL"));
}
} // namespace crossdesk::about
@@ -0,0 +1,18 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-21
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _ABOUT_CATALOG_H_
#define _ABOUT_CATALOG_H_
#include "crossdesk_ui.h"
namespace crossdesk::about {
void Populate(const slint::ComponentHandle<ui::MainWindow>& window);
} // namespace crossdesk::about
#endif
@@ -30,6 +30,7 @@ uint32_t ClipboardController::event_type() const { return event_type_; }
void ClipboardController::Initialize() {
last_text_.clear();
deferred_local_session_.reset();
char *clipboard_text = SDL_GetClipboardText();
if (clipboard_text) {
@@ -42,6 +43,7 @@ void ClipboardController::Initialize() {
void ClipboardController::Shutdown() {
events_enabled_.store(false, std::memory_order_release);
deferred_local_session_.reset();
std::lock_guard<std::mutex> lock(pending_mutex_);
pending_remote_text_.reset();
}
@@ -126,6 +128,7 @@ void ClipboardController::ApplyPendingRemoteText() {
// SDL will normally emit SDL_EVENT_CLIPBOARD_UPDATE for this write. Update
// the baseline first so that event cannot echo the text back to the sender.
last_text_ = std::move(*pending_text);
deferred_local_session_.reset();
}
void ClipboardController::HandleLocalUpdate() {
@@ -134,6 +137,7 @@ void ClipboardController::HandleLocalUpdate() {
}
if (!SDL_HasClipboardText()) {
last_text_.clear();
deferred_local_session_.reset();
return;
}
@@ -146,12 +150,14 @@ void ClipboardController::HandleLocalUpdate() {
std::string text(clipboard_text);
SDL_free(clipboard_text);
if (text == last_text_) {
SendDeferredLocalText();
return;
}
// Record the value before sending. Duplicate SDL notifications and a
// remote write of the same value must not create a clipboard feedback loop.
last_text_ = text;
deferred_local_session_.reset();
if (text.empty()) {
return;
}
@@ -163,11 +169,50 @@ void ClipboardController::HandleLocalUpdate() {
SendToPeers(text);
}
void ClipboardController::SendDeferredLocalText() {
const auto session = deferred_local_session_.lock();
if (!session) {
return;
}
std::shared_lock lock(owner_.remote_sessions_mutex_);
const auto current = owner_.remote_sessions_.find(session->remote_id_);
if (current == owner_.remote_sessions_.end() || current->second != session ||
session->closing_ || !session->peer_ || !session->connection_established_ ||
!session->tab_opened_ || !session->tab_selected_ ||
!session->control_mouse_) {
deferred_local_session_.reset();
return;
}
if (!session->clipboard_allowed_.load()) {
return;
}
const int ret = SendReliableDataFrame(
session->peer_, last_text_.data(), last_text_.size(),
session->clipboard_label_.c_str());
if (ret != 0) {
LOG_WARN("Failed to send deferred clipboard data to peer [{}], ret={}",
session->remote_id_, ret);
return;
}
deferred_local_session_.reset();
}
int ClipboardController::SendToPeers(const std::string &text) {
std::shared_lock lock(owner_.remote_sessions_mutex_);
for (const auto &[remote_id, props] : owner_.remote_sessions_) {
if (!props || !props->peer_ || !props->connection_established_ ||
!props->clipboard_allowed_.load()) {
if (!props || props->closing_ || !props->peer_ ||
!props->connection_established_) {
continue;
}
if (!props->clipboard_allowed_.load()) {
// Copying in another local app temporarily releases keyboard control.
// Keep this copy for the same selected stream, without sharing it with
// background tabs or DeskHub previews.
if (props->tab_opened_ && props->tab_selected_ && props->control_mouse_) {
deferred_local_session_ = props;
}
continue;
}
@@ -41,6 +41,7 @@ public:
private:
int SendToPeers(const std::string &text);
void SendDeferredLocalText();
GuiRuntime &owner_;
uint32_t event_type_ = 0;
@@ -51,8 +52,11 @@ private:
std::weak_ptr<gui_detail::RemoteSession> pending_session_;
uint64_t pending_generation_ = 0;
std::string last_text_;
// UI thread only. Retain the destination of a local copy while its stream
// window is unfocused; last_text_ holds the latest value to send on return.
std::weak_ptr<gui_detail::RemoteSession> deferred_local_session_;
};
} // namespace crossdesk
#endif // _CLIPBOARD_CONTROLLER_H_
#endif // _CLIPBOARD_CONTROLLER_H_
@@ -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';
+13 -1
View File
@@ -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 "
+235
View File
@@ -0,0 +1,235 @@
import { ScrollView, TextEdit } from "std-widgets.slint";
import { SettingsDialog, SettingsButton } from "settings_controls.slint";
import { WorkbenchStyle } from "workbench_controls.slint";
import { UiRadius } from "ui_radius.slint";
import { GalleryStrings } from "device_strings.slint";
import { FontAwesomeIcons } from "common.slint";
import "../assets/fonts/message_spacing.ttf";
export enum AboutPage { overview, components, component, document, source }
export struct AboutDocument { title: string, text: string }
export struct AboutComponent {
id: string,
name: string,
version: string,
license: string,
source-url: string,
documents: [AboutDocument],
}
export global AboutStrings {
out property <string> software-license: GalleryStrings.tr("软件许可", "Software license", "Лицензия программы");
out property <string> components: GalleryStrings.tr("开源组件", "Open-source components", "Компоненты с открытым кодом");
out property <string> source: GalleryStrings.tr("源码与构建说明", "Source code and build instructions", "Исходный код и сборка");
out property <string> notices: GalleryStrings.tr("许可与版权声明", "Licenses and copyright notices", "Лицензии и авторские права");
out property <string> view-source: GalleryStrings.tr("查看源码", "View source code", "Открыть исходный код");
out property <string> back: GalleryStrings.tr("返回", "Back", "Назад");
}
export component AboutNavigationGroup inherits Rectangle {
height: rows.preferred-height + 2px;
background: WorkbenchStyle.surface;
border-radius: UiRadius.control;
border-width: 1px; border-color: WorkbenchStyle.separator;
clip: true;
rows := VerticalLayout {
x: 1px; y: 1px; width: parent.width - 2px;
height: self.preferred-height; padding: 0px; spacing: 0px;
@children
}
}
export component AboutRowSeparator inherits Rectangle {
height: 1px;
Rectangle {
x: 14px; width: parent.width - 30px; height: 1px;
background: WorkbenchStyle.separator;
}
}
export component AboutNavigationRow inherits Rectangle {
in property <string> title;
in property <string> subtitle;
in property <string> icon;
in property <string> detail;
in property <bool> grouped: false;
in property <bool> external: false;
callback clicked;
min-width: 0px;
min-height: max(44px, labels.preferred-height + 20px);
background: touch.pressed ? WorkbenchStyle.pressed-surface
: touch.has-hover ? WorkbenchStyle.hover-surface : WorkbenchStyle.surface;
border-radius: root.grouped ? 0px : UiRadius.control;
border-width: root.grouped ? 0px : 1px; border-color: WorkbenchStyle.separator;
accessible-role: button;
accessible-label: root.title + (root.subtitle != "" ? ", " + root.subtitle : "")
+ (root.detail != "" ? ", " + root.detail : "");
accessible-action-default => { root.clicked(); }
if root.icon != "": Rectangle {
x: 12px; y: (parent.height - self.height) / 2;
width: 28px; height: 28px;
border-radius: UiRadius.control;
background: WorkbenchStyle.field-surface;
Text {
text: root.icon; font-family: "Font Awesome 6 Free"; font-weight: 900;
font-size: 13px; color: WorkbenchStyle.muted;
horizontal-alignment: center; vertical-alignment: center;
accessible-role: none;
}
}
labels := VerticalLayout {
x: root.icon != "" ? 52px : 14px;
y: (parent.height - self.height) / 2;
width: parent.width - self.x - 36px - (root.detail != "" ? detail-label.width + 12px : 0px);
height: self.preferred-height; spacing: 4px;
Text { text: root.title; color: WorkbenchStyle.text; font-size: 13px; wrap: word-wrap; }
if root.subtitle != "": Text {
text: root.subtitle; color: WorkbenchStyle.muted; font-size: 11px; wrap: word-wrap;
}
}
detail-label := Text {
x: parent.width - self.width - 36px; height: parent.height;
width: min(100px, self.preferred-width);
text: root.detail; color: WorkbenchStyle.muted; font-size: 11px;
vertical-alignment: center; overflow: elide;
accessible-role: none;
}
Text {
x: parent.width - 30px; width: 18px; height: parent.height;
text: root.external ? FontAwesomeIcons.arrow-up-right-from-square : FontAwesomeIcons.angle-right;
font-family: "Font Awesome 6 Free"; font-weight: 900;
color: touch.has-hover ? WorkbenchStyle.accent : WorkbenchStyle.muted;
font-size: 11px; vertical-alignment: center; horizontal-alignment: center;
accessible-role: none;
}
touch := TouchArea { mouse-cursor: pointer; clicked => { root.clicked(); } }
}
export component AboutPanel inherits SettingsDialog {
show-close-button: false;
in-out property <AboutPage> page: AboutPage.components;
in-out property <int> component-index: 0;
private property <int> document-index: 0;
in property <[AboutComponent]> components;
in property <int> openfec-index: -1;
in property <string> revision;
in property <string> source-url;
in property <string> build-url;
private property <AboutComponent> selected: root.components[root.component-index];
callback back;
callback open-link(string);
title: root.page == AboutPage.components ? AboutStrings.components
: root.page == AboutPage.source ? AboutStrings.source
: root.page == AboutPage.document ? AboutStrings.notices
: root.selected.name;
if root.page != AboutPage.document: ScrollView {
x: 24px; y: root.content-top; width: parent.width - 48px + 22px;
height: footer.y - self.y - 16px;
viewport-width: self.visible-width;
viewport-height: content.preferred-height;
horizontal-scrollbar-policy: always-off;
vertical-scrollbar-policy: as-needed;
content := VerticalLayout {
width: parent.width; height: self.preferred-height;
padding: 0px; padding-right: 22px; spacing: 10px;
if root.page == AboutPage.components: VerticalLayout {
padding: 0px; spacing: 10px;
Text {
text: GalleryStrings.tr("以下列出本版本的主要开源组件。各组件遵循各自的开源许可。", "Main open-source components used in this build. Each component is covered by its own license.", "Основные открытые компоненты этой сборки. Каждый распространяется по своей лицензии.");
color: WorkbenchStyle.muted; font-size: 12px; wrap: word-wrap;
}
Rectangle {
height: notice.preferred-height + 32px;
background: WorkbenchStyle.field-surface; border-radius: UiRadius.control;
notice := VerticalLayout {
x: 16px; y: 16px; width: parent.width - 32px;
height: self.preferred-height; spacing: 12px;
Text { text: GalleryStrings.tr("OpenFEC 告知", "OpenFEC notice", "Уведомление OpenFEC"); color: WorkbenchStyle.text; font-size: 14px; font-weight: 600; }
Text {
text: GalleryStrings.tr("本应用使用 OpenFEC 提供前向纠错功能,遵循 CeCILL-C 1.0 许可。该许可规定了有限保证及责任限制,详情见许可全文。", "This application uses OpenFEC for forward error correction under CeCILL-C 1.0, which provides limited warranties and limits liability. See the full license for details.", "Приложение использует OpenFEC для прямого исправления ошибок по лицензии CeCILL-C 1.0 с ограниченной гарантией и ответственностью. Подробности — в тексте лицензии.");
color: WorkbenchStyle.muted; font-size: 13px; wrap: word-wrap;
font-family: "CrossDesk Message Spacing";
}
Text {
text: "(c) Copyright 2009 - 2012 INRIA - All rights reserved";
color: WorkbenchStyle.muted; font-size: 11px; wrap: word-wrap;
font-family: "CrossDesk Message Spacing";
}
if root.openfec-index >= 0: AboutNavigationRow {
title: GalleryStrings.tr("OpenFEC 许可与源码", "OpenFEC license and source", "Лицензия и исходный код OpenFEC");
clicked => { root.component-index = root.openfec-index; root.page = AboutPage.component; }
}
}
}
for component[index] in root.components: VerticalLayout {
padding: 0px; spacing: 0px;
if component.id != "crossdesk" && component.id != "openfec": AboutNavigationRow {
title: component.name;
subtitle: component.version + " · " + component.license;
clicked => { root.component-index = index; root.page = AboutPage.component; }
}
}
}
if root.page == AboutPage.component: VerticalLayout {
padding: 0px; spacing: 12px;
Text { text: root.selected.version + " · " + root.selected.license; color: WorkbenchStyle.muted; font-size: 12px; wrap: word-wrap; }
AboutNavigationRow {
title: AboutStrings.view-source; external: true;
clicked => { root.open-link(root.selected.source-url); }
}
Text { text: AboutStrings.notices; color: WorkbenchStyle.text; font-size: 13px; font-weight: 600; }
for document[index] in root.selected.documents: AboutNavigationRow {
title: document.title;
clicked => { root.document-index = index; root.page = AboutPage.document; }
}
if root.selected.documents.length == 0: Text {
text: GalleryStrings.tr("本组件未内置许可文本,请通过“查看源码”查阅许可与版权声明。", "License text is not bundled for this component. Use View source code to read its license and copyright notices.", "Текст лицензии не включён. Откройте исходный код для просмотра лицензии и авторских прав.");
color: WorkbenchStyle.muted; font-size: 12px; wrap: word-wrap;
}
}
if root.page == AboutPage.source: VerticalLayout {
padding: 0px; spacing: 12px;
Text {
text: root.revision != "" ? GalleryStrings.tr("源码版本:", "Source revision: ", "Ревизия исходного кода: ") + root.revision
: GalleryStrings.tr("本版本源码信息暂不可用。", "Source revision is unavailable for this build.", "Ревизия исходного кода этой сборки недоступна.");
color: WorkbenchStyle.text; font-size: 13px; wrap: word-wrap;
}
AboutNavigationRow { title: AboutStrings.view-source; external: true; clicked => { root.open-link(root.source-url); } }
AboutNavigationRow {
title: GalleryStrings.tr("构建说明", "Build instructions", "Инструкции по сборке"); external: true;
clicked => { root.open-link(root.build-url); }
}
AboutNavigationRow {
title: GalleryStrings.tr("项目主页", "Project website", "Сайт проекта"); external: true;
clicked => { root.open-link("https://crossdesk.cn"); }
}
}
}
}
if root.page == AboutPage.document: VerticalLayout {
x: 24px; y: root.content-top; width: parent.width - 48px;
height: footer.y - self.y - 16px; spacing: 12px;
Text { text: root.selected.documents[root.document-index].title; color: WorkbenchStyle.text; font-size: 13px; font-weight: 600; wrap: word-wrap; }
TextEdit {
text: root.selected.documents[root.document-index].text;
read-only: true; wrap: word-wrap; font-size: 14px;
// Use the same 1.5em line spacing as other long-form reading views.
font-family: "CrossDesk Message Spacing";
vertical-stretch: 1;
key-pressed(event) => {
if event.text == Key.Escape { root.back(); return accept; }
return reject;
}
}
}
footer := VerticalLayout {
x: 24px; y: parent.height - self.height - 24px;
width: parent.width - 48px; height: self.preferred-height; spacing: 12px;
Rectangle { height: 1px; background: WorkbenchStyle.separator; }
HorizontalLayout {
alignment: end;
SettingsButton { text: AboutStrings.back; clicked => { root.back(); } }
}
}
}
+4
View File
@@ -32,9 +32,11 @@ export global FontAwesomeIcons {
out property <string> angle-right: "\u{f105}";
out property <string> angle-up: "\u{f106}";
out property <string> arrow-right-long: "\u{f178}";
out property <string> arrow-up-right-from-square: "\u{f35d}";
out property <string> bars: "\u{f0c9}";
out property <string> check: "\u{f00c}";
out property <string> circle-arrow-up: "\u{f0aa}";
out property <string> code: "\u{f121}";
out property <string> compress: "\u{f066}";
out property <string> computer-mouse: "\u{f8cc}";
out property <string> copy: "\u{f0c5}";
@@ -42,8 +44,10 @@ export global FontAwesomeIcons {
out property <string> expand: "\u{f065}";
out property <string> eye: "\u{f06e}";
out property <string> eye-slash: "\u{f070}";
out property <string> file-lines: "\u{f15c}";
out property <string> folder-open: "\u{f07c}";
out property <string> keyboard: "\u{f11c}";
out property <string> layer-group: "\u{f5fd}";
out property <string> minus: "\u{f068}";
out property <string> pen: "\u{f304}";
out property <string> rotate: "\u{f2f1}";
@@ -1,6 +1,7 @@
// Single AOT entry point. Generating one header for the entire UI avoids
// duplicate imported-component definitions when C++ uses all three windows.
export { AnnouncementItem, MessageStrings } from "message_panel.slint";
export { AboutComponent, AboutDocument, AboutPage } from "about_panel.slint";
export { MainWindow, RecentConnection, UpdateCheckStatus, AutostartStatus, UiStrings } from "main_window.slint";
export { StreamWindow, StreamTab, FileTransferEntry, NetworkStatsRow, StreamStrings } from "stream_window.slint";
export { ServerWindow, ServerStatusBar, ServerStrings, ControllerEntry } from "server_window.slint";
+241 -47
View File
@@ -1,3 +1,4 @@
import { AboutPanel, AboutPage, AboutComponent, AboutStrings, AboutNavigationRow, AboutNavigationGroup, AboutRowSeparator } from "about_panel.slint";
import { UiRadius } from "ui_radius.slint";
import { WorkbenchStyle, WorkbenchButton, WorkbenchCard, WorkbenchNotice } from "workbench_controls.slint";
import { GalleryStrings } from "device_strings.slint";
@@ -99,6 +100,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 +411,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 +473,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 +505,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;
@@ -522,12 +537,27 @@ export component MainWindow inherits Window {
if !root.self-host-settings-open { root.self-host-settings-error = ""; }
}
in-out property <bool> about-open: false;
in-out property <AboutPage> about-page: overview;
private property <length> about-scroll-y: 0px;
in-out property <int> about-component-index: 0;
in property <[AboutComponent]> about-components;
in property <int> about-openfec-index: -1;
in property <string> about-revision;
in property <string> about-source-url;
in property <string> about-build-url;
callback open-about-link(string);
function about-back() {
if root.about-page == AboutPage.document { root.about-page = AboutPage.component; }
else if root.about-page == AboutPage.component && root.about-component-index != 0 { root.about-page = AboutPage.components; }
else { root.about-page = AboutPage.overview; }
}
in-out property <bool> sponsorship-open: false;
// Automatic prompts must not interrupt another dialog or menu.
private property <bool> sponsorship-unobstructed: !root.gallery-mode
&& !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 +569,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 +592,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 +626,35 @@ 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; }
else {
root.about-page = AboutPage.overview;
root.about-component-index = 0;
root.about-scroll-y = 0px;
}
}
changed about-page => {
// Restore dialog shortcuts after leaving the selectable license text.
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 => {
@@ -819,7 +879,10 @@ export component MainWindow inherits Window {
return accept;
}
if root.about-open && !root.update-open {
if event.text == Key.Escape { root.about-open = false; }
if event.text == Key.Escape {
if root.about-page != AboutPage.overview { root.about-back(); }
else { root.about-open = false; }
}
return accept;
}
if root.sponsorship-open && root.sponsorship-unobstructed {
@@ -1423,25 +1486,24 @@ export component MainWindow inherits Window {
text: root.current-version;
font-size: 11px; color: WorkbenchStyle.muted; vertical-alignment: center;
}
if !root.signal-connected && !root.signal-tls-error: Rectangle {
x: parent.width - self.width - 20px;
y: 0px;
height: parent.height;
width: reconnect-label.preferred-width;
reconnect-label := Text {
text: UiStrings.signal-reconnect;
color: WorkbenchStyle.accent;
font-size: 11px;
horizontal-alignment: center;
vertical-alignment: center;
}
TouchArea {
mouse-cursor: pointer;
clicked => {
root.copy-feedback = CopyFeedback.idle;
root.signal-reconnect-requested();
}
}
if !root.signal-connected && !root.signal-tls-error: WorkbenchButton {
x: parent.width - self.width - 20px;
y: (parent.height - self.height) / 2;
width: max(64px, reconnect-label-metrics.preferred-width + 22px);
height: 22px;
text: UiStrings.signal-reconnect;
text-size: 11px;
text-color: WorkbenchStyle.accent;
clicked => {
root.copy-feedback = CopyFeedback.idle;
root.signal-reconnect-requested();
}
reconnect-label-metrics := Text {
visible: false;
text: UiStrings.signal-reconnect;
font-size: 11px;
font-weight: 500;
}
}
}
}
@@ -2329,17 +2391,36 @@ export component MainWindow inherits Window {
}
if root.about-open: Rectangle {
// Use the same bounds throughout the About navigation stack.
private property <length> dialog-width: min(480px, self.width - 40px);
private property <length> dialog-height: min(520px, self.height - 32px);
background: #15223850;
z: 46;
TouchArea { clicked => { focus-sink.focus(); } }
SettingsDialog {
if root.about-page != AboutPage.overview: AboutPanel {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
width: min(480px, parent.width - 40px);
width: parent.dialog-width;
min-height: 0px; max-height: parent.height - 32px;
height: parent.dialog-height;
page <=> root.about-page;
component-index <=> root.about-component-index;
components: root.about-components;
openfec-index: root.about-openfec-index;
revision: root.about-revision;
source-url: root.about-source-url;
build-url: root.about-build-url;
back => { root.about-back(); }
open-link(url) => { root.open-about-link(url); }
}
if root.about-page == AboutPage.overview: SettingsDialog {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
width: parent.dialog-width;
// The overlay excludes any custom titlebar on every platform.
min-height: 0px; max-height: parent.height - 32px;
// Keep the dialog stable as update status and release details change.
height: min(488px, parent.height - 32px);
height: parent.dialog-height;
title: UiStrings.about;
close-label: MessageStrings.close;
close => { root.about-open = false; }
@@ -2352,6 +2433,8 @@ export component MainWindow inherits Window {
height: about-footer.y - self.y - 16px;
viewport-width: self.visible-width;
viewport-height: about-content.preferred-height;
// Keep the position when navigation recreates the overview.
viewport-y <=> root.about-scroll-y;
horizontal-scrollbar-policy: always-off;
vertical-scrollbar-policy: as-needed;
about-content := VerticalLayout {
@@ -2429,6 +2512,26 @@ export component MainWindow inherits Window {
Text { text: UiStrings.copyright; color: WorkbenchStyle.muted; font-size: 11px; wrap: word-wrap; }
Text { text: UiStrings.license; color: WorkbenchStyle.muted; font-size: 11px; wrap: word-wrap; }
}
AboutNavigationGroup {
AboutNavigationRow {
grouped: true;
title: AboutStrings.software-license;
detail: "GPL v3";
clicked => { root.about-component-index = 0; root.about-page = AboutPage.component; }
}
AboutRowSeparator { }
AboutNavigationRow {
grouped: true;
title: AboutStrings.components;
clicked => { root.about-page = AboutPage.components; }
}
AboutRowSeparator { }
AboutNavigationRow {
grouped: true;
title: AboutStrings.source;
clicked => { root.about-page = AboutPage.source; }
}
}
}
}
about-footer := VerticalLayout {
@@ -2436,25 +2539,23 @@ export component MainWindow inherits Window {
width: parent.width - 48px; height: self.preferred-height;
spacing: 16px;
Rectangle { height: 1px; background: WorkbenchStyle.separator; }
if root.update-status-text != "": Text {
text: root.update-status-text;
color: root.update-check-status == UpdateCheckStatus.failed ? #b3261e
: root.update-check-status == UpdateCheckStatus.up-to-date ? WorkbenchStyle.success : WorkbenchStyle.muted;
font-size: 12px; wrap: word-wrap;
}
HorizontalLayout {
alignment: end; spacing: 8px;
// Status updates use the spare footer space without reflowing the content.
Text {
min-width: 0px; height: 32px; horizontal-stretch: 1;
text: root.update-status-text;
color: root.update-check-status == UpdateCheckStatus.failed ? #b3261e
: root.update-check-status == UpdateCheckStatus.up-to-date ? WorkbenchStyle.success : WorkbenchStyle.muted;
font-size: 12px; wrap: word-wrap; overflow: elide;
vertical-alignment: center;
}
if root.update-available: WorkbenchButton {
width: max(80px, about-update-button-metrics.preferred-width + 24px);
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 +2615,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 +2689,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 +2704,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;
}
}
}
}
+4 -1
View File
@@ -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
@@ -1,5 +1,8 @@
#include "keyboard_capturer.h"
#include <string>
#include <vector>
#include "rd_log.h"
#include "windows_input_injector.h"
#include "windows_input_marker.h"
@@ -388,4 +391,60 @@ int PlatformKeyboardCapturer::SendKeyboardCommand(int key_code, bool is_down,
return 0;
}
int PlatformKeyboardCapturer::SendTextCommand(const std::string& text) {
if (text.empty() || text.size() > kMaxTextInputBytes ||
text.find('\0') != std::string::npos) {
return -1;
}
// Validate the entire UTF-8 commit before injecting any characters.
const int byte_count = static_cast<int>(text.size());
const int length = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS,
text.data(), byte_count, nullptr, 0);
if (length == 0) return -1;
std::wstring characters(length, L'\0');
if (MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, text.data(),
byte_count, characters.data(), length) != length) {
return -1;
}
std::vector<INPUT> inputs;
inputs.reserve(characters.size() * 2);
for (size_t index = 0; index < characters.size(); ++index) {
const wchar_t character = characters[index];
INPUT input{};
input.type = INPUT_KEYBOARD;
input.ki.dwExtraInfo = static_cast<ULONG_PTR>(kInjectedKeyboardInputMarker);
if (character == L'\r' || character == L'\n' || character == L'\t') {
input.ki.wVk = character == L'\t' ? VK_TAB : VK_RETURN;
if (character == L'\r' && index + 1 < characters.size() &&
characters[index + 1] == L'\n') {
++index;
}
} else {
// VK_PACKET carries UTF-16 units, including both halves of emoji.
input.ki.wScan = static_cast<WORD>(character);
input.ki.dwFlags = KEYEVENTF_UNICODE;
}
inputs.push_back(input);
input.ki.dwFlags |= KEYEVENTF_KEYUP;
inputs.push_back(input);
}
// One batch keeps surrogate pairs and key releases together with the text.
const UINT count = static_cast<UINT>(inputs.size());
const UINT sent = SendInputOnUserDesktop(inputs.data(), count);
if (sent != count) {
const DWORD error = GetLastError();
if (sent < count && sent % 2 != 0) {
SendInputOnUserDesktop(inputs[sent]); // Best-effort release.
}
LOG_WARN(
"SendInput failed for committed text, sent={}, expected={}, err={}",
sent, count, error);
SetLastError(error);
return -1;
}
return 0;
}
} // namespace crossdesk
@@ -1,7 +1,7 @@
/*
* @Author: DI JUNKUN
* @Date: 2024-11-22
* Copyright (c) 2024 by DI JUNKUN, All Rights Reserved.
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _KEYBOARD_CAPTURER_H_
@@ -29,6 +29,8 @@ class PlatformKeyboardCapturer final : public KeyboardCapturer {
int Hook(OnKeyAction on_key_action, void* user_ptr) override;
int Unhook() override;
bool IsHookActive() const override;
bool SupportsTextInput() const override { return true; }
int SendTextCommand(const std::string& text) override;
int SendKeyboardCommand(int key_code, bool is_down, uint32_t scan_code = 0,
bool extended = false) override;
@@ -13,26 +13,32 @@
namespace crossdesk {
namespace {
DWORD InjectOnUserDesktop(INPUT input) {
struct InputResult {
UINT sent;
DWORD error;
};
InputResult InjectOnUserDesktop(INPUT* inputs, UINT count) {
if (!IsCurrentSessionUnlocked()) {
return ERROR_REQUIRES_INTERACTIVE_WINDOWSTATION;
return {0, ERROR_REQUIRES_INTERACTIVE_WINDOWSTATION};
}
ScopedInteractiveDesktop desktop;
if (!desktop.Bind(L"")) {
const DWORD error = GetLastError();
return error != ERROR_SUCCESS ? error : ERROR_GEN_FAILURE;
return {0, error != ERROR_SUCCESS ? error : ERROR_GEN_FAILURE};
}
if (!desktop.IsReceivingInput()) {
return ERROR_REQUIRES_INTERACTIVE_WINDOWSTATION;
return {0, ERROR_REQUIRES_INTERACTIVE_WINDOWSTATION};
}
// Lock screens and UAC still require the session-checked service path.
if (_wcsicmp(desktop.name().c_str(), L"Default") != 0) {
return ERROR_ACCESS_DENIED;
return {0, ERROR_ACCESS_DENIED};
}
SetLastError(ERROR_SUCCESS);
if (SendInput(1, &input, sizeof(INPUT)) == 1) return ERROR_SUCCESS;
const UINT sent = SendInput(count, inputs, sizeof(INPUT));
if (sent == count) return {sent, ERROR_SUCCESS};
const DWORD error = GetLastError();
return error != ERROR_SUCCESS ? error : ERROR_GEN_FAILURE;
return {sent, error != ERROR_SUCCESS ? error : ERROR_GEN_FAILURE};
}
class UserDesktopInputWorker {
@@ -48,9 +54,9 @@ class UserDesktopInputWorker {
thread_.join();
}
DWORD Send(INPUT input) {
std::packaged_task<DWORD()> task(
[input] { return InjectOnUserDesktop(input); });
InputResult Send(INPUT* inputs, UINT count) {
std::packaged_task<InputResult()> task(
[inputs, count] { return InjectOnUserDesktop(inputs, count); });
auto result = task.get_future();
{
std::lock_guard<std::mutex> lock(mutex_);
@@ -76,7 +82,7 @@ class UserDesktopInputWorker {
std::mutex mutex_;
std::condition_variable wake_;
std::queue<std::packaged_task<DWORD()>> pending_;
std::queue<std::packaged_task<InputResult()>> pending_;
bool stopping_ = false;
std::thread thread_;
};
@@ -84,8 +90,18 @@ class UserDesktopInputWorker {
} // namespace
UINT SendInputOnUserDesktop(const INPUT& input) {
INPUT copy = input;
return SendInputOnUserDesktop(&copy, 1);
}
UINT SendInputOnUserDesktop(INPUT* inputs, UINT count) {
if (!inputs || count == 0) {
SetLastError(ERROR_INVALID_PARAMETER);
return 0;
}
static UserDesktopInputWorker worker;
const DWORD error = worker.Send(input);
const InputResult result = worker.Send(inputs, count);
const DWORD error = result.error;
if (error != ERROR_SUCCESS) {
// Include session identity even when Windows reports no useful SendInput
// error (for example UIPI). Keep repeated mouse-move failures bounded.
@@ -102,7 +118,7 @@ UINT SendInputOnUserDesktop(const INPUT& input) {
}
}
SetLastError(error);
return error == ERROR_SUCCESS ? 1 : 0;
return result.sent;
}
} // namespace crossdesk
@@ -16,6 +16,10 @@ namespace crossdesk {
// worker's failure code in the calling thread's last-error value.
UINT SendInputOnUserDesktop(const INPUT& input);
// Synchronously injects one ordered batch. The caller owns the buffer until
// this function returns, including while the worker is using it.
UINT SendInputOnUserDesktop(INPUT* inputs, UINT count);
} // namespace crossdesk
#endif
#endif
@@ -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
+199
View File
@@ -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
+267
View File
@@ -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
+61
View File
@@ -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") ==
+8
View File
@@ -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)
@@ -44,6 +49,9 @@ function setup_options_and_dependencies()
end
add_requireconfs("**.python", {version = "3.12", override = true, configs = {pgo = false}})
-- Keep application and build-tool dependencies on the same OpenSSL release.
add_requireconfs("**.openssl", "**.openssl3",
{version = "3.3.2", system = false, override = true})
add_requires("spdlog 1.14.1", {system = false})
add_requires("slint 1.17.1", {configs = {shared = true}})
add_requires("libsdl3 3.2.26", {configs = {shared = false}})
@@ -0,0 +1,134 @@
-- Embed notices so the About pages also work in offline/portable installations.
rule("crossdesk.about_resources")
after_load(function(target)
target:add("includedirs", path.join(target:autogendir(), "about"))
end)
before_build(function(target)
import("core.base.json")
import("core.project.config")
local root = os.projectdir()
local function read(relative)
return assert(io.readfile(path.join(root, relative)))
end
local function git(directory, args)
local result
try {function() result = os.iorunv("git", table.join({"-C", directory}, args)) end}
return result and result:trim() or ""
end
local repository = "https://github.com/kunkundi/crossdesk"
local revision = git(root, {"rev-parse", "HEAD"})
local source = repository .. (revision ~= "" and "/tree/" .. revision or "")
local mini_root = path.join(root, "deps/submodules/minirtc")
local mini_revision = git(mini_root, {"rev-parse", "HEAD"})
local mini_source = "https://github.com/kunkundi/minirtc" ..
(mini_revision ~= "" and "/tree/" .. mini_revision or "")
local catalog = json.decode(read("apps/ios/CrossDeskMobile/Resources/ThirdPartyLicenses.json"))
local known = {}
for _, component in ipairs(catalog.components) do known[component.id] = component end
-- These desktop dependencies are not part of the shared mobile catalog.
local desktop = {
slint = {"Slint", "https://github.com/slint-ui/slint/tree/v"},
libsdl3 = {"SDL", "https://github.com/libsdl-org/SDL/tree/release-"},
tinyfiledialogs = {"tinyfiledialogs", "https://sourceforge.net/projects/tinyfiledialogs/"},
["cpp-httplib"] = {"cpp-httplib", "https://github.com/yhirose/cpp-httplib/tree/v"},
openh264 = {"OpenH264", "https://github.com/cisco/openh264/tree/v"},
miniaudio = {"miniaudio", "https://github.com/mackron/miniaudio/tree/"},
aom = {"libaom", "https://aomedia.googlesource.com/aom/+/v"}
}
local components = {{
id = "crossdesk", name = "CrossDesk", version = config.get("CROSSDESK_VERSION") or "0.0.0",
license = "GPL-3.0-only", sourceURL = source,
documents = {{title = "GNU GPL v3", text = read("LICENSE")}}
}, {
id = "minirtc", name = "MiniRTC", version = mini_revision:sub(1, 12),
license = "LGPL-3.0-only", sourceURL = mini_source,
documents = {{title = "GNU LGPL v3", text = read("deps/submodules/minirtc/LICENSE")},
{title = "GNU GPL v3", text = read("LICENSE")}}
}}
local seen = {}
for _, dependency in ipairs(table.join({target}, target:orderdeps())) do
for _, package in ipairs(dependency:orderpkgs()) do
local name = package:name()
if not seen[name] then
seen[name] = true
local version = package:get("version") or ""
local shared = known[name]
local extra = desktop[name]
local item = {id = name, name = shared and shared.name or extra and extra[1] or name,
version = version, license = package:license() or "",
sourceURL = "https://github.com/xmake-io/xmake-repo/tree/master/packages/" ..
name:sub(1, 1) .. "/" .. name,
documents = json.mark_as_array({})}
if shared and shared.version:gsub("^v", "") == version:gsub("^v", "") then
item.license = shared.license
item.sourceURL = shared.sourceURL
item.documents = shared.documents
elseif extra then
item.sourceURL = extra[2] .. (name == "tinyfiledialogs" and "" or version:gsub("^v", ""))
end
-- Prefer installed notices when a package is absent from the shared catalog.
if #item.documents == 0 then
local install = package:installdir()
if install then
for _, file in ipairs(os.files(path.join(install, "share/licenses/**"))) do
local text = io.readfile(file)
if text and not text:find("\0", 1, true) then
table.insert(item.documents, {title = path.relative(file, install), text = text})
end
end
local notice_header = name == "tinyfiledialogs" and "include/tinyfiledialogs.h"
or name == "openh264" and "include/wels/codec_api.h"
if notice_header then
local header_path = path.join(install, notice_header)
local header = os.isfile(header_path) and io.readfile(header_path)
local notice = header and header:match("^/%*.-%*/")
if notice then
table.insert(item.documents, {title = notice_header, text = notice})
end
end
end
end
if name == "slint" then
table.insert(item.documents, {title = "GNU GPL v3", text = read("LICENSE")})
end
table.insert(components, item)
end
end
end
table.sort(components, function(a, b)
if a.id == b.id then return false end
if a.id == "crossdesk" then return true end
if b.id == "crossdesk" then return false end
return a.name:lower() < b.name:lower()
end)
local data = json.encode({components = components, revision = revision:sub(1, 12),
sourceURL = source,
buildURL = repository .. "/blob/" .. (revision ~= "" and revision or "main") .. "/docs/BUILD_EN.md"},
{pure = true, orderkeys = true})
-- A byte array avoids MSVC's limit on individual/concatenated string literals.
local bytes = {}
for index = 1, #data do
bytes[index] = tostring(data:byte(index)) .. (index % 32 == 0 and "\n" or "")
end
local output = [[/*
* @Author: DI JUNKUN
* @Date: 2026-10-21
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _ABOUT_RESOURCES_H_
#define _ABOUT_RESOURCES_H_
// Generated by the desktop build. Do not edit.
inline constexpr unsigned char kAboutResources[] = {
]] .. table.concat(bytes, ",") .. "\n};\n\n#endif\n"
local destination = path.join(target:autogendir(), "about", "about_resources.h")
if not os.isfile(destination) or io.readfile(destination) ~= output then
os.mkdir(path.directory(destination))
io.writefile(destination, output)
end
end)
rule_end()
+4
View File
@@ -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})
+55 -2
View File
@@ -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
@@ -517,6 +560,7 @@ function setup_targets()
target("gui")
set_kind("object")
add_rules("crossdesk.about_resources")
set_languages("c++20")
-- spdlog 1.14 bundles fmt 10, whose consteval parser is rejected by
-- current Apple Clang in C++20 mode. Keep the established dependency
@@ -524,6 +568,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.
@@ -537,6 +586,7 @@ function setup_targets()
add_files("apps/desktop/src/gui/render.cpp", "apps/desktop/src/gui/application/gui_application.cpp",
"apps/desktop/src/gui/rendering/*.cpp",
"apps/desktop/src/gui/runtime/*.cpp",
"apps/desktop/src/gui/features/about/*.cpp",
"apps/desktop/src/platform/common/gui/service_status_runtime.cpp",
"apps/desktop/src/gui/features/devices/*.cpp", "apps/desktop/src/gui/features/input/*.cpp",
"apps/desktop/src/gui/features/clipboard/*.cpp", "apps/desktop/src/gui/features/file_transfer/*.cpp",
@@ -644,6 +694,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
+53
View File
@@ -43,6 +43,58 @@ def write_json(path, value):
write(path, json.dumps(value, ensure_ascii=False, indent=2) + '\n')
def write_about_resources(output, version):
"""Supply the embedded catalog required by the shared desktop About UI."""
def git(directory, *args):
try:
return subprocess.check_output(['git', '-C', str(directory), *args],
text=True, stderr=subprocess.DEVNULL).strip()
except (OSError, subprocess.CalledProcessError):
return ''
revision = git(ROOT, 'rev-parse', 'HEAD')
mini = git(ROOT / 'deps/submodules/minirtc', 'rev-parse', 'HEAD')
repository = 'https://github.com/kunkundi/crossdesk'
source = repository + (f'/tree/{revision}' if revision else '')
gpl = {'title': 'GNU GPL v3', 'text': (ROOT / 'LICENSE').read_text()}
components = [
{'id': 'crossdesk', 'name': 'CrossDesk', 'version': version,
'license': 'GPL-3.0-only', 'sourceURL': source, 'documents': [gpl]},
{'id': 'minirtc', 'name': 'MiniRTC', 'version': mini[:12],
'license': 'LGPL-3.0-only',
'sourceURL': 'https://github.com/kunkundi/minirtc' + (f'/tree/{mini}' if mini else ''),
'documents': [{'title': 'GNU LGPL v3',
'text': (ROOT / 'deps/submodules/minirtc/LICENSE').read_text()}, gpl]},
{'id': 'slint', 'name': 'Slint', 'version': '1.17.1', 'license': 'GPL-3.0-only',
'sourceURL': 'https://github.com/slint-ui/slint/tree/v1.17.1',
'documents': [{'title': 'Slint license', 'text': (SOURCE / 'LICENSE.md').read_text()}, gpl]},
{'id': 'cpp-httplib', 'name': 'cpp-httplib', 'version': 'v0.26.0', 'license': 'MIT',
'sourceURL': 'https://github.com/yhirose/cpp-httplib/tree/v0.26.0', 'documents': []}]
shared = json.loads((ROOT / 'apps/ios/CrossDeskMobile/Resources/ThirdPartyLicenses.json').read_text())['components']
shared += json.loads((ROOT / 'apps/android/licenses/additional-notices.json').read_text())
seen = {item['id'] for item in components}
for item in shared:
name = item['id']
package = APP / '.native/packages' / name[0] / name
if name not in seen and any((package / tag).is_dir() for tag in
[item['version'], item['version'].removeprefix('v'),
'v' + item['version'].removeprefix('v')]):
components.append(item)
seen.add(name)
components.sort(key=lambda item: (item['id'] != 'crossdesk', item['name'].lower()))
data = json.dumps({'components': components, 'revision': revision[:12],
'sourceURL': source,
'buildURL': f'{repository}/blob/{revision or "main"}/apps/harmony/README.md'},
ensure_ascii=False, sort_keys=True).encode()
body = '\n'.join(','.join(str(byte) for byte in data[i:i + 32]) + ','
for i in range(0, len(data), 32))
write(output, '/*\n * @Author: DI JUNKUN\n * @Date: 2026-10-21\n'
' * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.\n */\n\n'
'#ifndef _ABOUT_RESOURCES_H_\n#define _ABOUT_RESOURCES_H_\n\n'
'// Generated by the Harmony desktop build. Do not edit.\n'
'inline constexpr unsigned char kAboutResources[] = {\n' + body + '\n};\n\n#endif\n')
def main():
bundle = json.loads((APP / 'AppScope/app.json5').read_text())['app']['bundleName']
parser = argparse.ArgumentParser(description=__doc__)
@@ -157,6 +209,7 @@ def main():
urllib.request.urlretrieve('https://raw.githubusercontent.com/yhirose/cpp-httplib/v0.26.0/httplib.h', header)
if hashlib.sha256(header.read_bytes()).hexdigest() != '4a42da28ce477d06f53942c172197d3b83b4fe42b7e37b8af78c70e063c06da7':
raise SystemExit('cpp-httplib source checksum mismatch')
write_about_resources(generated / 'about_resources.h', args.version)
legacy_types = PROJECT / 'entry/src/main/cpp/types/libslint_probe'
if legacy_types.exists():
run([studio / 'tools/hvigor/bin/hvigorw', 'clean', '--no-daemon'], cwd=PROJECT, env=env)
+7
View File
@@ -0,0 +1,7 @@
-- Python's recipe looks up its dependency as "openssl". Keep that name while
-- using the OpenSSL 3 recipe, including its sources, patches and build scripts.
package("openssl")
set_base("openssl3")
-- Rebuild consumers cached against the old OpenSSL 1.x ABI.
set_policy("package.strict_compatibility", true)
package_end()
+1 -1
View File
@@ -1 +1 @@
includes("cpp-httplib", "rust", "slint")
includes("cpp-httplib", "openssl", "rust", "slint")
+3
View File
@@ -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` 仅为本地构建示例版本号;调整时请同步修改配置命令与打包参数,避免程序内版本号与安装包不一致。
## 构建依赖
+155
View File
@@ -0,0 +1,155 @@
# 桌面端应用内升级
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` 只影响桌面安装包,不改变移动端版本。
`prepare-version` 前置任务统一生成完整桌面版本,通过 job outputs 传给
Linux、macOS、Windows 和发布任务。无日期标签使用前置任务运行当天的
北京时间日期,显式日期标签使用标签中的日期;后续任务不再重新计算。
构建跨午夜或单独重跑发布任务时,继续使用前置任务确定的版本。
不要使用 `vauto-update-20261020` 这类分支名作为版本号验证升级。
新版本必须增加数字版本或 patch;只改变日期不视为升级。例如先手动安装
`v1.7.2-20261020`,再验证到 `v1.7.3-20261020` 的升级。
分支手动构建只上传 Actions 构建产物,不会自动发布版本清单。
发布脚本按数字版本和 patch 比较版本,忽略构建日期。编辑或重新发布旧的
桌面 Release 时,保留服务器现有清单并在 Actions 日志中说明跳过原因;
同版本仍可更新发布说明,新版本正常发布。各平台下载记录也不会被较低
版本覆盖,包括独立移动端发布和桌面 Release 附带的移动端安装包。
桌面 Release 中所有桌面安装包及便携包的完整版本必须与标签去掉 `v`
前缀后的版本一致,包括日期和 patch 的位置;不能只按数字版本比较。
任一包不匹配时,脚本报告标签、平台、文件名及双方版本,终止清单生成,
不写入输出文件。手动发布时应使用与安装包一致的完整版本标签,例如
`v1.7.4-20261021`。iOS、Android 安装包保持独立版本。
本地构建可以设置同名环境变量,或使用:
```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
```
发布前仍须在各平台的隔离安装环境验证:授权取消、其他会话占用、服务
退出、安装失败、升级器自身更新和新程序启动确认。不要在开发机上通过
自动运行生产安装包来代替这些安装测试。
+2
View File
@@ -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.
+1
View File
@@ -15,6 +15,7 @@ if desktop_platform then
includes(path.join(source_dir, "apps/desktop/xmake/options.lua"))
includes(path.join(source_dir, "apps/desktop/xmake/platform.lua"))
includes(path.join(source_dir, "apps/desktop/xmake/rules/slint.lua"))
includes(path.join(source_dir, "apps/desktop/xmake/rules/about_resources.lua"))
includes(path.join(source_dir, "apps/desktop/xmake/targets.lua"))
setup_options_and_dependencies()