Compare commits

..
Author SHA1 Message Date
dijunkun bd7f9505d9 [docs] document update public key build option 2026-10-24 21:03:55 +08:00
dijunkun 4650e48ea3 [feat] add configurable landscape entry for mobile sessions 2026-10-24 03:28:23 +08:00
dijunkun 182899d238 [fix] scope sleep inhibition to active screen sharing, refs #123 2026-10-24 01:24:38 +08:00
dijunkun 1d465239cf [chore] update MiniRTC for web file transfers 2026-10-23 23:01:39 +08:00
dijunkun 80ea75ff78 [ui] highlight disconnect buttons and confirm session closure 2026-10-23 14:19:18 +08:00
dijunkun ff7b954949 [ui] add animated logo toolbar and disconnect confirmation 2026-10-23 13:56:25 +08:00
dijunkun 0fbefd309f [fix] preserve remote mouse gestures across control overlays 2026-10-23 10:11:01 +08:00
dijunkun f269c56e5b [fix] unify network statistics resolution formatting 2026-10-22 19:30:54 +08:00
dijunkun c6a2f6a35b [feat] display video codecs and refine network statistics 2026-10-22 19:18:37 +08:00
dijunkun 193dcc4be0 [feat] prefer hardware video codecs by default 2026-10-22 17:20:48 +08:00
dijunkun f8f4aa3cea [fix] prevent remote input from blocking transport callbacks 2026-10-22 16:10:58 +08:00
dijunkun c25e7266e5 [fix] retain failed keyboard releases per controller 2026-10-22 16:10:44 +08:00
dijunkun 5e494bfa3b [fix] support selective mouse button release across desktop platforms 2026-10-22 16:10:34 +08:00
dijunkun f6a2637540 [fix] send mouse movement over unreliable transport 2026-10-22 10:59:36 +08:00
kunkundi 621f2ff205 [fix] update MiniRTC for Windows hardware encoding 2026-10-22 10:46:24 +08:00
dijunkun 5ac6e153c8 [fix] preserve Windows remote sessions during shutdown 2026-10-22 04:17:27 +08:00
dijunkun dd5c572852 [feat] enable Windows MFT codecs without CUDA 2026-10-22 03:25:24 +08:00
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
127 changed files with 6177 additions and 957 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 \
+2
View File
@@ -154,6 +154,8 @@ PC 端可控制和被控制,复用桌面界面与桌面墙;手机和平板
- [常见问题](docs/FAQ.md):连接失败、黑屏、系统权限、文件位置与构建问题。
- [提交问题或建议](https://github.com/kunkundi/crossdesk/issues):请提供两端系统、客户端版本、连接方式和可复现步骤。
桌面端的 `CROSSDESK_UPDATE_PUBLIC_KEY` 是可选编译参数,用于验证应用内升级清单的 Ed25519 签名。不配置仍可正常编译、运行和检查版本,但更新只能手动下载;需要应用内自动安装更新时,须配置与发布私钥匹配的公钥。配置方式见 [更新公钥](docs/BUILD.md#update-public-key),密钥生成与发布流程见 [桌面端应用内升级](docs/UPDATER.md)。
<a id="sponsor"></a>
## 支持 CrossDesk
+2
View File
@@ -154,6 +154,8 @@ Host your own signaling and TURN services, then enable them under **Settings →
- [FAQ](docs/FAQ.md#english): connections, blank video, permissions, received files, and build issues.
- [Report an issue or suggestion](https://github.com/kunkundi/crossdesk/issues): include both operating systems, client versions, connection mode, and reproduction steps.
The optional desktop build parameter `CROSSDESK_UPDATE_PUBLIC_KEY` verifies Ed25519 signatures on in-app update manifests. Without it, the app still builds, runs, and checks for new versions, but updates require manual download. In-app update installation requires a public key matching the release signing private key. See [Update public key](docs/BUILD_EN.md#update-public-key) for configuration and the [desktop updater guide](docs/UPDATER.md) (Chinese) for key generation and publishing.
<a id="sponsor"></a>
## Support CrossDesk
+2 -1
View File
@@ -9,6 +9,7 @@
- 48 kHz 单声道 PCM 播放,支持静音,音频队列有容量限制。
- 相对鼠标(触控板)和绝对位置模式;单指点击、绝对模式按住拖动、长按右键,以及外接键盘、鼠标。画面手势与 iOS 一致:双指可在整个触控区域(包括画面外黑边)捏合,以触点为中心缩放(1–10 倍),放大后双指拖动画面,原始比例下双指上下滑动控制滚轮。相对鼠标移到可见画面的上、下边缘后继续向外移动,会反向上下平移画面,不触发左右平移;未放大时和按住虚拟鼠标键拖动时同样生效,停止移动即停止平移,抬手保留位置。移动到极限时,画面可见部分至少占当前显示区域高度的 20%,与缩放倍数无关;整幅画面高度不足该比例时完整保留,范围计入上下黑边;缩放结束及缩回 1 倍时保留平移位置,仅在超过可见边界时限制位置;切换显示器恢复居中。
- 横竖屏等比例显示、远端多显示器切换。与 iOS 一致的画面质量(低/中/高)、采集帧率(30/60 fps)、画面偏好;支持能力检测、电脑端确认、失败/5 秒超时回退及连续操作的应答排序。
- “设置 → 屏幕方向 → 连接后自动横屏”默认开启并保存在本机。手机进入会话后先保持横屏,检测到手机实际横过来后才恢复跟随设备方向;竖持或平放不会立即切回竖屏。关闭后沿用自动旋转,平板与多窗口不受此选项影响。退出会话恢复首页方向,返回同一后台会话不重复锁定已解锁的方向。
- 根据常见移动设备解码能力和屏幕尺寸设置起始参数:显示长边不超过 1280 像素时默认低画质(最高 720p)/30 fps,其余默认中画质(最高 1080p)/30 fps。用户保存的画面偏好不变,仍可手动选择高画质或 60 fps。
- 网络状态按视频、音频、数据、合计展示收发速率和丢包率,并显示实际提交帧率、分辨率、画面延时、平滑 RTT、P2P/TURN 和实际 SRTP 状态。每秒刷新,过期或无效测量显示“—”。
- 可拖动的悬浮控制球、三列控制菜单;默认使用系统输入法,保留候选词、纠错和听写,确认文字后直接输入远端。电脑键盘固定底部,分“字母数字 / 符号功能”两页,共用末两行布局,按系统键盘高度适配横竖屏;统一切换、收起按钮和 Android 键盘触感反馈。
@@ -151,7 +152,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 秒没有有效进展则报告失败。接收时校验大小并追踪完整字节范围,允许跨线程回调乱序,未完成的文件不会提供保存入口。同名文件分开存储,不覆盖已有文件;可通过“保存收到的文件”导出最近收到的文件,连续接收多个文件时应逐个保存。
@@ -19,6 +19,7 @@ final class BackgroundSession implements NativeSession.Listener {
RemoteVideoSettings videoSettings;
int signalState, selectedDisplay, width, height;
boolean muted, remoteServiceAvailable, background, ended;
boolean waitingForLandscape;
File receivedFile;
BackgroundSession(Context context, NativeSession session, String host, int port, String remote) {
@@ -72,6 +72,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
private int videoWidth,videoHeight;
private final RemotePictureInPicture pictureInPicture=new RemotePictureInPicture();
private RemoteVideoView video;
private RemoteOrientation remoteOrientation;
private SessionControls controls;
private Dialog passwordDialog, progressDialog, privacyDialog;
private AlertDialog disconnectDialog;
@@ -95,7 +96,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
private Runnable backAction;
@Override public void onCreate(Bundle state){
super.onCreate(state);ui=new MobileUi(this);history=new RecentConnections(this);
super.onCreate(state);ui=new MobileUi(this);history=new RecentConnections(this);remoteOrientation=new RemoteOrientation(this);
pageHost=new PageHost(this);setContentView(pageHost);
preferences=getSharedPreferences("settings",MODE_PRIVATE);host=preferences.getString("host","api.crossdesk.cn");port=preferences.getInt("port",9099);
recentPresence.sender=ids->{if(signaling!=null&&!signaling.isClosed())signaling.presenceRequest(ids);};
@@ -178,14 +179,18 @@ public final class MainActivity extends Activity implements NativeSession.Listen
TextView chevron=text(" ›",23,false);chevron.setTextColor(0xFFB7B7BD);item.addView(chevron);item.setContentDescription(title+(detail.isEmpty()?"":","+detail));item.setClickable(true);item.setFocusable(true);item.setOnClickListener(v->action.run());parent.addView(item);
return item;
}
@android.annotation.SuppressLint("SourceLockedOrientationActivity") // Phones keep the portrait home; tablets and multi-window follow user rotation settings.
private void updateHomeOrientation(){
private boolean isTablet(){
boolean tablet=getResources().getConfiguration().smallestScreenWidthDp>=600;
if(android.os.Build.VERSION.SDK_INT>=30){
android.graphics.Rect bounds=getWindowManager().getMaximumWindowMetrics().getBounds();
tablet|=Math.min(bounds.width(),bounds.height())>=dp(600);
}
int orientation=tablet||isInMultiWindowMode()?ActivityInfo.SCREEN_ORIENTATION_FULL_USER:ActivityInfo.SCREEN_ORIENTATION_PORTRAIT;
return tablet;
}
@android.annotation.SuppressLint("SourceLockedOrientationActivity") // Phones keep the portrait home; tablets and multi-window follow user rotation settings.
private void updateHomeOrientation(){
remoteOrientation.end();
int orientation=isTablet()||isInMultiWindowMode()?ActivityInfo.SCREEN_ORIENTATION_FULL_USER:ActivityInfo.SCREEN_ORIENTATION_PORTRAIT;
if(getRequestedOrientation()!=orientation)setRequestedOrientation(orientation);
}
private void home(String message){
@@ -321,8 +326,8 @@ public final class MainActivity extends Activity implements NativeSession.Listen
ProgressBar spinner=new ProgressBar(this);spinner.setIndeterminateTintList(ColorStateList.valueOf(BLUE));body.addView(spinner,ui.size(34,34));ui.gap(body,14);TextView title=text("正在连接",20,true);title.setGravity(Gravity.CENTER);body.addView(title);ui.gap(body,6);status=text("正在连接信令服务器…",14,false);status.setGravity(Gravity.CENTER);status.setTextColor(SECONDARY);status.setMinHeight(dp(36));body.addView(status);card.addView(body,ui.size(-1,-2));ui.divider(card);card.addView(ui.action("取消连接",RED,()->end("")),ui.size(-1,46));
dialog.setContentView(card);dialog.setCanceledOnTouchOutside(false);dialog.setOnCancelListener(d->end(""));Window w=dialog.getWindow();if(w!=null){w.setBackgroundDrawableResource(android.R.color.transparent);w.setDimAmount(.08f);}dialog.show();if(w!=null)w.setLayout(dp(292),-2);
}
private void sessionScreen(){
installRoot("session",true);setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR);video=new RemoteVideoView(this,session);video.setRelative(preferences.getBoolean("relativeMouse",true));canvas.addView(video,0,new FrameLayout.LayoutParams(-1,-1));
private void sessionScreen(boolean waitForLandscape){
installRoot("session",true);remoteOrientation.begin(waitForLandscape&&!isTablet()&&!isInMultiWindowMode());video=new RemoteVideoView(this,session);video.setRelative(preferences.getBoolean("relativeMouse",true));canvas.addView(video,0,new FrameLayout.LayoutParams(-1,-1));
controls=new SessionControls(this,ui,new SessionControls.Actions(){
public void disconnect(){confirmDisconnect();}public void display(){selectDisplay();}public void clipboard(){MainActivity.this.clipboard();}
public void sendFile(){chooseFile();}public void saveFile(){saveReceivedFile();}public void secureAttention(){MainActivity.this.secureAttention();}
@@ -343,6 +348,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
}
private void confirmDisconnect(){disconnectDialog=new AlertDialog.Builder(this).setTitle("断开远程连接?").setMessage("断开后将返回首页").setNegativeButton("取消",null).setPositiveButton("断开连接",(d,w)->end("")).show();}
private void end(String reason){
remoteOrientation.end();
if(backgroundSession!=null){BackgroundSession retained=backgroundSession;backgroundSession=null;retained.detach(this);retained.finish(reason);}
boolean closeWindow=isInPictureInPictureMode();
pictureInPicture.disconnect();updatePictureInPicture();
@@ -362,6 +368,9 @@ public final class MainActivity extends Activity implements NativeSession.Listen
mouse.addView(segments(new String[]{"相对位置","绝对位置"},relative?0:1,true,index->preferences.edit().putBoolean("relativeMouse",index==0).apply()));
LinearLayout picture=section(content,"画面偏好",MobileUi.SETTINGS_CARD_RADIUS);picture.setPadding(dp(12),dp(12),dp(12),dp(12));int preference=Math.max(0,Math.min(2,preferences.getInt("videoPreference",1)));
picture.addView(segments(RemoteVideoSettings.PREFERENCES,preference,true,index->preferences.edit().putInt("videoPreference",index).apply()));ui.gap(content,28);
LinearLayout direction=section(content,"屏幕方向",MobileUi.SETTINGS_CARD_RADIUS);LinearLayout directionRow=row();directionRow.setPadding(dp(16),dp(10),dp(16),dp(10));directionRow.addView(text("连接后自动横屏",16,false),new LinearLayout.LayoutParams(0,-2,1));
Switch autoLandscape=new Switch(this);autoLandscape.setContentDescription("连接后自动横屏");autoLandscape.setChecked(preferences.getBoolean("autoLandscape",true));autoLandscape.setOnCheckedChangeListener((button,checked)->preferences.edit().putBoolean("autoLandscape",checked).apply());directionRow.addView(autoLandscape);direction.addView(directionRow);
footer(content,"手机连接后先进入横屏,将手机横过来后恢复跟随设备方向。关闭后始终跟随设备方向。平板和多窗口不受影响。");
LinearLayout server=section(content,"",MobileUi.SETTINGS_CARD_RADIUS);LinearLayout fields=column();fields.setVisibility(View.GONE);boolean custom=!host.equals("api.crossdesk.cn")||port!=9099;
link(server,"服务器",custom?"自定义":"默认",()->fields.setVisibility(fields.getVisibility()==View.VISIBLE?View.GONE:View.VISIBLE));server.addView(fields);
LinearLayout toggleRow=row();toggleRow.setPadding(dp(16),dp(8),dp(16),dp(8));toggleRow.addView(text("使用自定义服务器",16,false),new LinearLayout.LayoutParams(0,-2,1));Switch toggle=new Switch(this);toggle.setContentDescription("使用自定义服务器");toggle.setChecked(custom);toggleRow.addView(toggle);fields.addView(toggleRow);
@@ -477,7 +486,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);}}
@@ -593,7 +602,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
// Transport readiness can be reported again after ICE changes its selected path.
// The first success consumes pendingPassword; a duplicate must not save it again.
if(session==null||ready)return;ready=true;if(progressDialog!=null){progressDialog.dismiss();progressDialog=null;}
try{history.save(serverKey(),connectedRemote,connectedRemote,hostPlatform,rememberPassword,pendingPassword);}catch(Exception error){toast("密码保存失败,本次连接仍可使用");}pendingPassword="";sessionScreen();
try{history.save(serverKey(),connectedRemote,connectedRemote,hostPlatform,rememberPassword,pendingPassword);}catch(Exception error){toast("密码保存失败,本次连接仍可使用");}pendingPassword="";sessionScreen(preferences.getBoolean("autoLandscape",true));
if(!canUsePictureInPicture())prepareBackgroundSession(true);
if(videoSettings.supported&&videoSettings.requestId==0)updateVideoSettings(videoSettings.selection);
if(foreground&&preferences.getBoolean("networkConsent",false))remoteVersionCheck.connected();
@@ -670,6 +679,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
super.onConfigurationChanged(config);
releaseKeys();
if(!page.equals("session"))updateHomeOrientation();
else if(!isInPictureInPictureMode()&&(isTablet()||isInMultiWindowMode()))remoteOrientation.followDevice();
sizePasswordDialog();
if(privacyDialog instanceof PrivacyConsentDialog)((PrivacyConsentDialog)privacyDialog).updateSize();
if(controls!=null&&!pictureInPictureUi)controls.post(controls::constrainPanels);
@@ -688,6 +698,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
retained.muted=muted;retained.selectedDisplay=selectedDisplay;retained.remoteServiceAvailable=remoteServiceAvailable;
retained.clipboard=remoteClipboard;retained.videoSettings=videoSettings;retained.receivedFile=receivedFile;
retained.width=videoWidth;retained.height=videoHeight;
retained.waitingForLandscape=remoteOrientation.waitingForLandscape();
}
private boolean prepareBackgroundSession(boolean inform){
if(!ready||session==null||session.isClosed()||isFinishing()||!hasNetworkConsent())return false;
@@ -723,7 +734,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
hostPlatform=retained.platform;displays=retained.displays;muted=retained.muted;selectedDisplay=retained.selectedDisplay;
remoteServiceAvailable=retained.remoteServiceAvailable;remoteClipboard=retained.clipboard;receivedFile=retained.receivedFile;
videoSettings=retained.videoSettings;ready=true;retained.attach(this);
if(rebuild)sessionScreen();
if(rebuild)sessionScreen(retained.waitingForLandscape);
if(retained.width>0&&retained.height>0)videoSize(retained.width,retained.height);
if(controls!=null){controls.setMuted(muted);controls.videoSettings(videoSettings);controls.fileStatus(retained.fileStatus,receivedFile!=null);}
retained.setBackground(false);signaling(signalState,localIdentity);
@@ -771,9 +782,9 @@ public final class MainActivity extends Activity implements NativeSession.Listen
super.onPictureInPictureUiStateChanged(state);
if(android.os.Build.VERSION.SDK_INT>=35&&state.isTransitioningToPip())setPictureInPictureUi(true);
}
@Override protected void onResume(){super.onResume();if(!isInPictureInPictureMode())enteredPictureInPicture=false;setPictureInPictureUi(isInPictureInPictureMode());updatePictureInPicture();if(ready&&!canUsePictureInPicture())prepareBackgroundSession(true);if(backgroundSession!=null)requestBackgroundNotifications();}
@Override protected void onResume(){super.onResume();remoteOrientation.resume();if(!isInPictureInPictureMode())enteredPictureInPicture=false;setPictureInPictureUi(isInPictureInPictureMode());updatePictureInPicture();if(ready&&!canUsePictureInPicture())prepareBackgroundSession(true);if(backgroundSession!=null)requestBackgroundNotifications();}
// onStart marks a new visit; focus/resume callbacks must not reopen a refused notice.
@Override protected void onPause(){releaseKeys();if(controls!=null)controls.cancelMouseInput();if(video!=null)video.releaseMouse();super.onPause();}
@Override protected void onPause(){remoteOrientation.pause();releaseKeys();if(controls!=null)controls.cancelMouseInput();if(video!=null)video.releaseMouse();super.onPause();}
@Override protected void onStart(){super.onStart();foreground=true;restoreBackgroundSession();appUpdates.setEnabled(preferences.getBoolean("networkConsent",false));if(!preferences.getBoolean("networkConsent",false))consent(this::renderSignaling);ensureSignaling();showConnectionMessage();}
@Override protected void onStop(){if(controls!=null)controls.closeKeyboard();foreground=false;appUpdates.setEnabled(false);if(privacyDialog!=null)privacyDialog.dismiss();pageHost.finish();
if(connectionDialog!=null)connectionDialog.dismiss();
@@ -791,5 +802,5 @@ public final class MainActivity extends Activity implements NativeSession.Listen
boolean retained=!isFinishing()&&backgroundSession!=null&&!backgroundSession.ended&&RemoteSessionService.current()==backgroundSession;
if(backgroundSession!=null)backgroundSession.detach(this);
if(!retained){if(session!=null)session.close();stopSignaling();}else{mainHandler.removeCallbacks(presenceTick);session=signaling=null;}
super.onDestroy();}
remoteOrientation.end();super.onDestroy();}
}
@@ -0,0 +1,57 @@
package cn.crossdesk.mobile;
import android.app.Activity;
import android.content.pm.ActivityInfo;
import android.view.OrientationEventListener;
/** Holds the initial landscape until the phone itself has been turned sideways. */
final class RemoteOrientation {
private final Activity activity;
private final OrientationEventListener sensor;
private boolean waitingForLandscape, resumed;
RemoteOrientation(Activity activity) {
this.activity = activity;
sensor = new OrientationEventListener(activity) {
@Override public void onOrientationChanged(int degrees) {
if (!resumed || !waitingForLandscape || activity.isInPictureInPictureMode()) return;
// Use physical orientation, never the window rotated by our own request.
// Ignore flat/unknown poses and keep a margin away from the diagonal.
if ((degrees >= 60 && degrees <= 120) || (degrees >= 240 && degrees <= 300)) {
followDevice();
}
}
};
}
void begin(boolean autoLandscape) {
sensor.disable();
waitingForLandscape = autoLandscape && sensor.canDetectOrientation();
apply();
if (resumed && waitingForLandscape) sensor.enable();
}
boolean waitingForLandscape() { return waitingForLandscape; }
void resume() {
resumed = true;
if (waitingForLandscape && !activity.isInPictureInPictureMode()) sensor.enable();
}
void pause() { resumed = false; sensor.disable(); }
void end() { waitingForLandscape = false; sensor.disable(); }
void followDevice() {
waitingForLandscape = false;
sensor.disable();
apply();
}
private void apply() {
int orientation = waitingForLandscape
? ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
: ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR;
if (activity.getRequestedOrientation() != orientation) activity.setRequestedOrientation(orientation);
}
}
@@ -0,0 +1,23 @@
<?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.SessionHelper" type="win32" />
<description>CrossDesk Session Helper</description>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
</application>
</compatibility>
</assembly>
@@ -4,13 +4,10 @@ IDI_ICON1 ICON "crossdesk.ico"
#define CREATEPROCESS_MANIFEST_RESOURCE_ID 1
#define RT_MANIFEST 24
#ifdef CROSSDESK_PORTABLE
CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "crossdesk_portable.manifest"
#elif defined(CROSSDESK_DEBUG)
CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "crossdesk_debug.manifest"
#else
CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "crossdesk.manifest"
#endif
// The service supplies either the user's token (desktop observer) or a SYSTEM
// token (secure capture/input). Requiring elevation here prevents the observer
// from starting with CreateProcessAsUserW in installed release builds (740).
CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "crossdesk_session_helper.manifest"
#define CROSSDESK_FILE_DESCRIPTION "CrossDesk Session Helper"
#define CROSSDESK_ORIGINAL_FILENAME "crossdesk_session_helper.exe"
@@ -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
+25 -2
View File
@@ -20,6 +20,7 @@
#include "platform/windows/gui/slint_backend.h"
#include "service_host.h"
#include "service_lifetime.h"
#include "shutdown_order.h"
#include "unattended_config.h"
#endif
@@ -30,6 +31,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"
@@ -204,8 +208,6 @@ int HandleServiceCliCommand(const std::string& command) {
destination.SetTurnMode(source.GetTurnMode()) == 0 &&
destination.SetVideoEncodeFormat(source.GetVideoEncodeFormat()) ==
0 &&
destination.SetHardwareVideoCodec(source.IsHardwareVideoCodec()) ==
0 &&
destination.SetScreenCaptureMethod(
crossdesk::ScreenCaptureMethod::Auto) == 0 &&
destination.SetPrivacyScreen(false) == 0 &&
@@ -403,6 +405,16 @@ static int RunApplication(
}
int main(int argc, char* argv[]) {
#ifdef _WIN32
// Apply in every process, including the daemon and --child. Windows does
// not inherit shutdown priority from a parent process.
const DWORD shutdown_error = crossdesk::ConfigureWindowsShutdownOrder(
crossdesk::WindowsShutdownRole::host);
if (shutdown_error != ERROR_SUCCESS) {
std::cerr << "Could not configure Windows host shutdown order, error="
<< shutdown_error << '\n';
}
#endif
#ifdef __linux__
// Run before GUI, configuration, logging or display initialization.
if (argc == 2 &&
@@ -472,6 +484,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;
@@ -41,6 +41,8 @@ int ConfigCenter::Load() {
}
bool persist_config_migration = false;
// Hardware codecs are always preferred, with software fallback in MiniRTC.
persist_config_migration |= ini_->Delete(section_, "hardware_video_codec");
// These options now belong only to the active remote connection. Discard
// legacy local defaults so they cannot affect this host or new connections.
persist_config_migration |= ini_->Delete(section_, "video_quality");
@@ -78,8 +80,6 @@ int ConfigCenter::Load() {
ini_->GetLongValue(section_, "video_encode_format",
static_cast<long>(video_encode_format_)));
hardware_video_codec_ = ini_->GetBoolValue(section_, "hardware_video_codec",
hardware_video_codec_);
const char* turn_mode_value = ini_->GetValue(section_, "turn_mode", nullptr);
if (turn_mode_value != nullptr && strlen(turn_mode_value) > 0) {
const long parsed_turn_mode = ini_->GetLongValue(
@@ -205,7 +205,6 @@ int ConfigCenter::Save() {
static_cast<long>(screen_capture_method_));
ini_->SetLongValue(section_, "video_encode_format",
static_cast<long>(video_encode_format_));
ini_->SetBoolValue(section_, "hardware_video_codec", hardware_video_codec_);
ini_->SetLongValue(section_, "turn_mode", static_cast<long>(turn_mode_));
ini_->SetBoolValue(section_, "enable_turn",
turn_mode_ != TURN_MODE::DISABLED);
@@ -271,13 +270,6 @@ int ConfigCenter::SetVideoEncodeFormat(VIDEO_ENCODE_FORMAT format) {
return 0;
}
int ConfigCenter::SetHardwareVideoCodec(bool enabled) {
if (enabled && !IsHardwareVideoCodecAvailable()) return -1;
if (StoreValues({{"hardware_video_codec", enabled ? "true" : "false"}}) != 0) return -1;
hardware_video_codec_ = enabled;
return 0;
}
int ConfigCenter::SetTurnMode(TURN_MODE mode) {
if (!IsValidTurnModeValue(static_cast<long>(mode))) return -1;
if (StoreValues({{"turn_mode", std::to_string(static_cast<int>(mode))},
@@ -425,20 +417,6 @@ ConfigCenter::VIDEO_ENCODE_FORMAT ConfigCenter::GetVideoEncodeFormat() const {
return video_encode_format_;
}
bool ConfigCenter::IsHardwareVideoCodecAvailable() {
#if (((defined(_WIN32) || defined(__linux__)) && !defined(__aarch64__) && \
!defined(__arm__) && USE_CUDA) || \
defined(__APPLE__))
return true;
#else
return false;
#endif
}
bool ConfigCenter::IsHardwareVideoCodec() const {
return IsHardwareVideoCodecAvailable() && hardware_video_codec_;
}
ConfigCenter::TURN_MODE ConfigCenter::GetTurnMode() const {
return turn_mode_;
}
@@ -37,7 +37,6 @@ class ConfigCenter {
int SetLanguage(LANGUAGE language);
int SetScreenCaptureMethod(ScreenCaptureMethod method);
int SetVideoEncodeFormat(VIDEO_ENCODE_FORMAT video_encode_format);
int SetHardwareVideoCodec(bool hardware_video_codec);
int SetTurnMode(TURN_MODE turn_mode);
int SetTurn(bool enable_turn, bool force_relay);
int SetServerHost(const std::string& signal_server_host);
@@ -57,9 +56,6 @@ class ConfigCenter {
LANGUAGE GetLanguage() const;
ScreenCaptureMethod GetScreenCaptureMethod() const;
VIDEO_ENCODE_FORMAT GetVideoEncodeFormat() const;
// Whether this build includes a hardware video codec backend.
static bool IsHardwareVideoCodecAvailable();
bool IsHardwareVideoCodec() const;
TURN_MODE GetTurnMode() const;
bool IsEnableTurn() const;
bool IsForceRelay() const;
@@ -92,7 +88,6 @@ class ConfigCenter {
LANGUAGE language_ = LANGUAGE::CHINESE;
ScreenCaptureMethod screen_capture_method_ = ScreenCaptureMethod::Auto;
VIDEO_ENCODE_FORMAT video_encode_format_ = VIDEO_ENCODE_FORMAT::H264;
bool hardware_video_codec_ = false;
TURN_MODE turn_mode_ = TURN_MODE::AUTO_UDP_TCP;
std::string signal_server_host_ = "";
std::string signal_server_host_default_ = "api.crossdesk.cn";
@@ -32,6 +32,9 @@ class MouseController : public DeviceController {
virtual int Destroy() = 0;
virtual int SendMouseCommand(RemoteAction remote_action,
int display_index) = 0;
// Bits 0/1/2 are left/right/middle. Return the subset confirmed released,
// including buttons already up. Must not require valid display geometry.
virtual unsigned ReleasePressedButtons(unsigned mask = 7) = 0;
virtual void UpdateDisplayInfoList(
const std::vector<DisplayInfo>&) {}
};
@@ -32,7 +32,7 @@ struct InteractionState {
bool audio_capture_ = false;
int screen_width_ = 1280;
int screen_height_ = 720;
int selected_display_ = 0;
std::atomic<int> selected_display_{0};
char remote_id_display_[12] = "";
unsigned char audio_buffer_[720]{};
int audio_len_ = 0;
@@ -77,7 +77,7 @@ struct UiState {
bool show_connection_status_window_ = false;
bool fullscreen_button_pressed_ = false;
bool is_client_mode_ = false;
bool is_server_mode_ = false;
std::atomic<bool> is_server_mode_{false};
bool reload_recent_connections_ = true;
bool show_offline_warning_window_ = false;
std::string offline_warning_text_;
@@ -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"
@@ -54,6 +56,7 @@
#include <windows.h>
#include "platform/windows/gui/slint_backend.h"
#include "platform/windows/gui/shutdown_session_guard.h"
#include "platform/windows/gui/tray/win_tray.h"
#include "platform/windows/gui/window_icons.h"
#elif defined(__APPLE__)
@@ -86,6 +89,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 {
@@ -460,6 +466,10 @@ std::string ConnectionStatusText(ConnectionStatus status, int language) {
}
}
std::string FormatResolution(int width, int height) {
return std::to_string(width) + " × " + std::to_string(height);
}
std::string FormatBitrate(uint32_t bits_per_second) {
char text[48];
if (bits_per_second < 1000) {
@@ -467,7 +477,7 @@ std::string FormatBitrate(uint32_t bits_per_second) {
} else if (bits_per_second < 1000000) {
std::snprintf(text, sizeof(text), "%u kbps", bits_per_second / 1000);
} else {
std::snprintf(text, sizeof(text), "%.1f mbps", bits_per_second / 1000000.0);
std::snprintf(text, sizeof(text), "%.1f Mbps", bits_per_second / 1000000.0);
}
return text;
}
@@ -918,6 +928,10 @@ void DragWindow(WindowHandle& component, int phase, float mouse_x,
} // namespace
struct GuiApplication::SlintUi {
#if _WIN32
std::unique_ptr<ShutdownSessionGuard> shutdown_guard;
std::wstring_view shutdown_reason;
#endif
gallery::Workspace gallery;
std::filesystem::path gallery_path;
std::vector<std::string> gallery_ids;
@@ -1110,7 +1124,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 +1158,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);
@@ -1178,7 +1205,7 @@ void GuiApplication::InitializeSettings() {
bool GuiApplication::InitializeSDL() {
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
ConfigureLinuxCudaGraphics(config_center_->IsHardwareVideoCodec());
ConfigureLinuxCudaGraphics();
// Standard Wayland top-level windows cannot choose an absolute screen
// position. Use the session's XWayland compatibility server for the GUI so
// auxiliary windows such as the controlled-side status window can be
@@ -1198,6 +1225,9 @@ bool GuiApplication::InitializeSDL() {
"windows while retaining Wayland portal capture and input");
}
#endif
// SDL disables the screensaver by default. A background remote desktop app
// should leave automatic display sleep under the user's system settings.
SDL_SetHint(SDL_HINT_VIDEO_ALLOW_SCREENSAVER, "1");
if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO)) {
LOG_ERROR("SDL initialization failed: {}", SDL_GetError());
return false;
@@ -1266,6 +1296,10 @@ void GuiApplication::InitializeUi() {
slint::set_xdg_app_id("crossdesk");
#endif
ui_ = std::make_unique<SlintUi>();
#if _WIN32
ui_->shutdown_guard =
std::make_unique<ShutdownSessionGuard>(windows_rdp_handoff_connected_);
#endif
ui_->sponsorship_prompt_pending =
config_center_->GetSponsorshipPromptDismissedVersion() != CROSSDESK_VERSION;
ui_->video_presenter =
@@ -1450,11 +1484,6 @@ void GuiApplication::ResetSettingsUi(bool gallery_window) {
auto& main = ui_->Window(gallery_window);
main->set_language_index(language_button_value_);
main->set_codec_index(video_encode_format_button_value_);
const bool hardware_codec_available =
ConfigCenter::IsHardwareVideoCodecAvailable();
main->set_hardware_codec_available(hardware_codec_available);
main->set_hardware_codec_enabled(hardware_codec_available &&
enable_hardware_video_codec_);
main->set_turn_enabled(enable_turn_);
main->set_force_relay_enabled(config_center_->IsForceRelay());
main->set_self_hosted_enabled(enable_self_hosted_);
@@ -1735,11 +1764,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");
@@ -2207,6 +2269,11 @@ void GuiApplication::Tick() {
slint::quit_event_loop();
return;
}
#if _WIN32
if (ui_->shutdown_guard) {
ui_->shutdown_guard->Update(ui_->shutdown_reason);
}
#endif
PollUpdateCheck();
HandleSessionCleanup();
HandlePasswordChangeResult();
@@ -2326,6 +2393,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() ||
@@ -2734,6 +2831,15 @@ void GuiApplication::UpdateLocalization() {
if (!ui_ || ui_->localized_language == language) {
return;
}
#if _WIN32
// All views refer to static literals; selecting the text needs no allocation
// and only runs when the language changes. Tick still checks connection state.
ui_->shutdown_reason = language == 0
? L"远程控制连接中,保留连接以确认或取消关机。"
: language == 2
? L"Удалённый сеанс активен. Соединение сохранено для подтверждения или отмены выключения."
: L"Remote session active. Keeping the connection available to confirm or cancel shutdown.";
#endif
ui_localization::ApplyMainWindowStrings(ui_->main, language);
ui_localization::ApplyStreamWindowStrings(ui_->main, language);
ui_localization::ApplyMainWindowStrings(ui_->wall, language);
@@ -3537,6 +3643,13 @@ void GuiApplication::SyncStreamWindow() {
(*ui_->stream)->set_selected_display(props->selected_display_);
const auto net = props->net_traffic_stats_.Load();
const auto codecs = props->net_traffic_stats_.LoadVideoCodecs();
(*ui_->stream)->set_stats_encoder(UiText(
status == ConnectionStatus::Connected && !codecs.remote_encoder.empty()
? codecs.remote_encoder : "—"));
(*ui_->stream)->set_stats_decoder(UiText(
status == ConnectionStatus::Connected && !codecs.local_decoder.empty()
? codecs.local_decoder : "—"));
(*ui_->stream)->set_stats_encryption_enabled(
status == ConnectionStatus::Connected && net.srtp_active);
std::vector<ui::NetworkStatsRow> stats_rows;
@@ -3560,8 +3673,8 @@ void GuiApplication::SyncStreamWindow() {
net.total_inbound_stats, net.total_outbound_stats);
ui::UpdateModelRows(*ui_->stats_model, std::move(stats_rows));
(*ui_->stream)
->set_stats_resolution(UiText(std::to_string(props->video_width_) + "x" +
std::to_string(props->video_height_)));
->set_stats_resolution(UiText(FormatResolution(props->video_width_,
props->video_height_)));
(*ui_->stream)
->set_stats_connection_mode(
UiText(props->traversal_mode_ == TraversalMode::P2P
@@ -3649,8 +3762,7 @@ void GuiApplication::SyncStreamVideoFrame() {
const auto result = ui_->video_presenter->Present(frame);
if (result.width > 0 && result.height > 0) {
(*ui_->stream)
->set_stats_resolution(UiText(std::to_string(result.width) + "x" +
std::to_string(result.height)));
->set_stats_resolution(UiText(FormatResolution(result.width, result.height)));
}
if (result.new_frame) {
++props->frame_count_;
@@ -4189,8 +4301,6 @@ bool GuiApplication::SaveSettingsFromUi(bool gallery_window) {
language_button_value_ =
localization::detail::ClampLanguageIndex(main->get_language_index());
video_encode_format_button_value_ = std::clamp(main->get_codec_index(), 0, 1);
enable_hardware_video_codec_ = ConfigCenter::IsHardwareVideoCodecAvailable() &&
main->get_hardware_codec_enabled();
enable_turn_ = main->get_turn_enabled();
enable_self_hosted_ = main->get_self_hosted_enabled();
#if !defined(__OHOS__)
@@ -4207,7 +4317,6 @@ bool GuiApplication::SaveSettingsFromUi(bool gallery_window) {
config_center_->SetVideoEncodeFormat(
static_cast<ConfigCenter::VIDEO_ENCODE_FORMAT>(
video_encode_format_button_value_));
config_center_->SetHardwareVideoCodec(enable_hardware_video_codec_);
config_center_->SetTurn(enable_turn_, main->get_force_relay_enabled());
main->set_force_relay_enabled(config_center_->IsForceRelay());
#if !defined(__OHOS__)
@@ -4274,7 +4383,6 @@ void GuiApplication::ApplySettingsFromConfig(bool reconnect) {
localization_language_index_ = localization::detail::ClampLanguageIndex(language_button_value_);
localization_language_ = config_center_->GetLanguage();
video_encode_format_button_value_ = static_cast<int>(config_center_->GetVideoEncodeFormat());
enable_hardware_video_codec_ = config_center_->IsHardwareVideoCodec();
enable_turn_ = config_center_->IsEnableTurn();
enable_self_hosted_ = config_center_->IsSelfHosted();
enable_autostart_ = config_center_->IsEnableAutostart();
@@ -4289,7 +4397,6 @@ void GuiApplication::ApplySettingsFromConfig(bool reconnect) {
std::snprintf(signal_server_port_self_, sizeof(signal_server_port_self_), "%s", port_text.c_str());
language_button_value_last_ = language_button_value_;
video_encode_format_button_value_last_ = video_encode_format_button_value_;
enable_hardware_video_codec_last_ = enable_hardware_video_codec_;
enable_turn_last_ = enable_turn_;
enable_self_hosted_last_ = enable_self_hosted_;
enable_autostart_last_ = enable_autostart_;
@@ -4308,17 +4415,14 @@ void GuiApplication::ApplySettingsFromConfig(bool reconnect) {
// configuration. Keep the application and its console running throughout.
CloseAllRemoteSessions();
} else {
const bool hardware = config_center_->IsHardwareVideoCodec();
const bool av1 = config_center_->GetVideoEncodeFormat() ==
ConfigCenter::VIDEO_ENCODE_FORMAT::AV1;
const auto turn = static_cast<TurnMode>(config_center_->GetTurnMode());
if (hardware != params_.hardware_acceleration || av1 != params_.av1_encoding ||
turn != params_.turn_mode) {
if (av1 != params_.av1_encoding || turn != params_.turn_mode) {
// Update the listening peer for incoming connections and the template
// copied by ConnectTo for outgoing connections. Existing sessions retain
// the codec/ICE snapshot with which they negotiated.
if (!peer_ || UpdateConnectionSettings(peer_, hardware, av1, turn) == 0) {
params_.hardware_acceleration = hardware;
if (!peer_ || UpdateConnectionSettings(peer_, true, av1, turn) == 0) {
params_.av1_encoding = av1;
params_.turn_mode = turn;
} else {
@@ -4382,15 +4486,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 +4511,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();
}
@@ -4702,6 +4816,7 @@ void GuiApplication::ReleasePressedInput() {
SendRemoteInput(action);
}
ui_->pressed_mouse_buttons.clear();
if (ui_->stream) (*ui_->stream)->invoke_reset_input_state();
}
void GuiApplication::ReleaseControl() {
@@ -5634,9 +5749,12 @@ void GuiApplication::SyncGallery() {
}
const auto video = props->received_video_stats_.Get(now);
device.resolution = video.width > 0 && video.height > 0
? UiText(std::to_string(video.width) + " × " + std::to_string(video.height))
? UiText(FormatResolution(video.width, video.height))
: UiText("—");
device.fps = video.fps ? UiText(std::to_string(*video.fps) + " FPS") : UiText("—");
const auto codecs = props->net_traffic_stats_.LoadVideoCodecs();
device.encoder = UiText(codecs.remote_encoder.empty() ? "—" : codecs.remote_encoder);
device.decoder = UiText(codecs.local_decoder.empty() ? "—" : codecs.local_decoder);
const bool has_report = props->net_traffic_stats_.HasRecentReport(now);
const auto stats = props->net_traffic_stats_.Load();
// Use negotiated transport state, never the local encryption preference.
@@ -6087,6 +6205,7 @@ void GuiApplication::SyncGallery() {
}
void GuiApplication::Cleanup() {
remote_input_.Stop();
#if _WIN32
windows_rdp_handoff_allowed_->store(false, std::memory_order_relaxed);
windows_rdp_handoff_connected_->store(false, std::memory_order_relaxed);
@@ -6100,6 +6219,9 @@ void GuiApplication::Cleanup() {
return;
}
ui_->file_dialog_lifetime.reset();
#if _WIN32
ui_->shutdown_guard.reset();
#endif
#if defined(__APPLE__)
CancelNativeFileDialog();
#endif
@@ -6153,6 +6275,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_;
@@ -78,13 +78,11 @@ struct TranslationRow {
X(console_settings_next_file, u8"已保存,将用于后续接收的文件。", "Saved; applies to subsequently received files.", u8"Сохранено. Будет применено к следующим получаемым файлам.") \
X(console_settings_autostart, u8"已保存,在下次图形桌面登录时生效。", "Saved; applies at the next graphical desktop login.", u8"Сохранено. Будет применено при следующем входе в графический сеанс.") \
X(console_settings_daemon, u8"已保存,普通模式重启后生效;无头控制台仍保持前台运行。", "Saved for the next normal-mode start; the headless console stays in the foreground.", u8"Сохранено для следующего запуска в обычном режиме. Консоль остаётся на переднем плане.") \
X(console_settings_hardware_unavailable, u8"当前构建不支持硬件编解码,未保存。", "Hardware codecs are unavailable in this build; not saved.", u8"Аппаратные кодеки недоступны в этой сборке. Не сохранено.") \
X(console_settings_server_required, u8"请先设置有效的服务器地址和端口,再启用自托管。", "Set a valid server host and port before enabling self-hosting.", u8"Укажите адрес и порт сервера перед включением собственного сервера.") \
X(console_settings_path_invalid, u8"请选择已存在且可写的绝对目录路径;default 使用默认目录。", "Use an existing writable absolute directory, or default for the default location.", u8"Укажите существующий доступный для записи абсолютный путь к каталогу или default.") \
X(console_settings_readonly, u8"该选项由系统或控制端管理,不可在此修改。", "This option is managed by the system or controller and is read-only here.", u8"Этот параметр управляется системой или клиентом и здесь недоступен для изменения.") \
X(console_setting_language, u8"语言", "Language", u8"Язык") \
X(console_setting_codec, u8"视频编码", "Video codec", u8"Видеокодек") \
X(console_setting_hardware, u8"硬件编解码", "Hardware codecs", u8"Аппаратные кодеки") \
X(console_setting_turn, u8"中继模式", "TURN relay mode", u8"Режим ретрансляции TURN") \
X(console_setting_self_hosted, u8"自托管", "Self-hosted server", u8"Собственный сервер") \
X(console_setting_host, u8"服务器地址", "Server host", u8"Адрес сервера") \
@@ -117,6 +115,11 @@ struct TranslationRow {
X(recent_connections, u8"近期连接", "Recent Connections", \
u8"Недавние подключения") \
X(disconnect, u8"断开连接", "Disconnect", u8"Отключить") \
X(stream_confirm_disconnect, u8"断开当前连接?", "Disconnect this session?", \
u8"Отключить текущий сеанс?") \
X(stream_disconnect_description, u8"当前远程控制会话将结束。", \
"The current remote session will end.", \
u8"Текущий сеанс удалённого управления будет завершён.") \
X(select_display, u8"选择显示器", "Select Display", u8"Выбрать дисплей") \
X(display_screen, u8"显示屏", "Display", u8"Экран") \
X(expand_control_bar, u8"展开控制栏", "Expand Control Bar", \
@@ -234,8 +237,6 @@ struct TranslationRow {
"Video Encode Format:", u8"Формат кодека видео:") \
X(av1, u8"AV1", "AV1", "AV1") \
X(h264, u8"H.264", "H.264", "H.264") \
X(enable_hardware_video_codec, u8"启用硬件编解码器:", \
"Enable Hardware Video Codec:", u8"Использовать аппаратный кодек:") \
X(enable_turn, u8"启用中继服务:", \
"Enable TURN Service:", u8"Включить TURN-сервис:") \
X(force_relay, u8"强制中继连接:", "Force Relay:", u8"Только через реле:") \
@@ -317,6 +318,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", \
@@ -369,6 +377,8 @@ struct TranslationRow {
X(connection_mode_direct, u8"直连", "Direct", u8"Прямой") \
X(connection_mode_relay, u8"中继", "Relay", u8"Релейный") \
X(transport_encryption, u8"加密传输", "Encryption", u8"Шифрование") \
X(remote_video_encoder, u8"被控端编码器", "Remote encoder", u8"Кодер удалённой стороны") \
X(local_video_decoder, u8"控制端解码器", "Local decoder", u8"Локальный декодер") \
X(transport_encryption_enabled, u8"已开启", "Enabled", u8"Включено") \
X(transport_encryption_disabled, u8"未开启", "Disabled", u8"Выключено") \
X(online, u8"在线", "Online", u8"Онлайн") \
@@ -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_
@@ -0,0 +1,245 @@
#include "features/devices/screen_sharing_power.h"
#include "rd_log.h"
#ifdef _WIN32
#include <windows.h>
#elif defined(__APPLE__)
#include <IOKit/pwr_mgt/IOPMLib.h>
#elif defined(__linux__) && !defined(__OHOS__)
#include <dbus/dbus.h>
#include <unistd.h>
#else
#include <SDL3/SDL.h>
#endif
namespace crossdesk {
#if defined(__linux__) && !defined(__OHOS__)
namespace {
constexpr const char* kApplication = "CrossDesk";
constexpr const char* kReason = "Sharing the desktop with a remote controller";
constexpr int kDbusTimeoutMs = 1000;
struct ConnectionCloser {
void operator()(DBusConnection* connection) const {
dbus_connection_close(connection);
dbus_connection_unref(connection);
}
};
using BusConnection = std::unique_ptr<DBusConnection, ConnectionCloser>;
using BusMessage = std::unique_ptr<DBusMessage, decltype(&dbus_message_unref)>;
BusConnection OpenBus(DBusBusType type) {
DBusError error;
dbus_error_init(&error);
BusConnection connection(dbus_bus_get_private(type, &error));
if (connection) {
dbus_connection_set_exit_on_disconnect(connection.get(), false);
} else {
LOG_WARN("Cannot connect to power management bus: {}",
error.message ? error.message : "unknown error");
}
dbus_error_free(&error);
return connection;
}
BusMessage Call(DBusConnection* connection, DBusMessage* message) {
return BusMessage(dbus_connection_send_with_reply_and_block(
connection, message, kDbusTimeoutMs, nullptr),
dbus_message_unref);
}
} // namespace
#endif
struct ScreenSharingPower::Impl {
#ifdef _WIN32
HANDLE request = INVALID_HANDLE_VALUE;
bool display = false;
bool system = false;
void Start() {
wchar_t reason_text[] = L"CrossDesk is sharing the desktop";
REASON_CONTEXT reason{};
reason.Version = POWER_REQUEST_CONTEXT_VERSION;
reason.Flags = POWER_REQUEST_CONTEXT_SIMPLE_STRING;
reason.Reason.SimpleReasonString = reason_text;
request = PowerCreateRequest(&reason);
if (request == INVALID_HANDLE_VALUE) {
LOG_WARN("Cannot create screen sharing power request: {}", GetLastError());
return;
}
// Handle-based requests also work in the unattended host, which has no
// SDL video subsystem, and do not depend on the calling thread's lifetime.
system = PowerSetRequest(request, PowerRequestSystemRequired) != 0;
if (!system)
LOG_WARN("Cannot prevent system idle sleep: {}", GetLastError());
display = PowerSetRequest(request, PowerRequestDisplayRequired) != 0;
if (!display)
LOG_WARN("Cannot prevent display idle sleep: {}", GetLastError());
}
void Stop() {
if (request == INVALID_HANDLE_VALUE) return;
if (display) PowerClearRequest(request, PowerRequestDisplayRequired);
if (system) PowerClearRequest(request, PowerRequestSystemRequired);
CloseHandle(request);
request = INVALID_HANDLE_VALUE;
display = system = false;
}
#elif defined(__APPLE__)
IOPMAssertionID display = kIOPMNullAssertionID;
IOPMAssertionID system = kIOPMNullAssertionID;
void Start() {
const auto reason = CFSTR("CrossDesk is sharing the desktop");
auto result = IOPMAssertionCreateWithName(
kIOPMAssertionTypePreventUserIdleSystemSleep, kIOPMAssertionLevelOn,
reason, &system);
if (result != kIOReturnSuccess)
LOG_WARN("Cannot prevent system idle sleep: {}", result);
result = IOPMAssertionCreateWithName(
kIOPMAssertionTypePreventUserIdleDisplaySleep, kIOPMAssertionLevelOn,
reason, &display);
if (result != kIOReturnSuccess)
LOG_WARN("Cannot prevent display idle sleep: {}", result);
}
void Stop() {
if (display != kIOPMNullAssertionID) IOPMAssertionRelease(display);
if (system != kIOPMNullAssertionID) IOPMAssertionRelease(system);
display = system = kIOPMNullAssertionID;
}
#elif defined(__linux__) && !defined(__OHOS__)
BusConnection session;
dbus_uint32_t cookie = 0;
bool desktop_inhibited = false;
bool gnome = false;
int sleep_fd = -1;
bool InhibitDesktop(bool use_gnome) {
const char* service = use_gnome ? "org.gnome.SessionManager"
: "org.freedesktop.ScreenSaver";
const char* path = use_gnome ? "/org/gnome/SessionManager"
: "/org/freedesktop/ScreenSaver";
BusMessage message(dbus_message_new_method_call(service, path, service,
"Inhibit"),
dbus_message_unref);
if (!message) return false;
dbus_bool_t appended;
if (use_gnome) {
const dbus_uint32_t window = 0;
const dbus_uint32_t flags = 4 | 8; // Suspend and session idle.
appended = dbus_message_append_args(
message.get(), DBUS_TYPE_STRING, &kApplication, DBUS_TYPE_UINT32,
&window, DBUS_TYPE_STRING, &kReason, DBUS_TYPE_UINT32, &flags,
DBUS_TYPE_INVALID);
} else {
appended = dbus_message_append_args(
message.get(), DBUS_TYPE_STRING, &kApplication, DBUS_TYPE_STRING,
&kReason, DBUS_TYPE_INVALID);
}
if (!appended) return false;
auto reply = Call(session.get(), message.get());
if (!reply || !dbus_message_get_args(reply.get(), nullptr, DBUS_TYPE_UINT32,
&cookie, DBUS_TYPE_INVALID))
return false;
gnome = use_gnome;
desktop_inhibited = true;
return true;
}
void Start() {
if (!dbus_threads_init_default()) {
LOG_WARN("Cannot initialize D-Bus for screen sharing power management");
return;
}
// These session APIs work without an SDL window, including native Wayland
// and a Slint window hidden in the tray. GNOME's API inhibits both kinds of
// idle sleep; other desktops use ScreenSaver plus logind below.
session = OpenBus(DBUS_BUS_SESSION);
if (session && InhibitDesktop(true)) return;
if (!session || !InhibitDesktop(false))
LOG_WARN("Cannot inhibit desktop idle while sharing the screen");
auto system_bus = OpenBus(DBUS_BUS_SYSTEM);
if (!system_bus) return;
BusMessage message(dbus_message_new_method_call(
"org.freedesktop.login1", "/org/freedesktop/login1",
"org.freedesktop.login1.Manager", "Inhibit"),
dbus_message_unref);
const char* what = "sleep:idle";
const char* mode = "block";
if (!message || !dbus_message_append_args(
message.get(), DBUS_TYPE_STRING, &what,
DBUS_TYPE_STRING, &kApplication, DBUS_TYPE_STRING,
&kReason, DBUS_TYPE_STRING, &mode, DBUS_TYPE_INVALID)) {
LOG_WARN("Cannot create system sleep inhibition request");
return;
}
auto reply = Call(system_bus.get(), message.get());
// get_args duplicates the returned fd with CLOEXEC. Holding this fd keeps
// the inhibitor alive independently of the reply or D-Bus connection.
if (!reply || !dbus_message_get_args(reply.get(), nullptr, DBUS_TYPE_UNIX_FD,
&sleep_fd, DBUS_TYPE_INVALID))
LOG_WARN("Cannot inhibit system sleep while sharing the screen");
}
void Stop() {
if (sleep_fd >= 0) {
close(sleep_fd);
sleep_fd = -1;
}
if (session && desktop_inhibited &&
dbus_connection_get_is_connected(session.get())) {
const char* service = gnome ? "org.gnome.SessionManager"
: "org.freedesktop.ScreenSaver";
const char* path = gnome ? "/org/gnome/SessionManager"
: "/org/freedesktop/ScreenSaver";
BusMessage message(dbus_message_new_method_call(
service, path, service,
gnome ? "Uninhibit" : "UnInhibit"),
dbus_message_unref);
if (message && dbus_message_append_args(
message.get(), DBUS_TYPE_UINT32, &cookie,
DBUS_TYPE_INVALID)) {
dbus_message_set_no_reply(message.get(), true);
dbus_connection_send(session.get(), message.get(), nullptr);
dbus_connection_flush(session.get());
}
}
// A private connection also revokes the desktop inhibitor if UnInhibit
// could not be sent. Nothing is retained by unrelated D-Bus clients.
session.reset();
desktop_inhibited = false;
gnome = false;
cookie = 0;
}
#else
void Start() {
if (!SDL_DisableScreenSaver())
LOG_WARN("Cannot prevent display idle sleep: {}", SDL_GetError());
}
void Stop() { SDL_EnableScreenSaver(); }
#endif
};
ScreenSharingPower::ScreenSharingPower() : impl_(std::make_unique<Impl>()) {}
ScreenSharingPower::~ScreenSharingPower() { SetSharing(false); }
void ScreenSharingPower::SetSharing(bool sharing) {
if (sharing_ == sharing) return;
sharing_ = sharing;
if (sharing) {
impl_->Start();
LOG_INFO("Requested display and system idle-sleep inhibition for screen sharing");
} else {
impl_->Stop();
LOG_INFO("Released screen sharing power inhibitors");
}
}
} // namespace crossdesk
@@ -0,0 +1,33 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-24
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _SCREEN_SHARING_POWER_H_
#define _SCREEN_SHARING_POWER_H_
#include <memory>
namespace crossdesk {
// Owns temporary display/system idle-sleep inhibitors for the shared capture
// source. Call on the UI thread; repeated updates do not acquire extra requests.
class ScreenSharingPower {
public:
ScreenSharingPower();
~ScreenSharingPower();
ScreenSharingPower(const ScreenSharingPower&) = delete;
ScreenSharingPower& operator=(const ScreenSharingPower&) = delete;
void SetSharing(bool sharing);
private:
struct Impl;
std::unique_ptr<Impl> impl_;
bool sharing_ = false;
};
} // namespace crossdesk
#endif
@@ -21,7 +21,6 @@
#ifdef _WIN32
#include <nlohmann/json.hpp>
#include "platform/windows/screen_capturer/screen_capturer_win.h"
#include "platform/windows/input/mouse/mouse_controller.h"
#include "service_host.h"
#endif
@@ -299,6 +298,21 @@ bool SessionDeviceManager::ShouldCaptureCursor() const {
});
}
bool SessionDeviceManager::HasConnectedController() const {
std::shared_lock lock(owner_.connection_status_mutex_);
return std::any_of(owner_.connection_status_.begin(),
owner_.connection_status_.end(), [](const auto& entry) {
return entry.second == ConnectionStatus::Connected;
});
}
void SessionDeviceManager::UpdateScreenSharingPower() {
// Wayland keeps capture warm after the last disconnect. Only live viewers
// justify keeping the host awake; an outgoing viewing session does not.
screen_sharing_power_.SetSharing(owner_.screen_capturer_is_started_ &&
HasConnectedController());
}
int SessionDeviceManager::StartScreenCapturer() {
#ifdef __APPLE__
if (!owner_.EnsureMacScreenRecordingPermission()) {
@@ -318,8 +332,10 @@ int SessionDeviceManager::StartScreenCapturer() {
cursor_capture_dirty_.store(false, std::memory_order_relaxed);
const bool show_cursor = ShouldCaptureCursor();
LOG_INFO("Start screen capturer, show cursor: {}", show_cursor);
screen_sharing_power_.SetSharing(HasConnectedController());
const int ret = screen_capturer_->Start(show_cursor);
if (ret != 0) {
screen_sharing_power_.SetSharing(false);
LOG_ERROR("Start screen capturer failed: {}", ret);
}
return ret;
@@ -330,29 +346,49 @@ int SessionDeviceManager::StopScreenCapturer() {
LOG_INFO("Stop screen capturer");
screen_capturer_->Stop();
}
owner_.screen_capturer_is_started_ = false;
screen_sharing_power_.SetSharing(false);
return 0;
}
bool SessionDeviceManager::ReleaseRemoteMouseButtons() {
return remote_mouse_buttons_.ReleaseAll(
[this](unsigned mask) { return ReleaseMouseButtonMask(mask); });
}
bool SessionDeviceManager::ReleaseRemoteMouseButtons(const std::string& remote_id,
unsigned mask) {
return remote_mouse_buttons_.ReleaseController(
remote_id, [this](unsigned buttons) { return ReleaseMouseButtonMask(buttons); }, mask);
}
unsigned SessionDeviceManager::ReleaseMouseButtonMask(unsigned mask) {
unsigned released = mask;
{
std::lock_guard lock(mouse_controller_mutex_);
if (mouse_controller_) released &= mouse_controller_->ReleasePressedButtons(mask);
}
#ifdef _WIN32
void SessionDeviceManager::ReleaseRemoteMouseButtons() {
if (auto mouse = dynamic_cast<PlatformMouseController*>(mouse_controller_))
mouse->ReleasePressedButtons();
std::lock_guard lock(service_mouse_mutex_);
for (int button = 0; button < 3; ++button) {
const unsigned bit = 1u << button;
if (!(service_mouse_buttons_ & bit)) continue;
if (!(service_mouse_buttons_ & mask & bit)) continue;
const auto response = SendCrossDeskSecureDesktopMouseInput(
service_mouse_x_, service_mouse_y_, 0, (button + 1) * 2, 1000);
const auto json = nlohmann::json::parse(response, nullptr, false);
if (json.is_object() && json.value("ok", false)) {
service_mouse_buttons_ &= ~bit;
} else {
released &= ~bit;
LOG_WARN("Service mouse button release failed: button={}, response={}",
button, response);
}
}
#endif
return released;
}
#ifdef _WIN32
std::string SessionDeviceManager::SendServiceMouseCommand(int x, int y,
int wheel, int flag) {
std::lock_guard lock(service_mouse_mutex_);
@@ -398,7 +434,10 @@ bool SessionDeviceManager::SyncDisplayInfo() {
});
if (changed) display_info_list_ = displays;
}
if (changed && mouse_controller_) mouse_controller_->UpdateDisplayInfoList(displays);
if (changed) {
std::lock_guard lock(mouse_controller_mutex_);
if (mouse_controller_) mouse_controller_->UpdateDisplayInfoList(displays);
}
return true;
}
@@ -434,6 +473,7 @@ int SessionDeviceManager::StartMouseController() {
#endif
SyncDisplayInfo();
std::lock_guard lock(mouse_controller_mutex_);
mouse_controller_ =
static_cast<MouseController *>(device_controller_factory_->Create(
DeviceControllerFactory::Device::Mouse));
@@ -452,10 +492,16 @@ int SessionDeviceManager::StartMouseController() {
return init_ret;
}
int SessionDeviceManager::StopMouseController() {
#ifdef _WIN32
int SessionDeviceManager::StopMouseController(bool force) {
// Retain the native button state across transient cleanup failures. The UI
// can keep pumping while the input worker retries, including with no peers.
if (!force && owner_.remote_input_.HasPendingReleases()) return 1;
// A disconnect has revoked queued input. Finish its running injection and
// release callback before destroying the native controller.
owner_.remote_input_.WaitIdle();
if (!force && owner_.remote_input_.HasPendingReleases()) return 1;
ReleaseRemoteMouseButtons();
#endif
std::lock_guard lock(mouse_controller_mutex_);
if (mouse_controller_) {
mouse_controller_->Destroy();
delete mouse_controller_;
@@ -620,6 +666,8 @@ void SessionDeviceManager::PushAudio(const std::string& remote_id,
}
void SessionDeviceManager::UpdateInteractions() {
// Release even when pending remote-input cleanup makes this tick return early.
UpdateScreenSharingPower();
#ifdef _WIN32
if (owner_.screen_capturer_is_started_ &&
cursor_capture_dirty_.load(std::memory_order_relaxed)) {
@@ -636,9 +684,9 @@ void SessionDeviceManager::UpdateInteractions() {
owner_.screen_capturer_is_started_ && !owner_.start_mouse_controller_ &&
owner_.mouse_controller_is_started_;
if (stop_wayland_mouse_before_screen) {
LOG_INFO("Stopping Wayland mouse controller before screen capturer to "
if (StopMouseController() != 0) return;
LOG_INFO("Stopped Wayland mouse controller before screen capturer to "
"cleanly release the shared portal session");
StopMouseController();
owner_.mouse_controller_is_started_ = false;
}
#endif
@@ -653,14 +701,15 @@ void SessionDeviceManager::UpdateInteractions() {
owner_.screen_capturer_is_started_ = false;
}
UpdateScreenSharingPower();
if (owner_.start_mouse_controller_ && !owner_.mouse_controller_is_started_) {
if (StartMouseController() == 0) {
owner_.mouse_controller_is_started_ = true;
}
} else if (!owner_.start_mouse_controller_ &&
owner_.mouse_controller_is_started_) {
StopMouseController();
owner_.mouse_controller_is_started_ = false;
if (StopMouseController() == 0) owner_.mouse_controller_is_started_ = false;
}
if (owner_.screen_capturer_is_started_ && mouse_controller_ && SyncDisplayInfo()) {
@@ -750,6 +799,7 @@ bool SessionDeviceManager::SendKeyboardCommand(int key_code, bool is_down,
bool SessionDeviceManager::SendMouseCommand(const RemoteAction &action,
int selected_display) {
std::lock_guard lock(mouse_controller_mutex_);
if (mouse_controller_) {
return mouse_controller_->SendMouseCommand(action, selected_display) == 0;
}
@@ -763,19 +813,20 @@ int SessionDeviceManager::SwitchDisplay(int display_id) {
return ret;
}
void SessionDeviceManager::ResetToInitialDisplay() {
InputOutcome SessionDeviceManager::ResetToInitialDisplay() {
if (!screen_capturer_) {
return;
return InputOutcome::ignored;
}
const int ret = screen_capturer_->ResetToInitialMonitor();
if (ret != 0) {
LOG_WARN("Reset to initial display failed, ret={}", ret);
return;
return InputOutcome::failed;
}
// Capture backends start at display 0. Reset the input mapping as well so
// the next connection cannot show that display while controlling another.
owner_.selected_display_ = 0;
SyncDisplayInfo();
return InputOutcome::applied;
}
std::vector<DisplayInfo> SessionDeviceManager::display_info_list() const {
@@ -795,8 +846,10 @@ std::vector<HostDisplay> SessionDeviceManager::host_display_list() const {
}
void SessionDeviceManager::DestroyDevices() {
owner_.screen_capturer_is_started_ = false;
screen_sharing_power_.SetSharing(false);
speaker_capture_.Shutdown();
StopMouseController();
StopMouseController(true);
if (screen_capturer_) {
screen_capturer_->Destroy();
delete screen_capturer_;
@@ -14,6 +14,7 @@
#include <mutex>
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>
#include <remote_action.h>
@@ -21,6 +22,8 @@
#include "device_controller.h"
#include "display_info.h"
#include "features/input/captured_keyboard_queue.h"
#include "features/input/remote_mouse_buttons.h"
#include "screen_sharing_power.h"
#include "speaker_capture_controller.h"
struct SDL_AudioStream;
@@ -53,8 +56,15 @@ public:
int InitializeScreenCapturer();
int StartScreenCapturer();
int StopScreenCapturer();
bool ReleaseRemoteMouseButtons();
bool ReleaseRemoteMouseButtons(const std::string& remote_id,
unsigned mask = 7);
template <typename Send>
InputOutcome ApplyRemoteMouse(const std::string& remote_id, MouseFlag flag,
Send send) {
return remote_mouse_buttons_.Apply(remote_id, flag, std::move(send));
}
#ifdef _WIN32
void ReleaseRemoteMouseButtons();
std::string SendServiceMouseCommand(int x, int y, int wheel, int flag);
#endif
// Requests are asynchronous; Stop drains/revokes audio delivery before
@@ -62,7 +72,9 @@ public:
void StartSpeakerCapturer();
void StopSpeakerCapturer();
int StartMouseController();
int StopMouseController();
// Returns 1 while background releases are pending. force is only used
// during final destruction after the input dispatcher has been stopped.
int StopMouseController(bool force = false);
int StartKeyboardCapturer();
int StopKeyboardCapturer();
@@ -79,7 +91,7 @@ public:
bool extended);
bool SendMouseCommand(const RemoteAction &action, int selected_display);
int SwitchDisplay(int display_id);
void ResetToInitialDisplay();
InputOutcome ResetToInitialDisplay();
// Snapshot of the capture displays. The UI thread refreshes the list every
// tick while transport threads build host information and map mouse input
@@ -89,10 +101,13 @@ public:
std::vector<HostDisplay> host_display_list() const;
private:
unsigned ReleaseMouseButtonMask(unsigned mask);
void DrainCapturedKeyboardInput();
bool ShouldSendCapturedFrame(std::chrono::steady_clock::time_point now,
int fps);
bool ShouldCaptureCursor() const;
bool HasConnectedController() const;
void UpdateScreenSharingPower();
bool SyncDisplayInfo();
void SetDisplayInfoList(std::vector<DisplayInfo> displays);
void RecordCaptureCadence(std::chrono::steady_clock::time_point now, int fps,
@@ -103,10 +118,14 @@ private:
std::unordered_map<std::string, SDL_AudioStream*> output_streams_;
ScreenCapturerFactory *screen_capturer_factory_ = nullptr;
ScreenCapturer *screen_capturer_ = nullptr;
ScreenSharingPower screen_sharing_power_;
std::atomic<bool> cursor_capture_dirty_{true};
SpeakerCaptureController speaker_capture_;
DeviceControllerFactory *device_controller_factory_ = nullptr;
MouseController *mouse_controller_ = nullptr;
RemoteMouseButtons remote_mouse_buttons_;
// Input is executed on a worker while the UI starts/stops this controller.
std::mutex mouse_controller_mutex_;
KeyboardCapturer *keyboard_capturer_ = nullptr;
// Guards display_info_list_ (UI thread writes, transport threads read).
mutable std::mutex display_info_mutex_;
@@ -0,0 +1,29 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-22
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _INPUT_OUTCOME_H_
#define _INPUT_OUTCOME_H_
namespace crossdesk {
// Applied means the native/service API accepted the operation. It does not
// assert that the foreground application has consumed the injected event.
enum class InputOutcome { applied, ignored, failed };
inline const char* InputOutcomeName(InputOutcome outcome) {
switch (outcome) {
case InputOutcome::applied:
return "applied";
case InputOutcome::ignored:
return "ignored";
case InputOutcome::failed:
return "failed";
}
return "failed";
}
} // namespace crossdesk
#endif
@@ -4,6 +4,7 @@
#include <SDL3/SDL.h>
#include <algorithm>
#include <cstdint>
#include <mutex>
#include <nlohmann/json.hpp>
@@ -249,14 +250,14 @@ bool KeyboardController::InjectRemoteKey(int key_code, bool is_down,
extended);
}
void KeyboardController::ApplyRemoteEvent(const std::string& remote_id,
InputOutcome KeyboardController::ApplyRemoteEvent(const std::string& remote_id,
const RemoteAction& action) {
std::lock_guard input_lock(remote_input_mutex_);
const int key_code = static_cast<int>(action.k.key_value);
const bool is_down = action.k.flag == KeyFlag::key_down;
const bool injected =
InjectRemoteKey(key_code, is_down, action.k.scan_code, action.k.extended);
if (injected) pending_key_releases_.erase(key_code);
if (injected) pending_key_releases_.Supersede(key_code);
auto& state = remote_states_[remote_id];
state.last_seen_tick = static_cast<uint32_t>(SDL_GetTicks());
@@ -266,24 +267,28 @@ void KeyboardController::ApplyRemoteEvent(const std::string& remote_id,
} else if (!is_down && injected) {
state.pressed_keys.erase(key_code);
}
return injected ? InputOutcome::applied : InputOutcome::failed;
}
void KeyboardController::ApplyRemoteText(const std::string& remote_id,
InputOutcome KeyboardController::ApplyRemoteText(const std::string& remote_id,
const std::string& text) {
std::lock_guard input_lock(remote_input_mutex_);
if (!owner_.IsAuthorizedController(remote_id) ||
!owner_.devices_.SupportsTextInput()) return;
if (!owner_.devices_.SendTextCommand(text))
if (!owner_.IsAuthorizedController(remote_id)) return InputOutcome::ignored;
if (!owner_.devices_.SupportsTextInput()) return InputOutcome::failed;
if (!owner_.devices_.SendTextCommand(text)) {
LOG_WARN("Failed to inject committed remote text");
return InputOutcome::failed;
}
return InputOutcome::applied;
}
void KeyboardController::ApplyRemoteState(const std::string& remote_id,
InputOutcome KeyboardController::ApplyRemoteState(const std::string& remote_id,
const RemoteAction& action) {
std::lock_guard input_lock(remote_input_mutex_);
auto& state = remote_states_[remote_id];
if (action.ks.seq != 0 && state.last_seq != 0 &&
static_cast<int32_t>(action.ks.seq - state.last_seq) <= 0) {
return;
return InputOutcome::ignored;
}
state.last_seq = action.ks.seq;
@@ -300,56 +305,60 @@ void KeyboardController::ApplyRemoteState(const std::string& remote_id,
}
const auto changes = ReconcileKeyboardState(state.pressed_keys, desired_keys);
bool can_press = true;
bool applied = true;
for (const auto& key : changes) {
if (key.is_down && !can_press) continue;
if (!InjectRemoteKey(key.key_code, key.is_down, key.scan_code, key.extended)) {
// Do not replay a shortcut with a stale/missing modifier. The next
// heartbeat will retry from the state that was actually injected.
can_press = false;
applied = false;
continue;
}
pending_key_releases_.erase(key.key_code);
pending_key_releases_.Supersede(key.key_code);
if (key.is_down)
state.pressed_keys[key.key_code] =
{key.key_code, key.scan_code, key.extended};
else
state.pressed_keys.erase(key.key_code);
}
return applied ? InputOutcome::applied : InputOutcome::failed;
}
void KeyboardController::ReleaseRemotePressedKeys(const std::string& remote_id,
bool KeyboardController::ReleaseRemotePressedKeys(const std::string& remote_id,
const char* reason) {
std::lock_guard input_lock(remote_input_mutex_);
ReleaseRemotePressedKeysLocked(remote_id, reason);
return ReleaseRemotePressedKeysLocked(remote_id, reason);
}
void KeyboardController::ReleaseRemotePressedKeysLocked(
bool KeyboardController::ReleaseRemotePressedKeysLocked(
std::string remote_id, const char* reason) {
const auto state_it = remote_states_.find(remote_id);
if (state_it == remote_states_.end()) return;
auto keys_to_release = std::move(state_it->second.pressed_keys);
remote_states_.erase(state_it);
if (!keys_to_release.empty()) {
LOG_WARN("Releasing {} remote keyboard keys for remote_id={}, reason={}",
keys_to_release.size(), remote_id, reason ? reason : "unknown");
}
for (const auto& [code, key] : keys_to_release) {
if (!InjectRemoteKey(key.key_code, false, key.scan_code, key.extended)) {
pending_key_releases_[code] = key;
} else {
pending_key_releases_.erase(code);
if (state_it != remote_states_.end()) {
const auto& keys = state_it->second.pressed_keys;
if (!keys.empty()) {
LOG_WARN("Releasing {} remote keyboard keys for remote_id={}, reason={}",
keys.size(), remote_id, reason ? reason : "unknown");
}
// Stage before erasing: subsequent dispatcher retries must still see the
// keys if injection fails, even though active/heartbeat state is gone.
pending_key_releases_.Queue(remote_id, keys);
remote_states_.erase(state_it);
}
return pending_key_releases_.ReleaseController(
remote_id, [this](const KeyboardKey& key) {
return InjectRemoteKey(key.key_code, false, key.scan_code, key.extended);
});
}
void KeyboardController::ReleaseAllRemotePressedKeys(const char* reason) {
{
std::lock_guard input_lock(remote_input_mutex_);
while (!remote_states_.empty())
ReleaseRemotePressedKeysLocked(remote_states_.begin()->first, reason);
}
CheckRemoteTimeouts();
std::lock_guard input_lock(remote_input_mutex_);
auto ids = pending_key_releases_.ControllerIds();
for (const auto& [id, _] : remote_states_) ids.push_back(id);
std::sort(ids.begin(), ids.end());
ids.erase(std::unique(ids.begin(), ids.end()), ids.end());
// A failure remains pending; shutdown must never spin until it succeeds.
for (const auto& id : ids) ReleaseRemotePressedKeysLocked(id, reason);
}
void KeyboardController::CheckRemoteTimeouts() {
@@ -359,7 +368,7 @@ void KeyboardController::CheckRemoteTimeouts() {
const uint32_t now = static_cast<uint32_t>(SDL_GetTicks());
// Retain failed releases in ordinary mode as well as privacy mode. Retry
// only on the ordinary desktop, never through the security UI helper.
if (!pending_key_releases_.empty() &&
if (!pending_key_releases_.Empty() &&
(last_release_retry_tick_ == 0 ||
now - last_release_retry_tick_ >= kReleaseRetryIntervalMs)
#ifdef _WIN32
@@ -367,15 +376,10 @@ void KeyboardController::CheckRemoteTimeouts() {
#endif
) {
last_release_retry_tick_ = now;
for (auto it = pending_key_releases_.begin();
it != pending_key_releases_.end();) {
const auto& key = it->second;
if (owner_.devices_.SendKeyboardCommand(
key.key_code, false, key.scan_code, key.extended))
it = pending_key_releases_.erase(it);
else
++it;
}
pending_key_releases_.RetryAll([this](const KeyboardKey& key) {
return owner_.devices_.SendKeyboardCommand(
key.key_code, false, key.scan_code, key.extended);
});
}
// Keep the timeout decision and release under the same lock, so a fresh
@@ -9,6 +9,8 @@
#include <remote_action.h>
#include "keyboard_state.h"
#include "input_outcome.h"
#include "pending_key_releases.h"
namespace crossdesk {
@@ -25,12 +27,12 @@ public:
void ForceReleasePressedKeys();
void RecoverCapturedState(const PressedKeys& pressed);
void SendHeartbeat(bool force);
void ApplyRemoteEvent(const std::string &remote_id,
InputOutcome ApplyRemoteEvent(const std::string &remote_id,
const RemoteAction &remote_action);
void ApplyRemoteText(const std::string& remote_id, const std::string& text);
void ApplyRemoteState(const std::string &remote_id,
InputOutcome ApplyRemoteText(const std::string& remote_id, const std::string& text);
InputOutcome ApplyRemoteState(const std::string &remote_id,
const RemoteAction &remote_action);
void ReleaseRemotePressedKeys(const std::string &remote_id,
bool ReleaseRemotePressedKeys(const std::string &remote_id,
const char *reason);
void ReleaseAllRemotePressedKeys(const char* reason);
void CheckRemoteTimeouts();
@@ -48,7 +50,7 @@ private:
bool InjectRemoteKey(int key_code, bool is_down, uint32_t scan_code,
bool extended);
// Caller holds remote_input_mutex_. Copy the id because this erases state.
void ReleaseRemotePressedKeysLocked(std::string remote_id, const char* reason);
bool ReleaseRemotePressedKeysLocked(std::string remote_id, const char* reason);
GuiRuntime &owner_;
PressedKeys pressed_keys_;
@@ -59,7 +61,7 @@ private:
// Guards all remote key state and serializes injection, cleanup and retries.
std::mutex remote_input_mutex_;
// Successful new input supersedes cleanup of an older press of that key.
PressedKeys pending_key_releases_;
PendingKeyReleases pending_key_releases_;
uint32_t last_release_retry_tick_ = 0;
};
@@ -0,0 +1,79 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-22
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _PENDING_KEY_RELEASES_H_
#define _PENDING_KEY_RELEASES_H_
#include <string>
#include <unordered_map>
#include <vector>
#include "keyboard_state.h"
namespace crossdesk {
// Caller serializes access with KeyboardController's remote-input mutex.
// Pending releases outlive the peer's active key/heartbeat state, but retain
// their controller identity so cleanup cannot report success prematurely.
class PendingKeyReleases {
public:
void Queue(const std::string& id, const PressedKeys& keys) {
if (keys.empty()) return;
auto& pending = peers_[id];
for (const auto& [code, key] : keys) pending[code] = key;
}
bool Empty() const { return peers_.empty(); }
bool Contains(const std::string& id) const { return peers_.count(id) != 0; }
std::vector<std::string> ControllerIds() const {
std::vector<std::string> ids;
ids.reserve(peers_.size());
for (const auto& [id, _] : peers_) ids.push_back(id);
return ids;
}
// A confirmed new event supersedes an older pending release of that key,
// including one belonging to a disconnected controller.
void Supersede(int key_code) {
for (auto it = peers_.begin(); it != peers_.end();) {
it->second.erase(key_code);
if (it->second.empty())
it = peers_.erase(it);
else
++it;
}
}
template <typename SendUp>
bool ReleaseController(const std::string& id, SendUp send_up) {
auto peer = peers_.find(id);
if (peer == peers_.end()) return true;
auto& keys = peer->second;
for (auto it = keys.begin(); it != keys.end();) {
if (send_up(it->second))
it = keys.erase(it);
else
++it;
}
if (!keys.empty()) return false;
peers_.erase(peer);
return true;
}
template <typename SendUp>
void RetryAll(SendUp send_up) {
// One bounded pass, even when every native release fails.
for (const auto& id : ControllerIds()) ReleaseController(id, send_up);
}
private:
std::unordered_map<std::string, PressedKeys> peers_;
};
} // namespace crossdesk
#endif
@@ -0,0 +1,343 @@
#include "remote_input_dispatcher.h"
#include <algorithm>
#include <utility>
namespace crossdesk {
RemoteInputDispatcher::RemoteInputDispatcher(Apply apply, Release release,
Report report,
ResetDisplay reset_display,
ReleaseMouse release_mouse)
: apply_(std::move(apply)),
release_(std::move(release)),
report_(std::move(report)),
reset_display_(std::move(reset_display)),
release_mouse_(std::move(release_mouse)),
worker_([this] { Run(); }) {}
RemoteInputDispatcher::~RemoteInputDispatcher() { Stop(); }
bool RemoteInputDispatcher::Accepts(const RemoteAction& action) {
return action.type == ControlType::mouse ||
action.type == ControlType::keyboard ||
action.type == ControlType::keyboard_state ||
action.type == ControlType::text_input ||
action.type == ControlType::display_id ||
action.type == ControlType::service_command;
}
void RemoteInputDispatcher::Connect(const std::string& id) {
if (id.empty()) return;
std::lock_guard lock(mutex_);
if (stopping_ || !connected_.insert(id).second) return;
logs_.erase(id); // A new session must get its first diagnostic.
// In-flight resets finish before this session's input; queued old resets
// must not change the new session's display. Failed releases are retained.
RemoveResetsLocked();
ready_.notify_one();
}
RemoteInputDispatcher::Result RemoteInputDispatcher::Push(
const std::string& id, const RemoteAction& action) {
// HostInfo owns raw arrays; it must never enter this value-copy queue.
if (!Accepts(action)) return Result::rejected;
std::lock_guard lock(mutex_);
if (stopping_ || !connected_.count(id)) return Result::rejected;
if (IsMove(action) && !pending_.empty()) {
auto& last = pending_.back();
if (last.id == id && last.IsInput() && IsMove(*last.action)) {
last.action = action;
last.queued_at = Clock::now();
return Result::coalesced;
}
}
if (pending_counts_[id] >= kMaxPendingPerController) {
QueueReleaseLocked(id);
return Result::overflow;
}
pending_.push_back({id, action, Clock::now(), ItemKind::input});
++pending_counts_[id];
ready_.notify_one();
return Result::queued;
}
void RemoteInputDispatcher::Disconnect(const std::string& id) {
std::lock_guard lock(mutex_);
if (stopping_ || !connected_.erase(id)) return;
QueueReleaseLocked(id);
if (connected_.empty() && reset_display_) {
RemoveResetsLocked();
pending_.push_back(
{{}, std::nullopt, Clock::now(), ItemKind::reset_display});
}
ready_.notify_one();
}
void RemoteInputDispatcher::WaitIdle() {
std::unique_lock lock(mutex_);
idle_.wait(lock, [this] { return pending_.empty() && !executing_; });
}
bool RemoteInputDispatcher::HasPendingReleases() {
std::lock_guard lock(mutex_);
return !blocked_.empty();
}
void RemoteInputDispatcher::Stop() {
std::call_once(stop_once_, [this] {
{
std::lock_guard lock(mutex_);
stopping_ = true;
auto ids = std::move(connected_);
connected_.clear();
ids.insert(blocked_.begin(), blocked_.end());
for (const auto& item : pending_)
if (!item.IsReset()) ids.insert(item.id);
pending_.clear();
pending_counts_.clear();
retries_.clear();
for (const auto& id : ids) {
pending_.push_back({id, std::nullopt, Clock::now()});
pending_counts_[id] = 1;
}
}
ready_.notify_one();
worker_.join();
});
}
bool RemoteInputDispatcher::IsMove(const RemoteAction& action) {
return action.type == ControlType::mouse && action.m.flag == MouseFlag::move;
}
unsigned RemoteInputDispatcher::MouseUpMask(const RemoteAction& action) {
if (action.type != ControlType::mouse) return 0;
switch (action.m.flag) {
case MouseFlag::left_up:
return 1;
case MouseFlag::right_up:
return 2;
case MouseFlag::middle_up:
return 4;
default:
return 0;
}
}
void RemoteInputDispatcher::RemoveResetsLocked() {
pending_.erase(
std::remove_if(pending_.begin(), pending_.end(),
[](const Item& item) { return item.IsReset(); }),
pending_.end());
if (pending_.empty() && !executing_) idle_.notify_all();
}
void RemoteInputDispatcher::QueueReleaseLocked(const std::string& id) {
pending_.erase(
std::remove_if(pending_.begin(), pending_.end(),
[&](const Item& item) { return item.id == id; }),
pending_.end());
pending_counts_.erase(id);
blocked_.insert(id);
// Repeated overflow/disconnect must not defeat the retry backoff.
if (auto retry = retries_.find(id); retry != retries_.end()) {
retry->second.mouse_mask = 0; // Escalate a button repair on disconnect.
} else {
pending_.push_back({id, std::nullopt, Clock::now()});
pending_counts_[id] = 1;
}
ready_.notify_one();
}
void RemoteInputDispatcher::QueueMouseReleaseLocked(const std::string& id,
unsigned mask) {
// A concurrent disconnect/overflow already requested the broader cleanup.
if (!blocked_.insert(id).second) return;
// Keep subsequent clicks/keys queued, but don't apply them until the old
// button is confirmed up. Never replay a timed-out down/click operation.
pending_.push_front(
{id, std::nullopt, Clock::now(), ItemKind::cleanup, mask});
++pending_counts_[id];
ready_.notify_one();
}
std::optional<RemoteInputDispatcher::Item> RemoteInputDispatcher::NextLocked() {
const auto now = Clock::now();
auto next = retries_.end();
for (auto it = retries_.begin(); it != retries_.end(); ++it)
if (next == retries_.end() || it->second.due < next->second.due) next = it;
auto it =
std::find_if(pending_.begin(), pending_.end(), [&](const Item& item) {
return !item.IsInput() || !blocked_.count(item.id);
});
// Alternate due retries with runnable queued work. Multiple slow cleanup
// calls must not monopolize this worker; continuous input must not starve
// recovery either. Input for the recovering controller remains blocked.
if (next != retries_.end() && next->second.due <= now &&
(it == pending_.end() || !last_was_retry_)) {
last_was_retry_ = true;
return Item{next->first, std::nullopt, next->second.due, ItemKind::cleanup,
next->second.mouse_mask};
}
if (it == pending_.end()) return std::nullopt;
last_was_retry_ = false;
Item item = std::move(*it);
pending_.erase(it);
if (!item.IsReset() && --pending_counts_.at(item.id) == 0)
pending_counts_.erase(item.id);
return item;
}
bool RemoteInputDispatcher::ShouldReportLocked(const Item& item,
Clock::time_point now,
int64_t queued_ms,
int64_t execution_ms,
InputOutcome outcome) {
if (!report_) return false;
auto& state = logs_[item.id];
auto& last = outcome == InputOutcome::failed
? state.failure[item.IsInput() ? 0 : 1]
: state.slow;
if (outcome != InputOutcome::failed && queued_ms < 50 && execution_ms < 50)
return false;
if (last != Clock::time_point{} && now - last < std::chrono::seconds(2))
return false;
last = now;
return true;
}
void RemoteInputDispatcher::RemoveQueuedCleanupLocked(const std::string& id) {
// Cleanup queued during the running call covers the same old state. Merge
// duplicates so they cannot bypass backoff or release a later session twice.
const size_t before = pending_.size();
pending_.erase(std::remove_if(pending_.begin(), pending_.end(),
[&](const Item& queued) {
return queued.id == id && queued.IsCleanup();
}),
pending_.end());
const size_t removed = before - pending_.size();
if (removed && (pending_counts_.at(id) -= removed) == 0)
pending_counts_.erase(id);
}
void RemoteInputDispatcher::ScheduleRetryLocked(const std::string& id,
unsigned mouse_mask,
Clock::time_point finished) {
auto [retry, inserted] = retries_.try_emplace(id);
retry->second.mouse_mask = mouse_mask;
if (!inserted)
retry->second.delay =
std::min(retry->second.delay * 2, std::chrono::milliseconds(1000));
retry->second.due = finished + retry->second.delay;
blocked_.insert(id);
}
void RemoteInputDispatcher::CompleteCleanupLocked(const Item& item,
InputOutcome outcome,
Clock::time_point finished) {
if (stopping_) {
retries_.erase(item.id);
blocked_.erase(item.id);
return;
}
// A successful button repair cannot discharge a concurrent request to
// release all keyboard/mouse state after disconnect or queue overflow.
const bool queued_full_cleanup =
item.mouse_mask &&
std::any_of(pending_.begin(), pending_.end(), [&](const Item& queued) {
return queued.id == item.id && queued.IsCleanup() && !queued.mouse_mask;
});
if (queued_full_cleanup) {
retries_.erase(item.id); // The queued full cleanup owns recovery now.
return;
}
auto retry = retries_.find(item.id);
if (item.mouse_mask && retry != retries_.end() && !retry->second.mouse_mask) {
if (outcome == InputOutcome::failed)
ScheduleRetryLocked(item.id, 0, finished);
else
retry->second.due = finished;
return;
}
RemoveQueuedCleanupLocked(item.id);
if (outcome == InputOutcome::failed) {
ScheduleRetryLocked(item.id, item.mouse_mask, finished);
} else {
retries_.erase(item.id);
blocked_.erase(item.id);
}
}
InputOutcome RemoteInputDispatcher::Execute(const Item& item) {
if (item.IsReset()) return reset_display_();
if (item.IsInput()) return apply_(item.id, *item.action);
if (item.mouse_mask && release_mouse_)
return release_mouse_(item.id, item.mouse_mask);
return release_(item.id);
}
void RemoteInputDispatcher::Run() {
std::unique_lock lock(mutex_);
for (;;) {
auto next = NextLocked();
if (!next) {
if (stopping_) return;
auto due = Clock::time_point::max();
for (const auto& [_, retry] : retries_) due = std::min(due, retry.due);
if (due == Clock::time_point::max())
ready_.wait(lock);
else
ready_.wait_until(lock, due);
continue;
}
Item item = std::move(*next);
executing_ = true;
lock.unlock();
const auto started = Clock::now();
InputOutcome outcome = InputOutcome::failed;
bool threw = false;
try {
outcome = Execute(item);
} catch (...) {
threw = true;
}
const auto finished = Clock::now();
const auto queued_ms =
std::chrono::duration_cast<std::chrono::milliseconds>(started -
item.queued_at)
.count();
const auto execution_ms =
std::chrono::duration_cast<std::chrono::milliseconds>(finished -
started)
.count();
lock.lock();
if (item.IsCleanup()) {
CompleteCleanupLocked(item, outcome, finished);
} else if (item.IsInput() && !stopping_) {
if (threw && connected_.count(item.id)) {
QueueReleaseLocked(item.id);
} else if (outcome == InputOutcome::failed) {
if (const unsigned mask = MouseUpMask(*item.action))
QueueMouseReleaseLocked(item.id, mask);
}
}
const bool report =
ShouldReportLocked(item, finished, queued_ms, execution_ms, outcome);
if (!connected_.count(item.id) && !blocked_.count(item.id) &&
!pending_counts_.count(item.id))
logs_.erase(item.id);
lock.unlock();
if (report) {
try {
report_(item.id, queued_ms, execution_ms, outcome);
} catch (...) {
} // Diagnostics must not terminate the input worker.
}
lock.lock();
executing_ = false;
if (pending_.empty()) idle_.notify_all();
}
}
} // namespace crossdesk
@@ -0,0 +1,123 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-22
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _REMOTE_INPUT_DISPATCHER_H_
#define _REMOTE_INPUT_DISPATCHER_H_
#include <remote_action.h>
#include <array>
#include <chrono>
#include <condition_variable>
#include <cstdint>
#include <deque>
#include <functional>
#include <mutex>
#include <optional>
#include <string>
#include <thread>
#include <unordered_map>
#include <unordered_set>
#include "input_outcome.h"
namespace crossdesk {
// Serializes native input, recovery and display resets off the RTC thread.
class RemoteInputDispatcher {
public:
using Apply =
std::function<InputOutcome(const std::string&, const RemoteAction&)>;
using Release = std::function<InputOutcome(const std::string&)>;
using Report =
std::function<void(const std::string&, int64_t, int64_t, InputOutcome)>;
using ResetDisplay = std::function<InputOutcome()>;
// Optional targeted adapter; legacy callers fall back to full Release.
using ReleaseMouse =
std::function<InputOutcome(const std::string&, unsigned)>;
enum class Result { queued, coalesced, rejected, overflow };
static constexpr size_t kMaxPendingPerController = 128;
RemoteInputDispatcher(Apply apply, Release release, Report report = {},
ResetDisplay reset_display = {},
ReleaseMouse release_mouse = {});
~RemoteInputDispatcher();
static bool Accepts(const RemoteAction& action);
void Connect(const std::string& id);
Result Push(const std::string& id, const RemoteAction& action);
void Disconnect(const std::string& id);
// Scheduled recovery is not idle work. Check HasPendingReleases() as well,
// or Stop() before destroying the devices used by callbacks.
void WaitIdle();
bool HasPendingReleases();
// Cancels pending input/timers and makes a final bounded cleanup attempt.
void Stop();
private:
using Clock = std::chrono::steady_clock;
enum class ItemKind { input, cleanup, reset_display };
struct Item {
std::string id;
std::optional<RemoteAction> action; // Present only for ordinary input.
Clock::time_point queued_at;
ItemKind kind = ItemKind::cleanup;
// Zero requests full controller cleanup; otherwise only these mouse
// buttons.
unsigned mouse_mask = 0;
bool IsReset() const { return kind == ItemKind::reset_display; }
bool IsInput() const { return kind == ItemKind::input; }
bool IsCleanup() const { return kind == ItemKind::cleanup; }
};
struct Retry {
Clock::time_point due;
std::chrono::milliseconds delay{100};
unsigned mouse_mask = 0;
};
struct LogState {
Clock::time_point slow{};
std::array<Clock::time_point, 2> failure{};
};
static bool IsMove(const RemoteAction& action);
static unsigned MouseUpMask(const RemoteAction& action);
void RemoveResetsLocked();
void QueueReleaseLocked(const std::string& id);
void QueueMouseReleaseLocked(const std::string& id, unsigned mask);
void RemoveQueuedCleanupLocked(const std::string& id);
void ScheduleRetryLocked(const std::string& id, unsigned mouse_mask,
Clock::time_point finished);
void CompleteCleanupLocked(const Item& item, InputOutcome outcome,
Clock::time_point finished);
std::optional<Item> NextLocked();
InputOutcome Execute(const Item& item);
bool ShouldReportLocked(const Item& item, Clock::time_point now,
int64_t queued_ms, int64_t execution_ms,
InputOutcome outcome);
void Run();
Apply apply_;
Release release_;
Report report_;
ResetDisplay reset_display_;
ReleaseMouse release_mouse_;
std::mutex mutex_;
std::condition_variable ready_, idle_;
std::deque<Item> pending_;
std::unordered_set<std::string> connected_, blocked_;
std::unordered_map<std::string, size_t> pending_counts_;
std::unordered_map<std::string, Retry> retries_;
std::unordered_map<std::string, LogState> logs_;
bool last_was_retry_ = false;
bool stopping_ = false;
bool executing_ = false;
std::once_flag stop_once_;
std::thread worker_;
};
} // namespace crossdesk
#endif
@@ -0,0 +1,78 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-22
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _REMOTE_MOUSE_BUTTONS_H_
#define _REMOTE_MOUSE_BUTTONS_H_
#include <remote_action.h>
#include <array>
#include <mutex>
#include <string>
#include "input_outcome.h"
namespace crossdesk {
// One physical button cannot belong to two controllers at once. A second
// controller's down/up must not steal or release an existing drag.
class RemoteMouseButtons {
public:
template <typename Send>
InputOutcome Apply(const std::string& id, MouseFlag flag, Send send) {
if (id.empty()) return InputOutcome::ignored;
std::lock_guard lock(mutex_);
const int value = static_cast<int>(flag);
if (value < 1 || value > 6) return send();
auto& owner = owners_[(value - 1) / 2];
const bool down = value % 2 == 1;
if ((!owner.empty() && owner != id) || (!down && owner.empty()))
return InputOutcome::ignored;
// A service timeout may follow successful injection. Retain an attempted
// down until an up or explicit cleanup is confirmed, including exceptions.
if (down) owner = id;
const auto outcome = send();
if (!down && outcome == InputOutcome::applied) owner.clear();
return outcome;
}
template <typename Release>
bool ReleaseController(const std::string& id, Release release,
unsigned requested_mask = 7) {
if (id.empty()) return true;
std::lock_guard lock(mutex_);
unsigned mask = 0;
for (size_t i = 0; i < owners_.size(); ++i)
if (owners_[i] == id && (requested_mask & (1u << i))) mask |= 1u << i;
return ReleaseLocked(mask, release);
}
template <typename Release>
bool ReleaseAll(Release release) {
std::lock_guard lock(mutex_);
// Also clear any backend state retained after a desktop/route transition.
return ReleaseLocked(7, release);
}
private:
template <typename Release>
bool ReleaseLocked(unsigned mask, Release release) {
if (!mask) return true;
// The sink returns the subset confirmed released, so one failed button
// does not retain ownership of other successfully released buttons.
const unsigned released = release(mask) & mask;
for (size_t i = 0; i < owners_.size(); ++i)
if (released & (1u << i)) owners_[i].clear();
return released == mask;
}
std::mutex mutex_;
std::array<std::string, 3> owners_;
};
} // namespace crossdesk
#endif
@@ -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';
@@ -307,8 +465,6 @@ int SettingsManager::Load() {
static_cast<int>(owner_.config_center_->GetLanguage()));
owner_.video_encode_format_button_value_ =
static_cast<int>(owner_.config_center_->GetVideoEncodeFormat());
owner_.enable_hardware_video_codec_ =
owner_.config_center_->IsHardwareVideoCodec();
owner_.enable_turn_ = owner_.config_center_->IsEnableTurn();
owner_.enable_self_hosted_ = owner_.config_center_->IsSelfHosted();
owner_.enable_autostart_ = owner_.config_center_->IsEnableAutostart();
@@ -328,8 +484,6 @@ int SettingsManager::Load() {
owner_.language_button_value_last_ = owner_.language_button_value_;
owner_.video_encode_format_button_value_last_ =
owner_.video_encode_format_button_value_;
owner_.enable_hardware_video_codec_last_ =
owner_.enable_hardware_video_codec_;
owner_.enable_turn_last_ = owner_.enable_turn_;
owner_.enable_self_hosted_last_ = owner_.enable_self_hosted_;
owner_.enable_autostart_last_ = owner_.enable_autostart_;
@@ -63,7 +63,7 @@ private:
int legacy_video_quality;
int legacy_video_frame_rate;
int video_encode_format;
bool enable_hardware_video_codec;
bool legacy_enable_hardware_video_codec;
bool enable_turn;
bool enable_srtp;
unsigned char key[16];
@@ -77,7 +77,7 @@ private:
int legacy_video_quality;
int legacy_video_frame_rate;
int video_encode_format;
bool enable_hardware_video_codec;
bool legacy_enable_hardware_video_codec;
bool enable_turn;
bool enable_srtp;
unsigned char key[16];
@@ -190,22 +190,16 @@ void GuiRuntime::HandleServerControllerDisconnected(
const std::string& remote_id, const char* reason) {
transfers_.Cancel(peer_events_.get(), remote_id);
preview_subscriptions_.Remove(remote_id);
keyboard_.ReleaseRemotePressedKeys(remote_id, reason);
#ifdef _WIN32
devices_.ReleaseRemoteMouseButtons();
#endif
{
std::lock_guard lock(remote_pointer_input_mutex_);
last_remote_pointer_input_time_.erase(remote_id);
}
bool has_connected_controller = false;
std::string remaining_controller_id;
{
std::unique_lock lock(connection_status_mutex_);
connection_status_.erase(remote_id);
remote_input_.Disconnect(remote_id);
devices_.OnControllerConnectionsChanged();
connection_host_names_.erase(remote_id);
codec_status_controllers_.erase(remote_id);
privacy_sessions_.Disconnected(remote_id);
for (const auto& [id, status] : connection_status_) {
if (status != ConnectionStatus::Connected) {
@@ -252,7 +246,6 @@ void GuiRuntime::HandleServerControllerDisconnected(
start_keyboard_capturer_ = false;
remote_client_id_.clear();
audio_capture_ = false;
devices_.ResetToInitialDisplay();
}
int GuiRuntime::RequestSingleDevicePresence(const std::string& remote_id,
+63 -6
View File
@@ -9,6 +9,7 @@
#include <mutex>
#include <shared_mutex>
#include <string>
#include <system_error>
#include <thread>
#include <vector>
@@ -16,6 +17,9 @@
#include "localization.h"
#include "platform/video_renderer.h"
#include "rd_log.h"
#ifdef _WIN32
#include "platform/windows/input/windows_input_injector.h"
#endif
#if defined(__linux__) && !defined(__OHOS__)
#include "platform/linux/headless/headless_console.h"
#endif
@@ -24,9 +28,43 @@ namespace crossdesk {
GuiRuntime::GuiRuntime()
: clipboard_(*this), devices_(*this), transfers_(*this), settings_(*this),
keyboard_(*this) {}
keyboard_(*this),
remote_input_(
[this](const std::string& id, const RemoteAction& action) {
#ifdef _WIN32
ScopedUserDesktopInputThread input_thread;
#endif
return ApplyRemoteInput(id, action);
},
[this](const std::string& id) {
#ifdef _WIN32
ScopedUserDesktopInputThread input_thread;
#endif
const bool keys_released =
keyboard_.ReleaseRemotePressedKeys(id, "input_queue_reset");
const bool mouse_released = devices_.ReleaseRemoteMouseButtons(id);
std::lock_guard lock(remote_pointer_input_mutex_);
last_remote_pointer_input_time_.erase(id);
return keys_released && mouse_released ? InputOutcome::applied
: InputOutcome::failed;
},
[](const std::string& id, int64_t queued_ms, int64_t execution_ms,
InputOutcome outcome) {
LOG_WARN("Remote input result: peer={} queue_ms={} execution_ms={} "
"result={}",
id, queued_ms, execution_ms, InputOutcomeName(outcome));
},
[this] { return devices_.ResetToInitialDisplay(); },
[this](const std::string& id, unsigned mask) {
#ifdef _WIN32
ScopedUserDesktopInputThread input_thread;
#endif
return devices_.ReleaseRemoteMouseButtons(id, mask)
? InputOutcome::applied : InputOutcome::failed;
}) {}
GuiRuntime::~GuiRuntime() {
remote_input_.Stop();
#if _WIN32
windows_rdp_handoff_allowed_->store(false, std::memory_order_relaxed);
windows_rdp_handoff_connected_->store(false, std::memory_order_relaxed);
@@ -38,6 +76,7 @@ GuiRuntime::~GuiRuntime() {
void GuiRuntime::SendRemoteInput(const RemoteAction& action) {
const bool mouse = action.type == ControlType::mouse;
const bool mouse_move = mouse && action.m.flag == MouseFlag::move;
if (!mouse && action.type != ControlType::keyboard &&
action.type != ControlType::keyboard_state)
return;
@@ -71,9 +110,15 @@ void GuiRuntime::SendRemoteInput(const RemoteAction& action) {
}
// Reuse exactly the leader's mapped coordinates and key metadata. Neither
// gallery filters nor the follower's preview dimensions affect input.
const int result = SendReliableDataFrame(
props->peer_, message.data(), message.size(),
mouse ? props->mouse_label_.c_str() : props->keyboard_label_.c_str());
// Move updates supersede old positions and must not wait for KCP retries.
// Use the negotiated unreliable stream; clicks and wheel events remain
// on the reliable mouse stream, with their own absolute coordinates.
const int result = mouse_move
? SendDataFrame(props->peer_, message.data(), message.size(),
props->data_label_.c_str())
: SendReliableDataFrame(
props->peer_, message.data(), message.size(),
mouse ? props->mouse_label_.c_str() : props->keyboard_label_.c_str());
if (result != 0)
LOG_WARN("Send remote input failed, remote_id={}, ret={}", id, result);
}
@@ -96,7 +141,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 "
@@ -181,7 +237,8 @@ int GuiRuntime::CreateConnectionPeer() {
strncpy(params_.log_path, dll_log_path_.c_str(),
sizeof(params_.log_path) - 1);
params_.log_path[sizeof(params_.log_path) - 1] = '\0';
params_.hardware_acceleration = config_center_->IsHardwareVideoCodec();
// MiniRTC selects the available hardware backend and falls back to software.
params_.hardware_acceleration = true;
#if defined(_WIN32) || (defined(__linux__) && !defined(__OHOS__)) || defined(__APPLE__)
// Windows and Linux retain pooled CPU NV12 frames or CUDA device frames;
// macOS retains VideoToolbox CVPixelBuffers for direct Metal sampling. All
@@ -17,6 +17,7 @@
#include "features/devices/session_device_manager.h"
#include "features/file_transfer/file_transfer_manager.h"
#include "features/input/keyboard_controller.h"
#include "features/input/remote_input_dispatcher.h"
#include "features/settings/settings_manager.h"
#include "privacy_controller.h"
#include "runtime/gui_state.h"
@@ -90,6 +91,8 @@ class GuiRuntime : protected gui_detail::GuiState {
// UI thread: send mapped mouse/keyboard input to the current control target,
// or every connected session while that target leads DeskHub group control.
void SendRemoteInput(const RemoteAction& action);
InputOutcome ApplyRemoteInput(const std::string& remote_id, const RemoteAction& action);
InputOutcome InjectRemoteMouse(const RemoteAction& action, int display);
std::string gallery_group_control_id_;
void ResetRemoteServiceStatus(RemoteSession& props);
@@ -133,6 +136,7 @@ class GuiRuntime : protected gui_detail::GuiState {
FileTransferManager transfers_;
SettingsManager settings_;
KeyboardController keyboard_;
RemoteInputDispatcher remote_input_;
std::shared_ptr<PeerEventHandler> peer_events_;
std::atomic<bool> video_frame_dirty_{false};
std::unique_ptr<VideoRenderer> video_renderer_;
@@ -8,6 +8,9 @@
#define _NET_TRAFFIC_STATS_CACHE_H_
#include <atomic>
#include <cstring>
#include <mutex>
#include <string>
#include "minirtc.h"
#include "runtime/connection_latency.h"
@@ -15,7 +18,8 @@
namespace crossdesk::gui_detail {
// Fields are independent observations and may come from different reports.
// Relaxed accesses keep each field safe without publishing any other state.
// Relaxed accesses keep scalar fields safe without publishing other state;
// codec names are copied under their own mutex.
class NetTrafficStatsCache {
static_assert(std::atomic<uint32_t>::is_always_lock_free &&
std::atomic<float>::is_always_lock_free &&
@@ -26,7 +30,31 @@ class NetTrafficStatsCache {
public:
using Clock = std::chrono::steady_clock;
struct VideoCodecNames {
std::string remote_encoder;
std::string local_decoder;
};
void StoreRemoteVideoEncoder(const std::string& encoder) {
std::lock_guard lock(codec_mutex_);
codecs_.remote_encoder = encoder;
}
VideoCodecNames LoadVideoCodecs() const {
std::lock_guard lock(codec_mutex_);
return codecs_;
}
void Store(const MiniRtcNetTrafficStats& stats) {
{
std::lock_guard lock(codec_mutex_);
local_encoder_.assign(
stats.video_encoder,
strnlen(stats.video_encoder, sizeof(stats.video_encoder)));
codecs_.local_decoder.assign(
stats.video_decoder,
strnlen(stats.video_decoder, sizeof(stats.video_decoder)));
}
video_inbound_stats_.Store(stats.video_inbound_stats);
video_outbound_stats_.Store(stats.video_outbound_stats);
audio_inbound_stats_.Store(stats.audio_inbound_stats);
@@ -46,6 +74,11 @@ class NetTrafficStatsCache {
MiniRtcNetTrafficStats Load() const {
MiniRtcNetTrafficStats stats{};
{
std::lock_guard lock(codec_mutex_);
local_encoder_.copy(stats.video_encoder, sizeof(stats.video_encoder) - 1);
codecs_.local_decoder.copy(stats.video_decoder, sizeof(stats.video_decoder) - 1);
}
stats.video_inbound_stats = video_inbound_stats_.Load();
stats.video_outbound_stats = video_outbound_stats_.Load();
stats.audio_inbound_stats = audio_inbound_stats_.Load();
@@ -80,6 +113,10 @@ class NetTrafficStatsCache {
void Reset() {
Store(MiniRtcNetTrafficStats{});
{
std::lock_guard lock(codec_mutex_);
codecs_ = {};
}
ResetLatency();
last_report_ms_.store(0, std::memory_order_relaxed);
}
@@ -130,6 +167,9 @@ class NetTrafficStatsCache {
std::atomic<double> rtt_ms_{-1};
std::atomic<int64_t> last_report_ms_{0};
ConnectionLatencyStats connection_latency_;
mutable std::mutex codec_mutex_;
std::string local_encoder_;
VideoCodecNames codecs_;
};
} // namespace crossdesk::gui_detail
@@ -209,6 +209,16 @@ void PeerEventHandler::OnReceiveDataBuffer(
return;
}
if (remote_action.type == ControlType::video_codec_status) {
if (auto props = runtime->FindRemoteSession(remote_id);
props && source_id == props->control_data_label_ &&
!props->closing_ &&
props->connection_status_ == ConnectionStatus::Connected) {
props->net_traffic_stats_.StoreRemoteVideoEncoder(remote_action.vcs.encoder);
}
return;
}
if (remote_action.type == ControlType::video_settings_status) {
if (auto props = runtime->FindRemoteSession(remote_id)) {
if (source_id != props->control_data_label_) return;
@@ -280,29 +290,22 @@ void PeerEventHandler::OnReceiveDataBuffer(
runtime->QueuePrivacyCommand(remote_id, remote_action.pc);
return;
}
if (remote_action.type == ControlType::text_input) {
// Share the ordered keyboard stream with delete/return and shortcuts.
if (source_id == runtime->keyboard_label_ &&
runtime->IsAuthorizedController(remote_id))
runtime->keyboard_.ApplyRemoteText(remote_id, remote_action.text);
if (RemoteInputDispatcher::Accepts(remote_action)) {
if (!runtime->IsAuthorizedController(remote_id)) return;
if (remote_action.type == ControlType::text_input &&
source_id != runtime->keyboard_label_) return;
if (runtime->remote_input_.Push(remote_id, remote_action) ==
RemoteInputDispatcher::Result::overflow)
LOG_WARN("Remote input queue overflow: peer={}, clearing stale input and "
"releasing keys/buttons",
remote_id);
return;
}
const bool operates_host = remote_action.type == ControlType::mouse ||
remote_action.type == ControlType::keyboard || remote_action.type == ControlType::keyboard_state ||
remote_action.type == ControlType::service_command || remote_action.type == ControlType::display_id ||
remote_action.type == ControlType::audio_capture;
if (operates_host) {
if (remote_action.type == ControlType::audio_capture) {
if (!runtime->IsAuthorizedController(remote_id)) return;
#ifdef _WIN32
// KeyboardController checks the desktop immediately before each native
// key injection, including state reconciliation and cleanup. Avoid a
// second synchronous session query for every received keyboard packet.
const bool keyboard_input = remote_action.type == ControlType::keyboard ||
remote_action.type == ControlType::keyboard_state;
if (!keyboard_input && runtime->privacy_.Engaged() &&
!IsWindowsPrivacyDesktopAvailable()) {
if (runtime->privacy_.Engaged() && !IsWindowsPrivacyDesktopAvailable())
runtime->privacy_.SuspendForDesktop();
}
#endif
}
if (remote_action.type == ControlType::service_status) {
@@ -312,28 +315,6 @@ void PeerEventHandler::OnReceiveDataBuffer(
return;
}
if (remote_action.type == ControlType::service_command) {
if (runtime->privacy_.Engaged()) {
runtime->privacy_.Fail("Security shortcut requested; privacy screen will turn off");
}
#if _WIN32
if (remote_action.c.flag == ServiceCommandFlag::send_sas) {
runtime->pending_windows_service_sas_.store(true,
std::memory_order_relaxed);
} else if (remote_action.c.flag == ServiceCommandFlag::lock_workstation) {
if (!LockWorkStation()) {
LOG_WARN("Remote lock workstation request failed, error={}",
GetLastError());
}
}
#elif defined(__OHOS__)
if (!SendHarmonySystemShortcut(remote_action.c.flag))
LOG_WARN("Harmony system shortcut unsupported or unauthorized, command={}",
static_cast<int>(remote_action.c.flag));
#endif
return;
}
if (remote_action.type == ControlType::cursor_state) {
std::shared_ptr<GuiRuntime::RemoteSession> props;
{
@@ -455,6 +436,12 @@ void PeerEventHandler::OnReceiveDataBuffer(
{
std::unique_lock lock(runtime->connection_status_mutex_);
runtime->connection_host_names_[remote_id] = host_name;
if (source_id == runtime->control_data_label_) {
if (remote_action.i.supports_video_codec_status)
runtime->codec_status_controllers_.insert(remote_id);
else
runtime->codec_status_controllers_.erase(remote_id);
}
}
LOG_INFO("Remote hostname: [{}]", host_name);
if (source_id == runtime->control_data_label_) {
@@ -464,75 +451,7 @@ void PeerEventHandler::OnReceiveDataBuffer(
FreeRemoteAction(remote_action);
}
} else {
// remote
if (runtime->is_server_mode_ &&
remote_action.type == ControlType::mouse) {
std::lock_guard lock(runtime->remote_pointer_input_mutex_);
runtime->last_remote_pointer_input_time_[remote_id] =
std::chrono::steady_clock::now();
}
#if _WIN32
if (runtime->is_server_mode_ && remote_action.type == ControlType::mouse) {
static const bool process_elevated = IsAdministratorProcess();
std::string response;
const bool delivered = DispatchDesktopInput(
[&] {
return PreferUserDesktopInput(
IsCurrentSessionUserDesktopActive(),
runtime->windows_consent_ui_.load(),
runtime->local_service_available_.load(), process_elevated);
},
[&] {
SetLastError(ERROR_SUCCESS);
const bool sent = runtime->devices_.SendMouseCommand(
remote_action, runtime->selected_display_);
const DWORD error = GetLastError();
const bool blocked =
error == ERROR_ACCESS_DENIED || error == ERROR_GEN_FAILURE;
return DesktopInputResult{
sent, IsDesktopTransitionInputError(error) || blocked, blocked};
},
[&]() -> DesktopInputResult {
int absolute_x = 0;
int absolute_y = 0;
if (!BuildAbsoluteMousePosition(
runtime->devices_.display_info_list(),
runtime->selected_display_, remote_action.m.x,
remote_action.m.y, &absolute_x, &absolute_y)) {
LOG_WARN(
"Secure desktop mouse injection skipped, invalid display "
"mapping: display_index={}, x={}, y={}",
runtime->selected_display_, remote_action.m.x,
remote_action.m.y);
return {};
}
response = runtime->devices_.SendServiceMouseCommand(
absolute_x, absolute_y, remote_action.m.s,
static_cast<int>(remote_action.m.flag));
auto json = nlohmann::json::parse(response, nullptr, false);
const bool sent = json.is_object() && json.value("ok", false);
const std::string error =
json.is_object() ? json.value("error", "") : "";
return {sent, IsDesktopInputSetupPending(error)};
});
if (!delivered && !response.empty()) {
LogSecureDesktopInputBlocked(
&runtime->last_local_secure_input_block_log_tick_, "local",
runtime->WindowsInputStage().c_str());
LOG_WARN(
"Secure desktop mouse injection failed, x={}, y={}, wheel={}, "
"flag={}, response={}",
remote_action.m.x, remote_action.m.y, remote_action.m.s,
static_cast<int>(remote_action.m.flag), response);
}
return;
}
#endif
if (remote_action.type == ControlType::mouse) {
runtime->devices_.SendMouseCommand(remote_action,
runtime->selected_display_);
} else if (remote_action.type == ControlType::audio_capture) {
if (remote_action.type == ControlType::audio_capture) {
// A negotiated subscription remains authoritative if a legacy capture
// command arrives after it (e.g. when unmuting an inactive window).
const auto subscription = runtime->preview_subscriptions_.Get(remote_id);
@@ -544,20 +463,139 @@ void PeerEventHandler::OnReceiveDataBuffer(
else if (ret != 0)
// Legacy/web transports without the per-peer API retain their fallback.
runtime->devices_.StopSpeakerCapturer();
} else if (remote_action.type == ControlType::keyboard) {
runtime->keyboard_.ApplyRemoteEvent(remote_id, remote_action);
} else if (remote_action.type == ControlType::keyboard_state) {
runtime->keyboard_.ApplyRemoteState(remote_id, remote_action);
} else if (remote_action.type == ControlType::display_id) {
const int ret = runtime->devices_.SwitchDisplay(remote_action.d);
if (ret == 0) {
runtime->selected_display_ = remote_action.d;
} else {
LOG_WARN("Display switch skipped, invalid display_id={}",
remote_action.d);
}
}
}
}
} // namespace crossdesk
InputOutcome GuiRuntime::ApplyRemoteInput(const std::string& remote_id,
const RemoteAction& remote_action) {
if (!IsAuthorizedController(remote_id)) return InputOutcome::ignored;
if (remote_action.type == ControlType::text_input) {
return keyboard_.ApplyRemoteText(remote_id, remote_action.text);
}
#ifdef _WIN32
const bool keyboard_input = remote_action.type == ControlType::keyboard ||
remote_action.type == ControlType::keyboard_state;
if (!keyboard_input && privacy_.Engaged() &&
!IsWindowsPrivacyDesktopAvailable())
privacy_.SuspendForDesktop();
#endif
if (remote_action.type == ControlType::service_command) {
if (privacy_.Engaged()) {
privacy_.Fail(
"Security shortcut requested; privacy screen will turn off");
}
#if _WIN32
if (remote_action.c.flag == ServiceCommandFlag::send_sas) {
pending_windows_service_sas_.store(true, std::memory_order_relaxed);
return InputOutcome::applied;
} else if (remote_action.c.flag == ServiceCommandFlag::lock_workstation) {
if (!LockWorkStation()) {
LOG_WARN("Remote lock workstation request failed, error={}",
GetLastError());
return InputOutcome::failed;
}
return InputOutcome::applied;
}
#elif defined(__OHOS__)
if (!SendHarmonySystemShortcut(remote_action.c.flag)) {
LOG_WARN(
"Harmony system shortcut unsupported or unauthorized, command={}",
static_cast<int>(remote_action.c.flag));
return InputOutcome::failed;
}
return InputOutcome::applied;
#endif
return InputOutcome::ignored;
}
const int display = selected_display_.load();
if (is_server_mode_ && remote_action.type == ControlType::mouse) {
std::lock_guard lock(remote_pointer_input_mutex_);
last_remote_pointer_input_time_[remote_id] =
std::chrono::steady_clock::now();
}
if (remote_action.type == ControlType::mouse) {
return devices_.ApplyRemoteMouse(remote_id, remote_action.m.flag,
[this, &remote_action, display] {
return InjectRemoteMouse(remote_action, display);
});
} else if (remote_action.type == ControlType::keyboard) {
return keyboard_.ApplyRemoteEvent(remote_id, remote_action);
} else if (remote_action.type == ControlType::keyboard_state) {
return keyboard_.ApplyRemoteState(remote_id, remote_action);
} else if (remote_action.type == ControlType::display_id) {
const int ret = devices_.SwitchDisplay(remote_action.d);
if (ret == 0) {
selected_display_ = remote_action.d;
return InputOutcome::applied;
} else {
LOG_WARN("Display switch skipped, invalid display_id={}",
remote_action.d);
return InputOutcome::failed;
}
}
return InputOutcome::ignored;
}
InputOutcome GuiRuntime::InjectRemoteMouse(const RemoteAction& remote_action,
int display) {
#if _WIN32
if (is_server_mode_) {
static const bool process_elevated = IsAdministratorProcess();
std::string response;
const bool delivered = DispatchDesktopInput(
[&] {
return PreferUserDesktopInput(
IsCurrentSessionUserDesktopActive(), windows_consent_ui_.load(),
local_service_available_.load(), process_elevated);
},
[&] {
SetLastError(ERROR_SUCCESS);
const bool sent = devices_.SendMouseCommand(remote_action, display);
const DWORD error = GetLastError();
const bool blocked =
error == ERROR_ACCESS_DENIED || error == ERROR_GEN_FAILURE;
return DesktopInputResult{
sent, IsDesktopTransitionInputError(error) || blocked, blocked};
},
[&]() -> DesktopInputResult {
int absolute_x = 0;
int absolute_y = 0;
if (!BuildAbsoluteMousePosition(devices_.display_info_list(), display,
remote_action.m.x, remote_action.m.y,
&absolute_x, &absolute_y)) {
LOG_WARN(
"Secure desktop mouse injection skipped, invalid display "
"mapping: display_index={}, x={}, y={}",
display, remote_action.m.x, remote_action.m.y);
return {};
}
response = devices_.SendServiceMouseCommand(
absolute_x, absolute_y, remote_action.m.s,
static_cast<int>(remote_action.m.flag));
auto json = nlohmann::json::parse(response, nullptr, false);
const bool sent = json.is_object() && json.value("ok", false);
const std::string error =
json.is_object() ? json.value("error", "") : "";
return {sent, IsDesktopInputSetupPending(error)};
});
if (!delivered && !response.empty()) {
LogSecureDesktopInputBlocked(&last_local_secure_input_block_log_tick_,
"local", WindowsInputStage().c_str());
LOG_WARN(
"Secure desktop mouse injection failed, x={}, y={}, wheel={}, "
"flag={}, response={}",
remote_action.m.x, remote_action.m.y, remote_action.m.s,
static_cast<int>(remote_action.m.flag), response);
}
return delivered ? InputOutcome::applied : InputOutcome::failed;
}
#endif
return devices_.SendMouseCommand(remote_action, display)
? InputOutcome::applied : InputOutcome::failed;
}
} // namespace crossdesk
@@ -5,6 +5,7 @@
#include <algorithm>
#include <chrono>
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <memory>
@@ -295,6 +296,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
/*supports_privacy_screen=*/!runtime->unattended_host_,
CROSSDESK_VERSION, kClientPlatform);
remote_action.i.supports_video_settings = true;
remote_action.i.supports_video_codec_status = true;
remote_action.i.supports_text_input = runtime->devices_.SupportsTextInput();
remote_action.i.supports_deskhub_preview = true;
remote_action.i.supports_deskhub_resolution = true;
@@ -437,6 +439,8 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
runtime->ApplyPrivacyAdmission(automatic_enable);
}
runtime->connection_status_[remote_id] = status;
if (status == ConnectionStatus::Connected)
runtime->remote_input_.Connect(remote_id);
#if _WIN32
runtime->windows_rdp_handoff_connected_->store(std::any_of(
runtime->connection_status_.begin(), runtime->connection_status_.end(),
@@ -458,6 +462,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
/*supports_privacy_screen=*/!runtime->unattended_host_,
CROSSDESK_VERSION, kClientPlatform);
remote_action.i.supports_video_settings = true;
remote_action.i.supports_video_codec_status = true;
remote_action.i.supports_text_input = runtime->devices_.SupportsTextInput();
remote_action.i.supports_deskhub_preview = true;
remote_action.i.supports_deskhub_resolution = true;
@@ -575,7 +580,29 @@ void PeerEventHandler::OnNetStatusReport(
std::string remote_id(user_id, user_id_size);
auto props = runtime->FindRemoteSession(remote_id);
if (!props) return;
if (!props) {
bool report_codecs = false;
{
std::shared_lock lock(runtime->connection_status_mutex_);
const auto status = runtime->connection_status_.find(remote_id);
report_codecs = runtime->codec_status_controllers_.count(remote_id) != 0 &&
status != runtime->connection_status_.end() &&
status->second == ConnectionStatus::Connected;
}
if (net_traffic_stats && report_codecs) {
RemoteAction action{};
action.type = ControlType::video_codec_status;
std::snprintf(action.vcs.encoder, sizeof(action.vcs.encoder), "%.*s",
static_cast<int>(sizeof(net_traffic_stats->video_encoder)),
net_traffic_stats->video_encoder);
const auto message = action.to_json();
// Report only to this controller, after releasing the status lock.
SendReliableDataFrameToPeer(runtime->peer_, message.data(), message.size(),
runtime->control_data_label_.c_str(),
remote_id.data(), remote_id.size());
}
return;
}
if (props->traversal_mode_ != mode) {
props->net_traffic_stats_.ResetLatency();
props->traversal_mode_ = mode;
+2 -2
View File
@@ -18,6 +18,7 @@
#include <string>
#include <thread>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
@@ -149,14 +150,12 @@ struct UserSettingsState {
char connection_login_identity_[17] = "";
int language_button_value_ = 0;
int video_encode_format_button_value_ = 0;
bool enable_hardware_video_codec_ = true;
bool enable_turn_ = true;
char signal_server_ip_[256] = "api.crossdesk.cn";
char signal_server_port_[6] = "9099";
bool enable_self_hosted_ = false;
int language_button_value_last_ = 0;
int video_encode_format_button_value_last_ = 0;
bool enable_hardware_video_codec_last_ = false;
bool enable_turn_last_ = true;
bool enable_self_hosted_last_ = false;
bool enable_autostart_ = false;
@@ -208,6 +207,7 @@ struct ConnectionState {
std::shared_mutex connection_status_mutex_;
std::unordered_map<std::string, ConnectionStatus> connection_status_;
std::unordered_map<std::string, std::string> connection_host_names_;
std::unordered_set<std::string> codec_status_controllers_;
// Cursor position is sampled asynchronously after remote mouse input has
// been injected. Remember the input source so the sampled position is not
// immediately echoed back to that same controller as stale feedback.
+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";
@@ -15,6 +15,8 @@ export struct GalleryNetworkDevice {
loss: string,
resolution: string,
fps: string,
encoder: string,
decoder: string,
}
component NetworkRow inherits Rectangle {
@@ -145,6 +147,26 @@ component NetworkRow inherits Rectangle {
vertical-alignment: center; overflow: elide;
}
}
if !root.heading: HorizontalLayout {
x: 12px; y: 52px; width: parent.width - 24px; height: 20px;
spacing: 12px;
Rectangle {
width: (parent.width - 12px) / 2;
Text {
width: parent.width; height: parent.height;
text: GalleryStrings.tr("被控端编码器 ", "Remote encoder ", "Удалённый кодер ") + root.device.encoder;
font-size: 10px; color: #586f87; vertical-alignment: center; overflow: elide;
}
}
Rectangle {
width: (parent.width - 12px) / 2;
Text {
width: parent.width; height: parent.height;
text: GalleryStrings.tr("控制端解码器 ", "Local decoder ", "Локальный декодер ") + root.device.decoder;
font-size: 10px; color: #586f87; vertical-alignment: center; overflow: elide;
}
}
}
}
component NetworkEmptyState inherits Rectangle {
@@ -163,7 +185,7 @@ component NetworkEmptyState inherits Rectangle {
export component GalleryNetworkDialog inherits Rectangle {
in property <[GalleryNetworkDevice]> devices;
private property <length> row-height: 56px;
private property <length> row-height: 78px;
private property <length> list-height: max(2, min(root.devices.length, 6)) * root.row-height;
callback close();
background: #00000042;
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<defs>
<radialGradient id="edge">
<stop offset="95%" stop-color="white"/>
<stop offset="100%" stop-color="white" stop-opacity="0"/>
</radialGradient>
</defs>
<circle cx="32" cy="32" r="32" fill="url(#edge)"/>
</svg>

After

Width:  |  Height:  |  Size: 326 B

+351 -56
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";
@@ -68,7 +69,6 @@ export global UiStrings {
in-out property <string> capture-method: "Capture method";
in-out property <string> capture-method-auto: "Auto";
in-out property <string> codec: "Video codec";
in-out property <string> hardware-codec: "Hardware video codec";
in-out property <string> turn-relay: "TURN relay";
in-out property <string> force-relay: "Force relay";
in-out property <string> self-hosted: "Self-hosted server";
@@ -99,6 +99,8 @@ export global UiStrings {
in-out property <string> view-release-notes: "Release notes";
in-out property <string> later: "Later";
in-out property <string> download: "Download";
in-out property <string> install-update: "Install update";
in-out property <string> install-update-confirm;
in-out property <string> input-password: "Input password";
in-out property <string> reinput-password: "Incorrect password, please try again";
in-out property <string> remember-password: "Remember password";
@@ -408,6 +410,16 @@ export component MainWindow inherits Window {
callback read-all-announcements;
callback load-more-announcements;
in property <bool> update-available: false;
in property <bool> update-install-available: false;
in property <bool> update-install-busy: false;
changed update-install-available => {
if !root.update-install-available { root.update-confirm-open = false; }
}
changed update-install-busy => {
if root.update-install-busy { root.update-confirm-open = false; }
}
in property <string> update-install-status: "";
private property <string> update-action-text: root.update-install-available ? UiStrings.install-update : UiStrings.download;
in property <UpdateCheckStatus> update-check-status: idle;
private property <string> update-status-text:
root.update-check-status == UpdateCheckStatus.checking ? UiStrings.checking-for-updates :
@@ -443,7 +455,6 @@ export component MainWindow inherits Window {
in property <bool> capture-method-visible: false;
in-out property <int> capture-method-index: 0;
in-out property <int> codec-index: 0;
in-out property <bool> hardware-codec-enabled: true;
in-out property <bool> turn-enabled: true;
in-out property <bool> force-relay-enabled: false;
changed turn-enabled => {
@@ -460,7 +471,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.
@@ -471,9 +482,6 @@ export component MainWindow inherits Window {
in-out property <string> server-port: "";
in-out property <string> self-host-settings-error: "";
in property <bool> settings-session-active: false;
// Keep the setting visible on every platform. Unsupported builds expose it
// as disabled so the fixed settings layout never turns into a blank row.
in property <bool> hardware-codec-available: true;
callback copy-local-id() -> bool;
callback toggle-password-visibility;
@@ -492,6 +500,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,13 +532,29 @@ 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.disconnect-open
&& !root.device-group-open && !root.connection-dialog-open
&& !root.permission-dialog-open && !root.wayland-notice-open
&& !root.portable-service-dialog-open
@@ -539,6 +565,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: "";
@@ -548,6 +578,15 @@ export component MainWindow inherits Window {
in-out property <bool> delete-open: false;
private property <string> delete-remote-id: "";
private property <bool> delete-from-gallery: false;
in-out property <bool> disconnect-open: false;
private property <string> disconnect-remote-id: "";
private property <string> disconnect-device-name: "";
function dismiss-disconnect() {
root.disconnect-open = false;
root.disconnect-remote-id = "";
root.disconnect-device-name = "";
focus-sink.focus();
}
private property <CopyFeedback> copy-feedback: idle;
private property <bool> compact-language: root.language-index == 0;
private property <bool> recent-tooltip-visible: false;
@@ -558,6 +597,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 +631,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 +884,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 {
@@ -1325,13 +1393,18 @@ export component MainWindow inherits Window {
width: 28px; height: 28px;
soft: true;
primary: !connection.connected;
destructive: connection.connected;
icon: connection.connected ? FontAwesomeIcons.xmark : FontAwesomeIcons.arrow-right-long;
tooltip: connection.connected ? GalleryStrings.tr("断开连接", "Disconnect", "Отключить") : UiStrings.connect;
tooltip-above: true;
clicked => {
root.copy-feedback = CopyFeedback.idle;
root.recent-tooltip-visible = false;
if connection.connected { root.recent-disconnect(connection.remote-id); }
if connection.connected {
root.disconnect-remote-id = connection.remote-id;
root.disconnect-device-name = connection.display-name;
root.disconnect-open = true;
}
else { root.recent-connect(connection.remote-id); }
}
}
@@ -1423,25 +1496,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;
}
}
}
}
@@ -1867,6 +1939,98 @@ export component MainWindow inherits Window {
}
}
// Confirm the selected recent session before disconnecting it.
if root.disconnect-open: Rectangle {
background: #14233b66;
z: 48;
TouchArea { scroll-event(event) => { accept } }
FocusScope {
width: parent.width;
height: parent.height;
key-pressed(event) => {
if event.text == Key.Escape {
root.dismiss-disconnect();
return accept;
}
if event.text == Key.Tab {
if disconnect-cancel.has-focus {
disconnect-confirm.focus();
} else {
disconnect-cancel.focus();
}
return accept;
}
return reject;
}
Timer {
interval: 1ms;
running: true;
triggered => {
self.running = false;
// Defer focus until after Slint's accessibility update.
disconnect-cancel.focus();
}
}
DialogSurface {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
width: min(420px, parent.width - 40px);
height: disconnect-content.preferred-height;
border-color: WorkbenchStyle.border;
TouchArea { scroll-event(event) => { accept } }
disconnect-content := VerticalLayout {
padding: 24px;
spacing: 16px;
Text {
text: GalleryStrings.tr("确认断开连接?", "Disconnect this session?", "Отключить этот сеанс?");
color: WorkbenchStyle.text;
font-size: 18px;
font-weight: 600;
wrap: word-wrap;
}
VerticalLayout {
spacing: 6px;
Text {
text: root.disconnect-device-name;
color: WorkbenchStyle.text;
font-size: 14px;
font-weight: 600;
overflow: elide;
}
Text {
text: root.disconnect-remote-id;
color: WorkbenchStyle.muted;
font-size: 12px;
}
}
Text {
text: GalleryStrings.tr("当前远程控制会话将结束。", "The current remote session will end.", "Текущий сеанс удалённого управления завершится.");
color: WorkbenchStyle.muted;
font-size: 13px;
wrap: word-wrap;
}
HorizontalLayout {
alignment: end;
spacing: 8px;
disconnect-cancel := WorkbenchButton {
text: UiStrings.cancel;
clicked => { root.dismiss-disconnect(); }
}
disconnect-confirm := WorkbenchButton {
text: GalleryStrings.tr("断开连接", "Disconnect", "Отключить");
destructive: true;
clicked => {
let remote-id = root.disconnect-remote-id;
root.dismiss-disconnect();
root.recent-disconnect(remote-id);
}
}
}
}
}
}
}
// Offline/error warning modal.
if root.offline-warning != "": Rectangle {
background: #00000042;
@@ -2113,16 +2277,15 @@ export component MainWindow inherits Window {
}
SettingsGroup {
title: GalleryStrings.tr("视频", "Video", "Видео");
rows: root.capture-method-visible ? 3 : 2;
rows: root.capture-method-visible ? 2 : 1;
if root.capture-method-visible: SettingsRow {
label: UiStrings.capture-method; enabled: !root.settings-session-active;
SettingsSelect { x: parent.width - 116px; y: 4px; width: 104px; enabled: !root.settings-session-active; model: [UiStrings.capture-method-auto, "DXGI", "WGC", "GDI"]; current-index <=> root.capture-method-index; }
}
SettingsRow {
label: UiStrings.codec; enabled: !root.settings-session-active;
label: UiStrings.codec; enabled: !root.settings-session-active; divider: false;
SettingsSelect { x: parent.width - 116px; y: 4px; width: 104px; enabled: !root.settings-session-active; model: [UiStrings.codec-h264, UiStrings.codec-av1]; current-index <=> root.codec-index; }
}
SettingsToggleRow { label: UiStrings.hardware-codec; checked <=> root.hardware-codec-enabled; enabled: root.hardware-codec-available && !root.settings-session-active; divider: false; }
}
SettingsGroup {
title: GalleryStrings.tr("网络连接", "Network", "Сеть"); rows: 3;
@@ -2329,17 +2492,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 +2534,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 +2613,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 +2640,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 +2716,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 +2790,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 +2805,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;
}
}
}
}
File diff suppressed because it is too large Load Diff
+11 -3
View File
@@ -93,8 +93,6 @@ inline int ApplyMainWindowStrings(
strings.set_capture_method_auto(
Text(localization::screen_capture_method_auto[language]));
strings.set_codec(Text(localization::video_encode_format[language]));
strings.set_hardware_codec(
Text(localization::enable_hardware_video_codec[language]));
strings.set_turn_relay(Text(localization::enable_turn[language]));
strings.set_force_relay(Text(localization::force_relay[language]));
strings.set_self_hosted(
@@ -131,7 +129,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(
@@ -227,6 +228,11 @@ inline int ApplyStreamWindowStrings(
strings.set_fullscreen(Text(localization::fullscreen[language]));
strings.set_exit_fullscreen(Text(localization::exit_fullscreen[language]));
strings.set_disconnect(Text(localization::disconnect[language]));
strings.set_confirm_disconnect(
Text(localization::stream_confirm_disconnect[language]));
strings.set_disconnect_description(
Text(localization::stream_disconnect_description[language]));
strings.set_cancel(Text(localization::cancel[language]));
strings.set_file_transfer(
Text(localization::file_transfer_progress[language]));
strings.set_expand_control(Text(localization::expand_control_bar[language]));
@@ -240,6 +246,8 @@ inline int ApplyStreamWindowStrings(
strings.set_stats_connection_latency(Text(localization::connection_latency[language]));
strings.set_stats_connection_mode(
Text(localization::connection_mode[language]));
strings.set_stats_encoder(Text(localization::remote_video_encoder[language]));
strings.set_stats_decoder(Text(localization::local_video_decoder[language]));
strings.set_stats_encryption(
Text(localization::transport_encryption[language]));
strings.set_stats_encryption_enabled(
@@ -19,16 +19,17 @@ export global WorkbenchStyle {
export component WorkbenchButton inherits Rectangle {
in property <string> text;
in property <length> text-size: 13px;
in property <color> text-color: root.primary ? white : WorkbenchStyle.text;
in property <color> text-color: root.destructive || root.primary ? white : WorkbenchStyle.text;
in property <color> normal-border-color: WorkbenchStyle.control-border;
in property <string> icon;
in property <string> tooltip;
in property <bool> tooltip-above: false;
in property <bool> tooltip-suppressed: false;
in property <color> icon-color: root.primary
in property <color> icon-color: root.destructive ? white : root.primary
? (root.soft && !touch.has-hover && !root.show-focus ? WorkbenchStyle.accent : white)
: WorkbenchStyle.muted;
in property <bool> primary: false;
in property <bool> destructive: false;
in property <bool> soft: false;
in property <bool> quiet: false;
in property <bool> enabled: true;
@@ -45,11 +46,15 @@ export component WorkbenchButton inherits Rectangle {
height: 36px;
border-radius: UiRadius.control;
border-width: root.show-focus ? 2px : root.soft ? 0px : 1px;
border-color: root.show-focus ? (root.primary ? #193da0 : WorkbenchStyle.accent)
border-color: root.destructive
? (root.show-focus ? #7a271a : touch.pressed ? #912018 : touch.has-hover ? #b42318 : #d92d20)
: root.show-focus ? (root.primary ? #193da0 : WorkbenchStyle.accent)
: root.primary ? WorkbenchStyle.accent
: touch.has-hover ? WorkbenchStyle.hover-border
: root.quiet ? transparent : root.normal-border-color;
background: root.soft
background: root.destructive
? (touch.pressed ? #912018 : touch.has-hover ? #b42318 : #d92d20)
: root.soft
? (root.primary
? (touch.pressed ? #254cc0 : touch.has-hover || root.show-focus ? WorkbenchStyle.accent : #edf2ff)
: (touch.pressed ? WorkbenchStyle.pressed-surface : touch.has-hover ? WorkbenchStyle.hover-surface : WorkbenchStyle.field-surface))
@@ -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
@@ -9,12 +9,8 @@
namespace crossdesk {
void ConfigureLinuxCudaGraphics(bool hardware_acceleration) {
void ConfigureLinuxCudaGraphics() {
#if USE_CUDA && (defined(__x86_64__) || defined(__amd64__))
if (!hardware_acceleration) {
return;
}
// Respect explicit GPU/driver selection, including a request to use Mesa
// or software rendering. Native frames can still use the CPU fallback.
for (const char* name : {"__NV_PRIME_RENDER_OFFLOAD",
@@ -45,8 +41,6 @@ void ConfigureLinuxCudaGraphics(bool hardware_acceleration) {
return;
}
LOG_INFO("Selected NVIDIA PRIME rendering for CUDA/OpenGL video interop");
#else
(void)hardware_acceleration;
#endif
}
@@ -11,7 +11,7 @@ namespace crossdesk {
// Call before SDL or Slint initializes EGL/GLX. Hybrid laptops otherwise
// create the GUI context on the integrated GPU while NVDEC uses NVIDIA.
void ConfigureLinuxCudaGraphics(bool hardware_acceleration);
void ConfigureLinuxCudaGraphics();
} // namespace crossdesk
@@ -35,7 +35,6 @@ std::vector<ConsoleSetting> GetConsoleSettings(const ConfigCenter& config) {
return {
{"language", "console_setting_language", languages[language], "zh-CN / en-US / ru-RU"},
{"codec", "console_setting_codec", config.GetVideoEncodeFormat() == ConfigCenter::VIDEO_ENCODE_FORMAT::AV1 ? "av1" : "h264", "h264 / av1"},
{"hardware", "console_setting_hardware", OnOff(config.IsHardwareVideoCodec()), "on / off"},
{"turn", "console_setting_turn", turn_modes[turn], "off / auto / udp / tcp"},
{"self_hosted", "console_setting_self_hosted", OnOff(config.IsSelfHosted()), "on / off"},
{"server_host", "console_setting_host", config.GetSignalServerHost(), "example.com"},
@@ -54,7 +53,7 @@ SettingResult ApplyConsoleSetting(ConfigCenter& config, const std::string& key,
result.message = "console_settings_readonly";
return result;
}
const bool network = key == "codec" || key == "hardware" || key == "turn" ||
const bool network = key == "codec" || key == "turn" ||
key == "self_hosted" || key == "server_host" || key == "server_port";
if (network && connection_busy) {
result.message = "console_settings_busy";
@@ -111,13 +110,7 @@ SettingResult ApplyConsoleSetting(ConfigCenter& config, const std::string& key,
if (!boolean) return result;
const bool enabled = *boolean;
canonical = OnOff(enabled);
if (key == "hardware") {
if (enabled && !ConfigCenter::IsHardwareVideoCodecAvailable()) {
result.message = "console_settings_hardware_unavailable";
return result;
}
save = [&config, enabled] { return config.SetHardwareVideoCodec(enabled); };
} else if (key == "self_hosted") {
if (key == "self_hosted") {
if (enabled && (config.GetSignalServerHost().empty() || config.GetSignalServerPort() <= 0 || config.GetSignalServerPort() > 65535)) {
result.message = "console_settings_server_required";
return result;
@@ -138,7 +131,7 @@ SettingResult ApplyConsoleSetting(ConfigCenter& config, const std::string& key,
} else return result;
}
for (const auto& setting : GetConsoleSettings(config)) {
if (key != "autostart" && key != "hardware" && setting.key == key && setting.value == canonical) {
if (key != "autostart" && setting.key == key && setting.value == canonical) {
return {true, SettingEffect::none, "console_settings_unchanged"};
}
}
@@ -148,7 +141,7 @@ SettingResult ApplyConsoleSetting(ConfigCenter& config, const std::string& key,
if (previous_server != GetSignalServerSettings(config)) {
effect = SettingEffect::reconnect;
message = "console_settings_reconnect";
} else if (key == "codec" || key == "hardware" || key == "turn") {
} else if (key == "codec" || key == "turn") {
effect = SettingEffect::next_connection;
message = "console_settings_next_connection";
}
@@ -67,6 +67,7 @@ void PlatformMouseController::UpdateDisplayInfoList(
}
int PlatformMouseController::Destroy() {
ReleasePressedButtons();
CleanupWaylandPortal();
if (display_) {
@@ -77,6 +78,28 @@ int PlatformMouseController::Destroy() {
return 0;
}
unsigned PlatformMouseController::ReleasePressedButtons(unsigned mask) {
mask &= 7;
const unsigned pressed = pressed_buttons_ & mask;
for (unsigned i = 0; i < 3; ++i) {
const unsigned bit = 1u << i;
if (!(pressed & bit)) continue;
bool released = false;
if (use_wayland_portal_) {
RemoteAction up{};
up.type = ControlType::mouse;
up.m.flag = static_cast<MouseFlag>(2 + i * 2);
released = SendWaylandMouseCommand(up, 0) == 0;
} else if (display_) {
const unsigned buttons[] = {1, 3, 2};
released = XTestFakeButtonEvent(display_, buttons[i], False, CurrentTime) != 0;
XFlush(display_);
}
if (released) pressed_buttons_ &= ~bit;
}
return mask & ~pressed_buttons_;
}
int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
int display_index) {
if (remote_action.type != ControlType::mouse) {
@@ -84,7 +107,16 @@ int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
}
if (use_wayland_portal_) {
return SendWaylandMouseCommand(remote_action, display_index);
const int ret = SendWaylandMouseCommand(remote_action, display_index);
if (ret == 0) {
const int flag = static_cast<int>(remote_action.m.flag);
if (flag >= 1 && flag <= 6) {
const unsigned bit = 1u << ((flag - 1) / 2);
if (flag & 1) pressed_buttons_ |= bit;
else pressed_buttons_ &= ~bit;
}
}
return ret;
}
if (!display_) {
@@ -112,27 +144,27 @@ int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
break;
}
case MouseFlag::left_down:
XTestFakeButtonEvent(display_, 1, True, CurrentTime);
if (!XTestFakeButtonEvent(display_, 1, True, CurrentTime)) return -1;
XFlush(display_);
break;
case MouseFlag::left_up:
XTestFakeButtonEvent(display_, 1, False, CurrentTime);
if (!XTestFakeButtonEvent(display_, 1, False, CurrentTime)) return -1;
XFlush(display_);
break;
case MouseFlag::right_down:
XTestFakeButtonEvent(display_, 3, True, CurrentTime);
if (!XTestFakeButtonEvent(display_, 3, True, CurrentTime)) return -1;
XFlush(display_);
break;
case MouseFlag::right_up:
XTestFakeButtonEvent(display_, 3, False, CurrentTime);
if (!XTestFakeButtonEvent(display_, 3, False, CurrentTime)) return -1;
XFlush(display_);
break;
case MouseFlag::middle_down:
XTestFakeButtonEvent(display_, 2, True, CurrentTime);
if (!XTestFakeButtonEvent(display_, 2, True, CurrentTime)) return -1;
XFlush(display_);
break;
case MouseFlag::middle_up:
XTestFakeButtonEvent(display_, 2, False, CurrentTime);
if (!XTestFakeButtonEvent(display_, 2, False, CurrentTime)) return -1;
XFlush(display_);
break;
case MouseFlag::wheel_vertical: {
@@ -157,6 +189,12 @@ int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
break;
}
const int flag = static_cast<int>(remote_action.m.flag);
if (flag >= 1 && flag <= 6) {
const unsigned bit = 1u << ((flag - 1) / 2);
if (flag & 1) pressed_buttons_ |= bit;
else pressed_buttons_ &= ~bit;
}
return 0;
}
@@ -32,6 +32,7 @@ class PlatformMouseController final : public MouseController {
virtual int Init(std::vector<DisplayInfo> display_info_list);
virtual int Destroy();
virtual int SendMouseCommand(RemoteAction remote_action, int display_index);
unsigned ReleasePressedButtons(unsigned mask = 7) override;
void UpdateDisplayInfoList(const std::vector<DisplayInfo>& display_info_list);
private:
@@ -58,6 +59,7 @@ class PlatformMouseController final : public MouseController {
int screen_width_ = 0;
int screen_height_ = 0;
bool use_wayland_portal_ = false;
unsigned pressed_buttons_ = 0;
DBusConnection* dbus_connection_ = nullptr;
std::string wayland_session_handle_;
@@ -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
@@ -46,7 +46,37 @@ int PlatformMouseController::Init(std::vector<DisplayInfo> display_info_list) {
return 0;
}
int PlatformMouseController::Destroy() { return 0; }
int PlatformMouseController::Destroy() {
return ReleasePressedButtons() == 7 ? 0 : -1;
}
unsigned PlatformMouseController::ReleasePressedButtons(unsigned mask) {
mask &= 7;
const unsigned pressed = pressed_buttons_ & mask;
if (!pressed) return mask;
const auto cursor = CGEventCreate(nullptr);
if (!cursor) return mask & ~pressed;
const auto point = CGEventGetLocation(cursor);
CFRelease(cursor);
const CGEventType types[] = {kCGEventLeftMouseUp, kCGEventRightMouseUp,
kCGEventOtherMouseUp};
const CGMouseButton buttons[] = {kCGMouseButtonLeft, kCGMouseButtonRight,
kCGMouseButtonCenter};
for (unsigned i = 0; i < 3; ++i) {
const unsigned bit = 1u << i;
if (!(pressed & bit)) continue;
const auto event = CGEventCreateMouseEvent(nullptr, types[i], point, buttons[i]);
if (!event) continue;
CGEventSetIntegerValueField(event, kCGEventSourceUserData, kCrossDeskInjectedEvent);
CGEventPost(kCGHIDEventTap, event);
CFRelease(event);
pressed_buttons_ &= ~bit;
if (i == 0) { left_dragging_ = false; left_click_tracker_ = {}; }
if (i == 1) { right_dragging_ = false; right_click_tracker_ = {}; }
if (i == 2) middle_click_tracker_ = {};
}
return mask & ~pressed_buttons_;
}
void PlatformMouseController::UpdateDisplayInfoList(
const std::vector<DisplayInfo>& display_info_list) {
@@ -135,7 +165,6 @@ int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
switch (remote_action.m.flag) {
case MouseFlag::left_down:
mouse_type = kCGEventLeftMouseDown;
left_dragging_ = true;
click_state = BeginClick(left_click_tracker_, tracked_mouse_pos_x,
tracked_mouse_pos_y);
mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point,
@@ -144,7 +173,6 @@ int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
break;
case MouseFlag::left_up:
mouse_type = kCGEventLeftMouseUp;
left_dragging_ = false;
click_state = EndClick(left_click_tracker_, tracked_mouse_pos_x,
tracked_mouse_pos_y);
mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point,
@@ -153,7 +181,6 @@ int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
break;
case MouseFlag::right_down:
mouse_type = kCGEventRightMouseDown;
right_dragging_ = true;
click_state = BeginClick(right_click_tracker_, tracked_mouse_pos_x,
tracked_mouse_pos_y);
mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point,
@@ -162,7 +189,6 @@ int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
break;
case MouseFlag::right_up:
mouse_type = kCGEventRightMouseUp;
right_dragging_ = false;
click_state = EndClick(right_click_tracker_, tracked_mouse_pos_x,
tracked_mouse_pos_y);
mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point,
@@ -215,6 +241,16 @@ int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
kCrossDeskInjectedEvent);
CGEventPost(kCGHIDEventTap, mouse_event);
CFRelease(mouse_event);
const int flag = static_cast<int>(remote_action.m.flag);
if (flag >= 1 && flag <= 6) {
const unsigned bit = 1u << ((flag - 1) / 2);
if (flag & 1) pressed_buttons_ |= bit;
else pressed_buttons_ &= ~bit;
left_dragging_ = (pressed_buttons_ & 1) != 0;
right_dragging_ = (pressed_buttons_ & 2) != 0;
}
} else {
return -1;
}
return 0;
@@ -26,6 +26,7 @@ class PlatformMouseController final : public MouseController {
virtual int Init(std::vector<DisplayInfo> display_info_list);
virtual int Destroy();
virtual int SendMouseCommand(RemoteAction remote_action, int display_index);
unsigned ReleasePressedButtons(unsigned mask = 7) override;
void UpdateDisplayInfoList(
const std::vector<DisplayInfo>& display_info_list);
@@ -45,6 +46,7 @@ class PlatformMouseController final : public MouseController {
std::vector<DisplayInfo> display_info_list_;
bool left_dragging_ = false;
bool right_dragging_ = false;
unsigned pressed_buttons_ = 0;
ClickTracker left_click_tracker_;
ClickTracker right_click_tracker_;
ClickTracker middle_click_tracker_;
@@ -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
@@ -64,6 +64,7 @@ int GuiApplication::RunUnattended(void* stop_event) {
}
// Quiesce network callbacks before destroying the controllers they use.
remote_input_.Stop();
CloseAllRemoteSessions();
WaitForSessionCleanup();
keyboard_.ReleaseAllRemotePressedKeys("unattended_host_exit");
@@ -0,0 +1,121 @@
#include "shutdown_session_guard.h"
#include <utility>
#include "rd_log.h"
namespace crossdesk {
namespace {
constexpr wchar_t kShutdownWindowClass[] = L"CrossDesk.ShutdownSessionGuard";
}
ShutdownSessionGuard::ShutdownSessionGuard(
std::shared_ptr<std::atomic<bool>> connected)
: connected_(std::move(connected)) {}
ShutdownSessionGuard::~ShutdownSessionGuard() {
ClearReason();
if (window_) DestroyWindow(window_);
}
bool ShutdownSessionGuard::ReportError(const char* operation) {
const DWORD error = GetLastError();
const ULONGLONG now = GetTickCount64();
if (last_error_tick_ == 0 || now - last_error_tick_ >= 5000) {
LOG_WARN("Windows shutdown session guard {} failed, error={}", operation, error);
last_error_tick_ = now;
}
SetLastError(error);
return false;
}
bool ShutdownSessionGuard::Start() {
if (window_) return true;
WNDCLASSW klass{};
klass.lpfnWndProc = WindowProc;
klass.hInstance = GetModuleHandleW(nullptr);
klass.lpszClassName = kShutdownWindowClass;
if (!RegisterClassW(&klass) && GetLastError() != ERROR_CLASS_ALREADY_EXISTS)
return ReportError("register window");
// A top-level window receives session-end broadcasts even when the Slint
// windows are hidden in the tray. HWND_MESSAGE does not receive them.
window_ = CreateWindowExW(WS_EX_TOOLWINDOW, kShutdownWindowClass, L"CrossDesk",
WS_OVERLAPPED, 0, 0, 0, 0, nullptr, nullptr, klass.hInstance, this);
if (!window_) return ReportError("create window");
return true;
}
bool ShutdownSessionGuard::ClearReason() {
if (!reason_registered_) return true;
if (window_ && !ShutdownBlockReasonDestroy(window_))
return ReportError("clear reason");
reason_registered_ = false;
LOG_INFO("Windows shutdown session guard released");
return true;
}
bool ShutdownSessionGuard::SyncReason() {
if (shutdown_allowed_ || !connected_ ||
!connected_->load(std::memory_order_relaxed)) {
return ClearReason();
}
if (reason_registered_) return true;
if (!ShutdownBlockReasonCreate(window_, reason_.c_str()))
return ReportError("register reason");
reason_registered_ = true;
LOG_INFO("Windows shutdown session guard armed for connected controller");
return true;
}
bool ShutdownSessionGuard::Update(std::wstring_view reason) {
if (!Start()) return false;
if (std::wstring_view(reason_) != reason) {
// Keep the old registration and text on failure so the next tick retries
// removal before registering the newly selected language.
if (!ClearReason()) return false;
reason_.assign(reason);
}
return SyncReason();
}
LRESULT CALLBACK ShutdownSessionGuard::WindowProc(
HWND window, UINT message, WPARAM wparam, LPARAM lparam) {
if (message == WM_NCCREATE) {
const auto* create = reinterpret_cast<CREATESTRUCTW*>(lparam);
auto* guard = static_cast<ShutdownSessionGuard*>(create->lpCreateParams);
guard->window_ = window;
SetWindowLongPtrW(window, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(guard));
}
auto* guard = reinterpret_cast<ShutdownSessionGuard*>(
GetWindowLongPtrW(window, GWLP_USERDATA));
if (guard && message == WM_QUERYENDSESSION) {
const auto flags = static_cast<DWORD>(lparam);
// "Shut down anyway", logoff and Restart Manager must remain available.
if ((flags & (ENDSESSION_CRITICAL | ENDSESSION_LOGOFF | ENDSESSION_CLOSEAPP)) != 0)
guard->shutdown_allowed_ = true;
guard->SyncReason();
const bool retain = !guard->shutdown_allowed_ && guard->reason_registered_ &&
guard->connected_ && guard->connected_->load(std::memory_order_relaxed);
LOG_INFO("Windows shutdown query: flags=0x{:x}, retain_remote_session={}", flags, retain);
return retain ? FALSE : TRUE;
}
if (guard && message == WM_ENDSESSION) {
LOG_INFO("Windows session end: confirmed={}, flags=0x{:x}",
wparam != FALSE, static_cast<DWORD>(lparam));
guard->shutdown_allowed_ = wparam != FALSE;
guard->SyncReason();
// A cancelled shutdown must leave transport, capture and input running.
return 0;
}
if (guard && message == WM_NCDESTROY) {
guard->ClearReason();
// Destruction removes the native registration even if explicit removal
// failed. A later window must not inherit the destroyed window's state.
guard->reason_registered_ = false;
guard->window_ = nullptr;
SetWindowLongPtrW(window, GWLP_USERDATA, 0);
}
return DefWindowProcW(window, message, wparam, lparam);
}
} // namespace crossdesk
@@ -0,0 +1,48 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-22
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _SHUTDOWN_SESSION_GUARD_H_
#define _SHUTDOWN_SESSION_GUARD_H_
#include <Windows.h>
#include <atomic>
#include <memory>
#include <string>
#include <string_view>
namespace crossdesk {
// Owned and pumped by the UI thread. The transport may revoke the shared
// connection flag from another thread, including between UI ticks.
class ShutdownSessionGuard {
public:
explicit ShutdownSessionGuard(std::shared_ptr<std::atomic<bool>> connected);
~ShutdownSessionGuard();
ShutdownSessionGuard(const ShutdownSessionGuard&) = delete;
ShutdownSessionGuard& operator=(const ShutdownSessionGuard&) = delete;
bool Update(std::wstring_view reason);
private:
bool Start();
bool SyncReason();
bool ClearReason();
bool ReportError(const char* operation);
static LRESULT CALLBACK WindowProc(HWND window, UINT message,
WPARAM wparam, LPARAM lparam);
std::shared_ptr<std::atomic<bool>> connected_;
HWND window_ = nullptr;
std::wstring reason_;
bool reason_registered_ = false;
bool shutdown_allowed_ = false;
ULONGLONG last_error_tick_ = 0;
};
} // namespace crossdesk
#endif
@@ -11,6 +11,8 @@ namespace crossdesk {
int RunSlintRendererProbe() {
SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX);
// Renderer probing should not inhibit automatic display sleep either.
SDL_SetHint(SDL_HINT_VIDEO_ALLOW_SCREENSAVER, "1");
if (!SDL_Init(SDL_INIT_VIDEO)) {
return 1;
}
@@ -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;
@@ -40,11 +40,14 @@ void PlatformMouseController::UpdateDisplayInfoList(
display_info_list_.size());
}
int PlatformMouseController::Destroy() { return ReleasePressedButtons(); }
int PlatformMouseController::Destroy() {
return ReleasePressedButtons() == 7 ? 0 : -1;
}
int PlatformMouseController::ReleasePressedButtons() {
const unsigned pressed = pressed_buttons_.load();
if (!pressed) return 0;
unsigned PlatformMouseController::ReleasePressedButtons(unsigned mask) {
mask &= 7;
const unsigned pressed = pressed_buttons_.load() & mask;
if (!pressed) return mask;
INPUT input{};
input.type = INPUT_MOUSE;
input.mi.dwExtraInfo = kInjectedMouseInputMarker;
@@ -53,10 +56,10 @@ int PlatformMouseController::ReleasePressedButtons() {
if (pressed & 4) input.mi.dwFlags |= MOUSEEVENTF_MIDDLEUP;
if (SendInputOnUserDesktop(input) != 1) {
LOG_WARN("Release remote mouse buttons failed, error={}", GetLastError());
return -1;
return mask & ~pressed;
}
pressed_buttons_.fetch_and(~pressed);
return 0;
return mask;
}
int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
@@ -29,7 +29,7 @@ class PlatformMouseController final : public MouseController {
virtual int SendMouseCommand(RemoteAction remote_action, int display_index);
void UpdateDisplayInfoList(
const std::vector<DisplayInfo>& display_info_list) override;
int ReleasePressedButtons();
unsigned ReleasePressedButtons(unsigned mask = 7) override;
private:
std::vector<DisplayInfo> display_info_list_;
@@ -13,26 +13,36 @@
namespace crossdesk {
namespace {
DWORD InjectOnUserDesktop(INPUT input) {
struct InputResult {
UINT sent;
DWORD error;
bool requires_worker = false;
};
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;
// A library may have created a window/hook on an opted-in thread. This
// failure occurs before SendInput, so retrying on the worker is safe.
return {0, error != ERROR_SUCCESS ? error : ERROR_GEN_FAILURE,
error == ERROR_BUSY};
}
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 +58,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 +86,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 +94,23 @@ class UserDesktopInputWorker {
} // namespace
UINT SendInputOnUserDesktop(const INPUT& input) {
static UserDesktopInputWorker worker;
const DWORD error = worker.Send(input);
INPUT copy = input;
return SendInputOnUserDesktop(&copy, 1);
}
UINT SendInputOnUserDesktop(INPUT* inputs, UINT count) {
if (!inputs || count == 0) {
SetLastError(ERROR_INVALID_PARAMETER);
return 0;
}
InputResult result{0, ERROR_SUCCESS, true};
if (ScopedUserDesktopInputThread::IsCurrent())
result = InjectOnUserDesktop(inputs, count);
if (result.requires_worker) {
static UserDesktopInputWorker worker;
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 +127,7 @@ UINT SendInputOnUserDesktop(const INPUT& input) {
}
}
SetLastError(error);
return error == ERROR_SUCCESS ? 1 : 0;
return result.sent;
}
} // namespace crossdesk
@@ -11,11 +11,29 @@
namespace crossdesk {
// Executes on a worker without windows or hooks, attached to this process's
// current user input desktop. Returns a SendInput count and preserves the
// worker's failure code in the calling thread's last-error value.
// Opt in only on the remote input worker. UI/hook callers keep using the
// fallback worker. Desktop attachment is still checked for each injection.
class ScopedUserDesktopInputThread {
public:
ScopedUserDesktopInputThread() { ++depth_; }
~ScopedUserDesktopInputThread() { --depth_; }
ScopedUserDesktopInputThread(const ScopedUserDesktopInputThread&) = delete;
ScopedUserDesktopInputThread& operator=(const ScopedUserDesktopInputThread&) =
delete;
static bool IsCurrent() { return depth_ != 0; }
private:
inline static thread_local unsigned depth_ = 0;
};
// Executes directly on an opted-in worker, or on the fallback input worker,
// attached to this process's current user input desktop. Returns a SendInput
// count and preserves the 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
@@ -1,6 +1,6 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-07
* @Date: 2026-10-22
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
@@ -19,6 +19,15 @@ inline bool IsDesktopTransitionInputError(DWORD error) {
error == ERROR_REQUIRES_INTERACTIVE_WINDOWSTATION;
}
// Only call with an observation from the interactive session, never Session 0.
// Process presence (including LogonUI) cannot prove that Default is accessible:
// shutdown blocking UI may be active while WTS still reports an unlocked user.
inline bool IsSecureInputDesktop(bool available, const std::string& name,
DWORD error) {
if (!available) return error == ERROR_ACCESS_DENIED;
return !name.empty() && _stricmp(name.c_str(), "Default") != 0;
}
inline std::wstring GetDesktopNameW(HDESK desktop) {
DWORD bytes_needed = 0;
if (desktop == nullptr) {
@@ -32,6 +32,7 @@
#include "secure_desktop_frame_pacer.h"
#include "secure_desktop_nv12_cache.h"
#include "service_lifetime.h"
#include "shutdown_order.h"
#include "shared_capture_lifecycle.h"
#include "shared_capture_configuration.h"
#include "usbmmidd_policy.h"
@@ -398,13 +399,8 @@ void UpdateHelperState(HelperState* helper_state) {
// Process enumeration can straddle an unlock. Observe the input desktop
// afterwards, and expose the sample's start time for service-side rejection.
InputDesktopInfo desktop_info = GetInputDesktopInfo();
const bool input_desktop_is_winlogon =
_stricmp(desktop_info.name.c_str(), "Winlogon") == 0;
const bool inaccessible_secure_input_desktop =
!desktop_info.available &&
desktop_info.error_code == ERROR_ACCESS_DENIED && !logon_ui_visible;
const bool secure_desktop_active = input_desktop_is_winlogon ||
inaccessible_secure_input_desktop;
const bool secure_desktop_active = crossdesk::IsSecureInputDesktop(
desktop_info.available, desktop_info.name, desktop_info.error_code);
bool session_locked = false;
if (!QuerySessionLockState(helper_state->session_id, &session_locked)) {
session_locked =
@@ -2043,9 +2039,15 @@ void PrintUsage() {
} // namespace
int main(int argc, char* argv[]) {
const DWORD shutdown_error = crossdesk::ConfigureWindowsShutdownOrder(
crossdesk::WindowsShutdownRole::helper);
EnablePerMonitorDpiAwareness();
InitializeHelperLogger();
if (shutdown_error != ERROR_SUCCESS) {
LOG_WARN("Could not configure Windows helper shutdown order, error={}",
shutdown_error);
}
bool run_helper = false;
bool run_secure_input_helper = false;
@@ -2158,6 +2160,8 @@ int main(int argc, char* argv[]) {
bool last_consent_ui = false;
bool last_secure_desktop = false;
bool last_session_locked = false;
bool last_input_desktop_available = false;
DWORD last_input_desktop_error = ERROR_SUCCESS;
std::string last_stage;
while (true) {
UpdateHelperState(&helper_state);
@@ -2165,6 +2169,7 @@ int main(int argc, char* argv[]) {
std::string desktop_name;
bool session_locked = false;
bool input_desktop_available = false;
DWORD input_desktop_error = ERROR_SUCCESS;
bool lock_app_visible = false;
bool logon_ui_running = false;
bool consent_ui_visible = false;
@@ -2174,6 +2179,7 @@ int main(int argc, char* argv[]) {
desktop_name = helper_state.input_desktop_name;
session_locked = helper_state.session_locked;
input_desktop_available = helper_state.input_desktop_available;
input_desktop_error = helper_state.input_desktop_error_code;
lock_app_visible = helper_state.lock_app_visible;
logon_ui_running = helper_state.logon_ui_visible;
consent_ui_visible = helper_state.consent_ui_visible;
@@ -2188,6 +2194,8 @@ int main(int argc, char* argv[]) {
lock_app_visible, credential_ui_visible, secure_desktop_active);
if (desktop_name != last_desktop_name ||
input_desktop_available != last_input_desktop_available ||
input_desktop_error != last_input_desktop_error ||
session_locked != last_session_locked ||
lock_app_visible != last_lock_app ||
logon_ui_running != last_logon_ui ||
@@ -2195,11 +2203,15 @@ int main(int argc, char* argv[]) {
secure_desktop_active != last_secure_desktop || stage != last_stage) {
LOG_INFO(
"Session helper state: session_id={}, input_desktop='{}', "
"input_desktop_available={}, input_desktop_error={}, "
"session_locked={}, lock_app_visible={}, logon_ui_running={}, "
"consent_ui_visible={}, secure_desktop_active={}, stage={}",
current_session_id, desktop_name, session_locked, lock_app_visible,
current_session_id, desktop_name, input_desktop_available,
input_desktop_error, session_locked, lock_app_visible,
logon_ui_running, consent_ui_visible, secure_desktop_active, stage);
last_desktop_name = desktop_name;
last_input_desktop_available = input_desktop_available;
last_input_desktop_error = input_desktop_error;
last_session_locked = session_locked;
last_lock_app = lock_app_visible;
last_logon_ui = logon_ui_running;
@@ -0,0 +1,35 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-10-22
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _SHUTDOWN_ORDER_H_
#define _SHUTDOWN_ORDER_H_
#include <Windows.h>
namespace crossdesk {
enum class WindowsShutdownRole { host, helper };
// Windows shuts processes down from high to low (the default is 0x280).
// Keep the remote host alive while ordinary applications ask the user to
// resolve a blocked shutdown. The helpers must outlive the host. Stay inside
// the application range; 0x000-0x0ff is reserved for the operating system.
inline constexpr DWORD WindowsShutdownLevel(WindowsShutdownRole role) {
return role == WindowsShutdownRole::host ? 0x101 : 0x100;
}
inline DWORD ConfigureWindowsShutdownOrder(WindowsShutdownRole role) {
// Change only the order. The GUI's session guard separately defers shutdown
// while a controller is connected; a confirmed shutdown still ends CrossDesk.
if (SetProcessShutdownParameters(WindowsShutdownLevel(role), 0))
return ERROR_SUCCESS;
const DWORD error = GetLastError();
return error != ERROR_SUCCESS ? error : ERROR_GEN_FAILURE;
}
} // namespace crossdesk
#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") ==
@@ -160,7 +160,6 @@ bool ConfigureDocumentationDemo(
std::make_shared<slint::VectorModel<crossdesk::ui::RecentConnection>>(
std::move(connections)));
window->set_signal_connected(true);
window->set_hardware_codec_available(true);
#if defined(_WIN32) || defined(__APPLE__) || defined(__linux__)
window->set_privacy_setting_available(true);
#endif
@@ -937,7 +936,6 @@ int main() {
window->set_custom_titlebar(true);
window->set_wayland_titlebar(true);
window->set_settings_session_active(true);
window->set_hardware_codec_available(false);
window->set_portable_service_settings_visible(true);
assert(std::string(window->get_local_id()) == "123 456 789");
assert(window->get_connection_dialog_open());
@@ -945,7 +943,6 @@ int main() {
assert(window->get_custom_titlebar());
assert(window->get_wayland_titlebar());
assert(window->get_settings_session_active());
assert(!window->get_hardware_codec_available());
assert(window->get_portable_service_settings_visible());
if (const char *snapshot_path =
std::getenv("CROSSDESK_MAIN_UI_SNAPSHOT")) {
+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}})
+11 -7
View File
@@ -57,6 +57,16 @@ function setup_platform_settings()
"/usr/local/include/freetype2"
}, {system = true})
-- Screen sharing power inhibitors also use D-Bus in X11-only builds.
add_links("dbus-1")
add_existing_include_dirs({
"/usr/include/dbus-1.0",
"/usr/local/include/dbus-1.0"
}, {system = true})
for _, include_dir in ipairs(collect_dbus_arch_include_dirs()) do
add_includedirs(include_dir, {system = true})
end
if is_config("USE_DRM", true) then
add_links("drm")
add_defines("CROSSDESK_HAS_DRM=1")
@@ -69,11 +79,8 @@ function setup_platform_settings()
end
if is_config("USE_WAYLAND", true) then
add_links("dbus-1")
add_defines("CROSSDESK_HAS_WAYLAND_CAPTURER=1")
add_existing_include_dirs({
"/usr/include/dbus-1.0",
"/usr/local/include/dbus-1.0",
"/usr/include/pipewire-0.3",
"/usr/local/include/pipewire-0.3",
"/usr/include/pipewire",
@@ -85,9 +92,6 @@ function setup_platform_settings()
"/usr/local/include/spa",
"/opt/crossdesk-pipewire-sdk/include/spa-0.2"
}, {system = true})
for _, include_dir in ipairs(collect_dbus_arch_include_dirs()) do
add_includedirs(include_dir, {system = true})
end
else
add_defines("CROSSDESK_HAS_WAYLAND_CAPTURER=0")
end
@@ -98,7 +102,7 @@ function setup_platform_settings()
add_ldflags("-Wl,-ld_classic")
end
add_cxflags("-Wno-unused-variable")
add_frameworks("Cocoa", "CoreGraphics", "Metal", "QuartzCore",
add_frameworks("Cocoa", "CoreGraphics", "Metal", "QuartzCore", "IOKit",
"IOSurface",
"ScreenCaptureKit", "AVFoundation", "CoreMedia", "CoreVideo",
"CoreAudio", "AudioToolbox")
@@ -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})
+57 -7
View File
@@ -20,10 +20,6 @@ function setup_targets()
end
table.insert(windows_resource_defines,
"CROSSDESK_VERSION_NUMERIC=" .. table.concat(version_parts, ","))
local windows_helper_resource_defines = table.join(windows_resource_defines)
if is_config("CROSSDESK_PORTABLE", true) then
table.insert(windows_helper_resource_defines, "CROSSDESK_PORTABLE=1")
end
local function copy_slint_runtime(target)
if not target:is_plat("windows") then
@@ -262,7 +258,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 +485,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 +556,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 +564,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 +582,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",
@@ -557,6 +603,7 @@ function setup_targets()
add_files("apps/desktop/src/platform/windows/gui/tray/win_tray.cpp",
"apps/desktop/src/platform/windows/gui/slint_backend.cpp",
"apps/desktop/src/platform/windows/gui/window_icons.cpp",
"apps/desktop/src/platform/windows/gui/shutdown_session_guard.cpp",
"apps/desktop/src/platform/windows/gui/slint_renderer_probe.cpp",
"apps/desktop/src/platform/common/gui/opengl_video_renderer.cpp",
"apps/desktop/src/platform/common/gui/cuda_driver.cpp",
@@ -631,7 +678,7 @@ function setup_targets()
add_files("apps/desktop/src/platform/windows/service/session_helper_main.cpp")
add_files("apps/desktop/src/platform/windows/virtual_display/usbmmidd_virtual_display.cpp")
add_files("apps/desktop/resources/windows/crossdesk_session_helper.rc",
{defines = windows_helper_resource_defines})
{defines = windows_resource_defines})
add_includedirs("apps/desktop/src/common",
"apps/desktop/src/platform/windows/service",
"apps/desktop/src/platform/windows/screen_capturer",
@@ -644,6 +691,9 @@ function setup_targets()
target("crossdesk")
set_kind("binary")
add_deps("rd_log", "desktop_common", "gui", "single_instance")
if not is_config("CROSSDESK_PORTABLE", true) then
add_deps("crossdesk_updater", {inherit = false})
end
add_files("apps/desktop/src/app/*.cpp")
add_includedirs("apps/desktop/src", "apps/desktop/src/app", {public = true})
if is_os("windows") then
+2
View File
@@ -9,6 +9,8 @@
两者仍为开发版。已完成的模拟器验证及未完成的真机验收见各端说明。
手机控制端的“设置 → 屏幕方向 → 连接后自动横屏”默认开启并保存在本机。连接后先保持横屏,检测到手机实际横过来后恢复跟随设备方向;竖持、平放或斜持不会解除初始横屏。关闭后沿用自动旋转,平板和多窗口不受此选项影响。退出会话恢复首页方向,返回同一后台会话不重复锁定已解锁的方向;重力传感器不可用时沿用自动旋转。
## 构建
在仓库根目录执行,当前构建脚本面向 macOS:
+4
View File
@@ -244,6 +244,10 @@ class HarmonyMouse final : public MouseController {
return true;
}
public:
unsigned ReleasePressedButtons(unsigned mask = 7) override {
std::lock_guard lock(input_mutex);
return input.ReleaseMouseButtons(mask);
}
int Init(std::vector<DisplayInfo> displays) override {
if (displays.empty()) return -1;
std::lock_guard lock(input_mutex);

Some files were not shown because too many files have changed in this diff Show More