Compare commits

...
17 Commits
Author SHA1 Message Date
dijunkun c6a2f6a35b [feat] display video codecs and refine network statistics 2026-10-22 19:18:37 +08:00
dijunkun 193dcc4be0 [feat] prefer hardware video codecs by default 2026-10-22 17:20:48 +08:00
dijunkun f8f4aa3cea [fix] prevent remote input from blocking transport callbacks 2026-10-22 16:10:58 +08:00
dijunkun c25e7266e5 [fix] retain failed keyboard releases per controller 2026-10-22 16:10:44 +08:00
dijunkun 5e494bfa3b [fix] support selective mouse button release across desktop platforms 2026-10-22 16:10:34 +08:00
dijunkun f6a2637540 [fix] send mouse movement over unreliable transport 2026-10-22 10:59:36 +08:00
kunkundi 621f2ff205 [fix] update MiniRTC for Windows hardware encoding 2026-10-22 10:46:24 +08:00
dijunkun 5ac6e153c8 [fix] preserve Windows remote sessions during shutdown 2026-10-22 04:17:27 +08:00
dijunkun dd5c572852 [feat] enable Windows MFT codecs without CUDA 2026-10-22 03:25:24 +08:00
dijunkun 33d4199f7c [ui] turn the reconnect action into a button 2026-10-21 23:38:32 +08:00
dijunkun a6f4df405b [fix] update MiniRTC for signaling disconnect detection 2026-10-21 23:34:53 +08:00
dijunkun 97960f3a6b [ui] remove icons from about navigation rows 2026-10-21 23:34:44 +08:00
dijunkun 53c657aa55 [fix] share desktop build version across workflow jobs 2026-10-21 23:18:23 +08:00
dijunkun 941de898fb [fix] validate desktop artifact versions against release tags 2026-10-21 23:10:14 +08:00
dijunkun 6abcafc205 [fix] prevent older releases from overwriting update metadata 2026-10-21 23:03:32 +08:00
dijunkun 171e559b0d [feat] add desktop license and source information pages 2026-10-21 11:45:27 +08:00
dijunkun 41f1e29583 [fix] pin desktop and Python dependencies to OpenSSL 3.3.2 2026-10-21 11:07:16 +08:00
77 changed files with 2522 additions and 665 deletions
+66 -14
View File
@@ -50,7 +50,7 @@ def sign_update(payload, private_key, expected_public_key):
return {"payload": payload, "signature": signature.hex()} return {"payload": payload, "signature": signature.hex()}
def version_info(version, fallback_date=None): def version_fields(version):
version = version.removeprefix("v") version = version.removeprefix("v")
base = r"[0-9]+(?:\.[0-9]+){1,3}" base = r"[0-9]+(?:\.[0-9]+){1,3}"
for pattern in ( for pattern in (
@@ -60,18 +60,41 @@ def version_info(version, fallback_date=None):
): ):
match = re.fullmatch(pattern, version) match = re.fullmatch(pattern, version)
if match: if match:
fields = match.groupdict() return 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)}
raise ValueError(f"Invalid release version: {version}") 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): def artifact_downloads(directory):
downloads = {} downloads = {}
private_key = os.environ.get("CROSSDESK_UPDATE_PRIVATE_KEY", "") private_key = os.environ.get("CROSSDESK_UPDATE_PRIVATE_KEY", "")
@@ -129,10 +152,34 @@ def merge_release(current, tag, name, notes, downloads, published_date=None):
if not tag.startswith("v"): if not tag.startswith("v"):
raise ValueError(f"Expected a version tag, got {tag!r}") raise ValueError(f"Expected a version tag, got {tag!r}")
metadata = version_info(tag, published_date) 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 = dict(current)
result.pop("platforms", None) result.pop("platforms", None)
result["downloads"] = {**current["downloads"], **downloads} result["downloads"] = {**current["downloads"], **accepted_downloads}
if not mobile: if not mobile:
result.update({**metadata, "releaseName": name, "releaseNotes": notes, "tagName": tag}) result.update({**metadata, "releaseName": name, "releaseNotes": notes, "tagName": tag})
return result return result
@@ -159,11 +206,16 @@ def main():
parser.error("Supply --release-json or --tag, --name and --notes") parser.error("Supply --release-json or --tag, --name and --notes")
tag, name, notes = args.tag, args.name, args.notes.read_text() tag, name, notes = args.tag, args.name, args.notes.read_text()
published_date = None 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) downloads = artifact_downloads(args.artifacts)
result = merge_release(current, tag, name, notes, downloads, published_date) result = merge_release(current, tag, name, notes, downloads, published_date)
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n") if result == current:
print(f"Updated {tag}: {', '.join(downloads)}") 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__": if __name__ == "__main__":
+73 -219
View File
@@ -36,9 +36,68 @@ env:
CROSSDESK_DESKTOP_VERSION: ${{ inputs.desktop_version }} CROSSDESK_DESKTOP_VERSION: ${{ inputs.desktop_version }}
jobs: 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: build-linux:
name: Build Linux (${{ matrix.arch }}) name: Build Linux (${{ matrix.arch }})
needs: prepare-version
runs-on: ${{ matrix.runner }} runs-on: ${{ matrix.runner }}
env:
LEGAL_VERSION: ${{ needs.prepare-version.outputs.version }}
strategy: strategy:
matrix: matrix:
include: include:
@@ -58,69 +117,6 @@ jobs:
options: --user root options: --user root
steps: steps:
- name: Extract version number
shell: bash
run: |
VERSION_REF="${GITHUB_REF##*/}"
if [[ "${GITHUB_REF}" == refs/heads/* ]]; then
VERSION_REF="${CROSSDESK_DESKTOP_VERSION:-0.0.0}"
if [[ ! "${VERSION_REF}" =~ ^v?[0-9]+(\.[0-9]+){1,3}$ ]]; then
echo "::error::desktop_version must be a numeric version such as 1.7.2"
exit 1
fi
elif [[ -n "${CROSSDESK_DESKTOP_VERSION}" ]]; then
echo "::error::desktop_version is only supported for branch builds; tags determine release versions"
exit 1
fi
VERSION_BASE="${VERSION_REF#v}"
PATCH_NUMBER="${{ github.event.inputs.patch }}"
BUILD_DATE_OVERRIDE=""
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 - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v5
with: with:
@@ -218,8 +214,10 @@ jobs:
# macOS # macOS
build-macos: build-macos:
name: Build on macOS name: Build on macOS
needs: prepare-version
runs-on: ${{ matrix.runner }} runs-on: ${{ matrix.runner }}
env: env:
VERSION_NUM: ${{ needs.prepare-version.outputs.version }}
MACOSX_DEPLOYMENT_TARGET: "14.0" MACOSX_DEPLOYMENT_TARGET: "14.0"
EXPECTED_MACOS_SDK: "26.5" EXPECTED_MACOS_SDK: "26.5"
strategy: strategy:
@@ -239,56 +237,6 @@ jobs:
package_script: ./apps/desktop/scripts/macos/pkg_arm64.sh package_script: ./apps/desktop/scripts/macos/pkg_arm64.sh
steps: steps:
- name: Extract version number
id: version
shell: bash
run: |
VERSION_REF="${GITHUB_REF##*/}"
if [[ "${GITHUB_REF}" == refs/heads/* ]]; then
VERSION_REF="${CROSSDESK_DESKTOP_VERSION:-0.0.0}"
if [[ ! "${VERSION_REF}" =~ ^v?[0-9]+(\.[0-9]+){1,3}$ ]]; then
echo "::error::desktop_version must be a numeric version such as 1.7.2"
exit 1
fi
elif [[ -n "${CROSSDESK_DESKTOP_VERSION}" ]]; then
echo "::error::desktop_version is only supported for branch builds; tags determine release versions"
exit 1
fi
VERSION_BASE="${VERSION_REF#v}"
BUILD_DATE=$(TZ=Asia/Shanghai date +%Y%m%d)
PATCH_NUMBER="${{ github.event.inputs.patch }}"
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 - name: Select Xcode 26.6
shell: bash shell: bash
run: | run: |
@@ -380,62 +328,12 @@ jobs:
# Windows # Windows
build-windows-x64: build-windows-x64:
name: Build on Windows x64 name: Build on Windows x64
needs: prepare-version
runs-on: windows-2022 runs-on: windows-2022
env: env:
VERSION_NUM: ${{ needs.prepare-version.outputs.version }}
XMAKE_GLOBALDIR: D:\xmake_global XMAKE_GLOBALDIR: D:\xmake_global
steps: steps:
- name: Extract version number
shell: pwsh
run: |
$ref = $env:GITHUB_REF
$version = $ref -replace '^refs/(tags|heads)/', ''
if ($ref.StartsWith("refs/heads/")) {
$version = if ([string]::IsNullOrEmpty($env:CROSSDESK_DESKTOP_VERSION)) {
"0.0.0"
} else {
$env:CROSSDESK_DESKTOP_VERSION
}
if ($version -notmatch '^v?[0-9]+(\.[0-9]+){1,3}\z') {
throw "desktop_version must be a numeric version such as 1.7.2"
}
} elseif (![string]::IsNullOrEmpty($env:CROSSDESK_DESKTOP_VERSION)) {
throw "desktop_version is only supported for branch builds; tags determine release versions"
}
$version = $version -replace '^v', ''
$version = $version -replace '/', '-'
$BUILD_DATE = ([System.TimeZoneInfo]::ConvertTimeBySystemTimeZoneId((Get-Date), "China Standard Time")).ToString("yyyyMMdd")
$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 - name: Cache xmake dependencies
uses: actions/cache@v5 uses: actions/cache@v5
with: with:
@@ -636,7 +534,7 @@ jobs:
release: release:
name: Publish Release name: Publish Release
if: startsWith(github.ref, 'refs/tags/v') 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 runs-on: ubuntu-latest
concurrency: concurrency:
group: publish-release-metadata group: publish-release-metadata
@@ -651,50 +549,6 @@ jobs:
with: with:
path: artifacts 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 - name: Prepare release notes
run: | run: |
cat > "$RUNNER_TEMP/release-notes.md" << EOF cat > "$RUNNER_TEMP/release-notes.md" << EOF
@@ -724,18 +578,18 @@ jobs:
- name: Rename artifacts - name: Rename artifacts
run: | run: |
mkdir -p release 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-x64-${{ needs.prepare-version.outputs.version }}/* release/crossdesk-macos-x64-${{ needs.prepare-version.outputs.version }}.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-arm64-${{ needs.prepare-version.outputs.version }}/* release/crossdesk-macos-arm64-${{ needs.prepare-version.outputs.version }}.pkg
for ARCH in amd64 arm64; do for ARCH in amd64 arm64; do
for PACKAGE in crossdesk crossdesk-virtual-desktop-xfce; do for PACKAGE in crossdesk crossdesk-virtual-desktop-xfce; do
FILE="${PACKAGE}-linux-${ARCH}-${{ steps.version.outputs.VERSION_WITH_V }}.deb" FILE="${PACKAGE}-linux-${ARCH}-${{ needs.prepare-version.outputs.version }}.deb"
cp "artifacts/crossdesk-linux-${ARCH}-${{ steps.version.outputs.VERSION_WITH_V }}/${FILE}" "release/${FILE}" cp "artifacts/crossdesk-linux-${ARCH}-${{ needs.prepare-version.outputs.version }}/${FILE}" "release/${FILE}"
done done
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-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-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-${{ needs.prepare-version.outputs.version }}/* release/crossdesk-win-x64-${{ needs.prepare-version.outputs.version }}.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-portable-${{ needs.prepare-version.outputs.version }}/* release/crossdesk-win-x64-portable-${{ needs.prepare-version.outputs.version }}.zip
- name: List release files - name: List release files
run: ls -lh release/ run: ls -lh release/
@@ -743,10 +597,10 @@ jobs:
- name: Upload to Versioned GitHub Release - name: Upload to Versioned GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: 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. # The pushed tag already determines the release source commit.
target_commitish: ${{ github.event.repository.default_branch }} target_commitish: ${{ github.event.repository.default_branch }}
name: Release ${{ steps.version.outputs.VERSION_WITH_V }} name: Release ${{ needs.prepare-version.outputs.version }}
draft: false draft: false
prerelease: false prerelease: false
files: release/* files: release/*
@@ -789,7 +643,7 @@ jobs:
- name: Generate version.json for all platforms - name: Generate version.json for all platforms
env: 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 }} CROSSDESK_UPDATE_PRIVATE_KEY: ${{ secrets.CROSSDESK_UPDATE_PRIVATE_KEY }}
run: | run: |
python3 .github/scripts/update_version_manifest.py \ python3 .github/scripts/update_version_manifest.py \
@@ -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 CREATEPROCESS_MANIFEST_RESOURCE_ID 1
#define RT_MANIFEST 24 #define RT_MANIFEST 24
#ifdef CROSSDESK_PORTABLE // The service supplies either the user's token (desktop observer) or a SYSTEM
CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "crossdesk_portable.manifest" // token (secure capture/input). Requiring elevation here prevents the observer
#elif defined(CROSSDESK_DEBUG) // from starting with CreateProcessAsUserW in installed release builds (740).
CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "crossdesk_debug.manifest" CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "crossdesk_session_helper.manifest"
#else
CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "crossdesk.manifest"
#endif
#define CROSSDESK_FILE_DESCRIPTION "CrossDesk Session Helper" #define CROSSDESK_FILE_DESCRIPTION "CrossDesk Session Helper"
#define CROSSDESK_ORIGINAL_FILENAME "crossdesk_session_helper.exe" #define CROSSDESK_ORIGINAL_FILENAME "crossdesk_session_helper.exe"
+11 -2
View File
@@ -20,6 +20,7 @@
#include "platform/windows/gui/slint_backend.h" #include "platform/windows/gui/slint_backend.h"
#include "service_host.h" #include "service_host.h"
#include "service_lifetime.h" #include "service_lifetime.h"
#include "shutdown_order.h"
#include "unattended_config.h" #include "unattended_config.h"
#endif #endif
@@ -207,8 +208,6 @@ int HandleServiceCliCommand(const std::string& command) {
destination.SetTurnMode(source.GetTurnMode()) == 0 && destination.SetTurnMode(source.GetTurnMode()) == 0 &&
destination.SetVideoEncodeFormat(source.GetVideoEncodeFormat()) == destination.SetVideoEncodeFormat(source.GetVideoEncodeFormat()) ==
0 && 0 &&
destination.SetHardwareVideoCodec(source.IsHardwareVideoCodec()) ==
0 &&
destination.SetScreenCaptureMethod( destination.SetScreenCaptureMethod(
crossdesk::ScreenCaptureMethod::Auto) == 0 && crossdesk::ScreenCaptureMethod::Auto) == 0 &&
destination.SetPrivacyScreen(false) == 0 && destination.SetPrivacyScreen(false) == 0 &&
@@ -406,6 +405,16 @@ static int RunApplication(
} }
int main(int argc, char* argv[]) { 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__ #ifdef __linux__
// Run before GUI, configuration, logging or display initialization. // Run before GUI, configuration, logging or display initialization.
if (argc == 2 && if (argc == 2 &&
@@ -41,6 +41,8 @@ int ConfigCenter::Load() {
} }
bool persist_config_migration = false; 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 // These options now belong only to the active remote connection. Discard
// legacy local defaults so they cannot affect this host or new connections. // legacy local defaults so they cannot affect this host or new connections.
persist_config_migration |= ini_->Delete(section_, "video_quality"); persist_config_migration |= ini_->Delete(section_, "video_quality");
@@ -78,8 +80,6 @@ int ConfigCenter::Load() {
ini_->GetLongValue(section_, "video_encode_format", ini_->GetLongValue(section_, "video_encode_format",
static_cast<long>(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); const char* turn_mode_value = ini_->GetValue(section_, "turn_mode", nullptr);
if (turn_mode_value != nullptr && strlen(turn_mode_value) > 0) { if (turn_mode_value != nullptr && strlen(turn_mode_value) > 0) {
const long parsed_turn_mode = ini_->GetLongValue( const long parsed_turn_mode = ini_->GetLongValue(
@@ -205,7 +205,6 @@ int ConfigCenter::Save() {
static_cast<long>(screen_capture_method_)); static_cast<long>(screen_capture_method_));
ini_->SetLongValue(section_, "video_encode_format", ini_->SetLongValue(section_, "video_encode_format",
static_cast<long>(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_->SetLongValue(section_, "turn_mode", static_cast<long>(turn_mode_));
ini_->SetBoolValue(section_, "enable_turn", ini_->SetBoolValue(section_, "enable_turn",
turn_mode_ != TURN_MODE::DISABLED); turn_mode_ != TURN_MODE::DISABLED);
@@ -271,13 +270,6 @@ int ConfigCenter::SetVideoEncodeFormat(VIDEO_ENCODE_FORMAT format) {
return 0; 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) { int ConfigCenter::SetTurnMode(TURN_MODE mode) {
if (!IsValidTurnModeValue(static_cast<long>(mode))) return -1; if (!IsValidTurnModeValue(static_cast<long>(mode))) return -1;
if (StoreValues({{"turn_mode", std::to_string(static_cast<int>(mode))}, 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_; 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 { ConfigCenter::TURN_MODE ConfigCenter::GetTurnMode() const {
return turn_mode_; return turn_mode_;
} }
@@ -37,7 +37,6 @@ class ConfigCenter {
int SetLanguage(LANGUAGE language); int SetLanguage(LANGUAGE language);
int SetScreenCaptureMethod(ScreenCaptureMethod method); int SetScreenCaptureMethod(ScreenCaptureMethod method);
int SetVideoEncodeFormat(VIDEO_ENCODE_FORMAT video_encode_format); int SetVideoEncodeFormat(VIDEO_ENCODE_FORMAT video_encode_format);
int SetHardwareVideoCodec(bool hardware_video_codec);
int SetTurnMode(TURN_MODE turn_mode); int SetTurnMode(TURN_MODE turn_mode);
int SetTurn(bool enable_turn, bool force_relay); int SetTurn(bool enable_turn, bool force_relay);
int SetServerHost(const std::string& signal_server_host); int SetServerHost(const std::string& signal_server_host);
@@ -57,9 +56,6 @@ class ConfigCenter {
LANGUAGE GetLanguage() const; LANGUAGE GetLanguage() const;
ScreenCaptureMethod GetScreenCaptureMethod() const; ScreenCaptureMethod GetScreenCaptureMethod() const;
VIDEO_ENCODE_FORMAT GetVideoEncodeFormat() 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; TURN_MODE GetTurnMode() const;
bool IsEnableTurn() const; bool IsEnableTurn() const;
bool IsForceRelay() const; bool IsForceRelay() const;
@@ -92,7 +88,6 @@ class ConfigCenter {
LANGUAGE language_ = LANGUAGE::CHINESE; LANGUAGE language_ = LANGUAGE::CHINESE;
ScreenCaptureMethod screen_capture_method_ = ScreenCaptureMethod::Auto; ScreenCaptureMethod screen_capture_method_ = ScreenCaptureMethod::Auto;
VIDEO_ENCODE_FORMAT video_encode_format_ = VIDEO_ENCODE_FORMAT::H264; VIDEO_ENCODE_FORMAT video_encode_format_ = VIDEO_ENCODE_FORMAT::H264;
bool hardware_video_codec_ = false;
TURN_MODE turn_mode_ = TURN_MODE::AUTO_UDP_TCP; TURN_MODE turn_mode_ = TURN_MODE::AUTO_UDP_TCP;
std::string signal_server_host_ = ""; std::string signal_server_host_ = "";
std::string signal_server_host_default_ = "api.crossdesk.cn"; std::string signal_server_host_default_ = "api.crossdesk.cn";
@@ -32,6 +32,9 @@ class MouseController : public DeviceController {
virtual int Destroy() = 0; virtual int Destroy() = 0;
virtual int SendMouseCommand(RemoteAction remote_action, virtual int SendMouseCommand(RemoteAction remote_action,
int display_index) = 0; 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( virtual void UpdateDisplayInfoList(
const std::vector<DisplayInfo>&) {} const std::vector<DisplayInfo>&) {}
}; };
@@ -32,7 +32,7 @@ struct InteractionState {
bool audio_capture_ = false; bool audio_capture_ = false;
int screen_width_ = 1280; int screen_width_ = 1280;
int screen_height_ = 720; int screen_height_ = 720;
int selected_display_ = 0; std::atomic<int> selected_display_{0};
char remote_id_display_[12] = ""; char remote_id_display_[12] = "";
unsigned char audio_buffer_[720]{}; unsigned char audio_buffer_[720]{};
int audio_len_ = 0; int audio_len_ = 0;
@@ -77,7 +77,7 @@ struct UiState {
bool show_connection_status_window_ = false; bool show_connection_status_window_ = false;
bool fullscreen_button_pressed_ = false; bool fullscreen_button_pressed_ = false;
bool is_client_mode_ = false; bool is_client_mode_ = false;
bool is_server_mode_ = false; std::atomic<bool> is_server_mode_{false};
bool reload_recent_connections_ = true; bool reload_recent_connections_ = true;
bool show_offline_warning_window_ = false; bool show_offline_warning_window_ = false;
std::string offline_warning_text_; std::string offline_warning_text_;
@@ -36,6 +36,7 @@
#include "crossdesk_ui.h" #include "crossdesk_ui.h"
#include "fa_solid_900.h" #include "fa_solid_900.h"
#include "features/about/about_catalog.h"
#include "features/file_transfer/transfer_notification_layout.h" #include "features/file_transfer/transfer_notification_layout.h"
#include "features/gallery/gallery_fullscreen_transition.h" #include "features/gallery/gallery_fullscreen_transition.h"
#include "features/gallery/gallery_window_state.h" #include "features/gallery/gallery_window_state.h"
@@ -55,6 +56,7 @@
#include <windows.h> #include <windows.h>
#include "platform/windows/gui/slint_backend.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/tray/win_tray.h"
#include "platform/windows/gui/window_icons.h" #include "platform/windows/gui/window_icons.h"
#elif defined(__APPLE__) #elif defined(__APPLE__)
@@ -471,7 +473,7 @@ std::string FormatBitrate(uint32_t bits_per_second) {
} else if (bits_per_second < 1000000) { } else if (bits_per_second < 1000000) {
std::snprintf(text, sizeof(text), "%u kbps", bits_per_second / 1000); std::snprintf(text, sizeof(text), "%u kbps", bits_per_second / 1000);
} else { } 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; return text;
} }
@@ -922,6 +924,10 @@ void DragWindow(WindowHandle& component, int phase, float mouse_x,
} // namespace } // namespace
struct GuiApplication::SlintUi { struct GuiApplication::SlintUi {
#if _WIN32
std::unique_ptr<ShutdownSessionGuard> shutdown_guard;
std::wstring_view shutdown_reason;
#endif
gallery::Workspace gallery; gallery::Workspace gallery;
std::filesystem::path gallery_path; std::filesystem::path gallery_path;
std::vector<std::string> gallery_ids; std::vector<std::string> gallery_ids;
@@ -1195,7 +1201,7 @@ void GuiApplication::InitializeSettings() {
bool GuiApplication::InitializeSDL() { bool GuiApplication::InitializeSDL() {
#if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__) #if (defined(__linux__) && !defined(__OHOS__)) && !defined(__APPLE__)
ConfigureLinuxCudaGraphics(config_center_->IsHardwareVideoCodec()); ConfigureLinuxCudaGraphics();
// Standard Wayland top-level windows cannot choose an absolute screen // Standard Wayland top-level windows cannot choose an absolute screen
// position. Use the session's XWayland compatibility server for the GUI so // position. Use the session's XWayland compatibility server for the GUI so
// auxiliary windows such as the controlled-side status window can be // auxiliary windows such as the controlled-side status window can be
@@ -1283,6 +1289,10 @@ void GuiApplication::InitializeUi() {
slint::set_xdg_app_id("crossdesk"); slint::set_xdg_app_id("crossdesk");
#endif #endif
ui_ = std::make_unique<SlintUi>(); ui_ = std::make_unique<SlintUi>();
#if _WIN32
ui_->shutdown_guard =
std::make_unique<ShutdownSessionGuard>(windows_rdp_handoff_connected_);
#endif
ui_->sponsorship_prompt_pending = ui_->sponsorship_prompt_pending =
config_center_->GetSponsorshipPromptDismissedVersion() != CROSSDESK_VERSION; config_center_->GetSponsorshipPromptDismissedVersion() != CROSSDESK_VERSION;
ui_->video_presenter = ui_->video_presenter =
@@ -1467,11 +1477,6 @@ void GuiApplication::ResetSettingsUi(bool gallery_window) {
auto& main = ui_->Window(gallery_window); auto& main = ui_->Window(gallery_window);
main->set_language_index(language_button_value_); main->set_language_index(language_button_value_);
main->set_codec_index(video_encode_format_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_turn_enabled(enable_turn_);
main->set_force_relay_enabled(config_center_->IsForceRelay()); main->set_force_relay_enabled(config_center_->IsForceRelay());
main->set_self_hosted_enabled(enable_self_hosted_); main->set_self_hosted_enabled(enable_self_hosted_);
@@ -1785,6 +1790,11 @@ void GuiApplication::BindMainCallbacks(bool gallery_window) {
window->set_update_install_busy(updater_->busy()); window->set_update_install_busy(updater_->busy());
#endif #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_website([this] { OpenUrl("https://crossdesk.cn"); });
main->on_open_afdian_sponsor([this] { main->on_open_afdian_sponsor([this] {
OpenUrl("https://afdian.com/a/kunkundi"); OpenUrl("https://afdian.com/a/kunkundi");
@@ -2252,6 +2262,11 @@ void GuiApplication::Tick() {
slint::quit_event_loop(); slint::quit_event_loop();
return; return;
} }
#if _WIN32
if (ui_->shutdown_guard) {
ui_->shutdown_guard->Update(ui_->shutdown_reason);
}
#endif
PollUpdateCheck(); PollUpdateCheck();
HandleSessionCleanup(); HandleSessionCleanup();
HandlePasswordChangeResult(); HandlePasswordChangeResult();
@@ -2809,6 +2824,15 @@ void GuiApplication::UpdateLocalization() {
if (!ui_ || ui_->localized_language == language) { if (!ui_ || ui_->localized_language == language) {
return; 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::ApplyMainWindowStrings(ui_->main, language);
ui_localization::ApplyStreamWindowStrings(ui_->main, language); ui_localization::ApplyStreamWindowStrings(ui_->main, language);
ui_localization::ApplyMainWindowStrings(ui_->wall, language); ui_localization::ApplyMainWindowStrings(ui_->wall, language);
@@ -3612,6 +3636,13 @@ void GuiApplication::SyncStreamWindow() {
(*ui_->stream)->set_selected_display(props->selected_display_); (*ui_->stream)->set_selected_display(props->selected_display_);
const auto net = props->net_traffic_stats_.Load(); 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( (*ui_->stream)->set_stats_encryption_enabled(
status == ConnectionStatus::Connected && net.srtp_active); status == ConnectionStatus::Connected && net.srtp_active);
std::vector<ui::NetworkStatsRow> stats_rows; std::vector<ui::NetworkStatsRow> stats_rows;
@@ -3635,7 +3666,7 @@ void GuiApplication::SyncStreamWindow() {
net.total_inbound_stats, net.total_outbound_stats); net.total_inbound_stats, net.total_outbound_stats);
ui::UpdateModelRows(*ui_->stats_model, std::move(stats_rows)); ui::UpdateModelRows(*ui_->stats_model, std::move(stats_rows));
(*ui_->stream) (*ui_->stream)
->set_stats_resolution(UiText(std::to_string(props->video_width_) + "x" + ->set_stats_resolution(UiText(std::to_string(props->video_width_) + " × " +
std::to_string(props->video_height_))); std::to_string(props->video_height_)));
(*ui_->stream) (*ui_->stream)
->set_stats_connection_mode( ->set_stats_connection_mode(
@@ -4264,8 +4295,6 @@ bool GuiApplication::SaveSettingsFromUi(bool gallery_window) {
language_button_value_ = language_button_value_ =
localization::detail::ClampLanguageIndex(main->get_language_index()); localization::detail::ClampLanguageIndex(main->get_language_index());
video_encode_format_button_value_ = std::clamp(main->get_codec_index(), 0, 1); 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_turn_ = main->get_turn_enabled();
enable_self_hosted_ = main->get_self_hosted_enabled(); enable_self_hosted_ = main->get_self_hosted_enabled();
#if !defined(__OHOS__) #if !defined(__OHOS__)
@@ -4282,7 +4311,6 @@ bool GuiApplication::SaveSettingsFromUi(bool gallery_window) {
config_center_->SetVideoEncodeFormat( config_center_->SetVideoEncodeFormat(
static_cast<ConfigCenter::VIDEO_ENCODE_FORMAT>( static_cast<ConfigCenter::VIDEO_ENCODE_FORMAT>(
video_encode_format_button_value_)); video_encode_format_button_value_));
config_center_->SetHardwareVideoCodec(enable_hardware_video_codec_);
config_center_->SetTurn(enable_turn_, main->get_force_relay_enabled()); config_center_->SetTurn(enable_turn_, main->get_force_relay_enabled());
main->set_force_relay_enabled(config_center_->IsForceRelay()); main->set_force_relay_enabled(config_center_->IsForceRelay());
#if !defined(__OHOS__) #if !defined(__OHOS__)
@@ -4349,7 +4377,6 @@ void GuiApplication::ApplySettingsFromConfig(bool reconnect) {
localization_language_index_ = localization::detail::ClampLanguageIndex(language_button_value_); localization_language_index_ = localization::detail::ClampLanguageIndex(language_button_value_);
localization_language_ = config_center_->GetLanguage(); localization_language_ = config_center_->GetLanguage();
video_encode_format_button_value_ = static_cast<int>(config_center_->GetVideoEncodeFormat()); video_encode_format_button_value_ = static_cast<int>(config_center_->GetVideoEncodeFormat());
enable_hardware_video_codec_ = config_center_->IsHardwareVideoCodec();
enable_turn_ = config_center_->IsEnableTurn(); enable_turn_ = config_center_->IsEnableTurn();
enable_self_hosted_ = config_center_->IsSelfHosted(); enable_self_hosted_ = config_center_->IsSelfHosted();
enable_autostart_ = config_center_->IsEnableAutostart(); enable_autostart_ = config_center_->IsEnableAutostart();
@@ -4364,7 +4391,6 @@ void GuiApplication::ApplySettingsFromConfig(bool reconnect) {
std::snprintf(signal_server_port_self_, sizeof(signal_server_port_self_), "%s", port_text.c_str()); std::snprintf(signal_server_port_self_, sizeof(signal_server_port_self_), "%s", port_text.c_str());
language_button_value_last_ = language_button_value_; language_button_value_last_ = language_button_value_;
video_encode_format_button_value_last_ = video_encode_format_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_turn_last_ = enable_turn_;
enable_self_hosted_last_ = enable_self_hosted_; enable_self_hosted_last_ = enable_self_hosted_;
enable_autostart_last_ = enable_autostart_; enable_autostart_last_ = enable_autostart_;
@@ -4383,17 +4409,14 @@ void GuiApplication::ApplySettingsFromConfig(bool reconnect) {
// configuration. Keep the application and its console running throughout. // configuration. Keep the application and its console running throughout.
CloseAllRemoteSessions(); CloseAllRemoteSessions();
} else { } else {
const bool hardware = config_center_->IsHardwareVideoCodec();
const bool av1 = config_center_->GetVideoEncodeFormat() == const bool av1 = config_center_->GetVideoEncodeFormat() ==
ConfigCenter::VIDEO_ENCODE_FORMAT::AV1; ConfigCenter::VIDEO_ENCODE_FORMAT::AV1;
const auto turn = static_cast<TurnMode>(config_center_->GetTurnMode()); const auto turn = static_cast<TurnMode>(config_center_->GetTurnMode());
if (hardware != params_.hardware_acceleration || av1 != params_.av1_encoding || if (av1 != params_.av1_encoding || turn != params_.turn_mode) {
turn != params_.turn_mode) {
// Update the listening peer for incoming connections and the template // Update the listening peer for incoming connections and the template
// copied by ConnectTo for outgoing connections. Existing sessions retain // copied by ConnectTo for outgoing connections. Existing sessions retain
// the codec/ICE snapshot with which they negotiated. // the codec/ICE snapshot with which they negotiated.
if (!peer_ || UpdateConnectionSettings(peer_, hardware, av1, turn) == 0) { if (!peer_ || UpdateConnectionSettings(peer_, true, av1, turn) == 0) {
params_.hardware_acceleration = hardware;
params_.av1_encoding = av1; params_.av1_encoding = av1;
params_.turn_mode = turn; params_.turn_mode = turn;
} else { } else {
@@ -5722,6 +5745,9 @@ void GuiApplication::SyncGallery() {
? UiText(std::to_string(video.width) + " × " + std::to_string(video.height)) ? UiText(std::to_string(video.width) + " × " + std::to_string(video.height))
: UiText("—"); : UiText("—");
device.fps = video.fps ? UiText(std::to_string(*video.fps) + " FPS") : 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 bool has_report = props->net_traffic_stats_.HasRecentReport(now);
const auto stats = props->net_traffic_stats_.Load(); const auto stats = props->net_traffic_stats_.Load();
// Use negotiated transport state, never the local encryption preference. // Use negotiated transport state, never the local encryption preference.
@@ -6172,6 +6198,7 @@ void GuiApplication::SyncGallery() {
} }
void GuiApplication::Cleanup() { void GuiApplication::Cleanup() {
remote_input_.Stop();
#if _WIN32 #if _WIN32
windows_rdp_handoff_allowed_->store(false, std::memory_order_relaxed); windows_rdp_handoff_allowed_->store(false, std::memory_order_relaxed);
windows_rdp_handoff_connected_->store(false, std::memory_order_relaxed); windows_rdp_handoff_connected_->store(false, std::memory_order_relaxed);
@@ -6185,6 +6212,9 @@ void GuiApplication::Cleanup() {
return; return;
} }
ui_->file_dialog_lifetime.reset(); ui_->file_dialog_lifetime.reset();
#if _WIN32
ui_->shutdown_guard.reset();
#endif
#if defined(__APPLE__) #if defined(__APPLE__)
CancelNativeFileDialog(); CancelNativeFileDialog();
#endif #endif
@@ -78,13 +78,11 @@ struct TranslationRow {
X(console_settings_next_file, u8"已保存,将用于后续接收的文件。", "Saved; applies to subsequently received files.", u8"Сохранено. Будет применено к следующим получаемым файлам.") \ 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_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_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_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_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_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_language, u8"语言", "Language", u8"Язык") \
X(console_setting_codec, u8"视频编码", "Video codec", 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_turn, u8"中继模式", "TURN relay mode", u8"Режим ретрансляции TURN") \
X(console_setting_self_hosted, u8"自托管", "Self-hosted server", u8"Собственный сервер") \ X(console_setting_self_hosted, u8"自托管", "Self-hosted server", u8"Собственный сервер") \
X(console_setting_host, u8"服务器地址", "Server host", u8"Адрес сервера") \ X(console_setting_host, u8"服务器地址", "Server host", u8"Адрес сервера") \
@@ -234,8 +232,6 @@ struct TranslationRow {
"Video Encode Format:", u8"Формат кодека видео:") \ "Video Encode Format:", u8"Формат кодека видео:") \
X(av1, u8"AV1", "AV1", "AV1") \ X(av1, u8"AV1", "AV1", "AV1") \
X(h264, u8"H.264", "H.264", "H.264") \ X(h264, u8"H.264", "H.264", "H.264") \
X(enable_hardware_video_codec, u8"启用硬件编解码器:", \
"Enable Hardware Video Codec:", u8"Использовать аппаратный кодек:") \
X(enable_turn, u8"启用中继服务:", \ X(enable_turn, u8"启用中继服务:", \
"Enable TURN Service:", u8"Включить TURN-сервис:") \ "Enable TURN Service:", u8"Включить TURN-сервис:") \
X(force_relay, u8"强制中继连接:", "Force Relay:", u8"Только через реле:") \ X(force_relay, u8"强制中继连接:", "Force Relay:", u8"Только через реле:") \
@@ -376,6 +372,8 @@ struct TranslationRow {
X(connection_mode_direct, u8"直连", "Direct", u8"Прямой") \ X(connection_mode_direct, u8"直连", "Direct", u8"Прямой") \
X(connection_mode_relay, u8"中继", "Relay", u8"Релейный") \ X(connection_mode_relay, u8"中继", "Relay", u8"Релейный") \
X(transport_encryption, u8"加密传输", "Encryption", 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_enabled, u8"已开启", "Enabled", u8"Включено") \
X(transport_encryption_disabled, u8"未开启", "Disabled", u8"Выключено") \ X(transport_encryption_disabled, u8"未开启", "Disabled", u8"Выключено") \
X(online, u8"在线", "Online", 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
@@ -21,7 +21,6 @@
#ifdef _WIN32 #ifdef _WIN32
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include "platform/windows/screen_capturer/screen_capturer_win.h" #include "platform/windows/screen_capturer/screen_capturer_win.h"
#include "platform/windows/input/mouse/mouse_controller.h"
#include "service_host.h" #include "service_host.h"
#endif #endif
@@ -333,26 +332,44 @@ int SessionDeviceManager::StopScreenCapturer() {
return 0; 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 #ifdef _WIN32
void SessionDeviceManager::ReleaseRemoteMouseButtons() {
if (auto mouse = dynamic_cast<PlatformMouseController*>(mouse_controller_))
mouse->ReleasePressedButtons();
std::lock_guard lock(service_mouse_mutex_); std::lock_guard lock(service_mouse_mutex_);
for (int button = 0; button < 3; ++button) { for (int button = 0; button < 3; ++button) {
const unsigned bit = 1u << button; const unsigned bit = 1u << button;
if (!(service_mouse_buttons_ & bit)) continue; if (!(service_mouse_buttons_ & mask & bit)) continue;
const auto response = SendCrossDeskSecureDesktopMouseInput( const auto response = SendCrossDeskSecureDesktopMouseInput(
service_mouse_x_, service_mouse_y_, 0, (button + 1) * 2, 1000); service_mouse_x_, service_mouse_y_, 0, (button + 1) * 2, 1000);
const auto json = nlohmann::json::parse(response, nullptr, false); const auto json = nlohmann::json::parse(response, nullptr, false);
if (json.is_object() && json.value("ok", false)) { if (json.is_object() && json.value("ok", false)) {
service_mouse_buttons_ &= ~bit; service_mouse_buttons_ &= ~bit;
} else { } else {
released &= ~bit;
LOG_WARN("Service mouse button release failed: button={}, response={}", LOG_WARN("Service mouse button release failed: button={}, response={}",
button, response); button, response);
} }
} }
#endif
return released;
} }
#ifdef _WIN32
std::string SessionDeviceManager::SendServiceMouseCommand(int x, int y, std::string SessionDeviceManager::SendServiceMouseCommand(int x, int y,
int wheel, int flag) { int wheel, int flag) {
std::lock_guard lock(service_mouse_mutex_); std::lock_guard lock(service_mouse_mutex_);
@@ -398,7 +415,10 @@ bool SessionDeviceManager::SyncDisplayInfo() {
}); });
if (changed) display_info_list_ = displays; 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; return true;
} }
@@ -434,6 +454,7 @@ int SessionDeviceManager::StartMouseController() {
#endif #endif
SyncDisplayInfo(); SyncDisplayInfo();
std::lock_guard lock(mouse_controller_mutex_);
mouse_controller_ = mouse_controller_ =
static_cast<MouseController *>(device_controller_factory_->Create( static_cast<MouseController *>(device_controller_factory_->Create(
DeviceControllerFactory::Device::Mouse)); DeviceControllerFactory::Device::Mouse));
@@ -452,10 +473,16 @@ int SessionDeviceManager::StartMouseController() {
return init_ret; return init_ret;
} }
int SessionDeviceManager::StopMouseController() { int SessionDeviceManager::StopMouseController(bool force) {
#ifdef _WIN32 // 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(); ReleaseRemoteMouseButtons();
#endif std::lock_guard lock(mouse_controller_mutex_);
if (mouse_controller_) { if (mouse_controller_) {
mouse_controller_->Destroy(); mouse_controller_->Destroy();
delete mouse_controller_; delete mouse_controller_;
@@ -636,9 +663,9 @@ void SessionDeviceManager::UpdateInteractions() {
owner_.screen_capturer_is_started_ && !owner_.start_mouse_controller_ && owner_.screen_capturer_is_started_ && !owner_.start_mouse_controller_ &&
owner_.mouse_controller_is_started_; owner_.mouse_controller_is_started_;
if (stop_wayland_mouse_before_screen) { 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"); "cleanly release the shared portal session");
StopMouseController();
owner_.mouse_controller_is_started_ = false; owner_.mouse_controller_is_started_ = false;
} }
#endif #endif
@@ -659,8 +686,7 @@ void SessionDeviceManager::UpdateInteractions() {
} }
} else if (!owner_.start_mouse_controller_ && } else if (!owner_.start_mouse_controller_ &&
owner_.mouse_controller_is_started_) { owner_.mouse_controller_is_started_) {
StopMouseController(); if (StopMouseController() == 0) owner_.mouse_controller_is_started_ = false;
owner_.mouse_controller_is_started_ = false;
} }
if (owner_.screen_capturer_is_started_ && mouse_controller_ && SyncDisplayInfo()) { if (owner_.screen_capturer_is_started_ && mouse_controller_ && SyncDisplayInfo()) {
@@ -750,6 +776,7 @@ bool SessionDeviceManager::SendKeyboardCommand(int key_code, bool is_down,
bool SessionDeviceManager::SendMouseCommand(const RemoteAction &action, bool SessionDeviceManager::SendMouseCommand(const RemoteAction &action,
int selected_display) { int selected_display) {
std::lock_guard lock(mouse_controller_mutex_);
if (mouse_controller_) { if (mouse_controller_) {
return mouse_controller_->SendMouseCommand(action, selected_display) == 0; return mouse_controller_->SendMouseCommand(action, selected_display) == 0;
} }
@@ -763,19 +790,20 @@ int SessionDeviceManager::SwitchDisplay(int display_id) {
return ret; return ret;
} }
void SessionDeviceManager::ResetToInitialDisplay() { InputOutcome SessionDeviceManager::ResetToInitialDisplay() {
if (!screen_capturer_) { if (!screen_capturer_) {
return; return InputOutcome::ignored;
} }
const int ret = screen_capturer_->ResetToInitialMonitor(); const int ret = screen_capturer_->ResetToInitialMonitor();
if (ret != 0) { if (ret != 0) {
LOG_WARN("Reset to initial display failed, ret={}", ret); 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 // Capture backends start at display 0. Reset the input mapping as well so
// the next connection cannot show that display while controlling another. // the next connection cannot show that display while controlling another.
owner_.selected_display_ = 0; owner_.selected_display_ = 0;
SyncDisplayInfo(); SyncDisplayInfo();
return InputOutcome::applied;
} }
std::vector<DisplayInfo> SessionDeviceManager::display_info_list() const { std::vector<DisplayInfo> SessionDeviceManager::display_info_list() const {
@@ -796,7 +824,7 @@ std::vector<HostDisplay> SessionDeviceManager::host_display_list() const {
void SessionDeviceManager::DestroyDevices() { void SessionDeviceManager::DestroyDevices() {
speaker_capture_.Shutdown(); speaker_capture_.Shutdown();
StopMouseController(); StopMouseController(true);
if (screen_capturer_) { if (screen_capturer_) {
screen_capturer_->Destroy(); screen_capturer_->Destroy();
delete screen_capturer_; delete screen_capturer_;
@@ -14,6 +14,7 @@
#include <mutex> #include <mutex>
#include <string> #include <string>
#include <unordered_map> #include <unordered_map>
#include <utility>
#include <vector> #include <vector>
#include <remote_action.h> #include <remote_action.h>
@@ -21,6 +22,7 @@
#include "device_controller.h" #include "device_controller.h"
#include "display_info.h" #include "display_info.h"
#include "features/input/captured_keyboard_queue.h" #include "features/input/captured_keyboard_queue.h"
#include "features/input/remote_mouse_buttons.h"
#include "speaker_capture_controller.h" #include "speaker_capture_controller.h"
struct SDL_AudioStream; struct SDL_AudioStream;
@@ -53,8 +55,15 @@ public:
int InitializeScreenCapturer(); int InitializeScreenCapturer();
int StartScreenCapturer(); int StartScreenCapturer();
int StopScreenCapturer(); 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 #ifdef _WIN32
void ReleaseRemoteMouseButtons();
std::string SendServiceMouseCommand(int x, int y, int wheel, int flag); std::string SendServiceMouseCommand(int x, int y, int wheel, int flag);
#endif #endif
// Requests are asynchronous; Stop drains/revokes audio delivery before // Requests are asynchronous; Stop drains/revokes audio delivery before
@@ -62,7 +71,9 @@ public:
void StartSpeakerCapturer(); void StartSpeakerCapturer();
void StopSpeakerCapturer(); void StopSpeakerCapturer();
int StartMouseController(); 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 StartKeyboardCapturer();
int StopKeyboardCapturer(); int StopKeyboardCapturer();
@@ -79,7 +90,7 @@ public:
bool extended); bool extended);
bool SendMouseCommand(const RemoteAction &action, int selected_display); bool SendMouseCommand(const RemoteAction &action, int selected_display);
int SwitchDisplay(int display_id); int SwitchDisplay(int display_id);
void ResetToInitialDisplay(); InputOutcome ResetToInitialDisplay();
// Snapshot of the capture displays. The UI thread refreshes the list every // Snapshot of the capture displays. The UI thread refreshes the list every
// tick while transport threads build host information and map mouse input // tick while transport threads build host information and map mouse input
@@ -89,6 +100,7 @@ public:
std::vector<HostDisplay> host_display_list() const; std::vector<HostDisplay> host_display_list() const;
private: private:
unsigned ReleaseMouseButtonMask(unsigned mask);
void DrainCapturedKeyboardInput(); void DrainCapturedKeyboardInput();
bool ShouldSendCapturedFrame(std::chrono::steady_clock::time_point now, bool ShouldSendCapturedFrame(std::chrono::steady_clock::time_point now,
int fps); int fps);
@@ -107,6 +119,9 @@ private:
SpeakerCaptureController speaker_capture_; SpeakerCaptureController speaker_capture_;
DeviceControllerFactory *device_controller_factory_ = nullptr; DeviceControllerFactory *device_controller_factory_ = nullptr;
MouseController *mouse_controller_ = 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; KeyboardCapturer *keyboard_capturer_ = nullptr;
// Guards display_info_list_ (UI thread writes, transport threads read). // Guards display_info_list_ (UI thread writes, transport threads read).
mutable std::mutex display_info_mutex_; 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 <SDL3/SDL.h>
#include <algorithm>
#include <cstdint> #include <cstdint>
#include <mutex> #include <mutex>
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
@@ -249,14 +250,14 @@ bool KeyboardController::InjectRemoteKey(int key_code, bool is_down,
extended); extended);
} }
void KeyboardController::ApplyRemoteEvent(const std::string& remote_id, InputOutcome KeyboardController::ApplyRemoteEvent(const std::string& remote_id,
const RemoteAction& action) { const RemoteAction& action) {
std::lock_guard input_lock(remote_input_mutex_); std::lock_guard input_lock(remote_input_mutex_);
const int key_code = static_cast<int>(action.k.key_value); const int key_code = static_cast<int>(action.k.key_value);
const bool is_down = action.k.flag == KeyFlag::key_down; const bool is_down = action.k.flag == KeyFlag::key_down;
const bool injected = const bool injected =
InjectRemoteKey(key_code, is_down, action.k.scan_code, action.k.extended); 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]; auto& state = remote_states_[remote_id];
state.last_seen_tick = static_cast<uint32_t>(SDL_GetTicks()); 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) { } else if (!is_down && injected) {
state.pressed_keys.erase(key_code); 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) { const std::string& text) {
std::lock_guard input_lock(remote_input_mutex_); std::lock_guard input_lock(remote_input_mutex_);
if (!owner_.IsAuthorizedController(remote_id) || if (!owner_.IsAuthorizedController(remote_id)) return InputOutcome::ignored;
!owner_.devices_.SupportsTextInput()) return; if (!owner_.devices_.SupportsTextInput()) return InputOutcome::failed;
if (!owner_.devices_.SendTextCommand(text)) if (!owner_.devices_.SendTextCommand(text)) {
LOG_WARN("Failed to inject committed remote 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) { const RemoteAction& action) {
std::lock_guard input_lock(remote_input_mutex_); std::lock_guard input_lock(remote_input_mutex_);
auto& state = remote_states_[remote_id]; auto& state = remote_states_[remote_id];
if (action.ks.seq != 0 && state.last_seq != 0 && if (action.ks.seq != 0 && state.last_seq != 0 &&
static_cast<int32_t>(action.ks.seq - 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; 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); const auto changes = ReconcileKeyboardState(state.pressed_keys, desired_keys);
bool can_press = true; bool can_press = true;
bool applied = true;
for (const auto& key : changes) { for (const auto& key : changes) {
if (key.is_down && !can_press) continue; if (key.is_down && !can_press) continue;
if (!InjectRemoteKey(key.key_code, key.is_down, key.scan_code, key.extended)) { 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 // Do not replay a shortcut with a stale/missing modifier. The next
// heartbeat will retry from the state that was actually injected. // heartbeat will retry from the state that was actually injected.
can_press = false; can_press = false;
applied = false;
continue; continue;
} }
pending_key_releases_.erase(key.key_code); pending_key_releases_.Supersede(key.key_code);
if (key.is_down) if (key.is_down)
state.pressed_keys[key.key_code] = state.pressed_keys[key.key_code] =
{key.key_code, key.scan_code, key.extended}; {key.key_code, key.scan_code, key.extended};
else else
state.pressed_keys.erase(key.key_code); 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) { const char* reason) {
std::lock_guard input_lock(remote_input_mutex_); 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) { std::string remote_id, const char* reason) {
const auto state_it = remote_states_.find(remote_id); const auto state_it = remote_states_.find(remote_id);
if (state_it == remote_states_.end()) return; if (state_it != remote_states_.end()) {
auto keys_to_release = std::move(state_it->second.pressed_keys); const auto& keys = state_it->second.pressed_keys;
remote_states_.erase(state_it); if (!keys.empty()) {
LOG_WARN("Releasing {} remote keyboard keys for remote_id={}, reason={}",
if (!keys_to_release.empty()) { keys.size(), remote_id, reason ? reason : "unknown");
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);
} }
// 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) { void KeyboardController::ReleaseAllRemotePressedKeys(const char* reason) {
{ std::lock_guard input_lock(remote_input_mutex_);
std::lock_guard input_lock(remote_input_mutex_); auto ids = pending_key_releases_.ControllerIds();
while (!remote_states_.empty()) for (const auto& [id, _] : remote_states_) ids.push_back(id);
ReleaseRemotePressedKeysLocked(remote_states_.begin()->first, reason); std::sort(ids.begin(), ids.end());
} ids.erase(std::unique(ids.begin(), ids.end()), ids.end());
CheckRemoteTimeouts(); // A failure remains pending; shutdown must never spin until it succeeds.
for (const auto& id : ids) ReleaseRemotePressedKeysLocked(id, reason);
} }
void KeyboardController::CheckRemoteTimeouts() { void KeyboardController::CheckRemoteTimeouts() {
@@ -359,7 +368,7 @@ void KeyboardController::CheckRemoteTimeouts() {
const uint32_t now = static_cast<uint32_t>(SDL_GetTicks()); const uint32_t now = static_cast<uint32_t>(SDL_GetTicks());
// Retain failed releases in ordinary mode as well as privacy mode. Retry // Retain failed releases in ordinary mode as well as privacy mode. Retry
// only on the ordinary desktop, never through the security UI helper. // 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 || (last_release_retry_tick_ == 0 ||
now - last_release_retry_tick_ >= kReleaseRetryIntervalMs) now - last_release_retry_tick_ >= kReleaseRetryIntervalMs)
#ifdef _WIN32 #ifdef _WIN32
@@ -367,15 +376,10 @@ void KeyboardController::CheckRemoteTimeouts() {
#endif #endif
) { ) {
last_release_retry_tick_ = now; last_release_retry_tick_ = now;
for (auto it = pending_key_releases_.begin(); pending_key_releases_.RetryAll([this](const KeyboardKey& key) {
it != pending_key_releases_.end();) { return owner_.devices_.SendKeyboardCommand(
const auto& key = it->second; key.key_code, false, key.scan_code, key.extended);
if (owner_.devices_.SendKeyboardCommand( });
key.key_code, false, key.scan_code, key.extended))
it = pending_key_releases_.erase(it);
else
++it;
}
} }
// Keep the timeout decision and release under the same lock, so a fresh // Keep the timeout decision and release under the same lock, so a fresh
@@ -9,6 +9,8 @@
#include <remote_action.h> #include <remote_action.h>
#include "keyboard_state.h" #include "keyboard_state.h"
#include "input_outcome.h"
#include "pending_key_releases.h"
namespace crossdesk { namespace crossdesk {
@@ -25,12 +27,12 @@ public:
void ForceReleasePressedKeys(); void ForceReleasePressedKeys();
void RecoverCapturedState(const PressedKeys& pressed); void RecoverCapturedState(const PressedKeys& pressed);
void SendHeartbeat(bool force); void SendHeartbeat(bool force);
void ApplyRemoteEvent(const std::string &remote_id, InputOutcome ApplyRemoteEvent(const std::string &remote_id,
const RemoteAction &remote_action); const RemoteAction &remote_action);
void ApplyRemoteText(const std::string& remote_id, const std::string& text); InputOutcome ApplyRemoteText(const std::string& remote_id, const std::string& text);
void ApplyRemoteState(const std::string &remote_id, InputOutcome ApplyRemoteState(const std::string &remote_id,
const RemoteAction &remote_action); const RemoteAction &remote_action);
void ReleaseRemotePressedKeys(const std::string &remote_id, bool ReleaseRemotePressedKeys(const std::string &remote_id,
const char *reason); const char *reason);
void ReleaseAllRemotePressedKeys(const char* reason); void ReleaseAllRemotePressedKeys(const char* reason);
void CheckRemoteTimeouts(); void CheckRemoteTimeouts();
@@ -48,7 +50,7 @@ private:
bool InjectRemoteKey(int key_code, bool is_down, uint32_t scan_code, bool InjectRemoteKey(int key_code, bool is_down, uint32_t scan_code,
bool extended); bool extended);
// Caller holds remote_input_mutex_. Copy the id because this erases state. // 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_; GuiRuntime &owner_;
PressedKeys pressed_keys_; PressedKeys pressed_keys_;
@@ -59,7 +61,7 @@ private:
// Guards all remote key state and serializes injection, cleanup and retries. // Guards all remote key state and serializes injection, cleanup and retries.
std::mutex remote_input_mutex_; std::mutex remote_input_mutex_;
// Successful new input supersedes cleanup of an older press of that key. // 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; 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
@@ -465,8 +465,6 @@ int SettingsManager::Load() {
static_cast<int>(owner_.config_center_->GetLanguage())); static_cast<int>(owner_.config_center_->GetLanguage()));
owner_.video_encode_format_button_value_ = owner_.video_encode_format_button_value_ =
static_cast<int>(owner_.config_center_->GetVideoEncodeFormat()); 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_turn_ = owner_.config_center_->IsEnableTurn();
owner_.enable_self_hosted_ = owner_.config_center_->IsSelfHosted(); owner_.enable_self_hosted_ = owner_.config_center_->IsSelfHosted();
owner_.enable_autostart_ = owner_.config_center_->IsEnableAutostart(); owner_.enable_autostart_ = owner_.config_center_->IsEnableAutostart();
@@ -486,8 +484,6 @@ int SettingsManager::Load() {
owner_.language_button_value_last_ = owner_.language_button_value_; owner_.language_button_value_last_ = owner_.language_button_value_;
owner_.video_encode_format_button_value_last_ = owner_.video_encode_format_button_value_last_ =
owner_.video_encode_format_button_value_; 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_turn_last_ = owner_.enable_turn_;
owner_.enable_self_hosted_last_ = owner_.enable_self_hosted_; owner_.enable_self_hosted_last_ = owner_.enable_self_hosted_;
owner_.enable_autostart_last_ = owner_.enable_autostart_; owner_.enable_autostart_last_ = owner_.enable_autostart_;
@@ -63,7 +63,7 @@ private:
int legacy_video_quality; int legacy_video_quality;
int legacy_video_frame_rate; int legacy_video_frame_rate;
int video_encode_format; int video_encode_format;
bool enable_hardware_video_codec; bool legacy_enable_hardware_video_codec;
bool enable_turn; bool enable_turn;
bool enable_srtp; bool enable_srtp;
unsigned char key[16]; unsigned char key[16];
@@ -77,7 +77,7 @@ private:
int legacy_video_quality; int legacy_video_quality;
int legacy_video_frame_rate; int legacy_video_frame_rate;
int video_encode_format; int video_encode_format;
bool enable_hardware_video_codec; bool legacy_enable_hardware_video_codec;
bool enable_turn; bool enable_turn;
bool enable_srtp; bool enable_srtp;
unsigned char key[16]; unsigned char key[16];
@@ -190,22 +190,16 @@ void GuiRuntime::HandleServerControllerDisconnected(
const std::string& remote_id, const char* reason) { const std::string& remote_id, const char* reason) {
transfers_.Cancel(peer_events_.get(), remote_id); transfers_.Cancel(peer_events_.get(), remote_id);
preview_subscriptions_.Remove(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; bool has_connected_controller = false;
std::string remaining_controller_id; std::string remaining_controller_id;
{ {
std::unique_lock lock(connection_status_mutex_); std::unique_lock lock(connection_status_mutex_);
connection_status_.erase(remote_id); connection_status_.erase(remote_id);
remote_input_.Disconnect(remote_id);
devices_.OnControllerConnectionsChanged(); devices_.OnControllerConnectionsChanged();
connection_host_names_.erase(remote_id); connection_host_names_.erase(remote_id);
codec_status_controllers_.erase(remote_id);
privacy_sessions_.Disconnected(remote_id); privacy_sessions_.Disconnected(remote_id);
for (const auto& [id, status] : connection_status_) { for (const auto& [id, status] : connection_status_) {
if (status != ConnectionStatus::Connected) { if (status != ConnectionStatus::Connected) {
@@ -252,7 +246,6 @@ void GuiRuntime::HandleServerControllerDisconnected(
start_keyboard_capturer_ = false; start_keyboard_capturer_ = false;
remote_client_id_.clear(); remote_client_id_.clear();
audio_capture_ = false; audio_capture_ = false;
devices_.ResetToInitialDisplay();
} }
int GuiRuntime::RequestSingleDevicePresence(const std::string& remote_id, int GuiRuntime::RequestSingleDevicePresence(const std::string& remote_id,
+50 -5
View File
@@ -17,6 +17,9 @@
#include "localization.h" #include "localization.h"
#include "platform/video_renderer.h" #include "platform/video_renderer.h"
#include "rd_log.h" #include "rd_log.h"
#ifdef _WIN32
#include "platform/windows/input/windows_input_injector.h"
#endif
#if defined(__linux__) && !defined(__OHOS__) #if defined(__linux__) && !defined(__OHOS__)
#include "platform/linux/headless/headless_console.h" #include "platform/linux/headless/headless_console.h"
#endif #endif
@@ -25,9 +28,43 @@ namespace crossdesk {
GuiRuntime::GuiRuntime() GuiRuntime::GuiRuntime()
: clipboard_(*this), devices_(*this), transfers_(*this), settings_(*this), : 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() { GuiRuntime::~GuiRuntime() {
remote_input_.Stop();
#if _WIN32 #if _WIN32
windows_rdp_handoff_allowed_->store(false, std::memory_order_relaxed); windows_rdp_handoff_allowed_->store(false, std::memory_order_relaxed);
windows_rdp_handoff_connected_->store(false, std::memory_order_relaxed); windows_rdp_handoff_connected_->store(false, std::memory_order_relaxed);
@@ -39,6 +76,7 @@ GuiRuntime::~GuiRuntime() {
void GuiRuntime::SendRemoteInput(const RemoteAction& action) { void GuiRuntime::SendRemoteInput(const RemoteAction& action) {
const bool mouse = action.type == ControlType::mouse; const bool mouse = action.type == ControlType::mouse;
const bool mouse_move = mouse && action.m.flag == MouseFlag::move;
if (!mouse && action.type != ControlType::keyboard && if (!mouse && action.type != ControlType::keyboard &&
action.type != ControlType::keyboard_state) action.type != ControlType::keyboard_state)
return; return;
@@ -72,9 +110,15 @@ void GuiRuntime::SendRemoteInput(const RemoteAction& action) {
} }
// Reuse exactly the leader's mapped coordinates and key metadata. Neither // Reuse exactly the leader's mapped coordinates and key metadata. Neither
// gallery filters nor the follower's preview dimensions affect input. // gallery filters nor the follower's preview dimensions affect input.
const int result = SendReliableDataFrame( // Move updates supersede old positions and must not wait for KCP retries.
props->peer_, message.data(), message.size(), // Use the negotiated unreliable stream; clicks and wheel events remain
mouse ? props->mouse_label_.c_str() : props->keyboard_label_.c_str()); // 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) if (result != 0)
LOG_WARN("Send remote input failed, remote_id={}, ret={}", id, result); LOG_WARN("Send remote input failed, remote_id={}, ret={}", id, result);
} }
@@ -193,7 +237,8 @@ int GuiRuntime::CreateConnectionPeer() {
strncpy(params_.log_path, dll_log_path_.c_str(), strncpy(params_.log_path, dll_log_path_.c_str(),
sizeof(params_.log_path) - 1); sizeof(params_.log_path) - 1);
params_.log_path[sizeof(params_.log_path) - 1] = '\0'; 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__) #if defined(_WIN32) || (defined(__linux__) && !defined(__OHOS__)) || defined(__APPLE__)
// Windows and Linux retain pooled CPU NV12 frames or CUDA device frames; // Windows and Linux retain pooled CPU NV12 frames or CUDA device frames;
// macOS retains VideoToolbox CVPixelBuffers for direct Metal sampling. All // macOS retains VideoToolbox CVPixelBuffers for direct Metal sampling. All
@@ -17,6 +17,7 @@
#include "features/devices/session_device_manager.h" #include "features/devices/session_device_manager.h"
#include "features/file_transfer/file_transfer_manager.h" #include "features/file_transfer/file_transfer_manager.h"
#include "features/input/keyboard_controller.h" #include "features/input/keyboard_controller.h"
#include "features/input/remote_input_dispatcher.h"
#include "features/settings/settings_manager.h" #include "features/settings/settings_manager.h"
#include "privacy_controller.h" #include "privacy_controller.h"
#include "runtime/gui_state.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, // UI thread: send mapped mouse/keyboard input to the current control target,
// or every connected session while that target leads DeskHub group control. // or every connected session while that target leads DeskHub group control.
void SendRemoteInput(const RemoteAction& action); 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_; std::string gallery_group_control_id_;
void ResetRemoteServiceStatus(RemoteSession& props); void ResetRemoteServiceStatus(RemoteSession& props);
@@ -133,6 +136,7 @@ class GuiRuntime : protected gui_detail::GuiState {
FileTransferManager transfers_; FileTransferManager transfers_;
SettingsManager settings_; SettingsManager settings_;
KeyboardController keyboard_; KeyboardController keyboard_;
RemoteInputDispatcher remote_input_;
std::shared_ptr<PeerEventHandler> peer_events_; std::shared_ptr<PeerEventHandler> peer_events_;
std::atomic<bool> video_frame_dirty_{false}; std::atomic<bool> video_frame_dirty_{false};
std::unique_ptr<VideoRenderer> video_renderer_; std::unique_ptr<VideoRenderer> video_renderer_;
@@ -8,6 +8,9 @@
#define _NET_TRAFFIC_STATS_CACHE_H_ #define _NET_TRAFFIC_STATS_CACHE_H_
#include <atomic> #include <atomic>
#include <cstring>
#include <mutex>
#include <string>
#include "minirtc.h" #include "minirtc.h"
#include "runtime/connection_latency.h" #include "runtime/connection_latency.h"
@@ -15,7 +18,8 @@
namespace crossdesk::gui_detail { namespace crossdesk::gui_detail {
// Fields are independent observations and may come from different reports. // 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 { class NetTrafficStatsCache {
static_assert(std::atomic<uint32_t>::is_always_lock_free && static_assert(std::atomic<uint32_t>::is_always_lock_free &&
std::atomic<float>::is_always_lock_free && std::atomic<float>::is_always_lock_free &&
@@ -26,7 +30,31 @@ class NetTrafficStatsCache {
public: public:
using Clock = std::chrono::steady_clock; 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) { 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_inbound_stats_.Store(stats.video_inbound_stats);
video_outbound_stats_.Store(stats.video_outbound_stats); video_outbound_stats_.Store(stats.video_outbound_stats);
audio_inbound_stats_.Store(stats.audio_inbound_stats); audio_inbound_stats_.Store(stats.audio_inbound_stats);
@@ -46,6 +74,11 @@ class NetTrafficStatsCache {
MiniRtcNetTrafficStats Load() const { MiniRtcNetTrafficStats Load() const {
MiniRtcNetTrafficStats stats{}; 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_inbound_stats = video_inbound_stats_.Load();
stats.video_outbound_stats = video_outbound_stats_.Load(); stats.video_outbound_stats = video_outbound_stats_.Load();
stats.audio_inbound_stats = audio_inbound_stats_.Load(); stats.audio_inbound_stats = audio_inbound_stats_.Load();
@@ -80,6 +113,10 @@ class NetTrafficStatsCache {
void Reset() { void Reset() {
Store(MiniRtcNetTrafficStats{}); Store(MiniRtcNetTrafficStats{});
{
std::lock_guard lock(codec_mutex_);
codecs_ = {};
}
ResetLatency(); ResetLatency();
last_report_ms_.store(0, std::memory_order_relaxed); last_report_ms_.store(0, std::memory_order_relaxed);
} }
@@ -130,6 +167,9 @@ class NetTrafficStatsCache {
std::atomic<double> rtt_ms_{-1}; std::atomic<double> rtt_ms_{-1};
std::atomic<int64_t> last_report_ms_{0}; std::atomic<int64_t> last_report_ms_{0};
ConnectionLatencyStats connection_latency_; ConnectionLatencyStats connection_latency_;
mutable std::mutex codec_mutex_;
std::string local_encoder_;
VideoCodecNames codecs_;
}; };
} // namespace crossdesk::gui_detail } // namespace crossdesk::gui_detail
@@ -209,6 +209,16 @@ void PeerEventHandler::OnReceiveDataBuffer(
return; 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 (remote_action.type == ControlType::video_settings_status) {
if (auto props = runtime->FindRemoteSession(remote_id)) { if (auto props = runtime->FindRemoteSession(remote_id)) {
if (source_id != props->control_data_label_) return; if (source_id != props->control_data_label_) return;
@@ -280,29 +290,22 @@ void PeerEventHandler::OnReceiveDataBuffer(
runtime->QueuePrivacyCommand(remote_id, remote_action.pc); runtime->QueuePrivacyCommand(remote_id, remote_action.pc);
return; return;
} }
if (remote_action.type == ControlType::text_input) { if (RemoteInputDispatcher::Accepts(remote_action)) {
// Share the ordered keyboard stream with delete/return and shortcuts. if (!runtime->IsAuthorizedController(remote_id)) return;
if (source_id == runtime->keyboard_label_ && if (remote_action.type == ControlType::text_input &&
runtime->IsAuthorizedController(remote_id)) source_id != runtime->keyboard_label_) return;
runtime->keyboard_.ApplyRemoteText(remote_id, remote_action.text); 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; return;
} }
const bool operates_host = remote_action.type == ControlType::mouse || if (remote_action.type == ControlType::audio_capture) {
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 (!runtime->IsAuthorizedController(remote_id)) return; if (!runtime->IsAuthorizedController(remote_id)) return;
#ifdef _WIN32 #ifdef _WIN32
// KeyboardController checks the desktop immediately before each native if (runtime->privacy_.Engaged() && !IsWindowsPrivacyDesktopAvailable())
// 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()) {
runtime->privacy_.SuspendForDesktop(); runtime->privacy_.SuspendForDesktop();
}
#endif #endif
} }
if (remote_action.type == ControlType::service_status) { if (remote_action.type == ControlType::service_status) {
@@ -312,28 +315,6 @@ void PeerEventHandler::OnReceiveDataBuffer(
return; 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) { if (remote_action.type == ControlType::cursor_state) {
std::shared_ptr<GuiRuntime::RemoteSession> props; std::shared_ptr<GuiRuntime::RemoteSession> props;
{ {
@@ -455,6 +436,12 @@ void PeerEventHandler::OnReceiveDataBuffer(
{ {
std::unique_lock lock(runtime->connection_status_mutex_); std::unique_lock lock(runtime->connection_status_mutex_);
runtime->connection_host_names_[remote_id] = host_name; 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); LOG_INFO("Remote hostname: [{}]", host_name);
if (source_id == runtime->control_data_label_) { if (source_id == runtime->control_data_label_) {
@@ -464,75 +451,7 @@ void PeerEventHandler::OnReceiveDataBuffer(
FreeRemoteAction(remote_action); FreeRemoteAction(remote_action);
} }
} else { } else {
// remote if (remote_action.type == ControlType::audio_capture) {
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) {
// A negotiated subscription remains authoritative if a legacy capture // A negotiated subscription remains authoritative if a legacy capture
// command arrives after it (e.g. when unmuting an inactive window). // command arrives after it (e.g. when unmuting an inactive window).
const auto subscription = runtime->preview_subscriptions_.Get(remote_id); const auto subscription = runtime->preview_subscriptions_.Get(remote_id);
@@ -544,20 +463,139 @@ void PeerEventHandler::OnReceiveDataBuffer(
else if (ret != 0) else if (ret != 0)
// Legacy/web transports without the per-peer API retain their fallback. // Legacy/web transports without the per-peer API retain their fallback.
runtime->devices_.StopSpeakerCapturer(); 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 <algorithm>
#include <chrono> #include <chrono>
#include <cstdio>
#include <cstring> #include <cstring>
#include <filesystem> #include <filesystem>
#include <memory> #include <memory>
@@ -295,6 +296,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
/*supports_privacy_screen=*/!runtime->unattended_host_, /*supports_privacy_screen=*/!runtime->unattended_host_,
CROSSDESK_VERSION, kClientPlatform); CROSSDESK_VERSION, kClientPlatform);
remote_action.i.supports_video_settings = true; 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_text_input = runtime->devices_.SupportsTextInput();
remote_action.i.supports_deskhub_preview = true; remote_action.i.supports_deskhub_preview = true;
remote_action.i.supports_deskhub_resolution = true; remote_action.i.supports_deskhub_resolution = true;
@@ -437,6 +439,8 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
runtime->ApplyPrivacyAdmission(automatic_enable); runtime->ApplyPrivacyAdmission(automatic_enable);
} }
runtime->connection_status_[remote_id] = status; runtime->connection_status_[remote_id] = status;
if (status == ConnectionStatus::Connected)
runtime->remote_input_.Connect(remote_id);
#if _WIN32 #if _WIN32
runtime->windows_rdp_handoff_connected_->store(std::any_of( runtime->windows_rdp_handoff_connected_->store(std::any_of(
runtime->connection_status_.begin(), runtime->connection_status_.end(), runtime->connection_status_.begin(), runtime->connection_status_.end(),
@@ -458,6 +462,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
/*supports_privacy_screen=*/!runtime->unattended_host_, /*supports_privacy_screen=*/!runtime->unattended_host_,
CROSSDESK_VERSION, kClientPlatform); CROSSDESK_VERSION, kClientPlatform);
remote_action.i.supports_video_settings = true; 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_text_input = runtime->devices_.SupportsTextInput();
remote_action.i.supports_deskhub_preview = true; remote_action.i.supports_deskhub_preview = true;
remote_action.i.supports_deskhub_resolution = true; remote_action.i.supports_deskhub_resolution = true;
@@ -575,7 +580,29 @@ void PeerEventHandler::OnNetStatusReport(
std::string remote_id(user_id, user_id_size); std::string remote_id(user_id, user_id_size);
auto props = runtime->FindRemoteSession(remote_id); 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) { if (props->traversal_mode_ != mode) {
props->net_traffic_stats_.ResetLatency(); props->net_traffic_stats_.ResetLatency();
props->traversal_mode_ = mode; props->traversal_mode_ = mode;
+2 -2
View File
@@ -18,6 +18,7 @@
#include <string> #include <string>
#include <thread> #include <thread>
#include <unordered_map> #include <unordered_map>
#include <unordered_set>
#include <utility> #include <utility>
#include <vector> #include <vector>
@@ -149,14 +150,12 @@ struct UserSettingsState {
char connection_login_identity_[17] = ""; char connection_login_identity_[17] = "";
int language_button_value_ = 0; int language_button_value_ = 0;
int video_encode_format_button_value_ = 0; int video_encode_format_button_value_ = 0;
bool enable_hardware_video_codec_ = true;
bool enable_turn_ = true; bool enable_turn_ = true;
char signal_server_ip_[256] = "api.crossdesk.cn"; char signal_server_ip_[256] = "api.crossdesk.cn";
char signal_server_port_[6] = "9099"; char signal_server_port_[6] = "9099";
bool enable_self_hosted_ = false; bool enable_self_hosted_ = false;
int language_button_value_last_ = 0; int language_button_value_last_ = 0;
int video_encode_format_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_turn_last_ = true;
bool enable_self_hosted_last_ = false; bool enable_self_hosted_last_ = false;
bool enable_autostart_ = false; bool enable_autostart_ = false;
@@ -208,6 +207,7 @@ struct ConnectionState {
std::shared_mutex connection_status_mutex_; std::shared_mutex connection_status_mutex_;
std::unordered_map<std::string, ConnectionStatus> connection_status_; std::unordered_map<std::string, ConnectionStatus> connection_status_;
std::unordered_map<std::string, std::string> connection_host_names_; 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 // Cursor position is sampled asynchronously after remote mouse input has
// been injected. Remember the input source so the sampled position is not // been injected. Remember the input source so the sampled position is not
// immediately echoed back to that same controller as stale feedback. // immediately echoed back to that same controller as stale feedback.
+235
View File
@@ -0,0 +1,235 @@
import { ScrollView, TextEdit } from "std-widgets.slint";
import { SettingsDialog, SettingsButton } from "settings_controls.slint";
import { WorkbenchStyle } from "workbench_controls.slint";
import { UiRadius } from "ui_radius.slint";
import { GalleryStrings } from "device_strings.slint";
import { FontAwesomeIcons } from "common.slint";
import "../assets/fonts/message_spacing.ttf";
export enum AboutPage { overview, components, component, document, source }
export struct AboutDocument { title: string, text: string }
export struct AboutComponent {
id: string,
name: string,
version: string,
license: string,
source-url: string,
documents: [AboutDocument],
}
export global AboutStrings {
out property <string> software-license: GalleryStrings.tr("软件许可", "Software license", "Лицензия программы");
out property <string> components: GalleryStrings.tr("开源组件", "Open-source components", "Компоненты с открытым кодом");
out property <string> source: GalleryStrings.tr("源码与构建说明", "Source code and build instructions", "Исходный код и сборка");
out property <string> notices: GalleryStrings.tr("许可与版权声明", "Licenses and copyright notices", "Лицензии и авторские права");
out property <string> view-source: GalleryStrings.tr("查看源码", "View source code", "Открыть исходный код");
out property <string> back: GalleryStrings.tr("返回", "Back", "Назад");
}
export component AboutNavigationGroup inherits Rectangle {
height: rows.preferred-height + 2px;
background: WorkbenchStyle.surface;
border-radius: UiRadius.control;
border-width: 1px; border-color: WorkbenchStyle.separator;
clip: true;
rows := VerticalLayout {
x: 1px; y: 1px; width: parent.width - 2px;
height: self.preferred-height; padding: 0px; spacing: 0px;
@children
}
}
export component AboutRowSeparator inherits Rectangle {
height: 1px;
Rectangle {
x: 14px; width: parent.width - 30px; height: 1px;
background: WorkbenchStyle.separator;
}
}
export component AboutNavigationRow inherits Rectangle {
in property <string> title;
in property <string> subtitle;
in property <string> icon;
in property <string> detail;
in property <bool> grouped: false;
in property <bool> external: false;
callback clicked;
min-width: 0px;
min-height: max(44px, labels.preferred-height + 20px);
background: touch.pressed ? WorkbenchStyle.pressed-surface
: touch.has-hover ? WorkbenchStyle.hover-surface : WorkbenchStyle.surface;
border-radius: root.grouped ? 0px : UiRadius.control;
border-width: root.grouped ? 0px : 1px; border-color: WorkbenchStyle.separator;
accessible-role: button;
accessible-label: root.title + (root.subtitle != "" ? ", " + root.subtitle : "")
+ (root.detail != "" ? ", " + root.detail : "");
accessible-action-default => { root.clicked(); }
if root.icon != "": Rectangle {
x: 12px; y: (parent.height - self.height) / 2;
width: 28px; height: 28px;
border-radius: UiRadius.control;
background: WorkbenchStyle.field-surface;
Text {
text: root.icon; font-family: "Font Awesome 6 Free"; font-weight: 900;
font-size: 13px; color: WorkbenchStyle.muted;
horizontal-alignment: center; vertical-alignment: center;
accessible-role: none;
}
}
labels := VerticalLayout {
x: root.icon != "" ? 52px : 14px;
y: (parent.height - self.height) / 2;
width: parent.width - self.x - 36px - (root.detail != "" ? detail-label.width + 12px : 0px);
height: self.preferred-height; spacing: 4px;
Text { text: root.title; color: WorkbenchStyle.text; font-size: 13px; wrap: word-wrap; }
if root.subtitle != "": Text {
text: root.subtitle; color: WorkbenchStyle.muted; font-size: 11px; wrap: word-wrap;
}
}
detail-label := Text {
x: parent.width - self.width - 36px; height: parent.height;
width: min(100px, self.preferred-width);
text: root.detail; color: WorkbenchStyle.muted; font-size: 11px;
vertical-alignment: center; overflow: elide;
accessible-role: none;
}
Text {
x: parent.width - 30px; width: 18px; height: parent.height;
text: root.external ? FontAwesomeIcons.arrow-up-right-from-square : FontAwesomeIcons.angle-right;
font-family: "Font Awesome 6 Free"; font-weight: 900;
color: touch.has-hover ? WorkbenchStyle.accent : WorkbenchStyle.muted;
font-size: 11px; vertical-alignment: center; horizontal-alignment: center;
accessible-role: none;
}
touch := TouchArea { mouse-cursor: pointer; clicked => { root.clicked(); } }
}
export component AboutPanel inherits SettingsDialog {
show-close-button: false;
in-out property <AboutPage> page: AboutPage.components;
in-out property <int> component-index: 0;
private property <int> document-index: 0;
in property <[AboutComponent]> components;
in property <int> openfec-index: -1;
in property <string> revision;
in property <string> source-url;
in property <string> build-url;
private property <AboutComponent> selected: root.components[root.component-index];
callback back;
callback open-link(string);
title: root.page == AboutPage.components ? AboutStrings.components
: root.page == AboutPage.source ? AboutStrings.source
: root.page == AboutPage.document ? AboutStrings.notices
: root.selected.name;
if root.page != AboutPage.document: ScrollView {
x: 24px; y: root.content-top; width: parent.width - 48px + 22px;
height: footer.y - self.y - 16px;
viewport-width: self.visible-width;
viewport-height: content.preferred-height;
horizontal-scrollbar-policy: always-off;
vertical-scrollbar-policy: as-needed;
content := VerticalLayout {
width: parent.width; height: self.preferred-height;
padding: 0px; padding-right: 22px; spacing: 10px;
if root.page == AboutPage.components: VerticalLayout {
padding: 0px; spacing: 10px;
Text {
text: GalleryStrings.tr("以下列出本版本的主要开源组件。各组件遵循各自的开源许可。", "Main open-source components used in this build. Each component is covered by its own license.", "Основные открытые компоненты этой сборки. Каждый распространяется по своей лицензии.");
color: WorkbenchStyle.muted; font-size: 12px; wrap: word-wrap;
}
Rectangle {
height: notice.preferred-height + 32px;
background: WorkbenchStyle.field-surface; border-radius: UiRadius.control;
notice := VerticalLayout {
x: 16px; y: 16px; width: parent.width - 32px;
height: self.preferred-height; spacing: 12px;
Text { text: GalleryStrings.tr("OpenFEC 告知", "OpenFEC notice", "Уведомление OpenFEC"); color: WorkbenchStyle.text; font-size: 14px; font-weight: 600; }
Text {
text: GalleryStrings.tr("本应用使用 OpenFEC 提供前向纠错功能,遵循 CeCILL-C 1.0 许可。该许可规定了有限保证及责任限制,详情见许可全文。", "This application uses OpenFEC for forward error correction under CeCILL-C 1.0, which provides limited warranties and limits liability. See the full license for details.", "Приложение использует OpenFEC для прямого исправления ошибок по лицензии CeCILL-C 1.0 с ограниченной гарантией и ответственностью. Подробности — в тексте лицензии.");
color: WorkbenchStyle.muted; font-size: 13px; wrap: word-wrap;
font-family: "CrossDesk Message Spacing";
}
Text {
text: "(c) Copyright 2009 - 2012 INRIA - All rights reserved";
color: WorkbenchStyle.muted; font-size: 11px; wrap: word-wrap;
font-family: "CrossDesk Message Spacing";
}
if root.openfec-index >= 0: AboutNavigationRow {
title: GalleryStrings.tr("OpenFEC 许可与源码", "OpenFEC license and source", "Лицензия и исходный код OpenFEC");
clicked => { root.component-index = root.openfec-index; root.page = AboutPage.component; }
}
}
}
for component[index] in root.components: VerticalLayout {
padding: 0px; spacing: 0px;
if component.id != "crossdesk" && component.id != "openfec": AboutNavigationRow {
title: component.name;
subtitle: component.version + " · " + component.license;
clicked => { root.component-index = index; root.page = AboutPage.component; }
}
}
}
if root.page == AboutPage.component: VerticalLayout {
padding: 0px; spacing: 12px;
Text { text: root.selected.version + " · " + root.selected.license; color: WorkbenchStyle.muted; font-size: 12px; wrap: word-wrap; }
AboutNavigationRow {
title: AboutStrings.view-source; external: true;
clicked => { root.open-link(root.selected.source-url); }
}
Text { text: AboutStrings.notices; color: WorkbenchStyle.text; font-size: 13px; font-weight: 600; }
for document[index] in root.selected.documents: AboutNavigationRow {
title: document.title;
clicked => { root.document-index = index; root.page = AboutPage.document; }
}
if root.selected.documents.length == 0: Text {
text: GalleryStrings.tr("本组件未内置许可文本,请通过“查看源码”查阅许可与版权声明。", "License text is not bundled for this component. Use View source code to read its license and copyright notices.", "Текст лицензии не включён. Откройте исходный код для просмотра лицензии и авторских прав.");
color: WorkbenchStyle.muted; font-size: 12px; wrap: word-wrap;
}
}
if root.page == AboutPage.source: VerticalLayout {
padding: 0px; spacing: 12px;
Text {
text: root.revision != "" ? GalleryStrings.tr("源码版本:", "Source revision: ", "Ревизия исходного кода: ") + root.revision
: GalleryStrings.tr("本版本源码信息暂不可用。", "Source revision is unavailable for this build.", "Ревизия исходного кода этой сборки недоступна.");
color: WorkbenchStyle.text; font-size: 13px; wrap: word-wrap;
}
AboutNavigationRow { title: AboutStrings.view-source; external: true; clicked => { root.open-link(root.source-url); } }
AboutNavigationRow {
title: GalleryStrings.tr("构建说明", "Build instructions", "Инструкции по сборке"); external: true;
clicked => { root.open-link(root.build-url); }
}
AboutNavigationRow {
title: GalleryStrings.tr("项目主页", "Project website", "Сайт проекта"); external: true;
clicked => { root.open-link("https://crossdesk.cn"); }
}
}
}
}
if root.page == AboutPage.document: VerticalLayout {
x: 24px; y: root.content-top; width: parent.width - 48px;
height: footer.y - self.y - 16px; spacing: 12px;
Text { text: root.selected.documents[root.document-index].title; color: WorkbenchStyle.text; font-size: 13px; font-weight: 600; wrap: word-wrap; }
TextEdit {
text: root.selected.documents[root.document-index].text;
read-only: true; wrap: word-wrap; font-size: 14px;
// Use the same 1.5em line spacing as other long-form reading views.
font-family: "CrossDesk Message Spacing";
vertical-stretch: 1;
key-pressed(event) => {
if event.text == Key.Escape { root.back(); return accept; }
return reject;
}
}
}
footer := VerticalLayout {
x: 24px; y: parent.height - self.height - 24px;
width: parent.width - 48px; height: self.preferred-height; spacing: 12px;
Rectangle { height: 1px; background: WorkbenchStyle.separator; }
HorizontalLayout {
alignment: end;
SettingsButton { text: AboutStrings.back; clicked => { root.back(); } }
}
}
}
+4
View File
@@ -32,9 +32,11 @@ export global FontAwesomeIcons {
out property <string> angle-right: "\u{f105}"; out property <string> angle-right: "\u{f105}";
out property <string> angle-up: "\u{f106}"; out property <string> angle-up: "\u{f106}";
out property <string> arrow-right-long: "\u{f178}"; 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> bars: "\u{f0c9}";
out property <string> check: "\u{f00c}"; out property <string> check: "\u{f00c}";
out property <string> circle-arrow-up: "\u{f0aa}"; out property <string> circle-arrow-up: "\u{f0aa}";
out property <string> code: "\u{f121}";
out property <string> compress: "\u{f066}"; out property <string> compress: "\u{f066}";
out property <string> computer-mouse: "\u{f8cc}"; out property <string> computer-mouse: "\u{f8cc}";
out property <string> copy: "\u{f0c5}"; out property <string> copy: "\u{f0c5}";
@@ -42,8 +44,10 @@ export global FontAwesomeIcons {
out property <string> expand: "\u{f065}"; out property <string> expand: "\u{f065}";
out property <string> eye: "\u{f06e}"; out property <string> eye: "\u{f06e}";
out property <string> eye-slash: "\u{f070}"; out property <string> eye-slash: "\u{f070}";
out property <string> file-lines: "\u{f15c}";
out property <string> folder-open: "\u{f07c}"; out property <string> folder-open: "\u{f07c}";
out property <string> keyboard: "\u{f11c}"; out property <string> keyboard: "\u{f11c}";
out property <string> layer-group: "\u{f5fd}";
out property <string> minus: "\u{f068}"; out property <string> minus: "\u{f068}";
out property <string> pen: "\u{f304}"; out property <string> pen: "\u{f304}";
out property <string> rotate: "\u{f2f1}"; out property <string> rotate: "\u{f2f1}";
@@ -1,6 +1,7 @@
// Single AOT entry point. Generating one header for the entire UI avoids // Single AOT entry point. Generating one header for the entire UI avoids
// duplicate imported-component definitions when C++ uses all three windows. // duplicate imported-component definitions when C++ uses all three windows.
export { AnnouncementItem, MessageStrings } from "message_panel.slint"; 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 { MainWindow, RecentConnection, UpdateCheckStatus, AutostartStatus, UiStrings } from "main_window.slint";
export { StreamWindow, StreamTab, FileTransferEntry, NetworkStatsRow, StreamStrings } from "stream_window.slint"; export { StreamWindow, StreamTab, FileTransferEntry, NetworkStatsRow, StreamStrings } from "stream_window.slint";
export { ServerWindow, ServerStatusBar, ServerStrings, ControllerEntry } from "server_window.slint"; export { ServerWindow, ServerStatusBar, ServerStrings, ControllerEntry } from "server_window.slint";
@@ -15,6 +15,8 @@ export struct GalleryNetworkDevice {
loss: string, loss: string,
resolution: string, resolution: string,
fps: string, fps: string,
encoder: string,
decoder: string,
} }
component NetworkRow inherits Rectangle { component NetworkRow inherits Rectangle {
@@ -145,6 +147,26 @@ component NetworkRow inherits Rectangle {
vertical-alignment: center; overflow: elide; 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 { component NetworkEmptyState inherits Rectangle {
@@ -163,7 +185,7 @@ component NetworkEmptyState inherits Rectangle {
export component GalleryNetworkDialog inherits Rectangle { export component GalleryNetworkDialog inherits Rectangle {
in property <[GalleryNetworkDevice]> devices; 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; private property <length> list-height: max(2, min(root.devices.length, 6)) * root.row-height;
callback close(); callback close();
background: #00000042; background: #00000042;
+98 -40
View File
@@ -1,3 +1,4 @@
import { AboutPanel, AboutPage, AboutComponent, AboutStrings, AboutNavigationRow, AboutNavigationGroup, AboutRowSeparator } from "about_panel.slint";
import { UiRadius } from "ui_radius.slint"; import { UiRadius } from "ui_radius.slint";
import { WorkbenchStyle, WorkbenchButton, WorkbenchCard, WorkbenchNotice } from "workbench_controls.slint"; import { WorkbenchStyle, WorkbenchButton, WorkbenchCard, WorkbenchNotice } from "workbench_controls.slint";
import { GalleryStrings } from "device_strings.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: "Capture method";
in-out property <string> capture-method-auto: "Auto"; in-out property <string> capture-method-auto: "Auto";
in-out property <string> codec: "Video codec"; 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> turn-relay: "TURN relay";
in-out property <string> force-relay: "Force relay"; in-out property <string> force-relay: "Force relay";
in-out property <string> self-hosted: "Self-hosted server"; in-out property <string> self-hosted: "Self-hosted server";
@@ -455,7 +455,6 @@ export component MainWindow inherits Window {
in property <bool> capture-method-visible: false; in property <bool> capture-method-visible: false;
in-out property <int> capture-method-index: 0; in-out property <int> capture-method-index: 0;
in-out property <int> codec-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> turn-enabled: true;
in-out property <bool> force-relay-enabled: false; in-out property <bool> force-relay-enabled: false;
changed turn-enabled => { changed turn-enabled => {
@@ -483,9 +482,6 @@ export component MainWindow inherits Window {
in-out property <string> server-port: ""; in-out property <string> server-port: "";
in-out property <string> self-host-settings-error: ""; in-out property <string> self-host-settings-error: "";
in property <bool> settings-session-active: false; 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 copy-local-id() -> bool;
callback toggle-password-visibility; callback toggle-password-visibility;
@@ -536,6 +532,20 @@ export component MainWindow inherits Window {
if !root.self-host-settings-open { root.self-host-settings-error = ""; } if !root.self-host-settings-open { root.self-host-settings-error = ""; }
} }
in-out property <bool> about-open: false; 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; in-out property <bool> sponsorship-open: false;
// Automatic prompts must not interrupt another dialog or menu. // Automatic prompts must not interrupt another dialog or menu.
private property <bool> sponsorship-unobstructed: !root.gallery-mode private property <bool> sponsorship-unobstructed: !root.gallery-mode
@@ -627,6 +637,15 @@ export component MainWindow inherits Window {
changed about-open => { changed about-open => {
if root.about-open { root.dialog-focus-pending = true; } 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 => { changed sponsorship-open => {
@@ -855,7 +874,10 @@ export component MainWindow inherits Window {
return accept; return accept;
} }
if root.about-open && !root.update-open { 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; return accept;
} }
if root.sponsorship-open && root.sponsorship-unobstructed { if root.sponsorship-open && root.sponsorship-unobstructed {
@@ -1459,25 +1481,24 @@ export component MainWindow inherits Window {
text: root.current-version; text: root.current-version;
font-size: 11px; color: WorkbenchStyle.muted; vertical-alignment: center; font-size: 11px; color: WorkbenchStyle.muted; vertical-alignment: center;
} }
if !root.signal-connected && !root.signal-tls-error: Rectangle { if !root.signal-connected && !root.signal-tls-error: WorkbenchButton {
x: parent.width - self.width - 20px; x: parent.width - self.width - 20px;
y: 0px; y: (parent.height - self.height) / 2;
height: parent.height; width: max(64px, reconnect-label-metrics.preferred-width + 22px);
width: reconnect-label.preferred-width; height: 22px;
reconnect-label := Text { text: UiStrings.signal-reconnect;
text: UiStrings.signal-reconnect; text-size: 11px;
color: WorkbenchStyle.accent; text-color: WorkbenchStyle.accent;
font-size: 11px; clicked => {
horizontal-alignment: center; root.copy-feedback = CopyFeedback.idle;
vertical-alignment: center; root.signal-reconnect-requested();
} }
TouchArea { reconnect-label-metrics := Text {
mouse-cursor: pointer; visible: false;
clicked => { text: UiStrings.signal-reconnect;
root.copy-feedback = CopyFeedback.idle; font-size: 11px;
root.signal-reconnect-requested(); font-weight: 500;
} }
}
} }
} }
} }
@@ -2149,16 +2170,15 @@ export component MainWindow inherits Window {
} }
SettingsGroup { SettingsGroup {
title: GalleryStrings.tr("视频", "Video", "Видео"); title: GalleryStrings.tr("视频", "Video", "Видео");
rows: root.capture-method-visible ? 3 : 2; rows: root.capture-method-visible ? 2 : 1;
if root.capture-method-visible: SettingsRow { if root.capture-method-visible: SettingsRow {
label: UiStrings.capture-method; enabled: !root.settings-session-active; 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; } 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 { 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; } 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 { SettingsGroup {
title: GalleryStrings.tr("网络连接", "Network", "Сеть"); rows: 3; title: GalleryStrings.tr("网络连接", "Network", "Сеть"); rows: 3;
@@ -2365,17 +2385,36 @@ export component MainWindow inherits Window {
} }
if root.about-open: Rectangle { 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; background: #15223850;
z: 46; z: 46;
TouchArea { clicked => { focus-sink.focus(); } } TouchArea { clicked => { focus-sink.focus(); } }
SettingsDialog { if root.about-page != AboutPage.overview: AboutPanel {
x: (parent.width - self.width) / 2; x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 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. // The overlay excludes any custom titlebar on every platform.
min-height: 0px; max-height: parent.height - 32px; min-height: 0px; max-height: parent.height - 32px;
// Keep the dialog stable as update status and release details change. // Keep the dialog stable as update status and release details change.
height: min(488px, parent.height - 32px); height: parent.dialog-height;
title: UiStrings.about; title: UiStrings.about;
close-label: MessageStrings.close; close-label: MessageStrings.close;
close => { root.about-open = false; } close => { root.about-open = false; }
@@ -2388,6 +2427,8 @@ export component MainWindow inherits Window {
height: about-footer.y - self.y - 16px; height: about-footer.y - self.y - 16px;
viewport-width: self.visible-width; viewport-width: self.visible-width;
viewport-height: about-content.preferred-height; 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; horizontal-scrollbar-policy: always-off;
vertical-scrollbar-policy: as-needed; vertical-scrollbar-policy: as-needed;
about-content := VerticalLayout { about-content := VerticalLayout {
@@ -2465,6 +2506,26 @@ export component MainWindow inherits Window {
Text { text: UiStrings.copyright; color: WorkbenchStyle.muted; font-size: 11px; wrap: word-wrap; } 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; } 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 { about-footer := VerticalLayout {
@@ -2472,17 +2533,14 @@ export component MainWindow inherits Window {
width: parent.width - 48px; height: self.preferred-height; width: parent.width - 48px; height: self.preferred-height;
spacing: 16px; spacing: 16px;
Rectangle { height: 1px; background: WorkbenchStyle.separator; } 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 { HorizontalLayout {
alignment: end; spacing: 8px; 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 { if root.update-available: WorkbenchButton {
width: max(80px, about-update-button-metrics.preferred-width + 24px); width: max(80px, about-update-button-metrics.preferred-width + 24px);
height: 32px; height: 32px;
+92 -40
View File
@@ -64,6 +64,8 @@ export global StreamStrings {
in-out property <string> stats-video-latency: "Video delay"; in-out property <string> stats-video-latency: "Video delay";
in-out property <string> stats-connection-latency: "Connection RTT"; in-out property <string> stats-connection-latency: "Connection RTT";
in-out property <string> stats-connection-mode: "Mode"; in-out property <string> stats-connection-mode: "Mode";
in-out property <string> stats-encoder: "Remote encoder";
in-out property <string> stats-decoder: "Local decoder";
in-out property <string> stats-encryption: "Encryption"; in-out property <string> stats-encryption: "Encryption";
in-out property <string> stats-encryption-enabled: "Enabled"; in-out property <string> stats-encryption-enabled: "Enabled";
in-out property <string> stats-encryption-disabled: "Disabled"; in-out property <string> stats-encryption-disabled: "Disabled";
@@ -230,6 +232,8 @@ export component StreamWindow inherits Window {
in property <string> stats-video-latency: "—"; in property <string> stats-video-latency: "—";
in property <string> stats-connection-latency: "—"; in property <string> stats-connection-latency: "—";
in property <string> stats-connection-mode: ""; in property <string> stats-connection-mode: "";
in property <string> stats-encoder: "—";
in property <string> stats-decoder: "—";
in property <bool> stats-encryption-enabled: false; in property <bool> stats-encryption-enabled: false;
in property <bool> file-transfer-visible: false; in property <bool> file-transfer-visible: false;
in property <[FileTransferEntry]> file-transfers; in property <[FileTransferEntry]> file-transfers;
@@ -269,7 +273,7 @@ export component StreamWindow inherits Window {
private property <length> stats-row-height: 18px; private property <length> stats-row-height: 18px;
private property <length> stats-header-height: 18px; private property <length> stats-header-height: 18px;
private property <length> stats-content-height: private property <length> stats-content-height:
root.stats-header-height + root.stats-rows.length * root.stats-row-height + 65px; root.stats-header-height + root.stats-rows.length * root.stats-row-height + 114px;
private property <length> file-panel-x: 18px; private property <length> file-panel-x: 18px;
private property <length> file-panel-y: video.height - min(210px, video.height * 0.35); private property <length> file-panel-y: video.height - min(210px, video.height * 0.35);
private property <length> file-panel-width: min(430px, video.width * 0.6); private property <length> file-panel-width: min(430px, video.width * 0.6);
@@ -929,18 +933,17 @@ export component StreamWindow inherits Window {
private property <length> row-height: root.stats-row-height; private property <length> row-height: root.stats-row-height;
private property <length> label-column-width: 50px; private property <length> label-column-width: 50px;
private property <length> loss-column-width: private property <length> loss-column-width:
max((self.width - self.label-column-width) / 3, max(56px, stats-loss-label.preferred-width + 12px);
stats-loss-label.preferred-width + 8px);
private property <length> value-column-width: private property <length> value-column-width:
(self.width - self.label-column-width - self.loss-column-width) / 2; (self.width - self.label-column-width - self.loss-column-width) / 2;
private property <length> details-right-width: private property <length> details-right-width:
right-label-width-probe.preferred-width + 6px right-label-width-probe.preferred-width + 6px
+ max(resolution-width-probe.preferred-width, + max(resolution-width-probe.preferred-width,
connection-latency-width-probe.preferred-width, connection-latency-width-probe.preferred-width,
encryption-width-probe.preferred-width encryption-width-probe.preferred-width + 12px
+ (root.stats-encryption-enabled ? 14px : 0px)); + (root.stats-encryption-enabled ? 14px : 0px));
private property <length> details-right-x: private property <length> details-right-x:
min(self.width / 2, self.width - self.details-right-width - 6px); min(self.width / 2 + 4px, self.width - self.details-right-width - 8px);
left-label-width-probe := Text { left-label-width-probe := Text {
visible: false; visible: false;
@@ -970,7 +973,7 @@ export component StreamWindow inherits Window {
visible: false; visible: false;
text: root.stats-encryption-enabled text: root.stats-encryption-enabled
? StreamStrings.stats-encryption-enabled : StreamStrings.stats-encryption-disabled; ? StreamStrings.stats-encryption-enabled : StreamStrings.stats-encryption-disabled;
font-size: ImGuiFontStyle.body; font-size: ImGuiFontStyle.small;
font-weight: 600; font-weight: 600;
} }
@@ -979,7 +982,7 @@ export component StreamWindow inherits Window {
y: 0px; y: 0px;
width: parent.width; width: parent.width;
height: root.stats-header-height; height: root.stats-header-height;
background: #f3f5f8; background: #f5f7fa;
border-radius: UiRadius.control; border-radius: UiRadius.control;
Text { Text {
x: stats-table.label-column-width + 4px; x: stats-table.label-column-width + 4px;
@@ -1016,13 +1019,13 @@ export component StreamWindow inherits Window {
y: root.stats-header-height + index * stats-table.row-height; y: root.stats-header-height + index * stats-table.row-height;
width: parent.width; width: parent.width;
height: stats-table.row-height; height: stats-table.row-height;
background: index == root.stats-rows.length - 1 ? #edf3fc : transparent; background: index == root.stats-rows.length - 1 ? #edf4ff : transparent;
border-radius: UiRadius.control; border-radius: UiRadius.control;
Text { Text {
x: 4px; x: 4px;
width: stats-table.label-column-width - 8px; width: stats-table.label-column-width - 8px;
text: row.label; text: row.label;
color: #475467; color: index == root.stats-rows.length - 1 ? #275188 : #667085;
font-size: ImGuiFontStyle.body; font-size: ImGuiFontStyle.body;
font-weight: index == root.stats-rows.length - 1 ? 600 : 400; font-weight: index == root.stats-rows.length - 1 ? 600 : 400;
vertical-alignment: center; vertical-alignment: center;
@@ -1031,7 +1034,7 @@ export component StreamWindow inherits Window {
x: stats-table.label-column-width + 4px; x: stats-table.label-column-width + 4px;
width: stats-table.value-column-width - 8px; width: stats-table.value-column-width - 8px;
text: row.inbound; text: row.inbound;
color: #253247; color: index == root.stats-rows.length - 1 ? #275188 : #344054;
font-size: ImGuiFontStyle.body; font-size: ImGuiFontStyle.body;
font-weight: index == root.stats-rows.length - 1 ? 600 : 400; font-weight: index == root.stats-rows.length - 1 ? 600 : 400;
horizontal-alignment: right; horizontal-alignment: right;
@@ -1042,7 +1045,7 @@ export component StreamWindow inherits Window {
x: stats-table.label-column-width + stats-table.value-column-width + 4px; x: stats-table.label-column-width + stats-table.value-column-width + 4px;
width: stats-table.value-column-width - 8px; width: stats-table.value-column-width - 8px;
text: row.outbound; text: row.outbound;
color: #253247; color: index == root.stats-rows.length - 1 ? #275188 : #344054;
font-size: ImGuiFontStyle.body; font-size: ImGuiFontStyle.body;
font-weight: index == root.stats-rows.length - 1 ? 600 : 400; font-weight: index == root.stats-rows.length - 1 ? 600 : 400;
horizontal-alignment: right; horizontal-alignment: right;
@@ -1053,7 +1056,7 @@ export component StreamWindow inherits Window {
x: stats-table.label-column-width + stats-table.value-column-width * 2 + 4px; x: stats-table.label-column-width + stats-table.value-column-width * 2 + 4px;
width: stats-table.loss-column-width - 8px; width: stats-table.loss-column-width - 8px;
text: row.loss-rate; text: row.loss-rate;
color: #475467; color: index == root.stats-rows.length - 1 ? #275188 : #667085;
font-size: ImGuiFontStyle.body; font-size: ImGuiFontStyle.body;
font-weight: index == root.stats-rows.length - 1 ? 600 : 400; font-weight: index == root.stats-rows.length - 1 ? 600 : 400;
horizontal-alignment: right; horizontal-alignment: right;
@@ -1069,6 +1072,46 @@ export component StreamWindow inherits Window {
} }
} }
codec-label-width-probe := Text {
visible: false;
text: StreamStrings.stats-encoder + "\n" + StreamStrings.stats-decoder;
font-size: ImGuiFontStyle.small;
}
Rectangle {
x: 0px;
y: root.stats-header-height + root.stats-rows.length * stats-table.row-height
+ 70px;
width: parent.width; height: 44px;
background: #f7f9fc; border-radius: UiRadius.control;
border-width: 1px; border-color: #e8edf4;
Rectangle {
x: 8px; y: 22px; width: parent.width - 16px; height: 1px;
background: #e8edf4;
}
for codec[index] in [
{ label: StreamStrings.stats-encoder, value: root.stats-encoder },
{ label: StreamStrings.stats-decoder, value: root.stats-decoder },
]: Rectangle {
x: 8px; y: 2px + index * 21px;
width: parent.width - 16px; height: 19px;
Text {
x: 0px; y: 0px;
width: codec-label-width-probe.preferred-width;
height: parent.height; text: codec.label;
color: #667085; font-size: ImGuiFontStyle.small;
vertical-alignment: center;
}
Text {
x: codec-label-width-probe.preferred-width + 8px; y: 0px;
width: parent.width - self.x; height: parent.height;
text: codec.value; color: #344054;
font-size: ImGuiFontStyle.body; font-weight: 500;
horizontal-alignment: right;
vertical-alignment: center; overflow: elide;
}
}
}
for metric[index] in [ for metric[index] in [
{ label: "FPS", value: root.stats-fps }, { label: "FPS", value: root.stats-fps },
{ label: StreamStrings.stats-resolution, value: root.stats-resolution }, { label: StreamStrings.stats-resolution, value: root.stats-resolution },
@@ -1080,12 +1123,12 @@ export component StreamWindow inherits Window {
]: Rectangle { ]: Rectangle {
private property <length> encryption-icon-width: private property <length> encryption-icon-width:
index == 5 && root.stats-encryption-enabled ? 14px : 0px; index == 5 && root.stats-encryption-enabled ? 14px : 0px;
x: mod(index, 2) == 1 ? stats-table.details-right-x : 6px; x: mod(index, 2) == 1 ? stats-table.details-right-x : 8px;
y: root.stats-header-height + root.stats-rows.length * stats-table.row-height y: root.stats-header-height + root.stats-rows.length * stats-table.row-height
+ 2px + floor(index / 2) * 21px; + 4px + floor(index / 2) * 20px;
width: mod(index, 2) == 1 ? stats-table.details-right-width width: mod(index, 2) == 1 ? stats-table.width - stats-table.details-right-x - 8px
: stats-table.details-right-x - 14px; : stats-table.details-right-x - 18px;
height: 21px; height: 20px;
metric-label := Text { metric-label := Text {
x: 0px; x: 0px;
y: 0px; y: 0px;
@@ -1097,31 +1140,40 @@ export component StreamWindow inherits Window {
font-size: ImGuiFontStyle.small; font-size: ImGuiFontStyle.small;
vertical-alignment: center; vertical-alignment: center;
} }
metric-value := Text { value-width-probe := Text {
x: metric-label.width + 6px; visible: false; text: metric.value;
y: 0px; font-size: index >= 4 ? ImGuiFontStyle.small : ImGuiFontStyle.body;
height: parent.height;
width: parent.width - self.x - parent.encryption-icon-width;
text: metric.value;
color: index == 5 && root.stats-encryption-enabled ? #18794e : #253247;
font-size: ImGuiFontStyle.body;
font-weight: 600; font-weight: 600;
horizontal-alignment: left;
vertical-alignment: center;
overflow: elide;
} }
if index == 5 && root.stats-encryption-enabled: Text { metric-value := Rectangle {
x: metric-value.x + min(metric-value.preferred-width, metric-value.width) + 4px; private property <length> badge-padding: index >= 4 ? 6px : 0px;
y: metric-value.y + (metric-value.height - self.height) / 2; private property <length> icon-width: parent.encryption-icon-width;
width: 10px; width: index >= 4
height: 12px; ? min(max(0px, parent.width - metric-label.width - 6px),
text: FontAwesomeIcons.shield-halved; value-width-probe.preferred-width + self.badge-padding * 2 + self.icon-width)
font-family: "Font Awesome 6 Free"; : max(0px, parent.width - metric-label.width - 6px);
font-weight: 900; x: parent.width - self.width; y: 2px; height: 16px;
font-size: 10px; border-radius: 4px;
color: #238653; background: index == 4 ? #edf3fc : index == 5
horizontal-alignment: center; ? (root.stats-encryption-enabled ? #eaf6ee : #fff3e3) : transparent;
vertical-alignment: center; Text {
x: parent.badge-padding; y: 0px; height: parent.height;
width: max(0px, parent.width - parent.badge-padding * 2 - parent.icon-width);
text: metric.value;
color: index == 4 ? #275188 : index == 5
? (root.stats-encryption-enabled ? #18794e : #946015) : #253247;
font-size: index >= 4 ? ImGuiFontStyle.small : ImGuiFontStyle.body;
font-weight: 600; horizontal-alignment: right;
vertical-alignment: center; overflow: elide;
}
if index == 5 && root.stats-encryption-enabled: Text {
x: parent.width - parent.badge-padding - 10px; y: 2px;
width: 10px; height: 12px;
text: FontAwesomeIcons.shield-halved;
font-family: "Font Awesome 6 Free"; font-weight: 900;
font-size: 10px; color: #238653;
horizontal-alignment: center; vertical-alignment: center;
}
} }
} }
} }
+2 -2
View File
@@ -93,8 +93,6 @@ inline int ApplyMainWindowStrings(
strings.set_capture_method_auto( strings.set_capture_method_auto(
Text(localization::screen_capture_method_auto[language])); Text(localization::screen_capture_method_auto[language]));
strings.set_codec(Text(localization::video_encode_format[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_turn_relay(Text(localization::enable_turn[language]));
strings.set_force_relay(Text(localization::force_relay[language])); strings.set_force_relay(Text(localization::force_relay[language]));
strings.set_self_hosted( strings.set_self_hosted(
@@ -243,6 +241,8 @@ inline int ApplyStreamWindowStrings(
strings.set_stats_connection_latency(Text(localization::connection_latency[language])); strings.set_stats_connection_latency(Text(localization::connection_latency[language]));
strings.set_stats_connection_mode( strings.set_stats_connection_mode(
Text(localization::connection_mode[language])); 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( strings.set_stats_encryption(
Text(localization::transport_encryption[language])); Text(localization::transport_encryption[language]));
strings.set_stats_encryption_enabled( strings.set_stats_encryption_enabled(
@@ -9,12 +9,8 @@
namespace crossdesk { namespace crossdesk {
void ConfigureLinuxCudaGraphics(bool hardware_acceleration) { void ConfigureLinuxCudaGraphics() {
#if USE_CUDA && (defined(__x86_64__) || defined(__amd64__)) #if USE_CUDA && (defined(__x86_64__) || defined(__amd64__))
if (!hardware_acceleration) {
return;
}
// Respect explicit GPU/driver selection, including a request to use Mesa // Respect explicit GPU/driver selection, including a request to use Mesa
// or software rendering. Native frames can still use the CPU fallback. // or software rendering. Native frames can still use the CPU fallback.
for (const char* name : {"__NV_PRIME_RENDER_OFFLOAD", for (const char* name : {"__NV_PRIME_RENDER_OFFLOAD",
@@ -45,8 +41,6 @@ void ConfigureLinuxCudaGraphics(bool hardware_acceleration) {
return; return;
} }
LOG_INFO("Selected NVIDIA PRIME rendering for CUDA/OpenGL video interop"); LOG_INFO("Selected NVIDIA PRIME rendering for CUDA/OpenGL video interop");
#else
(void)hardware_acceleration;
#endif #endif
} }
@@ -11,7 +11,7 @@ namespace crossdesk {
// Call before SDL or Slint initializes EGL/GLX. Hybrid laptops otherwise // Call before SDL or Slint initializes EGL/GLX. Hybrid laptops otherwise
// create the GUI context on the integrated GPU while NVDEC uses NVIDIA. // create the GUI context on the integrated GPU while NVDEC uses NVIDIA.
void ConfigureLinuxCudaGraphics(bool hardware_acceleration); void ConfigureLinuxCudaGraphics();
} // namespace crossdesk } // namespace crossdesk
@@ -35,7 +35,6 @@ std::vector<ConsoleSetting> GetConsoleSettings(const ConfigCenter& config) {
return { return {
{"language", "console_setting_language", languages[language], "zh-CN / en-US / ru-RU"}, {"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"}, {"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"}, {"turn", "console_setting_turn", turn_modes[turn], "off / auto / udp / tcp"},
{"self_hosted", "console_setting_self_hosted", OnOff(config.IsSelfHosted()), "on / off"}, {"self_hosted", "console_setting_self_hosted", OnOff(config.IsSelfHosted()), "on / off"},
{"server_host", "console_setting_host", config.GetSignalServerHost(), "example.com"}, {"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"; result.message = "console_settings_readonly";
return result; 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"; key == "self_hosted" || key == "server_host" || key == "server_port";
if (network && connection_busy) { if (network && connection_busy) {
result.message = "console_settings_busy"; result.message = "console_settings_busy";
@@ -111,13 +110,7 @@ SettingResult ApplyConsoleSetting(ConfigCenter& config, const std::string& key,
if (!boolean) return result; if (!boolean) return result;
const bool enabled = *boolean; const bool enabled = *boolean;
canonical = OnOff(enabled); canonical = OnOff(enabled);
if (key == "hardware") { if (key == "self_hosted") {
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 (enabled && (config.GetSignalServerHost().empty() || config.GetSignalServerPort() <= 0 || config.GetSignalServerPort() > 65535)) { if (enabled && (config.GetSignalServerHost().empty() || config.GetSignalServerPort() <= 0 || config.GetSignalServerPort() > 65535)) {
result.message = "console_settings_server_required"; result.message = "console_settings_server_required";
return result; return result;
@@ -138,7 +131,7 @@ SettingResult ApplyConsoleSetting(ConfigCenter& config, const std::string& key,
} else return result; } else return result;
} }
for (const auto& setting : GetConsoleSettings(config)) { 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"}; return {true, SettingEffect::none, "console_settings_unchanged"};
} }
} }
@@ -148,7 +141,7 @@ SettingResult ApplyConsoleSetting(ConfigCenter& config, const std::string& key,
if (previous_server != GetSignalServerSettings(config)) { if (previous_server != GetSignalServerSettings(config)) {
effect = SettingEffect::reconnect; effect = SettingEffect::reconnect;
message = "console_settings_reconnect"; message = "console_settings_reconnect";
} else if (key == "codec" || key == "hardware" || key == "turn") { } else if (key == "codec" || key == "turn") {
effect = SettingEffect::next_connection; effect = SettingEffect::next_connection;
message = "console_settings_next_connection"; message = "console_settings_next_connection";
} }
@@ -67,6 +67,7 @@ void PlatformMouseController::UpdateDisplayInfoList(
} }
int PlatformMouseController::Destroy() { int PlatformMouseController::Destroy() {
ReleasePressedButtons();
CleanupWaylandPortal(); CleanupWaylandPortal();
if (display_) { if (display_) {
@@ -77,6 +78,28 @@ int PlatformMouseController::Destroy() {
return 0; 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 PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
int display_index) { int display_index) {
if (remote_action.type != ControlType::mouse) { if (remote_action.type != ControlType::mouse) {
@@ -84,7 +107,16 @@ int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
} }
if (use_wayland_portal_) { 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_) { if (!display_) {
@@ -112,27 +144,27 @@ int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
break; break;
} }
case MouseFlag::left_down: case MouseFlag::left_down:
XTestFakeButtonEvent(display_, 1, True, CurrentTime); if (!XTestFakeButtonEvent(display_, 1, True, CurrentTime)) return -1;
XFlush(display_); XFlush(display_);
break; break;
case MouseFlag::left_up: case MouseFlag::left_up:
XTestFakeButtonEvent(display_, 1, False, CurrentTime); if (!XTestFakeButtonEvent(display_, 1, False, CurrentTime)) return -1;
XFlush(display_); XFlush(display_);
break; break;
case MouseFlag::right_down: case MouseFlag::right_down:
XTestFakeButtonEvent(display_, 3, True, CurrentTime); if (!XTestFakeButtonEvent(display_, 3, True, CurrentTime)) return -1;
XFlush(display_); XFlush(display_);
break; break;
case MouseFlag::right_up: case MouseFlag::right_up:
XTestFakeButtonEvent(display_, 3, False, CurrentTime); if (!XTestFakeButtonEvent(display_, 3, False, CurrentTime)) return -1;
XFlush(display_); XFlush(display_);
break; break;
case MouseFlag::middle_down: case MouseFlag::middle_down:
XTestFakeButtonEvent(display_, 2, True, CurrentTime); if (!XTestFakeButtonEvent(display_, 2, True, CurrentTime)) return -1;
XFlush(display_); XFlush(display_);
break; break;
case MouseFlag::middle_up: case MouseFlag::middle_up:
XTestFakeButtonEvent(display_, 2, False, CurrentTime); if (!XTestFakeButtonEvent(display_, 2, False, CurrentTime)) return -1;
XFlush(display_); XFlush(display_);
break; break;
case MouseFlag::wheel_vertical: { case MouseFlag::wheel_vertical: {
@@ -157,6 +189,12 @@ int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
break; 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; return 0;
} }
@@ -32,6 +32,7 @@ class PlatformMouseController final : public MouseController {
virtual int Init(std::vector<DisplayInfo> display_info_list); virtual int Init(std::vector<DisplayInfo> display_info_list);
virtual int Destroy(); virtual int Destroy();
virtual int SendMouseCommand(RemoteAction remote_action, int display_index); virtual int SendMouseCommand(RemoteAction remote_action, int display_index);
unsigned ReleasePressedButtons(unsigned mask = 7) override;
void UpdateDisplayInfoList(const std::vector<DisplayInfo>& display_info_list); void UpdateDisplayInfoList(const std::vector<DisplayInfo>& display_info_list);
private: private:
@@ -58,6 +59,7 @@ class PlatformMouseController final : public MouseController {
int screen_width_ = 0; int screen_width_ = 0;
int screen_height_ = 0; int screen_height_ = 0;
bool use_wayland_portal_ = false; bool use_wayland_portal_ = false;
unsigned pressed_buttons_ = 0;
DBusConnection* dbus_connection_ = nullptr; DBusConnection* dbus_connection_ = nullptr;
std::string wayland_session_handle_; std::string wayland_session_handle_;
@@ -46,7 +46,37 @@ int PlatformMouseController::Init(std::vector<DisplayInfo> display_info_list) {
return 0; 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( void PlatformMouseController::UpdateDisplayInfoList(
const std::vector<DisplayInfo>& display_info_list) { const std::vector<DisplayInfo>& display_info_list) {
@@ -135,7 +165,6 @@ int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
switch (remote_action.m.flag) { switch (remote_action.m.flag) {
case MouseFlag::left_down: case MouseFlag::left_down:
mouse_type = kCGEventLeftMouseDown; mouse_type = kCGEventLeftMouseDown;
left_dragging_ = true;
click_state = BeginClick(left_click_tracker_, tracked_mouse_pos_x, click_state = BeginClick(left_click_tracker_, tracked_mouse_pos_x,
tracked_mouse_pos_y); tracked_mouse_pos_y);
mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point, mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point,
@@ -144,7 +173,6 @@ int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
break; break;
case MouseFlag::left_up: case MouseFlag::left_up:
mouse_type = kCGEventLeftMouseUp; mouse_type = kCGEventLeftMouseUp;
left_dragging_ = false;
click_state = EndClick(left_click_tracker_, tracked_mouse_pos_x, click_state = EndClick(left_click_tracker_, tracked_mouse_pos_x,
tracked_mouse_pos_y); tracked_mouse_pos_y);
mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point, mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point,
@@ -153,7 +181,6 @@ int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
break; break;
case MouseFlag::right_down: case MouseFlag::right_down:
mouse_type = kCGEventRightMouseDown; mouse_type = kCGEventRightMouseDown;
right_dragging_ = true;
click_state = BeginClick(right_click_tracker_, tracked_mouse_pos_x, click_state = BeginClick(right_click_tracker_, tracked_mouse_pos_x,
tracked_mouse_pos_y); tracked_mouse_pos_y);
mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point, mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point,
@@ -162,7 +189,6 @@ int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
break; break;
case MouseFlag::right_up: case MouseFlag::right_up:
mouse_type = kCGEventRightMouseUp; mouse_type = kCGEventRightMouseUp;
right_dragging_ = false;
click_state = EndClick(right_click_tracker_, tracked_mouse_pos_x, click_state = EndClick(right_click_tracker_, tracked_mouse_pos_x,
tracked_mouse_pos_y); tracked_mouse_pos_y);
mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point, mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point,
@@ -215,6 +241,16 @@ int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
kCrossDeskInjectedEvent); kCrossDeskInjectedEvent);
CGEventPost(kCGHIDEventTap, mouse_event); CGEventPost(kCGHIDEventTap, mouse_event);
CFRelease(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; return 0;
@@ -26,6 +26,7 @@ class PlatformMouseController final : public MouseController {
virtual int Init(std::vector<DisplayInfo> display_info_list); virtual int Init(std::vector<DisplayInfo> display_info_list);
virtual int Destroy(); virtual int Destroy();
virtual int SendMouseCommand(RemoteAction remote_action, int display_index); virtual int SendMouseCommand(RemoteAction remote_action, int display_index);
unsigned ReleasePressedButtons(unsigned mask = 7) override;
void UpdateDisplayInfoList( void UpdateDisplayInfoList(
const std::vector<DisplayInfo>& display_info_list); const std::vector<DisplayInfo>& display_info_list);
@@ -45,6 +46,7 @@ class PlatformMouseController final : public MouseController {
std::vector<DisplayInfo> display_info_list_; std::vector<DisplayInfo> display_info_list_;
bool left_dragging_ = false; bool left_dragging_ = false;
bool right_dragging_ = false; bool right_dragging_ = false;
unsigned pressed_buttons_ = 0;
ClickTracker left_click_tracker_; ClickTracker left_click_tracker_;
ClickTracker right_click_tracker_; ClickTracker right_click_tracker_;
ClickTracker middle_click_tracker_; ClickTracker middle_click_tracker_;
@@ -64,6 +64,7 @@ int GuiApplication::RunUnattended(void* stop_event) {
} }
// Quiesce network callbacks before destroying the controllers they use. // Quiesce network callbacks before destroying the controllers they use.
remote_input_.Stop();
CloseAllRemoteSessions(); CloseAllRemoteSessions();
WaitForSessionCleanup(); WaitForSessionCleanup();
keyboard_.ReleaseAllRemotePressedKeys("unattended_host_exit"); 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
@@ -40,11 +40,14 @@ void PlatformMouseController::UpdateDisplayInfoList(
display_info_list_.size()); display_info_list_.size());
} }
int PlatformMouseController::Destroy() { return ReleasePressedButtons(); } int PlatformMouseController::Destroy() {
return ReleasePressedButtons() == 7 ? 0 : -1;
}
int PlatformMouseController::ReleasePressedButtons() { unsigned PlatformMouseController::ReleasePressedButtons(unsigned mask) {
const unsigned pressed = pressed_buttons_.load(); mask &= 7;
if (!pressed) return 0; const unsigned pressed = pressed_buttons_.load() & mask;
if (!pressed) return mask;
INPUT input{}; INPUT input{};
input.type = INPUT_MOUSE; input.type = INPUT_MOUSE;
input.mi.dwExtraInfo = kInjectedMouseInputMarker; input.mi.dwExtraInfo = kInjectedMouseInputMarker;
@@ -53,10 +56,10 @@ int PlatformMouseController::ReleasePressedButtons() {
if (pressed & 4) input.mi.dwFlags |= MOUSEEVENTF_MIDDLEUP; if (pressed & 4) input.mi.dwFlags |= MOUSEEVENTF_MIDDLEUP;
if (SendInputOnUserDesktop(input) != 1) { if (SendInputOnUserDesktop(input) != 1) {
LOG_WARN("Release remote mouse buttons failed, error={}", GetLastError()); LOG_WARN("Release remote mouse buttons failed, error={}", GetLastError());
return -1; return mask & ~pressed;
} }
pressed_buttons_.fetch_and(~pressed); pressed_buttons_.fetch_and(~pressed);
return 0; return mask;
} }
int PlatformMouseController::SendMouseCommand(RemoteAction remote_action, int PlatformMouseController::SendMouseCommand(RemoteAction remote_action,
@@ -29,7 +29,7 @@ class PlatformMouseController final : public MouseController {
virtual int SendMouseCommand(RemoteAction remote_action, int display_index); virtual int SendMouseCommand(RemoteAction remote_action, int display_index);
void UpdateDisplayInfoList( void UpdateDisplayInfoList(
const std::vector<DisplayInfo>& display_info_list) override; const std::vector<DisplayInfo>& display_info_list) override;
int ReleasePressedButtons(); unsigned ReleasePressedButtons(unsigned mask = 7) override;
private: private:
std::vector<DisplayInfo> display_info_list_; std::vector<DisplayInfo> display_info_list_;
@@ -16,6 +16,7 @@ namespace {
struct InputResult { struct InputResult {
UINT sent; UINT sent;
DWORD error; DWORD error;
bool requires_worker = false;
}; };
InputResult InjectOnUserDesktop(INPUT* inputs, UINT count) { InputResult InjectOnUserDesktop(INPUT* inputs, UINT count) {
@@ -25,7 +26,10 @@ InputResult InjectOnUserDesktop(INPUT* inputs, UINT count) {
ScopedInteractiveDesktop desktop; ScopedInteractiveDesktop desktop;
if (!desktop.Bind(L"")) { if (!desktop.Bind(L"")) {
const DWORD error = GetLastError(); const DWORD error = GetLastError();
return {0, 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()) { if (!desktop.IsReceivingInput()) {
return {0, ERROR_REQUIRES_INTERACTIVE_WINDOWSTATION}; return {0, ERROR_REQUIRES_INTERACTIVE_WINDOWSTATION};
@@ -99,8 +103,13 @@ UINT SendInputOnUserDesktop(INPUT* inputs, UINT count) {
SetLastError(ERROR_INVALID_PARAMETER); SetLastError(ERROR_INVALID_PARAMETER);
return 0; return 0;
} }
static UserDesktopInputWorker worker; InputResult result{0, ERROR_SUCCESS, true};
const InputResult result = worker.Send(inputs, count); if (ScopedUserDesktopInputThread::IsCurrent())
result = InjectOnUserDesktop(inputs, count);
if (result.requires_worker) {
static UserDesktopInputWorker worker;
result = worker.Send(inputs, count);
}
const DWORD error = result.error; const DWORD error = result.error;
if (error != ERROR_SUCCESS) { if (error != ERROR_SUCCESS) {
// Include session identity even when Windows reports no useful SendInput // Include session identity even when Windows reports no useful SendInput
@@ -11,9 +11,23 @@
namespace crossdesk { namespace crossdesk {
// Executes on a worker without windows or hooks, attached to this process's // Opt in only on the remote input worker. UI/hook callers keep using the
// current user input desktop. Returns a SendInput count and preserves the // fallback worker. Desktop attachment is still checked for each injection.
// worker's failure code in the calling thread's last-error value. 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); UINT SendInputOnUserDesktop(const INPUT& input);
// Synchronously injects one ordered batch. The caller owns the buffer until // Synchronously injects one ordered batch. The caller owns the buffer until
@@ -1,6 +1,6 @@
/* /*
* @Author: DI JUNKUN * @Author: DI JUNKUN
* @Date: 2026-09-07 * @Date: 2026-10-22
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/ */
@@ -19,6 +19,15 @@ inline bool IsDesktopTransitionInputError(DWORD error) {
error == ERROR_REQUIRES_INTERACTIVE_WINDOWSTATION; 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) { inline std::wstring GetDesktopNameW(HDESK desktop) {
DWORD bytes_needed = 0; DWORD bytes_needed = 0;
if (desktop == nullptr) { if (desktop == nullptr) {
@@ -32,6 +32,7 @@
#include "secure_desktop_frame_pacer.h" #include "secure_desktop_frame_pacer.h"
#include "secure_desktop_nv12_cache.h" #include "secure_desktop_nv12_cache.h"
#include "service_lifetime.h" #include "service_lifetime.h"
#include "shutdown_order.h"
#include "shared_capture_lifecycle.h" #include "shared_capture_lifecycle.h"
#include "shared_capture_configuration.h" #include "shared_capture_configuration.h"
#include "usbmmidd_policy.h" #include "usbmmidd_policy.h"
@@ -398,13 +399,8 @@ void UpdateHelperState(HelperState* helper_state) {
// Process enumeration can straddle an unlock. Observe the input desktop // Process enumeration can straddle an unlock. Observe the input desktop
// afterwards, and expose the sample's start time for service-side rejection. // afterwards, and expose the sample's start time for service-side rejection.
InputDesktopInfo desktop_info = GetInputDesktopInfo(); InputDesktopInfo desktop_info = GetInputDesktopInfo();
const bool input_desktop_is_winlogon = const bool secure_desktop_active = crossdesk::IsSecureInputDesktop(
_stricmp(desktop_info.name.c_str(), "Winlogon") == 0; desktop_info.available, desktop_info.name, desktop_info.error_code);
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;
bool session_locked = false; bool session_locked = false;
if (!QuerySessionLockState(helper_state->session_id, &session_locked)) { if (!QuerySessionLockState(helper_state->session_id, &session_locked)) {
session_locked = session_locked =
@@ -2043,9 +2039,15 @@ void PrintUsage() {
} // namespace } // namespace
int main(int argc, char* argv[]) { int main(int argc, char* argv[]) {
const DWORD shutdown_error = crossdesk::ConfigureWindowsShutdownOrder(
crossdesk::WindowsShutdownRole::helper);
EnablePerMonitorDpiAwareness(); EnablePerMonitorDpiAwareness();
InitializeHelperLogger(); InitializeHelperLogger();
if (shutdown_error != ERROR_SUCCESS) {
LOG_WARN("Could not configure Windows helper shutdown order, error={}",
shutdown_error);
}
bool run_helper = false; bool run_helper = false;
bool run_secure_input_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_consent_ui = false;
bool last_secure_desktop = false; bool last_secure_desktop = false;
bool last_session_locked = false; bool last_session_locked = false;
bool last_input_desktop_available = false;
DWORD last_input_desktop_error = ERROR_SUCCESS;
std::string last_stage; std::string last_stage;
while (true) { while (true) {
UpdateHelperState(&helper_state); UpdateHelperState(&helper_state);
@@ -2165,6 +2169,7 @@ int main(int argc, char* argv[]) {
std::string desktop_name; std::string desktop_name;
bool session_locked = false; bool session_locked = false;
bool input_desktop_available = false; bool input_desktop_available = false;
DWORD input_desktop_error = ERROR_SUCCESS;
bool lock_app_visible = false; bool lock_app_visible = false;
bool logon_ui_running = false; bool logon_ui_running = false;
bool consent_ui_visible = false; bool consent_ui_visible = false;
@@ -2174,6 +2179,7 @@ int main(int argc, char* argv[]) {
desktop_name = helper_state.input_desktop_name; desktop_name = helper_state.input_desktop_name;
session_locked = helper_state.session_locked; session_locked = helper_state.session_locked;
input_desktop_available = helper_state.input_desktop_available; 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; lock_app_visible = helper_state.lock_app_visible;
logon_ui_running = helper_state.logon_ui_visible; logon_ui_running = helper_state.logon_ui_visible;
consent_ui_visible = helper_state.consent_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); lock_app_visible, credential_ui_visible, secure_desktop_active);
if (desktop_name != last_desktop_name || 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 || session_locked != last_session_locked ||
lock_app_visible != last_lock_app || lock_app_visible != last_lock_app ||
logon_ui_running != last_logon_ui || 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) { secure_desktop_active != last_secure_desktop || stage != last_stage) {
LOG_INFO( LOG_INFO(
"Session helper state: session_id={}, input_desktop='{}', " "Session helper state: session_id={}, input_desktop='{}', "
"input_desktop_available={}, input_desktop_error={}, "
"session_locked={}, lock_app_visible={}, logon_ui_running={}, " "session_locked={}, lock_app_visible={}, logon_ui_running={}, "
"consent_ui_visible={}, secure_desktop_active={}, stage={}", "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); logon_ui_running, consent_ui_visible, secure_desktop_active, stage);
last_desktop_name = desktop_name; 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_session_locked = session_locked;
last_lock_app = lock_app_visible; last_lock_app = lock_app_visible;
last_logon_ui = logon_ui_running; 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
@@ -160,7 +160,6 @@ bool ConfigureDocumentationDemo(
std::make_shared<slint::VectorModel<crossdesk::ui::RecentConnection>>( std::make_shared<slint::VectorModel<crossdesk::ui::RecentConnection>>(
std::move(connections))); std::move(connections)));
window->set_signal_connected(true); window->set_signal_connected(true);
window->set_hardware_codec_available(true);
#if defined(_WIN32) || defined(__APPLE__) || defined(__linux__) #if defined(_WIN32) || defined(__APPLE__) || defined(__linux__)
window->set_privacy_setting_available(true); window->set_privacy_setting_available(true);
#endif #endif
@@ -937,7 +936,6 @@ int main() {
window->set_custom_titlebar(true); window->set_custom_titlebar(true);
window->set_wayland_titlebar(true); window->set_wayland_titlebar(true);
window->set_settings_session_active(true); window->set_settings_session_active(true);
window->set_hardware_codec_available(false);
window->set_portable_service_settings_visible(true); window->set_portable_service_settings_visible(true);
assert(std::string(window->get_local_id()) == "123 456 789"); assert(std::string(window->get_local_id()) == "123 456 789");
assert(window->get_connection_dialog_open()); assert(window->get_connection_dialog_open());
@@ -945,7 +943,6 @@ int main() {
assert(window->get_custom_titlebar()); assert(window->get_custom_titlebar());
assert(window->get_wayland_titlebar()); assert(window->get_wayland_titlebar());
assert(window->get_settings_session_active()); assert(window->get_settings_session_active());
assert(!window->get_hardware_codec_available());
assert(window->get_portable_service_settings_visible()); assert(window->get_portable_service_settings_visible());
if (const char *snapshot_path = if (const char *snapshot_path =
std::getenv("CROSSDESK_MAIN_UI_SNAPSHOT")) { std::getenv("CROSSDESK_MAIN_UI_SNAPSHOT")) {
+3
View File
@@ -49,6 +49,9 @@ function setup_options_and_dependencies()
end end
add_requireconfs("**.python", {version = "3.12", override = true, configs = {pgo = false}}) 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("spdlog 1.14.1", {system = false})
add_requires("slint 1.17.1", {configs = {shared = true}}) add_requires("slint 1.17.1", {configs = {shared = true}})
add_requires("libsdl3 3.2.26", {configs = {shared = false}}) add_requires("libsdl3 3.2.26", {configs = {shared = false}})
@@ -0,0 +1,134 @@
-- Embed notices so the About pages also work in offline/portable installations.
rule("crossdesk.about_resources")
after_load(function(target)
target:add("includedirs", path.join(target:autogendir(), "about"))
end)
before_build(function(target)
import("core.base.json")
import("core.project.config")
local root = os.projectdir()
local function read(relative)
return assert(io.readfile(path.join(root, relative)))
end
local function git(directory, args)
local result
try {function() result = os.iorunv("git", table.join({"-C", directory}, args)) end}
return result and result:trim() or ""
end
local repository = "https://github.com/kunkundi/crossdesk"
local revision = git(root, {"rev-parse", "HEAD"})
local source = repository .. (revision ~= "" and "/tree/" .. revision or "")
local mini_root = path.join(root, "deps/submodules/minirtc")
local mini_revision = git(mini_root, {"rev-parse", "HEAD"})
local mini_source = "https://github.com/kunkundi/minirtc" ..
(mini_revision ~= "" and "/tree/" .. mini_revision or "")
local catalog = json.decode(read("apps/ios/CrossDeskMobile/Resources/ThirdPartyLicenses.json"))
local known = {}
for _, component in ipairs(catalog.components) do known[component.id] = component end
-- These desktop dependencies are not part of the shared mobile catalog.
local desktop = {
slint = {"Slint", "https://github.com/slint-ui/slint/tree/v"},
libsdl3 = {"SDL", "https://github.com/libsdl-org/SDL/tree/release-"},
tinyfiledialogs = {"tinyfiledialogs", "https://sourceforge.net/projects/tinyfiledialogs/"},
["cpp-httplib"] = {"cpp-httplib", "https://github.com/yhirose/cpp-httplib/tree/v"},
openh264 = {"OpenH264", "https://github.com/cisco/openh264/tree/v"},
miniaudio = {"miniaudio", "https://github.com/mackron/miniaudio/tree/"},
aom = {"libaom", "https://aomedia.googlesource.com/aom/+/v"}
}
local components = {{
id = "crossdesk", name = "CrossDesk", version = config.get("CROSSDESK_VERSION") or "0.0.0",
license = "GPL-3.0-only", sourceURL = source,
documents = {{title = "GNU GPL v3", text = read("LICENSE")}}
}, {
id = "minirtc", name = "MiniRTC", version = mini_revision:sub(1, 12),
license = "LGPL-3.0-only", sourceURL = mini_source,
documents = {{title = "GNU LGPL v3", text = read("deps/submodules/minirtc/LICENSE")},
{title = "GNU GPL v3", text = read("LICENSE")}}
}}
local seen = {}
for _, dependency in ipairs(table.join({target}, target:orderdeps())) do
for _, package in ipairs(dependency:orderpkgs()) do
local name = package:name()
if not seen[name] then
seen[name] = true
local version = package:get("version") or ""
local shared = known[name]
local extra = desktop[name]
local item = {id = name, name = shared and shared.name or extra and extra[1] or name,
version = version, license = package:license() or "",
sourceURL = "https://github.com/xmake-io/xmake-repo/tree/master/packages/" ..
name:sub(1, 1) .. "/" .. name,
documents = json.mark_as_array({})}
if shared and shared.version:gsub("^v", "") == version:gsub("^v", "") then
item.license = shared.license
item.sourceURL = shared.sourceURL
item.documents = shared.documents
elseif extra then
item.sourceURL = extra[2] .. (name == "tinyfiledialogs" and "" or version:gsub("^v", ""))
end
-- Prefer installed notices when a package is absent from the shared catalog.
if #item.documents == 0 then
local install = package:installdir()
if install then
for _, file in ipairs(os.files(path.join(install, "share/licenses/**"))) do
local text = io.readfile(file)
if text and not text:find("\0", 1, true) then
table.insert(item.documents, {title = path.relative(file, install), text = text})
end
end
local notice_header = name == "tinyfiledialogs" and "include/tinyfiledialogs.h"
or name == "openh264" and "include/wels/codec_api.h"
if notice_header then
local header_path = path.join(install, notice_header)
local header = os.isfile(header_path) and io.readfile(header_path)
local notice = header and header:match("^/%*.-%*/")
if notice then
table.insert(item.documents, {title = notice_header, text = notice})
end
end
end
end
if name == "slint" then
table.insert(item.documents, {title = "GNU GPL v3", text = read("LICENSE")})
end
table.insert(components, item)
end
end
end
table.sort(components, function(a, b)
if a.id == b.id then return false end
if a.id == "crossdesk" then return true end
if b.id == "crossdesk" then return false end
return a.name:lower() < b.name:lower()
end)
local data = json.encode({components = components, revision = revision:sub(1, 12),
sourceURL = source,
buildURL = repository .. "/blob/" .. (revision ~= "" and revision or "main") .. "/docs/BUILD_EN.md"},
{pure = true, orderkeys = true})
-- A byte array avoids MSVC's limit on individual/concatenated string literals.
local bytes = {}
for index = 1, #data do
bytes[index] = tostring(data:byte(index)) .. (index % 32 == 0 and "\n" or "")
end
local output = [[/*
* @Author: DI JUNKUN
* @Date: 2026-10-21
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _ABOUT_RESOURCES_H_
#define _ABOUT_RESOURCES_H_
// Generated by the desktop build. Do not edit.
inline constexpr unsigned char kAboutResources[] = {
]] .. table.concat(bytes, ",") .. "\n};\n\n#endif\n"
local destination = path.join(target:autogendir(), "about", "about_resources.h")
if not os.isfile(destination) or io.readfile(destination) ~= output then
os.mkdir(path.directory(destination))
io.writefile(destination, output)
end
end)
rule_end()
+4 -5
View File
@@ -20,10 +20,6 @@ function setup_targets()
end end
table.insert(windows_resource_defines, table.insert(windows_resource_defines,
"CROSSDESK_VERSION_NUMERIC=" .. table.concat(version_parts, ",")) "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) local function copy_slint_runtime(target)
if not target:is_plat("windows") then if not target:is_plat("windows") then
@@ -560,6 +556,7 @@ function setup_targets()
target("gui") target("gui")
set_kind("object") set_kind("object")
add_rules("crossdesk.about_resources")
set_languages("c++20") set_languages("c++20")
-- spdlog 1.14 bundles fmt 10, whose consteval parser is rejected by -- spdlog 1.14 bundles fmt 10, whose consteval parser is rejected by
-- current Apple Clang in C++20 mode. Keep the established dependency -- current Apple Clang in C++20 mode. Keep the established dependency
@@ -585,6 +582,7 @@ function setup_targets()
add_files("apps/desktop/src/gui/render.cpp", "apps/desktop/src/gui/application/gui_application.cpp", 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/rendering/*.cpp",
"apps/desktop/src/gui/runtime/*.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/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/devices/*.cpp", "apps/desktop/src/gui/features/input/*.cpp",
"apps/desktop/src/gui/features/clipboard/*.cpp", "apps/desktop/src/gui/features/file_transfer/*.cpp", "apps/desktop/src/gui/features/clipboard/*.cpp", "apps/desktop/src/gui/features/file_transfer/*.cpp",
@@ -605,6 +603,7 @@ function setup_targets()
add_files("apps/desktop/src/platform/windows/gui/tray/win_tray.cpp", 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/slint_backend.cpp",
"apps/desktop/src/platform/windows/gui/window_icons.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/windows/gui/slint_renderer_probe.cpp",
"apps/desktop/src/platform/common/gui/opengl_video_renderer.cpp", "apps/desktop/src/platform/common/gui/opengl_video_renderer.cpp",
"apps/desktop/src/platform/common/gui/cuda_driver.cpp", "apps/desktop/src/platform/common/gui/cuda_driver.cpp",
@@ -679,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/service/session_helper_main.cpp")
add_files("apps/desktop/src/platform/windows/virtual_display/usbmmidd_virtual_display.cpp") add_files("apps/desktop/src/platform/windows/virtual_display/usbmmidd_virtual_display.cpp")
add_files("apps/desktop/resources/windows/crossdesk_session_helper.rc", 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", add_includedirs("apps/desktop/src/common",
"apps/desktop/src/platform/windows/service", "apps/desktop/src/platform/windows/service",
"apps/desktop/src/platform/windows/screen_capturer", "apps/desktop/src/platform/windows/screen_capturer",
+4
View File
@@ -244,6 +244,10 @@ class HarmonyMouse final : public MouseController {
return true; return true;
} }
public: public:
unsigned ReleasePressedButtons(unsigned mask = 7) override {
std::lock_guard lock(input_mutex);
return input.ReleaseMouseButtons(mask);
}
int Init(std::vector<DisplayInfo> displays) override { int Init(std::vector<DisplayInfo> displays) override {
if (displays.empty()) return -1; if (displays.empty()) return -1;
std::lock_guard lock(input_mutex); std::lock_guard lock(input_mutex);
+7 -2
View File
@@ -111,12 +111,17 @@ bool Input::Apply(const RemoteAction& action) {
bool Input::Release() { bool Input::Release() {
for (const auto& change : ReconcileKeyboardState(keys_, {})) for (const auto& change : ReconcileKeyboardState(keys_, {}))
Key({change.key_code, change.scan_code, change.extended}, false); Key({change.key_code, change.scan_code, change.extended}, false);
for (int button = 0; button < 3; ++button) ReleaseMouseButtons(7);
if (buttons_ & (1u << button)) Pointer(2 + button * 2, x_, y_, 0);
// Failed releases remain pending so a later cleanup can retry them. // Failed releases remain pending so a later cleanup can retry them.
have_key_sequence_ = false; have_key_sequence_ = false;
return keys_.empty() && buttons_ == 0; return keys_.empty() && buttons_ == 0;
} }
unsigned Input::ReleaseMouseButtons(unsigned mask) {
mask &= 7;
for (int button = 0; button < 3; ++button)
if (buttons_ & mask & (1u << button)) Pointer(2 + button * 2, x_, y_, 0);
return mask & ~buttons_;
}
bool Input::LockWorkstation() { bool Input::LockWorkstation() {
// The public Harmony shortcut is Meta+L. Never carry a prior remote drag or // The public Harmony shortcut is Meta+L. Never carry a prior remote drag or
// modifier into it, and retain failed releases for the normal cleanup path. // modifier into it, and retain failed releases for the normal cleanup path.
+1
View File
@@ -16,6 +16,7 @@ class Input {
bool Configure(int width, int height, int display); bool Configure(int width, int height, int display);
bool Apply(const RemoteAction& action); bool Apply(const RemoteAction& action);
bool Release(); bool Release();
unsigned ReleaseMouseButtons(unsigned mask);
bool LockWorkstation(); bool LockWorkstation();
private: private:
bool Key(const KeyboardKey& key, bool down); bool Key(const KeyboardKey& key, bool down);
+53
View File
@@ -43,6 +43,58 @@ def write_json(path, value):
write(path, json.dumps(value, ensure_ascii=False, indent=2) + '\n') write(path, json.dumps(value, ensure_ascii=False, indent=2) + '\n')
def write_about_resources(output, version):
"""Supply the embedded catalog required by the shared desktop About UI."""
def git(directory, *args):
try:
return subprocess.check_output(['git', '-C', str(directory), *args],
text=True, stderr=subprocess.DEVNULL).strip()
except (OSError, subprocess.CalledProcessError):
return ''
revision = git(ROOT, 'rev-parse', 'HEAD')
mini = git(ROOT / 'deps/submodules/minirtc', 'rev-parse', 'HEAD')
repository = 'https://github.com/kunkundi/crossdesk'
source = repository + (f'/tree/{revision}' if revision else '')
gpl = {'title': 'GNU GPL v3', 'text': (ROOT / 'LICENSE').read_text()}
components = [
{'id': 'crossdesk', 'name': 'CrossDesk', 'version': version,
'license': 'GPL-3.0-only', 'sourceURL': source, 'documents': [gpl]},
{'id': 'minirtc', 'name': 'MiniRTC', 'version': mini[:12],
'license': 'LGPL-3.0-only',
'sourceURL': 'https://github.com/kunkundi/minirtc' + (f'/tree/{mini}' if mini else ''),
'documents': [{'title': 'GNU LGPL v3',
'text': (ROOT / 'deps/submodules/minirtc/LICENSE').read_text()}, gpl]},
{'id': 'slint', 'name': 'Slint', 'version': '1.17.1', 'license': 'GPL-3.0-only',
'sourceURL': 'https://github.com/slint-ui/slint/tree/v1.17.1',
'documents': [{'title': 'Slint license', 'text': (SOURCE / 'LICENSE.md').read_text()}, gpl]},
{'id': 'cpp-httplib', 'name': 'cpp-httplib', 'version': 'v0.26.0', 'license': 'MIT',
'sourceURL': 'https://github.com/yhirose/cpp-httplib/tree/v0.26.0', 'documents': []}]
shared = json.loads((ROOT / 'apps/ios/CrossDeskMobile/Resources/ThirdPartyLicenses.json').read_text())['components']
shared += json.loads((ROOT / 'apps/android/licenses/additional-notices.json').read_text())
seen = {item['id'] for item in components}
for item in shared:
name = item['id']
package = APP / '.native/packages' / name[0] / name
if name not in seen and any((package / tag).is_dir() for tag in
[item['version'], item['version'].removeprefix('v'),
'v' + item['version'].removeprefix('v')]):
components.append(item)
seen.add(name)
components.sort(key=lambda item: (item['id'] != 'crossdesk', item['name'].lower()))
data = json.dumps({'components': components, 'revision': revision[:12],
'sourceURL': source,
'buildURL': f'{repository}/blob/{revision or "main"}/apps/harmony/README.md'},
ensure_ascii=False, sort_keys=True).encode()
body = '\n'.join(','.join(str(byte) for byte in data[i:i + 32]) + ','
for i in range(0, len(data), 32))
write(output, '/*\n * @Author: DI JUNKUN\n * @Date: 2026-10-21\n'
' * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.\n */\n\n'
'#ifndef _ABOUT_RESOURCES_H_\n#define _ABOUT_RESOURCES_H_\n\n'
'// Generated by the Harmony desktop build. Do not edit.\n'
'inline constexpr unsigned char kAboutResources[] = {\n' + body + '\n};\n\n#endif\n')
def main(): def main():
bundle = json.loads((APP / 'AppScope/app.json5').read_text())['app']['bundleName'] bundle = json.loads((APP / 'AppScope/app.json5').read_text())['app']['bundleName']
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
@@ -157,6 +209,7 @@ def main():
urllib.request.urlretrieve('https://raw.githubusercontent.com/yhirose/cpp-httplib/v0.26.0/httplib.h', header) urllib.request.urlretrieve('https://raw.githubusercontent.com/yhirose/cpp-httplib/v0.26.0/httplib.h', header)
if hashlib.sha256(header.read_bytes()).hexdigest() != '4a42da28ce477d06f53942c172197d3b83b4fe42b7e37b8af78c70e063c06da7': if hashlib.sha256(header.read_bytes()).hexdigest() != '4a42da28ce477d06f53942c172197d3b83b4fe42b7e37b8af78c70e063c06da7':
raise SystemExit('cpp-httplib source checksum mismatch') raise SystemExit('cpp-httplib source checksum mismatch')
write_about_resources(generated / 'about_resources.h', args.version)
legacy_types = PROJECT / 'entry/src/main/cpp/types/libslint_probe' legacy_types = PROJECT / 'entry/src/main/cpp/types/libslint_probe'
if legacy_types.exists(): if legacy_types.exists():
run([studio / 'tools/hvigor/bin/hvigorw', 'clean', '--no-daemon'], cwd=PROJECT, env=env) run([studio / 'tools/hvigor/bin/hvigorw', 'clean', '--no-daemon'], cwd=PROJECT, env=env)
+7
View File
@@ -0,0 +1,7 @@
-- Python's recipe looks up its dependency as "openssl". Keep that name while
-- using the OpenSSL 3 recipe, including its sources, patches and build scripts.
package("openssl")
set_base("openssl3")
-- Rebuild consumers cached against the old OpenSSL 1.x ABI.
set_policy("package.strict_compatibility", true)
package_end()
+1 -1
View File
@@ -1 +1 @@
includes("cpp-httplib", "rust", "slint") includes("cpp-httplib", "openssl", "rust", "slint")
+2 -1
View File
@@ -55,11 +55,12 @@ xmake r crossdesk --headless
设置菜单与 GUI 共用 `config.ini`,每项修改校验通过后立即保存,无需另执行保存命令。输入 `cancel` 放弃正在编辑的值。控制台状态、菜单、密码提示和帮助跟随语言设置,支持中文、英文和俄文;命令名称保持不变。 设置菜单与 GUI 共用 `config.ini`,每项修改校验通过后立即保存,无需另执行保存命令。输入 `cancel` 放弃正在编辑的值。控制台状态、菜单、密码提示和帮助跟随语言设置,支持中文、英文和俄文;命令名称保持不变。
H.264 自动优先使用硬件编解码,不可用或初始化失败时回退到软件;AV1 使用软件编解码。硬件加速不再提供独立开关,旧配置中的 `hardware_video_codec` 会自动清理。
| 设置名称 | 可选值 / 格式 | 生效方式 | | 设置名称 | 可选值 / 格式 | 生效方式 |
| --- | --- | --- | | --- | --- | --- |
| `language` | `zh-CN` / `en-US` / `ru-RU` | 立即切换控制台和 GUI 语言,下次启动保留 | | `language` | `zh-CN` / `en-US` / `ru-RU` | 立即切换控制台和 GUI 语言,下次启动保留 |
| `codec` | `h264` / `av1` | 保存后重新连接 | | `codec` | `h264` / `av1` | 保存后重新连接 |
| `hardware` | `on` / `off` | 保存后重新连接;构建不支持时拒绝开启 |
| `turn` | `off` / `auto` / `udp` / `tcp` | 保存后重新连接;`udp` / `tcp` 表示强制中继 | | `turn` | `off` / `auto` / `udp` / `tcp` | 保存后重新连接;`udp` / `tcp` 表示强制中继 |
| `server_host` | 主机名、IP 或 HTTPS/WSS 信令地址,支持路径和显式端口 | 自托管未启用时保存备用,已启用时重连 | | `server_host` | 主机名、IP 或 HTTPS/WSS 信令地址,支持路径和显式端口 | 自托管未启用时保存备用,已启用时重连 |
| `server_port` | `1`–`65535` | 同上 | | `server_port` | `1`–`65535` | 同上 |
+2 -1
View File
@@ -54,11 +54,12 @@ Enter commands in that terminal while CrossDesk is running:
The menu shares the GUI's `config.ini`. Each valid change is saved immediately; `cancel` discards the value being edited. Console status, menus, password messages and help follow the saved Chinese, English or Russian language setting. Command names remain the same. The menu shares the GUI's `config.ini`. Each valid change is saved immediately; `cancel` discards the value being edited. Console status, menus, password messages and help follow the saved Chinese, English or Russian language setting. Command names remain the same.
H.264 automatically prefers hardware codecs, with software fallback when unavailable or initialization fails; AV1 uses software codecs. There is no separate hardware acceleration switch, and the legacy `hardware_video_codec` setting is removed automatically.
| Key | Values / format | When applied | | Key | Values / format | When applied |
| --- | --- | --- | | --- | --- | --- |
| `language` | `zh-CN` / `en-US` / `ru-RU` | Immediately, including the GUI; retained on restart | | `language` | `zh-CN` / `en-US` / `ru-RU` | Immediately, including the GUI; retained on restart |
| `codec` | `h264` / `av1` | Reconnect after saving | | `codec` | `h264` / `av1` | Reconnect after saving |
| `hardware` | `on` / `off` | Reconnect; enabling requires a build with hardware codec support |
| `turn` | `off` / `auto` / `udp` / `tcp` | Reconnect; UDP/TCP force relay use | | `turn` | `off` / `auto` / `udp` / `tcp` | Reconnect; UDP/TCP force relay use |
| `server_host` | Hostname, IP, or HTTPS/WSS signaling URL, with optional path and explicit port | Reconnect if self-hosting is enabled; otherwise saved for later | | `server_host` | Hostname, IP, or HTTPS/WSS signaling URL, with optional path and explicit port | Reconnect if self-hosting is enabled; otherwise saved for later |
| `server_port` | `1`–`65535` | Same as server host | | `server_port` | `1`–`65535` | Same as server host |
+16
View File
@@ -54,11 +54,27 @@ GitHub Actions 从分支手动打包时,在 `Build and Release` 的
构建使用 `0.0.0-日期`;版本标签构建继续使用标签中的版本号。 构建使用 `0.0.0-日期`;版本标签构建继续使用标签中的版本号。
`desktop_version` 只影响桌面安装包,不改变移动端版本。 `desktop_version` 只影响桌面安装包,不改变移动端版本。
`prepare-version` 前置任务统一生成完整桌面版本,通过 job outputs 传给
Linux、macOS、Windows 和发布任务。无日期标签使用前置任务运行当天的
北京时间日期,显式日期标签使用标签中的日期;后续任务不再重新计算。
构建跨午夜或单独重跑发布任务时,继续使用前置任务确定的版本。
不要使用 `vauto-update-20261020` 这类分支名作为版本号验证升级。 不要使用 `vauto-update-20261020` 这类分支名作为版本号验证升级。
新版本必须增加数字版本或 patch;只改变日期不视为升级。例如先手动安装 新版本必须增加数字版本或 patch;只改变日期不视为升级。例如先手动安装
`v1.7.2-20261020`,再验证到 `v1.7.3-20261020` 的升级。 `v1.7.2-20261020`,再验证到 `v1.7.3-20261020` 的升级。
分支手动构建只上传 Actions 构建产物,不会自动发布版本清单。 分支手动构建只上传 Actions 构建产物,不会自动发布版本清单。
发布脚本按数字版本和 patch 比较版本,忽略构建日期。编辑或重新发布旧的
桌面 Release 时,保留服务器现有清单并在 Actions 日志中说明跳过原因;
同版本仍可更新发布说明,新版本正常发布。各平台下载记录也不会被较低
版本覆盖,包括独立移动端发布和桌面 Release 附带的移动端安装包。
桌面 Release 中所有桌面安装包及便携包的完整版本必须与标签去掉 `v`
前缀后的版本一致,包括日期和 patch 的位置;不能只按数字版本比较。
任一包不匹配时,脚本报告标签、平台、文件名及双方版本,终止清单生成,
不写入输出文件。手动发布时应使用与安装包一致的完整版本标签,例如
`v1.7.4-20261021`。iOS、Android 安装包保持独立版本。
本地构建可以设置同名环境变量,或使用: 本地构建可以设置同名环境变量,或使用:
```sh ```sh
+5 -3
View File
@@ -95,13 +95,15 @@ sudo apt install "./crossdesk-virtual-desktop-xfce-linux-amd64-<version>.deb"
| 扬声器 | 播放声音 / 静音 | | 扬声器 | 播放声音 / 静音 |
| 文件夹 | 选择文件发送到远端,并查看传输进度 | | 文件夹 | 选择文件发送到远端,并查看传输进度 |
| 画面设置 | 在当前会话中调整画面质量、采集帧率与画面偏好 | | 画面设置 | 在当前会话中调整画面质量、采集帧率与画面偏好 |
| 网络状态 | 查看流量、丢包率、帧率、分辨率、直连 / 中继及加密状态 | | 网络状态 | 查看流量、丢包率、帧率、分辨率、直连 / 中继、加密状态,以及被控端编码器和控制端解码器 |
| 隐私屏 | 开启或关闭被控电脑的隐私屏;悬停查看当前状态与可用操作 | | 隐私屏 | 开启或关闭被控电脑的隐私屏;悬停查看当前状态与可用操作 |
| 全屏 | 进入 / 退出全屏 | | 全屏 | 进入 / 退出全屏 |
| 断开连接 | 结束当前远程会话 | | 断开连接 | 结束当前远程会话 |
桌面端可同步文本剪贴板。接收文件的位置可在“设置 → 常规 → 文件保存路径”调整。关闭主窗口会隐藏窗口并保持程序运行;需要完全退出时使用系统托盘或菜单栏中的退出入口。 桌面端可同步文本剪贴板。接收文件的位置可在“设置 → 常规 → 文件保存路径”调整。关闭主窗口会隐藏窗口并保持程序运行;需要完全退出时使用系统托盘或菜单栏中的退出入口。
普通会话与桌面墙的网络状态均显示实际使用的视频编解码器名称,包括回退后的软件编解码器。名称统一采用“实现名称 (编码格式)”,名称首字母大写、技术缩写保留全大写,例如 `VideoToolbox (H.264)`、`NVENC (H.264)`、`NVDEC (H.264)`、`SVT-AV1 (AV1)`、`Dav1d (AV1)` 和 `LibAOM (AV1)`;Media Foundation 额外标注 `HW`(硬件)或 `SW`(软件)。被控端需要支持编码器状态上报;旧版本或尚未获取到的信息显示“—”。
### 在会话中调整画面 ### 在会话中调整画面
连接成功后,点击工具栏中的 **画面设置**(滑杆图标),直接选择低 / 中 / 高画质、**30 / 60 fps**,以及帧率优先 / 画质优先 / 平衡。选项会发送给当前被控端,无需断开重连;实际画面效果取决于网络、设备与编码器能力。旧版被控端不支持时,菜单会提示并禁用选项。 连接成功后,点击工具栏中的 **画面设置**(滑杆图标),直接选择低 / 中 / 高画质、**30 / 60 fps**,以及帧率优先 / 画质优先 / 平衡。选项会发送给当前被控端,无需断开重连;实际画面效果取决于网络、设备与编码器能力。旧版被控端不支持时,菜单会提示并禁用选项。
@@ -117,14 +119,14 @@ sudo apt install "./crossdesk-virtual-desktop-xfce-linux-amd64-<version>.deb"
点击主窗口右上角 **☰ → 设置**,顶部切换 **常规 / 桌面墙**,修改后点击 **保存**。切换选项卡会保留未保存的修改,点击“取消”放弃修改。“常规”按界面、视频、网络连接等类别组织选项,可向下滚动查看其余设置。编码与连接选项建议在建立连接前调整;会话进行中部分选项不可修改。普通会话的画质、帧率和画面偏好在远程会话工具栏中调整。 点击主窗口右上角 **☰ → 设置**,顶部切换 **常规 / 桌面墙**,修改后点击 **保存**。切换选项卡会保留未保存的修改,点击“取消”放弃修改。“常规”按界面、视频、网络连接等类别组织选项,可向下滚动查看其余设置。编码与连接选项建议在建立连接前调整;会话进行中部分选项不可修改。普通会话的画质、帧率和画面偏好在远程会话工具栏中调整。
<p align="center"> <p align="center">
<img src="images/desktop-settings-zh.png" width="720" alt="新版常规设置:常规与桌面墙选项卡、语言、视频编码、硬件编解码及网络连接设置" /> <img src="images/desktop-settings-zh.png" width="720" alt="新版常规设置:常规与桌面墙选项卡、语言、视频编码及网络连接设置" />
</p> </p>
| 设置 | 说明 | | 设置 | 说明 |
| --- | --- | | --- | --- |
| 语言 | 中文、English、Русский | | 语言 | 中文、English、Русский |
| 采集方式(Windows) | 自动 / DXGI / WGC / GDI;无显示器主机使用“自动”启用自动适配 | | 采集方式(Windows) | 自动 / DXGI / WGC / GDI;无显示器主机使用“自动”启用自动适配 |
| 视频编码格式 | H.264 / AV1;硬件编解码是否可用取决于平台、硬件与构建选项 | | 视频编码格式 | H.264 / AV1;H.264 自动优先使用硬件编解码,不可用或初始化失败时回退到软件;桌面端 AV1 使用软件编解码 |
| 启用中继服务 | 允许借助 TURN 中继建立连接;P2P 连接失败时先检查此项 | | 启用中继服务 | 允许借助 TURN 中继建立连接;P2P 连接失败时先检查此项 |
| 强制中继连接 | 启用中继服务后可勾选,让连接通过中继建立;关闭中继服务会同时关闭此项 | | 强制中继连接 | 启用中继服务后可勾选,让连接通过中继建立;关闭中继服务会同时关闭此项 |
| 保存远程画面预览 | 默认开启,保存远程画面预览;关闭后缩略图区域显示被控端平台,并清除已有预览,保留连接记录和密码 | | 保存远程画面预览 | 默认开启,保存远程画面预览;关闭后缩略图区域显示被控端平台,并清除已有预览,保留连接记录和密码 |
+5 -3
View File
@@ -95,13 +95,15 @@ Switch devices using session tabs. Expand the control bar if it is collapsed, an
| Speaker | Play / mute remote audio | | Speaker | Play / mute remote audio |
| Folder | Select a file to send and view transfer progress | | Folder | Select a file to send and view transfer progress |
| Video settings | Adjust video quality, capture frame rate, and video preference during the current session | | Video settings | Adjust video quality, capture frame rate, and video preference during the current session |
| Network statistics | Inspect traffic, packet loss, frame rate, resolution, direct / relay mode, and encryption status | | Network statistics | Inspect traffic, packet loss, frame rate, resolution, direct / relay mode, encryption status, the remote encoder, and the local decoder |
| Privacy screen | Enable or disable the host's privacy screen; hover to see its status and available actions | | Privacy screen | Enable or disable the host's privacy screen; hover to see its status and available actions |
| Fullscreen | Enter / exit fullscreen | | Fullscreen | Enter / exit fullscreen |
| Disconnect | End the current remote session | | Disconnect | End the current remote session |
Desktop clients synchronize text clipboard contents. Configure the receiving directory under **Settings → General → File Save Path**. Closing the main window hides it and keeps the app running; use the system tray or menu-bar exit command to quit completely. Desktop clients synchronize text clipboard contents. Configure the receiving directory under **Settings → General → File Save Path**. Closing the main window hides it and keeps the app running; use the system tray or menu-bar exit command to quit completely.
Network statistics in both ordinary sessions and DeskHub show the video codecs actually in use, including software fallback. Names use “Implementation (Format)” with an initial capital and uppercase technical abbreviations, such as `VideoToolbox (H.264)`, `NVENC (H.264)`, `NVDEC (H.264)`, `SVT-AV1 (AV1)`, `Dav1d (AV1)`, and `LibAOM (AV1)`; Media Foundation also includes `HW` (hardware) or `SW` (software). The host must support encoder status reporting; older versions and information not yet available display “—”.
### Adjust video during a session ### Adjust video during a session
After connecting, open **Video settings** (the sliders icon) in the toolbar. Choose low / medium / high quality, **30 / 60 fps**, and frame-rate priority / quality priority / balanced adaptation. Changes are sent to the current host without reconnecting; actual results depend on the network, device, and encoder. For older hosts that do not support live video settings, the menu explains the limitation and disables the options. After connecting, open **Video settings** (the sliders icon) in the toolbar. Choose low / medium / high quality, **30 / 60 fps**, and frame-rate priority / quality priority / balanced adaptation. Changes are sent to the current host without reconnecting; actual results depend on the network, device, and encoder. For older hosts that do not support live video settings, the menu explains the limitation and disables the options.
@@ -117,14 +119,14 @@ After connecting, open **Video settings** (the sliders icon) in the toolbar. Cho
Open **☰ → Settings** in the top-right corner, choose **General / DeskHub**, and click **Save** to apply changes. Switching tabs preserves pending changes; **Cancel** discards them. General settings are grouped into interface, video, networking, and further sections available by scrolling. Configure codecs and connections before connecting; some settings are disabled during an active session. Adjust ordinary session quality, frame rate, and video preference from its toolbar. Open **☰ → Settings** in the top-right corner, choose **General / DeskHub**, and click **Save** to apply changes. Switching tabs preserves pending changes; **Cancel** discards them. General settings are grouped into interface, video, networking, and further sections available by scrolling. Configure codecs and connections before connecting; some settings are disabled during an active session. Adjust ordinary session quality, frame rate, and video preference from its toolbar.
<p align="center"> <p align="center">
<img src="images/desktop-settings-en.png" width="720" alt="Updated General settings with General and DeskHub tabs, language, video codec, hardware codecs, and networking options" /> <img src="images/desktop-settings-en.png" width="720" alt="Updated General settings with General and DeskHub tabs, language, video codec, and networking options" />
</p> </p>
| Setting | Purpose | | Setting | Purpose |
| --- | --- | | --- | --- |
| Language | 中文, English, Русский | | Language | 中文, English, Русский |
| Capture Method (Windows) | Auto / DXGI / WGC / GDI; choose Auto for automatic headless adaptation | | Capture Method (Windows) | Auto / DXGI / WGC / GDI; choose Auto for automatic headless adaptation |
| Video Encode Format | H.264 / AV1; hardware codec availability depends on the platform, device, and build options | | Video Encode Format | H.264 / AV1; H.264 automatically prefers hardware codecs, with software fallback when unavailable or initialization fails; desktop AV1 uses software codecs |
| Enable TURN Service | Allow relay-assisted connections; check this setting if P2P fails | | Enable TURN Service | Allow relay-assisted connections; check this setting if P2P fails |
| Force Relay | Require relay connections when TURN is enabled; turning TURN off also disables this option | | Force Relay | Require relay connections when TURN is enabled; turning TURN off also disables this option |
| Save remote screen previews | On by default. When disabled, the thumbnail area shows the remote device’s platform and existing previews are cleared; connection records and passwords are preserved | | Save remote screen previews | On by default. When disabled, the thumbnail area shows the remote device’s platform and existing previews are cleared; connection records and passwords are preserved |
+4 -1
View File
@@ -4,6 +4,7 @@
## 来源 / Sources ## 来源 / Sources
- 两张常规设置截图(`desktop-settings-zh.png` / `desktop-settings-en.png`)于 **2026-10-22** 从 `f8f4aa3c` 加当前工作区修改重新渲染,移除硬件编解码开关;使用现有 `slint_ui_smoke_test`、macOS arm64 和 2× 软件渲染,PPM 中间文件存放在工程外临时目录。
- 16 张中英文桌面截图更新于 **2026-10-19**,UI 源代码为 `1ef372d2`,使用 Slint 1.17.1、macOS arm64 和软件渲染器。图片直接渲染仓库中的 Slint 组件。 - 16 张中英文桌面截图更新于 **2026-10-19**,UI 源代码为 `1ef372d2`,使用 Slint 1.17.1、macOS arm64 和软件渲染器。图片直接渲染仓库中的 Slint 组件。
- 捕获程序来自现有 [`slint_ui_smoke_test`](../../apps/desktop/tests/slint_ui_smoke_test.cpp),在**工程外的临时目录**中复制源文件,仅为桌面墙调整演示设备数量、单元格宽度和网格窗口高度。Slint 组件没有改动,工程内没有新增或修改测试文件。 - 捕获程序来自现有 [`slint_ui_smoke_test`](../../apps/desktop/tests/slint_ui_smoke_test.cpp),在**工程外的临时目录**中复制源文件,仅为桌面墙调整演示设备数量、单元格宽度和网格窗口高度。Slint 组件没有改动,工程内没有新增或修改测试文件。
- 主窗口展示桌面墙入口、连接区、近期设备卡片与分组筛选;设置展示“常规 / 桌面墙”选项卡,关于窗口展示三个赞助入口。桌面墙网格展示六台演示设备;放大图展示只读预览、群控和接管入口,没有建立真实连接。 - 主窗口展示桌面墙入口、连接区、近期设备卡片与分组筛选;设置展示“常规 / 桌面墙”选项卡,关于窗口展示三个赞助入口。桌面墙网格展示六台演示设备;放大图展示只读预览、群控和接管入口,没有建立真实连接。
@@ -12,7 +13,9 @@
- 缩略图沿用既有公开页面截图:`fixtures/source.png` 来自 [CrossDesk Xmake 源码页](https://github.com/kunkundi/crossdesk/blob/ios-support/xmake.lua),`fixtures/website.png` 来自 [CrossDesk 官网](https://www.crossdesk.cn/),两者采集于 2026-09-07。它们只用于展示预览效果,不代表这些页面正在对应设备上运行,页面中的版本信息也不代表最新发布。 - 缩略图沿用既有公开页面截图:`fixtures/source.png` 来自 [CrossDesk Xmake 源码页](https://github.com/kunkundi/crossdesk/blob/ios-support/xmake.lua),`fixtures/website.png` 来自 [CrossDesk 官网](https://www.crossdesk.cn/),两者采集于 2026-09-07。它们只用于展示预览效果,不代表这些页面正在对应设备上运行,页面中的版本信息也不代表最新发布。
- `web-client.png` 保留 **2026-09-25** 从 [Web 客户端](https://web.crossdesk.cn/) 采集的 1280 × 720 连接首页,页面版本为 `2026.09.24.2`,ID 与密码为空。本次未重新采集 Web 页面;桌面设备预览继续复用这张图片。 - `web-client.png` 保留 **2026-09-25** 从 [Web 客户端](https://web.crossdesk.cn/) 采集的 1280 × 720 连接首页,页面版本为 `2026.09.24.2`,ID 与密码为空。本次未重新采集 Web 页面;桌面设备预览继续复用这张图片。
All 16 desktop images were refreshed on **2026-10-19** from UI source `1ef372d2`, using Slint 1.17.1 on macOS arm64. They render the actual components with demonstration data. The capture program is an external temporary copy of the existing source, adjusted only for DeskHub's six sample devices, 300 logical-pixel cells, and shorter grid window; no UI components or repository tests were changed. The About view includes all three sponsorship links. IDs, credentials, connection states, versions, and update results are illustrative. No remote sessions were opened. Public-page fixtures remain from 2026-09-07, and the Web screenshot remains from 2026-09-25. This refresh does not include native mobile screenshots. The two General settings images were refreshed again on **2026-10-22** from `f8f4aa3c` plus the current working-tree changes to remove the hardware codec switch, using the existing `slint_ui_smoke_test` on macOS arm64 at 2× software rendering scale. Intermediate PPM files were kept outside the repository.
All 16 desktop images were previously refreshed on **2026-10-19** from UI source `1ef372d2`, using Slint 1.17.1 on macOS arm64. They render the actual components with demonstration data. The capture program is an external temporary copy of the existing source, adjusted only for DeskHub's six sample devices, 300 logical-pixel cells, and shorter grid window; no UI components or repository tests were changed in that refresh. The About view includes all three sponsorship links. IDs, credentials, connection states, versions, and update results are illustrative. No remote sessions were opened. Public-page fixtures remain from 2026-09-07, and the Web screenshot remains from 2026-09-25. This refresh does not include native mobile screenshots.
## 文件 / Files ## 文件 / Files
Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 145 KiB

+7
View File
@@ -32,6 +32,7 @@ enum ControlType {
video_settings = 11, video_settings = 11,
video_settings_status = 12, video_settings_status = 12,
text_input = 13, text_input = 13,
video_codec_status = 14,
}; };
enum MouseFlag { enum MouseFlag {
@@ -138,6 +139,7 @@ struct HostInfo {
// Optional capability; absent in legacy desktop and web host information. // Optional capability; absent in legacy desktop and web host information.
bool supports_privacy_screen; bool supports_privacy_screen;
bool supports_video_settings; bool supports_video_settings;
bool supports_video_codec_status;
bool supports_text_input; bool supports_text_input;
bool supports_deskhub_preview; bool supports_deskhub_preview;
bool supports_deskhub_resolution; bool supports_deskhub_resolution;
@@ -179,6 +181,10 @@ struct VideoSettings {
bool accepted; // Only meaningful in video_settings_status. bool accepted; // Only meaningful in video_settings_status.
}; };
struct VideoCodecStatus {
char encoder[128];
};
inline bool ValidVideoSettings(const VideoSettings& settings) { inline bool ValidVideoSettings(const VideoSettings& settings) {
return settings.quality >= 0 && settings.quality <= 2 && return settings.quality >= 0 && settings.quality <= 2 &&
(settings.frame_rate == 30 || settings.frame_rate == 60) && (settings.frame_rate == 30 || settings.frame_rate == 60) &&
@@ -202,6 +208,7 @@ struct RemoteAction {
PrivacyCommand pc; PrivacyCommand pc;
PrivacyStatus ps; PrivacyStatus ps;
VideoSettings vs; VideoSettings vs;
VideoCodecStatus vcs;
}; };
// Committed UTF-8 text. Kept outside the POD union for automatic ownership. // Committed UTF-8 text. Kept outside the POD union for automatic ownership.
+16
View File
@@ -26,6 +26,7 @@ void ResetHostInfo(HostInfo& info) {
info.bottom = nullptr; info.bottom = nullptr;
info.supports_privacy_screen = false; info.supports_privacy_screen = false;
info.supports_video_settings = false; info.supports_video_settings = false;
info.supports_video_codec_status = false;
info.supports_text_input = false; info.supports_text_input = false;
info.supports_deskhub_preview = false; info.supports_deskhub_preview = false;
info.supports_deskhub_resolution = false; info.supports_deskhub_resolution = false;
@@ -177,6 +178,9 @@ std::string RemoteAction::ToJson(const RemoteAction& action) {
{"render_mode", static_cast<int>(action.cs.render_mode)}, {"render_mode", static_cast<int>(action.cs.render_mode)},
{"hidden_reason", static_cast<int>(action.cs.hidden_reason)}}; {"hidden_reason", static_cast<int>(action.cs.hidden_reason)}};
break; break;
case ControlType::video_codec_status:
object["video_codec"] = {{"encoder", action.vcs.encoder}};
break;
case ControlType::video_settings: case ControlType::video_settings:
case ControlType::video_settings_status: case ControlType::video_settings_status:
if (!ValidVideoSettings(action.vs)) return {}; if (!ValidVideoSettings(action.vs)) return {};
@@ -232,6 +236,7 @@ std::string RemoteAction::ToJson(const RemoteAction& action) {
{"displays", displays}, {"displays", displays},
{"supports_privacy_screen", action.i.supports_privacy_screen}, {"supports_privacy_screen", action.i.supports_privacy_screen},
{"supports_video_settings", action.i.supports_video_settings}, {"supports_video_settings", action.i.supports_video_settings},
{"supports_video_codec_status", action.i.supports_video_codec_status},
{"supports_text_input", action.i.supports_text_input}, {"supports_text_input", action.i.supports_text_input},
{"supports_deskhub_preview", action.i.supports_deskhub_preview}, {"supports_deskhub_preview", action.i.supports_deskhub_preview},
{"supports_deskhub_resolution", action.i.supports_deskhub_resolution}, {"supports_deskhub_resolution", action.i.supports_deskhub_resolution},
@@ -342,6 +347,15 @@ bool RemoteAction::FromJson(const std::string& json_string,
std::clamp(output.cs.visual_offset_y, -1.0f, 1.0f); std::clamp(output.cs.visual_offset_y, -1.0f, 1.0f);
break; break;
} }
case ControlType::video_codec_status: {
const auto encoder = object.at("video_codec").at("encoder").get<std::string>();
if (encoder.size() >= sizeof(output.vcs.encoder) ||
std::any_of(encoder.begin(), encoder.end(), [](unsigned char c) {
return c < 32 || c > 126;
})) return false;
std::memcpy(output.vcs.encoder, encoder.c_str(), encoder.size() + 1);
break;
}
case ControlType::video_settings: case ControlType::video_settings:
case ControlType::video_settings_status: { case ControlType::video_settings_status: {
const auto& settings = object.at("video_settings"); const auto& settings = object.at("video_settings");
@@ -436,6 +450,8 @@ bool RemoteAction::FromJson(const std::string& json_string,
host_info_object.value("supports_text_input", false); host_info_object.value("supports_text_input", false);
output.i.supports_video_settings = output.i.supports_video_settings =
host_info_object.value("supports_video_settings", false); host_info_object.value("supports_video_settings", false);
output.i.supports_video_codec_status =
host_info_object.value("supports_video_codec_status", false);
output.i.supports_deskhub_preview = output.i.supports_deskhub_preview =
host_info_object.value("supports_deskhub_preview", false); host_info_object.value("supports_deskhub_preview", false);
output.i.supports_deskhub_resolution = output.i.supports_deskhub_resolution =
+1
View File
@@ -15,6 +15,7 @@ if desktop_platform then
includes(path.join(source_dir, "apps/desktop/xmake/options.lua")) includes(path.join(source_dir, "apps/desktop/xmake/options.lua"))
includes(path.join(source_dir, "apps/desktop/xmake/platform.lua")) includes(path.join(source_dir, "apps/desktop/xmake/platform.lua"))
includes(path.join(source_dir, "apps/desktop/xmake/rules/slint.lua")) includes(path.join(source_dir, "apps/desktop/xmake/rules/slint.lua"))
includes(path.join(source_dir, "apps/desktop/xmake/rules/about_resources.lua"))
includes(path.join(source_dir, "apps/desktop/xmake/targets.lua")) includes(path.join(source_dir, "apps/desktop/xmake/targets.lua"))
setup_options_and_dependencies() setup_options_and_dependencies()