mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-24 21:57:08 +08:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd7f9505d9 | ||
|
|
4650e48ea3 | ||
|
|
182899d238 | ||
|
|
1d465239cf | ||
|
|
80ea75ff78 | ||
|
|
ff7b954949 | ||
|
|
0fbefd309f | ||
|
|
f269c56e5b | ||
|
|
c6a2f6a35b | ||
|
|
193dcc4be0 | ||
|
|
f8f4aa3cea | ||
|
|
c25e7266e5 | ||
|
|
5e494bfa3b | ||
|
|
f6a2637540 | ||
|
|
621f2ff205 | ||
|
|
5ac6e153c8 | ||
|
|
dd5c572852 | ||
|
|
33d4199f7c | ||
|
|
a6f4df405b | ||
|
|
97960f3a6b | ||
|
|
53c657aa55 | ||
|
|
941de898fb | ||
|
|
6abcafc205 | ||
|
|
171e559b0d | ||
|
|
41f1e29583 | ||
|
|
2ea462764d | ||
|
|
ef02ef2e6c | ||
|
|
e45350319c | ||
|
|
cf98775754 | ||
|
|
1aa23126d2 | ||
|
|
cb2ae207c4 | ||
|
|
a625890b28 | ||
|
|
62ba3693af | ||
|
|
4e5bf66734 |
@@ -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
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -111,6 +111,7 @@ DESKTOP_DIR="$DEB_DIR/usr/share/applications"
|
||||
|
||||
install -d "$DEBIAN_DIR" "$BIN_DIR" "$PRIVATE_DIR" "$DESKTOP_DIR"
|
||||
install -m 0755 "$BUILD_BINARY" "$PRIVATE_DIR/crossdesk-bin"
|
||||
install -m 0755 "$(dirname "$BUILD_BINARY")/crossdesk_updater" "$PRIVATE_DIR/crossdesk_updater"
|
||||
|
||||
# PipeWire is deliberately optional. The Wayland capturer resolves PipeWire
|
||||
# 0.3 with dlopen() so the same package can run on Ubuntu 20.04 without the
|
||||
@@ -156,7 +157,7 @@ for size in 16 24 32 48 64 96 128 256; do
|
||||
"$ICON_BASE_DIR/${size}x${size}/apps/${PKG_NAME}.png"
|
||||
done
|
||||
|
||||
RECOMMENDS="$PORTAL_RUNTIME_RECOMMENDS, $TRAY_RUNTIME_RECOMMENDS"
|
||||
RECOMMENDS="$PORTAL_RUNTIME_RECOMMENDS, $TRAY_RUNTIME_RECOMMENDS, policykit-1"
|
||||
if [[ -n "$EXTRA_RECOMMENDATION" ]]; then
|
||||
RECOMMENDS="$RECOMMENDS, $EXTRA_RECOMMENDATION"
|
||||
fi
|
||||
|
||||
@@ -51,6 +51,8 @@ mkdir -p build_pkg_temp
|
||||
mkdir -p "${MACOS_DIR}" "${RESOURCES_DIR}"
|
||||
|
||||
cp "${EXECUTABLE_PATH}" "${MACOS_DIR}/${APP_NAME_UPPER}"
|
||||
cp "$(dirname "${EXECUTABLE_PATH}")/crossdesk_updater" "${MACOS_DIR}/crossdesk_updater"
|
||||
chmod +x "${MACOS_DIR}/crossdesk_updater"
|
||||
chmod +x "${MACOS_DIR}/${APP_NAME_UPPER}"
|
||||
|
||||
if [ -f "${ICON_PATH}" ]; then
|
||||
|
||||
@@ -51,6 +51,8 @@ mkdir -p build_pkg_temp
|
||||
mkdir -p "${MACOS_DIR}" "${RESOURCES_DIR}"
|
||||
|
||||
cp "${EXECUTABLE_PATH}" "${MACOS_DIR}/${APP_NAME_UPPER}"
|
||||
cp "$(dirname "${EXECUTABLE_PATH}")/crossdesk_updater" "${MACOS_DIR}/crossdesk_updater"
|
||||
chmod +x "${MACOS_DIR}/crossdesk_updater"
|
||||
chmod +x "${MACOS_DIR}/${APP_NAME_UPPER}"
|
||||
|
||||
if [ -f "${ICON_PATH}" ]; then
|
||||
|
||||
@@ -71,6 +71,19 @@ verify_dependencies() {
|
||||
}
|
||||
|
||||
verify_dependencies "$APP_EXECUTABLE"
|
||||
UPDATER="$APP_BUNDLE/Contents/MacOS/crossdesk_updater"
|
||||
if [[ ! -x "$UPDATER" ]] || ! lipo "$UPDATER" -verify_arch "$EXPECTED_ARCH" >/dev/null 2>&1; then
|
||||
echo "Missing updater or incorrect updater architecture" >&2
|
||||
exit 1
|
||||
fi
|
||||
# The updater runs outside the bundle during installation, so it must not rely
|
||||
# on private frameworks that are being replaced.
|
||||
while IFS= read -r dependency; do
|
||||
case "$dependency" in
|
||||
/System/Library/*|/usr/lib/*) ;;
|
||||
*) echo "Updater has a non-system dynamic dependency: $dependency" >&2; exit 1 ;;
|
||||
esac
|
||||
done < <(otool -L "$UPDATER" | tail -n +2 | awk '{ print $1 }')
|
||||
|
||||
SLINT_LIBRARY="$(find "$FRAMEWORKS_DIR" -maxdepth 1 -type f -name 'libslint_cpp*.dylib' -print -quit 2>/dev/null || true)"
|
||||
if [[ -z "$SLINT_LIBRARY" ]]; then
|
||||
|
||||
@@ -39,6 +39,18 @@ RequestExecutionLevel admin
|
||||
|
||||
; Include LogicLib for process handling
|
||||
!include "LogicLib.nsh"
|
||||
!include "FileFunc.nsh"
|
||||
Var UpdateMode
|
||||
|
||||
Function .onInit
|
||||
${GetParameters} $0
|
||||
ClearErrors
|
||||
${GetOptions} $0 "/CROSSDESK_UPDATE" $1
|
||||
StrCpy $UpdateMode 0
|
||||
${IfNot} ${Errors}
|
||||
StrCpy $UpdateMode 1
|
||||
${EndIf}
|
||||
FunctionEnd
|
||||
|
||||
Name "${PRODUCT_NAME} ${PRODUCT_VERSION}"
|
||||
OutFile "crossdesk-win-x64-${PRODUCT_VERSION}.exe"
|
||||
@@ -55,6 +67,11 @@ InstallDirRegKey HKCU "Software\${PRODUCT_NAME}" "InstallDir"
|
||||
ShowInstDetails show
|
||||
|
||||
Section "MainSection"
|
||||
; The updater has already coordinated shutdown. Never kill unrelated
|
||||
; instances by name during an unattended upgrade.
|
||||
${If} $UpdateMode = 1
|
||||
Goto installApp
|
||||
${EndIf}
|
||||
; Check if CrossDesk is running
|
||||
StrCpy $1 "CrossDesk.exe"
|
||||
|
||||
@@ -81,10 +98,12 @@ installApp:
|
||||
Call StopInstalledService
|
||||
|
||||
SetOutPath "$INSTDIR"
|
||||
SetOverwrite ifnewer
|
||||
; The signed release determines file versions, not filesystem timestamps.
|
||||
SetOverwrite on
|
||||
|
||||
; Main application executable path
|
||||
File /oname=CrossDesk.exe "..\..\..\..\build\windows\x64\release\crossdesk.exe"
|
||||
File "..\..\..\..\build\windows\x64\release\crossdesk_updater.exe"
|
||||
; Bundle service-side binaries required by the Windows service flow
|
||||
File "..\..\..\..\build\windows\x64\release\crossdesk_service.exe"
|
||||
File "..\..\..\..\build\windows\x64\release\crossdesk_session_helper.exe"
|
||||
@@ -116,6 +135,7 @@ installApp:
|
||||
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_REG_KEY}" "DisplayName" "${PRODUCT_NAME}"
|
||||
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_REG_KEY}" "UninstallString" "$INSTDIR\uninstall.exe"
|
||||
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_REG_KEY}" "DisplayVersion" "${PRODUCT_VERSION}"
|
||||
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_REG_KEY}" "InstallLocation" "$INSTDIR"
|
||||
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_REG_KEY}" "Publisher" "${PRODUCT_PUBLISHER}"
|
||||
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_REG_KEY}" "URLInfoAbout" "${PRODUCT_WEB_SITE}"
|
||||
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINSTALL_REG_KEY}" "DisplayIcon" "$INSTDIR\CrossDesk.exe"
|
||||
@@ -167,6 +187,7 @@ uninstallApp:
|
||||
|
||||
; Delete main executable and uninstaller
|
||||
Delete "$INSTDIR\CrossDesk.exe"
|
||||
Delete "$INSTDIR\crossdesk_updater.exe"
|
||||
Delete "$INSTDIR\crossdesk_service.exe"
|
||||
Delete "$INSTDIR\crossdesk_session_helper.exe"
|
||||
Delete "$INSTDIR\crossdesk_privacy_cursor_helper.exe"
|
||||
@@ -196,9 +217,16 @@ SectionEnd
|
||||
|
||||
; ------ Functions ------
|
||||
Function LaunchApp
|
||||
${If} $UpdateMode = 1
|
||||
Return
|
||||
${EndIf}
|
||||
Exec "$INSTDIR\CrossDesk.exe"
|
||||
FunctionEnd
|
||||
|
||||
Function .onInstFailed
|
||||
SetErrorLevel 1
|
||||
FunctionEnd
|
||||
|
||||
Function StopInstalledService
|
||||
IfFileExists "$INSTDIR\CrossDesk.exe" 0 stop_with_sc
|
||||
IfFileExists "$INSTDIR\crossdesk_service.exe" 0 stop_with_sc
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(); } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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
@@ -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(©, 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
|
||||
@@ -0,0 +1,199 @@
|
||||
#include "updater/update_core.h"
|
||||
#include "updater/update_platform.h"
|
||||
#include "platform/single_instance.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <chrono>
|
||||
#include <iostream>
|
||||
#include <thread>
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#include <shellapi.h>
|
||||
#pragma comment(linker, "/subsystem:windows /entry:mainCRTStartup")
|
||||
#endif
|
||||
|
||||
namespace crossdesk::updater {
|
||||
namespace {
|
||||
struct Request {
|
||||
fs::path application;
|
||||
Artifact artifact;
|
||||
std::vector<std::string> restart_arguments;
|
||||
};
|
||||
Request ReadRequest(const fs::path& job) {
|
||||
const auto request = nlohmann::json::parse(ReadText(job / "request.json"));
|
||||
const auto application = fs::u8path(request.at("application").get<std::string>());
|
||||
if (!SupportedInstallation(application)) throw std::runtime_error("Invalid installation destination");
|
||||
const auto artifact = VerifyEnvelope(request.at("envelope").get<std::string>(), PublicKey(),
|
||||
PlatformKey(application), BuildVersion());
|
||||
return {application, artifact,
|
||||
request.value("restart_arguments", std::vector<std::string>{})};
|
||||
}
|
||||
|
||||
void WaitForCommit(const fs::path& job) {
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(120);
|
||||
while (true) {
|
||||
if (fs::exists(job / "cancel"))
|
||||
throw std::runtime_error("Update cancelled before application exit");
|
||||
if (fs::exists(job / "commit")) return;
|
||||
if (std::chrono::steady_clock::now() >= deadline)
|
||||
throw std::runtime_error("Application did not confirm update installation");
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
}
|
||||
}
|
||||
|
||||
int Install(const fs::path& job) {
|
||||
if (!IsPrivileged()) return 2;
|
||||
fs::path stage;
|
||||
int result = 1;
|
||||
try {
|
||||
const auto request = ReadRequest(job);
|
||||
stage = PrivateDirectory();
|
||||
const auto package = stage / request.artifact.filename;
|
||||
ExecuteInstall({
|
||||
[&] {
|
||||
// A privileged installer never executes the caller's writable file.
|
||||
// Copy into private storage, then verify the exact copy being installed.
|
||||
CopyPackage(job / request.artifact.filename, package, request.artifact);
|
||||
},
|
||||
[&] {
|
||||
#ifndef _WIN32
|
||||
// Authorization and staging finish while the GUI can still service
|
||||
// the remote connection. An empty directory publishes readiness
|
||||
// atomically without writing through a caller-controlled file.
|
||||
if (fs::exists(job / "cancel"))
|
||||
throw std::runtime_error("Update cancelled before application exit");
|
||||
if (!fs::create_directory(job / "ready"))
|
||||
throw std::runtime_error("Update readiness was already published");
|
||||
WaitForCommit(job);
|
||||
#endif
|
||||
WaitForApplicationExit(request.application);
|
||||
},
|
||||
[&] { InstallPackage(package, request.application); }});
|
||||
result = 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << error.what() << '\n';
|
||||
}
|
||||
RestoreServices();
|
||||
if (!stage.empty()) {
|
||||
std::error_code ignored;
|
||||
fs::remove_all(stage, ignored);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
int Coordinate(const fs::path& job) {
|
||||
fs::path application;
|
||||
std::vector<std::string> restart_arguments;
|
||||
bool committed = false;
|
||||
bool success = false;
|
||||
bool exited = false;
|
||||
std::string error;
|
||||
std::unique_ptr<MaintenanceLock> maintenance;
|
||||
try {
|
||||
const auto request = ReadRequest(job);
|
||||
application = request.application;
|
||||
restart_arguments = request.restart_arguments;
|
||||
auto pinned = PinHelper(ExecutablePath());
|
||||
{
|
||||
maintenance = std::make_unique<MaintenanceLock>(application);
|
||||
VerifyPackage(job / request.artifact.filename, request.artifact);
|
||||
#ifdef _WIN32
|
||||
WriteText(job / "ready", "ready\n");
|
||||
WaitForCommit(job);
|
||||
committed = true;
|
||||
#else
|
||||
// The root-owned helper publishes ready only after authorization and
|
||||
// verification of its private package copy, then waits for the GUI's
|
||||
// commit before touching the installation.
|
||||
const int code = RunElevated(HelperPath(application), job);
|
||||
committed = fs::exists(job / "commit");
|
||||
if (!committed)
|
||||
throw std::runtime_error("Update preparation failed or authorization was cancelled (code " +
|
||||
std::to_string(code) + ")");
|
||||
#endif
|
||||
platform::SingleInstanceGuard client;
|
||||
platform::SingleInstanceGuard launcher;
|
||||
const auto exit_deadline = std::chrono::steady_clock::now() + std::chrono::seconds(45);
|
||||
while (true) {
|
||||
std::string lease_error;
|
||||
const auto client_result = client.TryAcquire(platform::InstanceRole::kClient, lease_error);
|
||||
const auto launcher_result = launcher.TryAcquire(platform::InstanceRole::kLauncher, lease_error);
|
||||
if (client_result == platform::InstanceResult::kError ||
|
||||
launcher_result == platform::InstanceResult::kError)
|
||||
throw std::runtime_error("Cannot wait for application exit: " + lease_error);
|
||||
if (client_result == platform::InstanceResult::kAcquired &&
|
||||
launcher_result == platform::InstanceResult::kAcquired) break;
|
||||
if (std::chrono::steady_clock::now() >= exit_deadline)
|
||||
throw std::runtime_error("CrossDesk did not exit; no files were installed");
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
}
|
||||
exited = true;
|
||||
#ifdef _WIN32
|
||||
// This relocated copy is held read-only across UAC and installation.
|
||||
const int code = RunElevated(ExecutablePath(), job);
|
||||
#endif
|
||||
if (code != 0) throw std::runtime_error("Installation failed or authorization was cancelled (code " +
|
||||
std::to_string(code) + ")");
|
||||
success = true;
|
||||
}
|
||||
} catch (const std::exception& exception) { error = exception.what(); }
|
||||
try {
|
||||
WriteText(job / "result.json", nlohmann::json{{"success", success}, {"error", error}}.dump(2));
|
||||
} catch (const std::exception&) {}
|
||||
maintenance.reset();
|
||||
if (committed && exited) {
|
||||
try {
|
||||
Restart(application, restart_arguments);
|
||||
if (success) {
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(90);
|
||||
while (!fs::exists(job / "started") && std::chrono::steady_clock::now() < deadline)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(200));
|
||||
if (!fs::exists(job / "started")) {
|
||||
success = false;
|
||||
WriteText(job / "result.json", nlohmann::json{{"success", false},
|
||||
{"error", "Installation finished but the new application did not confirm startup. Open CrossDesk or run the installer manually."}}.dump(2));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (const std::exception& exception) {
|
||||
success = false;
|
||||
WriteText(job / "result.json", nlohmann::json{{"success", false},
|
||||
{"error", std::string("Could not restart CrossDesk: ") + exception.what()}}.dump(2));
|
||||
}
|
||||
}
|
||||
if (success) {
|
||||
// Keep request/result for diagnostics, remove the large download.
|
||||
std::error_code ignored;
|
||||
for (const auto& entry : fs::directory_iterator(job)) {
|
||||
if (entry.path().extension() == ".pkg" || entry.path().extension() == ".deb" ||
|
||||
(entry.path().extension() == ".exe" && entry.path() != ExecutablePath()))
|
||||
fs::remove(entry.path(), ignored);
|
||||
}
|
||||
}
|
||||
return success ? 0 : 1;
|
||||
}
|
||||
} // namespace
|
||||
} // namespace crossdesk::updater
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
using namespace crossdesk::updater;
|
||||
try {
|
||||
std::vector<std::string> args;
|
||||
#ifdef _WIN32
|
||||
int count = 0;
|
||||
auto wide = CommandLineToArgvW(GetCommandLineW(), &count);
|
||||
if (!wide) return 2;
|
||||
for (int i = 0; i < count; ++i) args.push_back(fs::path(wide[i]).u8string());
|
||||
LocalFree(wide);
|
||||
(void)argc; (void)argv;
|
||||
#else
|
||||
for (int i = 0; i < argc; ++i) args.emplace_back(argv[i]);
|
||||
#endif
|
||||
if (args.size() != 3) return 2;
|
||||
const auto job = fs::u8path(args[2]);
|
||||
if (!job.is_absolute() || fs::is_symlink(job) || !fs::is_directory(job)) return 2;
|
||||
if (args[1] == "--install") return Install(job);
|
||||
if (args[1] == "--coordinate" && OwnedJobDirectory(job)) return Coordinate(job);
|
||||
} catch (const std::exception& error) { std::cerr << error.what() << '\n'; }
|
||||
return 2;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
#include "updater/update_controller.h"
|
||||
#include "updater/update_platform.h"
|
||||
#include "common/filesystem_utf8.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace crossdesk::updater {
|
||||
Controller::Controller(fs::path cache) : cache_(std::move(cache)) {
|
||||
// A previous result survives a restart. Incomplete jobs are never replayed.
|
||||
try {
|
||||
const auto previous = PathFromUtf8(ReadText(cache_ / "last-update", 32768));
|
||||
if (!fs::exists(previous / "result.json")) {
|
||||
state_ = State::Failed;
|
||||
error_ = "The previous update was interrupted. Download and retry.\n" + PathUtf8(previous);
|
||||
return;
|
||||
}
|
||||
const auto result = nlohmann::json::parse(ReadText(previous / "result.json"));
|
||||
if (!result.value("success", false)) {
|
||||
state_ = State::Failed;
|
||||
error_ = result.value("error", std::string("Update did not complete")) +
|
||||
"\n" + PathUtf8(previous);
|
||||
} else if (fs::exists(previous / "started")) {
|
||||
// Retire receipts confirmed by older clients too, before temporary
|
||||
// storage is cleaned by the OS and a successful update looks interrupted.
|
||||
fs::remove(cache_ / "last-update");
|
||||
}
|
||||
} catch (const std::exception&) {}
|
||||
}
|
||||
void Controller::ConfirmStartup() {
|
||||
try {
|
||||
const auto previous = PathFromUtf8(ReadText(cache_ / "last-update", 32768));
|
||||
const auto result = nlohmann::json::parse(ReadText(previous / "result.json"));
|
||||
if (!result.value("success", false)) return;
|
||||
const auto request = nlohmann::json::parse(ReadText(previous / "request.json"));
|
||||
const auto artifact = VerifyEnvelope(request.at("envelope").get<std::string>(), PublicKey(),
|
||||
PlatformKey(ExecutablePath()), "0.0.0");
|
||||
auto built = BuildVersion();
|
||||
if (!built.empty() && built[0] == 'v') built.erase(0, 1);
|
||||
if (artifact.version == built) {
|
||||
WriteText(previous / "started", "started\n");
|
||||
// Acknowledge the coordinator first. Successful startup no longer needs
|
||||
// a persistent pointer into a temporary job directory.
|
||||
fs::remove(cache_ / "last-update");
|
||||
}
|
||||
} catch (const std::exception&) {}
|
||||
}
|
||||
Controller::~Controller() {
|
||||
Cancel();
|
||||
if (pending_.valid()) {
|
||||
try {
|
||||
const auto prepared = pending_.get();
|
||||
WriteText(prepared / "cancel", "cancel\n");
|
||||
} catch (const std::exception&) {}
|
||||
}
|
||||
}
|
||||
bool Controller::Available(const std::string& envelope) const {
|
||||
if (checked_ && checked_envelope_ == envelope) return available_;
|
||||
checked_ = true;
|
||||
checked_envelope_ = envelope;
|
||||
available_ = false;
|
||||
try {
|
||||
const auto application = ExecutablePath();
|
||||
if (!SupportedInstallation(application)) return false;
|
||||
VerifyEnvelope(envelope, PublicKey(), PlatformKey(application), BuildVersion());
|
||||
available_ = true;
|
||||
return available_;
|
||||
} catch (const std::exception&) { return false; }
|
||||
}
|
||||
void Controller::Start(const std::string& envelope) {
|
||||
if (busy() || state_ == State::Ready) return;
|
||||
error_.clear();
|
||||
progress_ = 0;
|
||||
cancel_ = false;
|
||||
state_ = State::Downloading;
|
||||
try {
|
||||
pending_ = std::async(std::launch::async, [this, envelope] {
|
||||
const auto application = ExecutablePath();
|
||||
if (!SupportedInstallation(application)) throw std::runtime_error("Unsupported installation");
|
||||
const auto artifact = VerifyEnvelope(envelope, PublicKey(), PlatformKey(application), BuildVersion());
|
||||
const auto job = PrivateDirectory();
|
||||
try {
|
||||
WriteText(job / "request.json", nlohmann::json{
|
||||
{"application", PathUtf8(application)}, {"envelope", envelope},
|
||||
{"restart_arguments", RestartArguments()}}.dump());
|
||||
DownloadPackage(artifact, job / artifact.filename, progress_, cancel_);
|
||||
if (cancel_) throw std::runtime_error("Update cancelled");
|
||||
// Relocate the coordinator so package installation can replace the
|
||||
// installed helper too. Its crypto/runtime dependencies are static.
|
||||
const auto helper = job / HelperPath(application).filename();
|
||||
fs::copy_file(HelperPath(application), helper);
|
||||
fs::permissions(helper, fs::perms::owner_all);
|
||||
Launch(helper, {"--coordinate", PathUtf8(job)});
|
||||
return job;
|
||||
} catch (...) {
|
||||
// Only this freshly created directory is owned by this worker.
|
||||
std::error_code ignored;
|
||||
fs::remove_all(job, ignored);
|
||||
throw;
|
||||
}
|
||||
});
|
||||
} catch (const std::exception& exception) {
|
||||
error_ = exception.what();
|
||||
state_ = State::Failed;
|
||||
}
|
||||
}
|
||||
bool Controller::Poll() {
|
||||
try {
|
||||
if (state_ == State::Downloading && pending_.valid() &&
|
||||
pending_.wait_for(std::chrono::seconds(0)) == std::future_status::ready) {
|
||||
job_ = pending_.get();
|
||||
// POSIX readiness includes the system authorization dialog and staging
|
||||
// the verified package; keep the GUI available while the user approves.
|
||||
deadline_ = std::chrono::steady_clock::now() + std::chrono::minutes(5);
|
||||
state_ = State::Preparing;
|
||||
}
|
||||
if (state_ != State::Preparing) return false;
|
||||
if (cancel_) throw std::runtime_error("Update cancelled");
|
||||
if (fs::exists(job_ / "result.json")) {
|
||||
const auto result = nlohmann::json::parse(ReadText(job_ / "result.json"));
|
||||
throw std::runtime_error(result.value("error", std::string("Update preparation failed")));
|
||||
}
|
||||
if (fs::exists(job_ / "ready")) {
|
||||
WriteText(cache_ / "last-update", PathUtf8(job_));
|
||||
WriteText(job_ / "commit", "install\n");
|
||||
state_ = State::Ready;
|
||||
return true;
|
||||
}
|
||||
if (std::chrono::steady_clock::now() >= deadline_)
|
||||
throw std::runtime_error("Update helper did not become ready");
|
||||
} catch (const std::exception& exception) {
|
||||
const bool cancelled = cancel_.load();
|
||||
if (state_ == State::Preparing) Cancel();
|
||||
error_ = exception.what();
|
||||
state_ = cancelled ? State::Idle : State::Failed;
|
||||
if (cancelled) error_.clear();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
void Controller::Cancel() {
|
||||
cancel_ = true;
|
||||
if (state_ == State::Preparing && !job_.empty()) {
|
||||
try { WriteText(job_ / "cancel", "cancel\n"); } catch (const std::exception&) {}
|
||||
}
|
||||
}
|
||||
} // namespace crossdesk::updater
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-20
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _UPDATE_CONTROLLER_H_
|
||||
#define _UPDATE_CONTROLLER_H_
|
||||
|
||||
#include "updater/update_core.h"
|
||||
#include <future>
|
||||
|
||||
namespace crossdesk::updater {
|
||||
class Controller {
|
||||
public:
|
||||
enum class State { Idle, Downloading, Preparing, Ready, Failed };
|
||||
explicit Controller(fs::path cache);
|
||||
~Controller();
|
||||
bool Available(const std::string& envelope) const;
|
||||
void Start(const std::string& envelope);
|
||||
// Returns true only after a live helper has acknowledged the prepared job.
|
||||
bool Poll();
|
||||
void Cancel();
|
||||
void ConfirmStartup();
|
||||
State state() const { return state_; }
|
||||
int progress() const { return progress_.load(); }
|
||||
const std::string& error() const { return error_; }
|
||||
bool busy() const { return state_ == State::Downloading || state_ == State::Preparing; }
|
||||
private:
|
||||
fs::path cache_, job_;
|
||||
State state_ = State::Idle;
|
||||
std::string error_;
|
||||
mutable std::string checked_envelope_;
|
||||
mutable bool checked_ = false;
|
||||
mutable bool available_ = false;
|
||||
std::atomic<int> progress_{0};
|
||||
std::atomic<bool> cancel_{false};
|
||||
std::future<fs::path> pending_;
|
||||
std::chrono::steady_clock::time_point deadline_{};
|
||||
};
|
||||
} // namespace crossdesk::updater
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,267 @@
|
||||
#include "updater/update_core.h"
|
||||
|
||||
#include <app_version.h>
|
||||
#include <httplib.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/rand.h>
|
||||
|
||||
#include <array>
|
||||
#include <fstream>
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
#include <vector>
|
||||
|
||||
#ifndef CROSSDESK_UPDATE_PUBLIC_KEY
|
||||
#define CROSSDESK_UPDATE_PUBLIC_KEY ""
|
||||
#endif
|
||||
#ifndef CROSSDESK_VERSION
|
||||
#define CROSSDESK_VERSION "0.0.0"
|
||||
#endif
|
||||
|
||||
namespace crossdesk::updater {
|
||||
namespace {
|
||||
constexpr std::uint64_t kMaxPackageSize = 2ULL * 1024 * 1024 * 1024;
|
||||
constexpr const char* kDownloadOrigin = "https://downloads.crossdesk.cn";
|
||||
|
||||
std::string Hex(const unsigned char* data, std::size_t length) {
|
||||
constexpr char digits[] = "0123456789abcdef";
|
||||
std::string result;
|
||||
for (std::size_t i = 0; i < length; ++i) {
|
||||
result += digits[data[i] >> 4];
|
||||
result += digits[data[i] & 15];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<unsigned char> Unhex(const std::string& value, std::size_t size) {
|
||||
if (value.size() != size * 2) throw std::runtime_error("Invalid signature/key size");
|
||||
std::vector<unsigned char> result(size);
|
||||
auto digit = [](char c) -> int {
|
||||
if (c >= '0' && c <= '9') return c - '0';
|
||||
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
|
||||
throw std::runtime_error("Expected lowercase hexadecimal");
|
||||
};
|
||||
for (std::size_t i = 0; i < size; ++i)
|
||||
result[i] = static_cast<unsigned char>(digit(value[2 * i]) * 16 + digit(value[2 * i + 1]));
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string ExpectedFilename(const std::string& platform, const std::string& version) {
|
||||
if (version.empty() || version.find_first_not_of("0123456789.-") != std::string::npos)
|
||||
throw std::runtime_error("Invalid release filename version");
|
||||
if (platform == "windows-x64") return "crossdesk-win-x64-v" + version + ".exe";
|
||||
if (platform == "windows-x64-portable")
|
||||
return "crossdesk-win-x64-portable-v" + version + ".zip";
|
||||
if (platform == "macos-x64" || platform == "macos-arm64")
|
||||
return "crossdesk-" + platform + "-v" + version + ".pkg";
|
||||
for (const std::string arch : {"amd64", "arm64"}) {
|
||||
if (platform == "linux-" + arch)
|
||||
return "crossdesk-linux-" + arch + "-v" + version + ".deb";
|
||||
if (platform == "linux-" + arch + "-virtual-desktop-xfce")
|
||||
return "crossdesk-virtual-desktop-xfce-linux-" + arch + "-v" + version + ".deb";
|
||||
}
|
||||
throw std::runtime_error("Unsupported update platform");
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::string PublicKey() { return CROSSDESK_UPDATE_PUBLIC_KEY; }
|
||||
std::string BuildVersion() { return CROSSDESK_VERSION; }
|
||||
std::string PathUtf8(const fs::path& path) { return path.u8string(); }
|
||||
|
||||
Artifact VerifyEnvelope(const std::string& envelope, const std::string& public_key,
|
||||
const std::string& platform, const std::string& current_version) {
|
||||
if (envelope.size() > 65536) throw std::runtime_error("Update envelope is too large");
|
||||
const auto wrapper = nlohmann::json::parse(envelope);
|
||||
const auto payload = wrapper.at("payload").get<std::string>();
|
||||
const auto signature = Unhex(wrapper.at("signature").get<std::string>(), 64);
|
||||
const auto key_bytes = Unhex(public_key, 32);
|
||||
std::unique_ptr<EVP_PKEY, decltype(&EVP_PKEY_free)> key(
|
||||
EVP_PKEY_new_raw_public_key(EVP_PKEY_ED25519, nullptr, key_bytes.data(), key_bytes.size()),
|
||||
EVP_PKEY_free);
|
||||
std::unique_ptr<EVP_MD_CTX, decltype(&EVP_MD_CTX_free)> context(EVP_MD_CTX_new(), EVP_MD_CTX_free);
|
||||
if (!key || !context || EVP_DigestVerifyInit(context.get(), nullptr, nullptr, nullptr, key.get()) != 1 ||
|
||||
EVP_DigestVerify(context.get(), signature.data(), signature.size(),
|
||||
reinterpret_cast<const unsigned char*>(payload.data()), payload.size()) != 1)
|
||||
throw std::runtime_error("Update signature verification failed");
|
||||
const auto value = nlohmann::json::parse(payload);
|
||||
if (value.at("schema") != 1) throw std::runtime_error("Unsupported update schema");
|
||||
Artifact artifact;
|
||||
artifact.platform = value.at("platform").get<std::string>();
|
||||
artifact.version = value.at("version").get<std::string>();
|
||||
artifact.filename = value.at("filename").get<std::string>();
|
||||
artifact.url = value.at("url").get<std::string>();
|
||||
artifact.sha256 = value.at("sha256").get<std::string>();
|
||||
if (!value.at("size").is_number_unsigned()) throw std::runtime_error("Invalid package size");
|
||||
artifact.size = value.at("size").get<std::uint64_t>();
|
||||
if (artifact.platform != platform || platform.find("portable") != std::string::npos ||
|
||||
!IsValidAppVersion(current_version) || !IsValidAppVersion(artifact.version) ||
|
||||
!IsNewerVersion(current_version, artifact.version))
|
||||
throw std::runtime_error("Update platform/version does not match this installation");
|
||||
if (!artifact.size || artifact.size > kMaxPackageSize ||
|
||||
artifact.filename != ExpectedFilename(platform, artifact.version) ||
|
||||
artifact.url != std::string(kDownloadOrigin) + "/" + artifact.filename)
|
||||
throw std::runtime_error("Invalid update package metadata");
|
||||
Unhex(artifact.sha256, 32);
|
||||
return artifact;
|
||||
}
|
||||
|
||||
std::string PlatformKey(const fs::path& executable, bool portable) {
|
||||
#if defined(_WIN32) && (defined(_M_X64) || defined(__x86_64__))
|
||||
(void)executable;
|
||||
return portable ? "windows-x64-portable" : "windows-x64";
|
||||
#elif defined(__APPLE__)
|
||||
(void)executable; (void)portable;
|
||||
#if defined(__aarch64__) || defined(__arm64__)
|
||||
return "macos-arm64";
|
||||
#else
|
||||
return "macos-x64";
|
||||
#endif
|
||||
#elif defined(__linux__) && !defined(__OHOS__)
|
||||
(void)portable;
|
||||
#if defined(__aarch64__)
|
||||
std::string key = "linux-arm64";
|
||||
#elif defined(__x86_64__)
|
||||
std::string key = "linux-amd64";
|
||||
#else
|
||||
return {};
|
||||
#endif
|
||||
std::ifstream marker(executable.parent_path() / "package-name");
|
||||
std::string package;
|
||||
if (!std::getline(marker, package)) return {};
|
||||
if (package == "crossdesk-virtual-desktop-xfce") key += "-virtual-desktop-xfce";
|
||||
else if (package != "crossdesk") return {};
|
||||
return key;
|
||||
#else
|
||||
(void)executable; (void)portable;
|
||||
return {};
|
||||
#endif
|
||||
}
|
||||
|
||||
std::string DownloadUrl(const std::string& manifest, const std::string& platform,
|
||||
const std::string& version) {
|
||||
try {
|
||||
const auto download = nlohmann::json::parse(manifest).at("downloads").at(platform);
|
||||
const auto filename = ExpectedFilename(platform, version);
|
||||
const auto url = std::string(kDownloadOrigin) + "/" + filename;
|
||||
if (download.at("version") == version && download.at("filename") == filename &&
|
||||
download.at("url") == url) return url;
|
||||
} catch (const std::exception&) {}
|
||||
return "https://crossdesk.cn";
|
||||
}
|
||||
|
||||
std::string SignedDownload(const std::string& manifest, const std::string& platform) {
|
||||
try {
|
||||
return nlohmann::json::parse(manifest).at("downloads").at(platform).at("update").dump();
|
||||
} catch (const std::exception&) { return {}; }
|
||||
}
|
||||
|
||||
std::string Sha256(const std::string& value) {
|
||||
unsigned char digest[EVP_MAX_MD_SIZE];
|
||||
unsigned int size = 0;
|
||||
if (EVP_Digest(value.data(), value.size(), digest, &size, EVP_sha256(), nullptr) != 1)
|
||||
throw std::runtime_error("SHA-256 failed");
|
||||
return Hex(digest, size);
|
||||
}
|
||||
|
||||
void VerifyPackage(const fs::path& file, const Artifact& artifact) {
|
||||
std::ifstream input(file, std::ios::binary);
|
||||
std::unique_ptr<EVP_MD_CTX, decltype(&EVP_MD_CTX_free)> context(EVP_MD_CTX_new(), EVP_MD_CTX_free);
|
||||
if (!input || !context || EVP_DigestInit_ex(context.get(), EVP_sha256(), nullptr) != 1)
|
||||
throw std::runtime_error("Cannot read update package");
|
||||
std::array<char, 65536> buffer{};
|
||||
std::uint64_t total = 0;
|
||||
while (input.read(buffer.data(), buffer.size()) || input.gcount()) {
|
||||
const auto size = static_cast<std::size_t>(input.gcount());
|
||||
total += size;
|
||||
if (total > artifact.size || EVP_DigestUpdate(context.get(), buffer.data(), size) != 1)
|
||||
throw std::runtime_error("Update package size/hash failed");
|
||||
}
|
||||
unsigned char digest[EVP_MAX_MD_SIZE];
|
||||
unsigned int size = 0;
|
||||
if (!input.eof() || total != artifact.size ||
|
||||
EVP_DigestFinal_ex(context.get(), digest, &size) != 1 || Hex(digest, size) != artifact.sha256)
|
||||
throw std::runtime_error("Update package verification failed");
|
||||
}
|
||||
|
||||
void DownloadPackage(const Artifact& artifact, const fs::path& destination,
|
||||
std::atomic<int>& progress, const std::atomic<bool>& cancel) {
|
||||
httplib::Client client(kDownloadOrigin);
|
||||
client.set_connection_timeout(10);
|
||||
client.set_read_timeout(30);
|
||||
client.set_write_timeout(10);
|
||||
client.set_max_timeout(30 * 60 * 1000);
|
||||
client.set_follow_location(false);
|
||||
#if defined(__linux__)
|
||||
for (const char* bundle : {"/etc/ssl/certs/ca-certificates.crt", "/etc/pki/tls/certs/ca-bundle.crt"}) {
|
||||
if (fs::exists(bundle)) { client.set_ca_cert_path(bundle); break; }
|
||||
}
|
||||
#endif
|
||||
std::ofstream file(destination, std::ios::binary | std::ios::trunc);
|
||||
if (!file) throw std::runtime_error("Cannot create update download");
|
||||
std::uint64_t received = 0;
|
||||
const auto response = client.Get(("/" + artifact.filename).c_str(),
|
||||
[&](const httplib::Response& headers) {
|
||||
return headers.status == 200 && !cancel.load();
|
||||
},
|
||||
[&](const char* data, std::size_t length) {
|
||||
if (cancel.load() || length > artifact.size - received) return false;
|
||||
file.write(data, static_cast<std::streamsize>(length));
|
||||
received += length;
|
||||
progress.store(static_cast<int>(received * 100 / artifact.size));
|
||||
return static_cast<bool>(file);
|
||||
});
|
||||
file.close();
|
||||
if (!response || response->status != 200 || !file || received != artifact.size || cancel.load())
|
||||
throw std::runtime_error(cancel.load() ? "Update cancelled" : "Update download failed");
|
||||
VerifyPackage(destination, artifact);
|
||||
}
|
||||
|
||||
void CopyPackage(const fs::path& source, const fs::path& destination, const Artifact& artifact) {
|
||||
std::ifstream input(source, std::ios::binary);
|
||||
std::ofstream output(destination, std::ios::binary | std::ios::trunc);
|
||||
if (!input || !output) throw std::runtime_error("Cannot stage installation package");
|
||||
std::array<char, 65536> buffer{};
|
||||
std::uint64_t copied = 0;
|
||||
while (input.read(buffer.data(), buffer.size()) || input.gcount()) {
|
||||
const auto length = static_cast<std::uint64_t>(input.gcount());
|
||||
if (length > artifact.size - copied) throw std::runtime_error("Installation package is too large");
|
||||
output.write(buffer.data(), input.gcount());
|
||||
if (!output) throw std::runtime_error("Cannot write installation package");
|
||||
copied += length;
|
||||
}
|
||||
output.close();
|
||||
if (!input.eof() || !output || copied != artifact.size)
|
||||
throw std::runtime_error("Installation package is incomplete");
|
||||
VerifyPackage(destination, artifact);
|
||||
}
|
||||
|
||||
std::string ReadText(const fs::path& file, std::size_t maximum) {
|
||||
std::ifstream input(file, std::ios::binary);
|
||||
if (!input) throw std::runtime_error("Cannot read update metadata");
|
||||
std::string value(maximum + 1, '\0');
|
||||
input.read(value.data(), static_cast<std::streamsize>(value.size()));
|
||||
value.resize(static_cast<std::size_t>(input.gcount()));
|
||||
if (value.size() > maximum || input.bad()) throw std::runtime_error("Invalid update metadata size");
|
||||
return value;
|
||||
}
|
||||
|
||||
void WriteText(const fs::path& file, const std::string& text) {
|
||||
std::ofstream output(file, std::ios::binary | std::ios::trunc);
|
||||
output << text;
|
||||
output.close();
|
||||
if (!output) throw std::runtime_error("Cannot write update state");
|
||||
}
|
||||
|
||||
std::string RandomId() {
|
||||
unsigned char bytes[16];
|
||||
if (RAND_bytes(bytes, sizeof(bytes)) != 1) throw std::runtime_error("Cannot generate update ID");
|
||||
return Hex(bytes, sizeof(bytes));
|
||||
}
|
||||
|
||||
void ExecuteInstall(const InstallActions& actions) {
|
||||
actions.verify();
|
||||
actions.wait_for_exit();
|
||||
actions.install();
|
||||
}
|
||||
} // namespace crossdesk::updater
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-20
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _UPDATE_CORE_H_
|
||||
#define _UPDATE_CORE_H_
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace crossdesk::updater {
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
struct Artifact {
|
||||
std::string platform;
|
||||
std::string version;
|
||||
std::string filename;
|
||||
std::string url;
|
||||
std::string sha256;
|
||||
std::uint64_t size = 0;
|
||||
};
|
||||
|
||||
// The envelope contains the exact signed UTF-8 payload and a hex Ed25519 signature.
|
||||
// Never reserialize the payload before verifying it.
|
||||
Artifact VerifyEnvelope(const std::string& envelope, const std::string& public_key,
|
||||
const std::string& platform,
|
||||
const std::string& current_version);
|
||||
std::string PublicKey();
|
||||
std::string BuildVersion();
|
||||
std::string PlatformKey(const fs::path& executable, bool portable = false);
|
||||
std::string DownloadUrl(const std::string& manifest, const std::string& platform,
|
||||
const std::string& version);
|
||||
std::string SignedDownload(const std::string& manifest, const std::string& platform);
|
||||
std::string Sha256(const std::string& value);
|
||||
void VerifyPackage(const fs::path& file, const Artifact& artifact);
|
||||
void CopyPackage(const fs::path& source, const fs::path& destination, const Artifact& artifact);
|
||||
void DownloadPackage(const Artifact& artifact, const fs::path& destination,
|
||||
std::atomic<int>& progress, const std::atomic<bool>& cancel);
|
||||
std::string ReadText(const fs::path& file, std::size_t maximum = 65536);
|
||||
void WriteText(const fs::path& file, const std::string& text);
|
||||
fs::path PrivateDirectory();
|
||||
std::string RandomId();
|
||||
// ABI boundary between the C++17 core and C++20 GUI (char8_t).
|
||||
std::string PathUtf8(const fs::path& path);
|
||||
|
||||
// Files and installation operations are injectable so failure ordering is tested
|
||||
// without invoking an actual installer.
|
||||
struct InstallActions {
|
||||
std::function<void()> verify;
|
||||
std::function<void()> wait_for_exit;
|
||||
std::function<void()> install;
|
||||
};
|
||||
void ExecuteInstall(const InstallActions& actions);
|
||||
} // namespace crossdesk::updater
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-10-20
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _UPDATE_PLATFORM_H_
|
||||
#define _UPDATE_PLATFORM_H_
|
||||
|
||||
#include "updater/update_core.h"
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk::updater {
|
||||
fs::path ExecutablePath();
|
||||
fs::path HelperPath(const fs::path& application);
|
||||
bool SupportedInstallation(const fs::path& application);
|
||||
bool IsPrivileged();
|
||||
bool OwnedJobDirectory(const fs::path& job);
|
||||
void Launch(const fs::path& executable, const std::vector<std::string>& arguments);
|
||||
int Run(const fs::path& executable, const std::vector<std::string>& arguments,
|
||||
bool elevate = false);
|
||||
int RunElevated(const fs::path& executable, const fs::path& job);
|
||||
void WaitForApplicationExit(const fs::path& application);
|
||||
void InstallPackage(const fs::path& package, const fs::path& application);
|
||||
std::vector<std::string> RestartArguments();
|
||||
void Restart(const fs::path& application, const std::vector<std::string>& arguments);
|
||||
void RestoreServices();
|
||||
// Pin relocated helper files against changes during elevation (Windows).
|
||||
std::shared_ptr<void> PinHelper(const fs::path& helper);
|
||||
|
||||
class MaintenanceLock {
|
||||
public:
|
||||
explicit MaintenanceLock(const fs::path& application);
|
||||
~MaintenanceLock();
|
||||
MaintenanceLock(const MaintenanceLock&) = delete;
|
||||
MaintenanceLock& operator=(const MaintenanceLock&) = delete;
|
||||
private:
|
||||
std::intptr_t handle_ = -1;
|
||||
};
|
||||
bool MaintenanceActive(const fs::path& application);
|
||||
} // namespace crossdesk::updater
|
||||
|
||||
#endif
|
||||
@@ -54,6 +54,15 @@ bool UpdateChecker::Poll(TimePoint now) {
|
||||
ScheduleRetry(now);
|
||||
return true;
|
||||
}
|
||||
// Branch names followed by a date are not application versions. Comparing
|
||||
// their first digit sequence would incorrectly treat the date as a major
|
||||
// version and report "up to date" for every ordinary release.
|
||||
if (!IsValidAppVersion(current_version_) || !IsValidAppVersion(result->version)) {
|
||||
LOG_WARN("Cannot compare invalid application versions: current={}, latest={}",
|
||||
current_version_, result->version);
|
||||
ScheduleRetry(now);
|
||||
return true;
|
||||
}
|
||||
latest_ = std::move(result);
|
||||
update_available_ =
|
||||
IsNewerVersionWithMetadata(current_version_, latest_->version,
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
|
||||
#include "rd_log.h"
|
||||
#include "linux_update_download.h"
|
||||
#ifndef __OHOS__
|
||||
#include "updater/update_core.h"
|
||||
#include "updater/update_platform.h"
|
||||
#endif
|
||||
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
@@ -138,6 +142,22 @@ std::optional<VersionInfo> CheckUpdate() {
|
||||
info->download_url = LinuxUpdateDownloadUrl(
|
||||
manifest, *info, architecture,
|
||||
error ? std::filesystem::path{} : executable.parent_path());
|
||||
#endif
|
||||
#ifndef __OHOS__
|
||||
const auto platform = updater::PlatformKey(updater::ExecutablePath(),
|
||||
#if CROSSDESK_PORTABLE
|
||||
true
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
);
|
||||
#if !defined(__linux__)
|
||||
info->download_url = updater::DownloadUrl(manifest.dump(), platform, info->version);
|
||||
#endif
|
||||
#if !CROSSDESK_PORTABLE
|
||||
if (updater::DownloadUrl(manifest.dump(), platform, info->version) != "https://crossdesk.cn")
|
||||
info->update_envelope = updater::SignedDownload(manifest.dump(), platform);
|
||||
#endif
|
||||
#endif
|
||||
LOG_INFO("Fetched version.json: latest_version={}, releaseDate={}, patch={}",
|
||||
info->version, info->release_date, info->patch);
|
||||
|
||||
@@ -47,11 +47,12 @@ function main(plat, portable)
|
||||
}
|
||||
local seen = {}
|
||||
for _, file in ipairs(gui:sourcefiles()) do
|
||||
assert(not seen[file], "duplicate GUI source: " .. file)
|
||||
seen[file] = true
|
||||
local normalized = file:gsub("\\", "/")
|
||||
assert(not seen[normalized], "duplicate GUI source: " .. file)
|
||||
seen[normalized] = true
|
||||
local defines = (gui:fileconfig(file) or {}).defines
|
||||
assert(contains(defines, 'CROSSDESK_VERSION="v1.5.3-2-20260913"') ==
|
||||
(version_files[file] == true), "unexpected version scope: " .. file)
|
||||
(version_files[normalized] == true), "unexpected version scope: " .. file)
|
||||
end
|
||||
for file in pairs(version_files) do
|
||||
assert(seen[file], "missing version consumer: " .. file)
|
||||
@@ -70,8 +71,8 @@ function main(plat, portable)
|
||||
wgc_plugin = "wgc_plugin"
|
||||
}
|
||||
for name, resource in pairs(resources) do
|
||||
local defines = targets[name]:fileconfig(
|
||||
"apps/desktop/resources/windows/" .. resource .. ".rc").defines
|
||||
local defines = targets[name]:fileconfig(path.translate(
|
||||
"apps/desktop/resources/windows/" .. resource .. ".rc")).defines
|
||||
assert(contains(defines, 'CROSSDESK_VERSION_STRING="v1.5.3-2-20260913"'))
|
||||
assert(contains(defines, "CROSSDESK_VERSION_NUMERIC=1,5,3,0"))
|
||||
assert(contains(defines, "CROSSDESK_PORTABLE=1") ==
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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}})
|
||||
|
||||
@@ -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()
|
||||
@@ -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})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
两者仍为开发版。已完成的模拟器验证及未完成的真机验收见各端说明。
|
||||
|
||||
手机控制端的“设置 → 屏幕方向 → 连接后自动横屏”默认开启并保存在本机。连接后先保持横屏,检测到手机实际横过来后恢复跟随设备方向;竖持、平放或斜持不会解除初始横屏。关闭后沿用自动旋转,平板和多窗口不受此选项影响。退出会话恢复首页方向,返回同一后台会话不重复锁定已解锁的方向;重力传感器不可用时沿用自动旋转。
|
||||
|
||||
## 构建
|
||||
|
||||
在仓库根目录执行,当前构建脚本面向 macOS:
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user