Compare commits

..
84 Commits
Author SHA1 Message Date
dijunkun 3b989fb70d [fix] set up Python for iOS CI 2026-08-31 02:20:46 +08:00
dijunkun 7595ab8bd8 [feat] add native iOS client support 2026-08-31 02:14:27 +08:00
dijunkun 126f455681 [fix] synchronize Linux Wayland cursor shapes 2026-08-27 09:41:36 +08:00
dijunkun d6d0a548b2 [fix] rename remote cursor shape to avoid X11 macro conflict 2026-08-26 00:01:25 +08:00
dijunkun 74ac849efe Merge branch 'slint-client' of https://github.com/kunkundi/crossdesk into slint-client 2026-08-25 23:25:27 +08:00
dijunkun e2cd18dba4 [fix] detect macOS remote cursor shapes 2026-08-25 23:24:20 +08:00
dijunkun d2b1329eca [fix] restore Linux cursor state build compatibility 2026-08-25 22:59:45 +08:00
dijunkun 0653a95250 [fix] update minirtc NVDEC reconfigure handling 2026-08-25 22:52:41 +08:00
dijunkun bd73ef55f1 [feat] synchronize remote cursor state 2026-08-25 22:25:18 +08:00
dijunkun b854512c24 [feat] integrate Linux tray with desktop environment 2026-08-25 18:17:53 +08:00
dijunkun 4528caf0f4 [feat] refine tray and main window behavior 2026-08-25 17:14:45 +08:00
dijunkun f6e89725aa [fix] update minirtc NVENC fallback 2026-08-25 16:39:13 +08:00
dijunkun 62f79a8e44 [fix] update minirtc video adaptation 2026-08-25 16:18:55 +08:00
dijunkun 096bc68d35 [fix] prevent remote mouse input from popup menus 2026-08-25 12:43:30 +08:00
dijunkun 8ea4668305 [refactor] modularize Slint video rendering backends 2026-08-25 11:45:38 +08:00
dijunkun 21eec76008 [feat] add OpenGL NV12 underlay rendering for Slint on Linux 2026-08-25 10:26:05 +08:00
dijunkun 3e63675787 [fix] update minirtc NVDEC resolution handling 2026-08-25 05:28:39 +08:00
dijunkun 439f137764 [fix] update minirtc signal join sequencing 2026-08-25 04:40:37 +08:00
dijunkun f537147f54 [fix] update minirtc ICE state handling 2026-08-25 04:28:34 +08:00
dijunkun 2186ebd7eb [fix] restore Linux GCC build compatibility 2026-08-25 04:05:11 +08:00
dijunkun c11d48fc1f [fix] correct controlled-side status window behavior 2026-08-25 03:54:51 +08:00
dijunkun 1690fc92a3 [fix] prevent green tint during NV12 scaling 2026-08-25 03:37:02 +08:00
dijunkun 0d9d385d10 [feat] add OpenGL NV12 underlay rendering for Slint on Windows 2026-08-25 02:16:14 +08:00
dijunkun 20f8137df2 [fix] update minirtc video bitrate serialization 2026-08-25 01:34:03 +08:00
dijunkun 29aae747c6 [feat] add native Metal video rendering for Slint on macOS 2026-08-25 00:41:18 +08:00
dijunkun 3f93dc05aa [fix] eliminate Slint UI frame rate cap on macOS and Linux 2026-08-24 16:35:09 +08:00
dijunkun 05885ecd48 [fix] eliminate 30 fps cap in remote desktop rendering pipeline 2026-08-24 16:22:32 +08:00
dijunkun ee92c4ce5f [fix] update minirtc NVDEC teardown, fixes #95 2026-08-24 14:46:58 +08:00
dijunkun d94443330c [feat] prioritize frame rate for remote desktop 2026-08-24 14:00:40 +08:00
dijunkun df72204619 [fix] update minirtc OpenH264 bitrate handling 2026-08-24 10:33:56 +08:00
dijunkun 3678271577 [feat] report client version and platform 2026-08-24 09:46:51 +08:00
dijunkun 6b5535ab3d [fix] keep display stream IDs stable after monitor changes 2026-08-24 00:32:43 +08:00
dijunkun 078bd1e520 [fix] recover interrupted password changes 2026-08-23 21:59:20 +08:00
dijunkun 718705c352 [fix] prevent NVCodec unload crashes, fixes #95 2026-08-23 21:16:41 +08:00
dijunkun 1ca5852280 [fix] update minirtc reliable transmission 2026-08-23 19:04:43 +08:00
dijunkun 1eda6cc183 [fix] update minirtc feedback handling 2026-08-21 16:19:49 +08:00
dijunkun 8d37f8f5fb [fix] update minirtc CBR encoder configuration 2026-08-21 15:51:20 +08:00
dijunkun 05b8aa629e [fix] update minirtc for Windows build 2026-08-21 14:36:21 +08:00
dijunkun 2cbceba953 [fix] use screen content defaults for desktop streaming 2026-08-21 14:29:44 +08:00
dijunkun 89d3c8b53f [fix] prevent expired probe cluster reuse 2026-08-20 18:30:04 +08:00
dijunkun 51a4190e97 [fix] propagate encoder bitrate update failures 2026-08-20 18:20:02 +08:00
dijunkun 3737cd0dd5 [fix] align encoder and network bitrate limits 2026-08-20 18:05:45 +08:00
dijunkun 00957d6740 [fix] serialize pacer tasks and harden probing 2026-08-20 17:42:50 +08:00
dijunkun 6ec86322b2 [fix] abort probing when ICE becomes unavailable 2026-08-20 17:12:45 +08:00
dijunkun ecc131b5ee [fix] gate media-less probing by transport capability 2026-08-20 16:56:49 +08:00
dijunkun 1aa50e9ffa [fix] schedule pacer with high precision tasks 2026-08-20 16:22:01 +08:00
dijunkun d49f5bc520 [fix] handle task queue wakeups correctly 2026-08-20 14:45:18 +08:00
dijunkun 8818b9b8be [fix] ensure video bitrate updates reach encoders 2026-08-19 16:33:47 +08:00
dijunkun 3be283238c [fix] prevent screen capture without connected peers 2026-08-19 15:51:37 +08:00
dijunkun 5d0453fff3 [fix] prevent ICE callback self-join during teardown 2026-08-18 17:10:03 +08:00
dijunkun 8a9775a571 [fix] adapt VideoToolbox encoder resolution 2026-08-18 16:42:42 +08:00
dijunkun 7f4f1882ae [fix] sync signal status after server switch 2026-08-17 00:07:01 +08:00
dijunkun 23e24e3d47 [fix] prevent websocket reconnect during shutdown 2026-08-16 23:07:02 +08:00
dijunkun 7e1b9cb00e [fix] use -ld_classic only on macOS Intel 2026-08-16 22:34:58 +08:00
dijunkun 0c70acbdbf [fix] eliminate macOS build warnings 2026-08-16 22:18:30 +08:00
dijunkun fcbbb0f51c [fix] check log path before logger initialization 2026-08-16 22:16:26 +08:00
dijunkun f4be0700bf [fix] remove deprecated -ld_classic linker flag 2026-08-16 22:05:22 +08:00
dijunkun a45670b04d [fix] prevent invalid file descriptors during ICE teardown 2026-08-16 21:54:02 +08:00
dijunkun ff329cae81 [fix] use libsrtp v2.7.0 release tag 2026-08-16 17:51:08 +08:00
dijunkun 092664784e [fix] synchronize authenticated password changes 2026-08-16 16:57:36 +08:00
dijunkun 1709eb0efb [feat] use server-issued TURN credentials 2026-08-16 16:41:26 +08:00
dijunkun d27bb5d7a4 [docs] add CrossDesk privacy policy 2026-08-08 03:26:02 +08:00
dijunkun 62c8f0a38d [docs] improve README structure and signing policy 2026-08-08 03:00:13 +08:00
dijunkun 21eee5ccdc [ci] add SignPath Windows test signing 2026-08-08 01:50:47 +08:00
dijunkun e6c26511cd [fix] capture Windows keyboard input with Raw Input, refs #94 2026-08-03 17:38:46 +08:00
dijunkun c0b880e1eb [fix] pass Windows resource compiler to Slint build 2026-08-01 02:08:05 +08:00
dijunkun c10f1499f2 [fix] encode web audio before RTP packetization, fixes #93 2026-07-31 23:58:56 +08:00
dijunkun d7a9c26ff9 [fix] scale local ID text to fit 2026-07-31 23:57:09 +08:00
dijunkun f809ad173c [fix] restore Linux keyboard injection 2026-07-31 23:57:09 +08:00
dijunkun 34155d0266 [fix] let SDL select available Linux audio driver 2026-07-31 23:57:09 +08:00
dijunkun 9d544788b0 [fix] use custom titlebar only with X11 2026-07-31 23:57:08 +08:00
dijunkun dd16e65b68 [fix] stabilize third-party dependency builds 2026-07-31 23:57:08 +08:00
dijunkun 91eac61a90 [docs] use GCC 10 toolchain for Linux builds 2026-07-31 23:57:08 +08:00
dijunkun 941cc79af5 [fix] use xmake-managed CMake for builds 2026-07-31 23:57:08 +08:00
dijunkun 0d6ecfc730 [ci] build Linux releases on Ubuntu 20.04 for compatibility 2026-07-31 23:57:08 +08:00
dijunkun d32974dc18 ci: add Linux capture build dependencies 2026-07-28 17:58:39 +08:00
dijunkun efd8b7c0ff fix: let ICE drive connection failure state 2026-07-28 17:43:25 +08:00
dijunkun 383452ed3b ci: enable Wayland for Linux release builds 2026-07-28 17:25:49 +08:00
dijunkun 2570e57958 ci: use macOS 26 SDK for release artifacts 2026-07-28 16:29:32 +08:00
dijunkun eed90dad34 revert: keep macOS main window titlebar opaque 2026-07-28 16:29:32 +08:00
dijunkun c53ce2a581 fix: prevent video callback crash during transport teardown 2026-07-28 15:26:13 +08:00
dijunkun 708c39bb99 fix: keep macOS main window titlebar opaque 2026-07-28 14:42:06 +08:00
dijunkun cee249a25b fix: align DTLS handling with SRTP negotiation 2026-07-28 14:11:17 +08:00
dijunkun 7c3b13409c fix: restore remote keyboard control in Slint client, refs #91 2026-07-28 14:11:16 +08:00
151 changed files with 16675 additions and 1899 deletions
+166
View File
@@ -0,0 +1,166 @@
name: Build iOS
on:
push:
branches:
- "**"
- "!ci/linux-build-image"
tags:
- "*"
workflow_dispatch:
inputs:
patch:
description: "Hotfix patch number, for example 1 or 2. Use 0 for a normal build."
required: false
default: "0"
permissions:
contents: read
env:
IOS_DEPLOYMENT_TARGET: "16.0"
EXPECTED_IOS_SDK: "26.5"
jobs:
build-ios:
name: Build iOS (arm64)
runs-on: macos-26
timeout-minutes: 120
steps:
- name: Extract version number
shell: bash
run: |
VERSION_REF="${GITHUB_REF##*/}"
VERSION_BASE="${VERSION_REF#v}"
BUILD_DATE=$(TZ=Asia/Shanghai date +%Y%m%d)
PATCH_NUMBER="${{ github.event.inputs.patch }}"
if [[ ! "${PATCH_NUMBER}" =~ ^[0-9]+$ ]]; then
PATCH_NUMBER=0
fi
if [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)-([0-9]{8})$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
PATCH_NUMBER="${BASH_REMATCH[3]}"
BUILD_DATE="${BASH_REMATCH[4]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})-([0-9]+)$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
BUILD_DATE="${BASH_REMATCH[3]}"
PATCH_NUMBER="${BASH_REMATCH[4]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})$ ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
BUILD_DATE="${BASH_REMATCH[3]}"
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)$ && "${PATCH_NUMBER}" == "0" ]]; then
VERSION_BASE="${BASH_REMATCH[1]}"
PATCH_NUMBER="${BASH_REMATCH[3]}"
fi
if [[ "${PATCH_NUMBER}" != "0" ]]; then
VERSION_NUM="v${VERSION_BASE}-${PATCH_NUMBER}-${BUILD_DATE}"
else
VERSION_NUM="v${VERSION_BASE}-${BUILD_DATE}"
fi
echo "VERSION_NUM=${VERSION_NUM}" >> "${GITHUB_ENV}"
echo "VERSION_NUM=${VERSION_NUM}"
- name: Select Xcode 26.6
shell: bash
run: |
sudo xcode-select -s /Applications/Xcode_26.6.app/Contents/Developer
xcodebuild -version
SDK_VERSION="$(xcrun --sdk iphoneos --show-sdk-version)"
if [[ "${SDK_VERSION}" != "${EXPECTED_IOS_SDK}" ]]; then
echo "Unexpected iOS SDK: expected ${EXPECTED_IOS_SDK}, got ${SDK_VERSION}" >&2
exit 1
fi
- name: Checkout code
uses: actions/checkout@v5
- name: Initialize submodules
run: git submodule update --init --recursive
- name: Cache xmake dependencies
uses: actions/cache@v5
with:
path: ios/.xmake/packages
key: "${{ runner.os }}-xmake-deps-ios-arm64-xcode26.6-python3.13-min16-${{ github.run_id }}"
restore-keys: |
${{ runner.os }}-xmake-deps-ios-arm64-xcode26.6-python3.13-min16-
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.13"
- name: Install xmake
run: brew install xmake
- name: Update xmake repositories
run: xmake repo -u
- name: Build CrossDesk Mobile
shell: bash
run: |
DERIVED_DATA="${RUNNER_TEMP}/CrossDeskMobileDerivedData"
echo "DERIVED_DATA=${DERIVED_DATA}" >> "${GITHUB_ENV}"
xcodebuild \
-project ios/CrossDeskMobile.xcodeproj \
-scheme CrossDeskMobile \
-configuration Release \
-destination 'generic/platform=iOS' \
-derivedDataPath "${DERIVED_DATA}" \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
COMPILER_INDEX_STORE_ENABLE=NO \
IPHONEOS_DEPLOYMENT_TARGET="${IOS_DEPLOYMENT_TARGET}" \
build
- name: Verify CrossDesk Mobile
shell: bash
run: |
APP_PATH="${DERIVED_DATA}/Build/Products/Release-iphoneos/CrossDeskMobile.app"
EXECUTABLE_PATH="${APP_PATH}/CrossDeskMobile"
INFO_PLIST="${APP_PATH}/Info.plist"
if [[ ! -x "${EXECUTABLE_PATH}" ]]; then
echo "CrossDeskMobile executable is missing from ${APP_PATH}" >&2
exit 1
fi
xcrun lipo "${EXECUTABLE_PATH}" -verify_arch arm64
MINIMUM_OS=$(/usr/libexec/PlistBuddy -c 'Print :MinimumOSVersion' "${INFO_PLIST}")
if [[ "${MINIMUM_OS}" != "${IOS_DEPLOYMENT_TARGET}" ]]; then
echo "Unexpected minimum iOS version: expected ${IOS_DEPLOYMENT_TARGET}, got ${MINIMUM_OS}" >&2
exit 1
fi
xcrun lipo -info "${EXECUTABLE_PATH}"
echo "Minimum iOS version: ${MINIMUM_OS}"
- name: Package CrossDesk Mobile
shell: bash
run: |
APP_PATH="${DERIVED_DATA}/Build/Products/Release-iphoneos/CrossDeskMobile.app"
PACKAGE_DIR="${RUNNER_TEMP}/crossdesk-ios-arm64-unsigned-${VERSION_NUM}"
PACKAGE_FILE="${GITHUB_WORKSPACE}/crossdesk-ios-arm64-unsigned-${VERSION_NUM}.zip"
mkdir -p "${PACKAGE_DIR}"
cp -R "${APP_PATH}" "${PACKAGE_DIR}/"
DSYM_PATH="${DERIVED_DATA}/Build/Products/Release-iphoneos/CrossDeskMobile.app.dSYM"
if [[ -d "${DSYM_PATH}" ]]; then
cp -R "${DSYM_PATH}" "${PACKAGE_DIR}/"
fi
ditto -c -k --sequesterRsrc --keepParent "${PACKAGE_DIR}" "${PACKAGE_FILE}"
echo "PACKAGE_FILE=${PACKAGE_FILE}" >> "${GITHUB_ENV}"
- name: Upload build artifact
uses: actions/upload-artifact@v6
with:
name: crossdesk-ios-arm64-unsigned-${{ env.VERSION_NUM }}
path: ${{ env.PACKAGE_FILE }}
if-no-files-found: error
+6 -4
View File
@@ -26,8 +26,9 @@ concurrency:
cancel-in-progress: true
env:
IMAGE_NAME: crossdesk/ubuntu22.04
STACK_TAG: buildenv-cuda12.6.3-xmake3.0.9-rust1.92.0
IMAGE_NAME: crossdesk/ubuntu20.04
STACK_TAG: buildenv-cuda12.6.3-gcc10-cmake3.31.6-pipewire0.3.48-xmake3.0.9-rust1.92.0
CMAKE_VERSION: 3.31.6
XMAKE_VERSION: 3.0.9
RUST_VERSION: 1.92.0
RUSTUP_VERSION: 1.28.2
@@ -44,11 +45,11 @@ jobs:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
base_image: nvidia/cuda:12.6.3-devel-ubuntu22.04
base_image: nvidia/cuda:12.6.3-devel-ubuntu20.04
- arch: arm64
platform: linux/arm64
runner: ubuntu-24.04-arm
base_image: ubuntu:22.04
base_image: ubuntu:20.04
steps:
- name: Set candidate tag
id: image
@@ -86,6 +87,7 @@ jobs:
sbom: false
build-args: |
BASE_IMAGE=${{ matrix.base_image }}
CMAKE_VERSION=${{ env.CMAKE_VERSION }}
XMAKE_VERSION=${{ env.XMAKE_VERSION }}
RUST_VERSION=${{ env.RUST_VERSION }}
RUSTUP_VERSION=${{ env.RUSTUP_VERSION }}
+113 -13
View File
@@ -13,8 +13,14 @@ on:
description: "Hotfix patch number, for example 1 or 2. Use 0 for a normal build."
required: false
default: "0"
sign_windows_test:
description: "Submit the Windows installer to SignPath using test-signing."
required: false
type: boolean
default: false
permissions:
actions: read
contents: write
env:
@@ -29,12 +35,12 @@ jobs:
include:
- arch: amd64
runner: ubuntu-24.04
image: crossdesk/ubuntu22.04:buildenv-cuda12.6.3-xmake3.0.9-rust1.92.0
image: crossdesk/ubuntu20.04:buildenv-cuda12.6.3-gcc10-cmake3.31.6-pipewire0.3.48-xmake3.0.9-rust1.92.0
package_script: ./scripts/linux/pkg_amd64.sh
- arch: arm64
runner: ubuntu-24.04-arm
image: crossdesk/ubuntu22.04:buildenv-cuda12.6.3-xmake3.0.9-rust1.92.0
image: crossdesk/ubuntu20.04:buildenv-cuda12.6.3-gcc10-cmake3.31.6-pipewire0.3.48-xmake3.0.9-rust1.92.0
package_script: ./scripts/linux/pkg_arm64.sh
container:
@@ -103,12 +109,33 @@ jobs:
- name: Verify Linux build image
run: verify-crossdesk-build-image
- name: Ensure Wayland build dependencies
shell: bash
run: |
if pkg-config --exists dbus-1 libdrm && \
test -f /opt/crossdesk-pipewire-sdk/include/pipewire-0.3/pipewire/pipewire.h && \
test -f /opt/crossdesk-pipewire-sdk/include/spa-0.2/spa/param/video/format-utils.h; then
exit 0
fi
apt-get update
apt-get install -y --no-install-recommends \
libdbus-1-dev \
libdrm-dev
rm -rf /var/lib/apt/lists/*
install-crossdesk-pipewire-sdk
pkg-config --exists dbus-1 libdrm
test -f /opt/crossdesk-pipewire-sdk/include/pipewire-0.3/pipewire/pipewire.h
test -f /opt/crossdesk-pipewire-sdk/include/spa-0.2/spa/param/video/format-utils.h
- name: Build CrossDesk
env:
CUDA_PATH: /usr/local/cuda
XMAKE_GLOBALDIR: /data
run: |
xmake f --CROSSDESK_VERSION=${LEGAL_VERSION} --USE_CUDA=true --root -y
xmake f --CROSSDESK_VERSION=${LEGAL_VERSION} --USE_CUDA=true --USE_WAYLAND=true \
--cc=gcc-10 --cxx=g++-10 --root -y
xmake b -vy --root crossdesk
- name: Package
@@ -116,13 +143,19 @@ jobs:
chmod +x ${{ matrix.package_script }}
${{ matrix.package_script }} ${LEGAL_VERSION}
- name: Verify packaged Ubuntu 22.04 glibc baseline
- name: Verify Ubuntu 20.04 package dependency resolution
run: |
PACKAGE_FILE="${GITHUB_WORKSPACE}/crossdesk-linux-${{ matrix.arch }}-${LEGAL_VERSION}.deb"
apt-get update
apt-get install --simulate "${PACKAGE_FILE}"
- name: Verify packaged Ubuntu 20.04 glibc baseline
run: |
PACKAGE_FILE="${GITHUB_WORKSPACE}/crossdesk-linux-${{ matrix.arch }}-${LEGAL_VERSION}.deb"
VERIFY_DIR="${RUNNER_TEMP}/crossdesk-glibc-${{ matrix.arch }}"
install -d "${VERIFY_DIR}"
dpkg-deb --extract "${PACKAGE_FILE}" "${VERIFY_DIR}"
verify-crossdesk-glibc-baseline "${VERIFY_DIR}" 2.35
verify-crossdesk-glibc-baseline "${VERIFY_DIR}" 2.31
- name: Upload artifact
uses: actions/upload-artifact@v6
@@ -136,19 +169,20 @@ jobs:
runs-on: ${{ matrix.runner }}
env:
MACOSX_DEPLOYMENT_TARGET: "14.0"
EXPECTED_MACOS_SDK: "26.5"
strategy:
matrix:
include:
- arch: x64
binary_arch: x86_64
runner: macos-15-intel
cache-key: intel-macos14
runner: macos-26-intel
cache-key: intel-macos26-xcode26.6-min14
out-dir: ./build/macosx/x86_64/release/crossdesk
package_script: ./scripts/macosx/pkg_x64.sh
- arch: arm64
binary_arch: arm64
runner: macos-14
cache-key: arm-macos14
runner: macos-26
cache-key: arm-macos26-xcode26.6-min14
out-dir: ./build/macosx/arm64/release/crossdesk
package_script: ./scripts/macosx/pkg_arm64.sh
@@ -193,6 +227,17 @@ jobs:
echo "BUILD_DATE=${BUILD_DATE}" >> $GITHUB_ENV
echo "PATCH_NUMBER=${PATCH_NUMBER}" >> $GITHUB_ENV
- name: Select Xcode 26.6
shell: bash
run: |
sudo xcode-select -s /Applications/Xcode_26.6.app/Contents/Developer
xcodebuild -version
SDK_VERSION="$(xcrun --sdk macosx --show-sdk-version)"
if [[ "${SDK_VERSION}" != "${EXPECTED_MACOS_SDK}" ]]; then
echo "Unexpected macOS SDK: expected ${EXPECTED_MACOS_SDK}, got ${SDK_VERSION}" >&2
exit 1
fi
- name: Cache xmake dependencies
uses: actions/cache@v5
with:
@@ -215,7 +260,7 @@ jobs:
- name: Build CrossDesk
run: |
xmake f --target_minver=${MACOSX_DEPLOYMENT_TARGET} --CROSSDESK_VERSION=${VERSION_NUM} --USE_CUDA=true -y
xmake f --target_minver=${MACOSX_DEPLOYMENT_TARGET} --CROSSDESK_VERSION=${VERSION_NUM} --USE_CUDA=false -y
xmake b -vy crossdesk
- name: Package CrossDesk app
@@ -237,7 +282,7 @@ jobs:
exit 1
fi
./scripts/macosx/verify_app.sh "${APP_PATH}" "${{ matrix.binary_arch }}"
./scripts/macosx/verify_app.sh "${APP_PATH}" "${{ matrix.binary_arch }}" "${EXPECTED_MACOS_SDK}"
- name: Upload build artifacts
uses: actions/upload-artifact@v6
@@ -371,6 +416,52 @@ jobs:
run: |
& "${{ github.workspace }}\scripts\windows\pkg_x64.ps1" $env:VERSION_NUM
- name: Upload unsigned Windows installer for test signing
if: github.event_name == 'workflow_dispatch' && inputs.sign_windows_test
id: upload-unsigned-windows-installer
uses: actions/upload-artifact@v7
with:
path: ${{ github.workspace }}/scripts/windows/crossdesk-win-x64-${{ env.VERSION_NUM }}.exe
archive: false
if-no-files-found: error
retention-days: 1
- name: Sign Windows installer with SignPath test certificate
if: github.event_name == 'workflow_dispatch' && inputs.sign_windows_test
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }}
project-slug: crossdesk
signing-policy-slug: test-signing
github-artifact-id: ${{ steps.upload-unsigned-windows-installer.outputs.artifact-id }}
wait-for-completion: true
output-artifact-directory: ${{ github.workspace }}\signpath\signed-installer
skip-decompress: true
parameters: |
version: ${{ toJSON(env.VERSION_NUM) }}
- name: Verify and use test-signed Windows installer
if: github.event_name == 'workflow_dispatch' && inputs.sign_windows_test
shell: pwsh
run: |
$installerName = "crossdesk-win-x64-${{ env.VERSION_NUM }}.exe"
$signedInstaller = Join-Path "${{ github.workspace }}\signpath\signed-installer" $installerName
$destination = Join-Path "${{ github.workspace }}\scripts\windows" $installerName
if (!(Test-Path $signedInstaller)) {
throw "SignPath did not return the signed installer: $signedInstaller"
}
$signature = Get-AuthenticodeSignature $signedInstaller
if (!$signature.SignerCertificate -or $signature.Status -eq "NotSigned") {
throw "SignPath returned an installer without an Authenticode signature."
}
Write-Host "Signer: $($signature.SignerCertificate.Subject)"
Write-Host "Signature status: $($signature.Status)"
Copy-Item $signedInstaller $destination -Force
- name: Build Portable CrossDesk
run: |
xmake f --CROSSDESK_VERSION=${{ env.VERSION_NUM }} --USE_CUDA=true --CROSSDESK_PORTABLE=true -y
@@ -424,8 +515,7 @@ jobs:
release:
name: Publish Release
if: startsWith(github.ref, 'refs/tags/v')
needs:
[build-linux, build-macos, build-windows-x64]
needs: [build-linux, build-macos, build-windows-x64]
runs-on: ubuntu-latest
steps:
@@ -506,6 +596,10 @@ jobs:
body: |
Binary release only. Source code is not included.
## Code signing policy
Free code signing provided by [SignPath.io](https://signpath.io/), certificate by [SignPath Foundation](https://signpath.org/).
- name: Create or update 'latest' tag
run: |
git config user.name "github-actions[bot]"
@@ -522,6 +616,12 @@ jobs:
prerelease: false
files: release/*
generate_release_notes: false
body: |
Binary release only. Source code is not included.
## Code signing policy
Free code signing provided by [SignPath.io](https://signpath.io/), certificate by [SignPath Foundation](https://signpath.org/).
- name: Upload artifacts to server
uses: burnett01/rsync-deployments@5.2
+6 -1
View File
@@ -1,9 +1,14 @@
# Xmake cache
.xmake/
build/
ios/Vendor/
ios/DerivedData/
ios/.xmake/
xcuserdata/
*.xcuserstate
# MacOS Cache
.DS_Store
# VSCode cache
.vscode
.vscode
+99
View File
@@ -0,0 +1,99 @@
# CrossDesk Privacy Policy
Last updated: August 8, 2026
[中文](#中文) | [English](#english)
## 中文
### 适用范围
本隐私政策适用于 CrossDesk 桌面客户端及项目提供的默认网络服务。用户自行部署或指定的服务器由相应服务器运营者负责,其数据处理方式不在本政策控制范围内。
### 网络通信
CrossDesk 是远程桌面软件,需要通过网络完成设备发现、连接协商及远程会话。客户端可能进行以下网络通信:
- **默认服务:** 未启用自托管时,客户端会连接预配置的 CrossDesk 服务(`api.crossdesk.cn`),用于设备注册、信令、网络地址转换(NAT)穿透,并在无法建立直接连接时提供中继。
- **版本检查:** 客户端启动时会从 CrossDesk 版本服务(`version.crossdesk.cn`)获取最新版本信息。当前即使启用自托管,版本检查仍会访问 CrossDesk 版本服务。
- **远程会话:** 用户主动发起或接受远程连接后,CrossDesk 会根据使用的功能在设备之间传输屏幕画面、音频、键盘和鼠标输入、剪贴板文本以及用户选择传输的文件。剪贴板文本可能在远程会话期间自动同步。这些数据可能通过直接连接传输,也可能在必要时通过配置的中继服务器转发。
- **自托管:** 用户可以在设置中指定自己的信令及中继服务器。自托管服务器的日志、数据保存和访问规则由其运营者决定。
### 处理的信息
为提供上述功能,CrossDesk 可能处理:
- 连接所需的设备标识及认证信息;
- IP 地址、端口、连接时间、连接状态等必要的网络元数据;
- 版本检查请求产生的标准网络请求信息;
- 用户授权的远程会话中处理的屏幕、音频、控制指令、剪贴板及文件内容。
### 信息的使用与保存
- 设备标识、连接设置及用户偏好会保存在本地设备上,以便后续使用。
- 通过官方信令和中继服务处理的信息仅用于建立、维持和转发 CrossDesk 连接,以及服务运行、安全防护和故障排查。
- 中继传输的远程会话内容仅用于实时转发,不用于广告或用户画像。
- CrossDesk 桌面客户端不包含广告功能,也不会主动上传诊断日志、崩溃报告或用户行为分析数据。
- 自托管服务器可能保存的日志和数据由其运营者控制,请向相应运营者了解其隐私与保存政策。
### 用户选择
用户可以:
- 配置自托管服务器,以替代默认信令和中继服务;
- 决定是否发起或接受远程会话;
- 决定是否选择并传输文件;
- 结束远程会话,以停止会话数据传输;
- 删除 CrossDesk 的本地配置、缓存及日志文件。
### 政策更新与联系方式
本政策可能随着 CrossDesk 功能或网络服务的变化而更新。更新内容将在本文件中发布。如有隐私相关问题,请联系 [junkun.di@hotmail.com](mailto:junkun.di@hotmail.com)。
---
## English
### Scope
This Privacy Policy applies to the CrossDesk desktop client and the default network services provided by the project. Servers deployed or selected by users are operated by their respective administrators, and their data-processing practices are outside the control of this policy.
### Network communications
CrossDesk is remote desktop software and requires network communications for device discovery, connection negotiation, and remote sessions. The client may perform the following communications:
- **Default services:** Unless self-hosting is enabled, the client connects to preconfigured CrossDesk services (`api.crossdesk.cn`) for device registration, signaling, network address translation (NAT) traversal, and relay when a direct connection cannot be established.
- **Update checks:** On startup, the client retrieves current release information from the CrossDesk version service (`version.crossdesk.cn`). Update checks currently continue to use the CrossDesk version service even when self-hosting is enabled.
- **Remote sessions:** After a user initiates or accepts a remote connection, CrossDesk transmits the data required by enabled features between devices. This may include screen content, audio, keyboard and mouse input, clipboard text, and files selected by the user. Clipboard text may be synchronized automatically during a remote session. Data may travel over a direct connection or through the configured relay server when necessary.
- **Self-hosting:** Users may configure their own signaling and relay servers. Logging, retention, and access policies for a self-hosted server are determined by its operator.
### Information processed
To provide these functions, CrossDesk may process:
- device identifiers and authentication information required for connections;
- necessary network metadata, such as IP addresses, ports, connection times, and connection status;
- standard network request information generated by update checks; and
- screen, audio, control, clipboard, and file content processed during a remote session authorized by the user.
### Use and storage of information
- Device identifiers, connection settings, and user preferences are stored locally for subsequent use.
- Information processed by the official signaling and relay services is used only to establish, maintain, and relay CrossDesk connections and for service operation, security, and troubleshooting.
- Remote-session content passing through a relay is used only for real-time forwarding and is not used for advertising or user profiling.
- The CrossDesk desktop client does not contain advertising functionality and does not intentionally upload diagnostic logs, crash reports, or behavioral analytics.
- Logs and data that may be retained by self-hosted servers are controlled by their operators. Contact the relevant operator for its privacy and retention practices.
### User choices
Users may:
- configure a self-hosted server instead of the default signaling and relay services;
- choose whether to initiate or accept a remote session;
- choose whether to select and transfer files;
- end a remote session to stop session-data transmission; and
- remove local CrossDesk configuration, cache, and log files.
### Changes and contact
This policy may be updated as CrossDesk features or network services change. Updates will be published in this file. For privacy-related questions, contact [junkun.di@hotmail.com](mailto:junkun.di@hotmail.com).
+152 -53
View File
@@ -1,38 +1,51 @@
# CrossDesk
<a href="https://hellogithub.com/repository/kunkundi/crossdesk" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=55d41367570345f1838e02fd12be7961&claim_uid=cb0OpZRrBuGVAfL&theme=small" alt="FeaturedHelloGitHub" /></a>
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-brightgreen.svg)]()
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-brightgreen.svg)](https://www.crossdesk.cn/)
[![License: GPL v3](https://img.shields.io/badge/License-GPL%20v3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
[![GitHub last commit](https://img.shields.io/github/last-commit/kunkundi/crossdesk)](https://github.com/kunkundi/crossdesk/commits/web-client)
[![Build Status](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml/badge.svg)](https://github.com/kunkundi/crossdesk/actions)
[![Docker Pulls](https://img.shields.io/docker/pulls/crossdesk/crossdesk-server)](https://hub.docker.com/r/crossdesk/crossdesk-server/tags)
[![GitHub issues](https://img.shields.io/github/issues/kunkundi/crossdesk.svg)]()
[![GitHub stars](https://img.shields.io/github/stars/kunkundi/crossdesk.svg?style=social)]()
[![GitHub forks](https://img.shields.io/github/forks/kunkundi/crossdesk.svg?style=social)]()
[![GitHub issues](https://img.shields.io/github/issues/kunkundi/crossdesk.svg)](https://github.com/kunkundi/crossdesk/issues)
[![GitHub stars](https://img.shields.io/github/stars/kunkundi/crossdesk.svg?style=social)](https://github.com/kunkundi/crossdesk/stargazers)
[![GitHub forks](https://img.shields.io/github/forks/kunkundi/crossdesk.svg?style=social)](https://github.com/kunkundi/crossdesk/forks)
[ [English](README_EN.md) / 中文 ]
PC 客户端
---
## 界面预览
### PC 客户端
![sup_example](https://github.com/user-attachments/assets/eeb64fbe-1f07-4626-be1c-b77396beb905)
Web 客户端
### Web 客户端
<p align="center">
<img width="850" height="550" alt="6bddcbed47ffd4b9988a4037c7f4f524" src="https://github.com/user-attachments/assets/e44f73f9-24ac-46a3-a189-b7f8b6669881" />
</p>
---
## 简介
CrossDesk 是一个轻量级的跨平台远程桌面软件,支持 Web 端控制远程设备。
CrossDesk 是 [MiniRTC](https://github.com/kunkundi/minirtc.git) 实时音视频传输库的实验性应用。MiniRTC 是一个轻量级的跨平台实时音视频传输库。它具有网络透传([RFC5245](https://datatracker.ietf.org/doc/html/rfc5245)),视频软硬编解码(H264/AV1),音频编解码([Opus](https://github.com/xiph/opus)),信令交互,网络拥塞控制,传输加密([SRTP](https://tools.ietf.org/html/rfc3711))等基础能力。
---
## 系统要求
| 平台 | 最低版本 |
|----------------|---------------------------|
| --- | --- |
| **Windows** | Windows 10 及以上 (64 位) |
| **macOS** | macOS Intel 15.0 及以上 ( 大于 14.0 小于 15.0 的版本可自行编译实现兼容 )<br> macOS Apple Silicon 14.0 及以上 |
| **Linux** | Ubuntu 22.04 及以上 ( 低版本可自行编译实现兼容 ) |
| **Linux** | Ubuntu 20.04 及以上 |
---
## 使用
@@ -47,14 +60,17 @@ CrossDesk 是 [MiniRTC](https://github.com/kunkundi/minirtc.git) 实时音视频
发起连接前,可在设置中自定义配置项,如语言、视频编码格式等。
![settings](https://github.com/user-attachments/assets/8bc5468d-7bbb-4e30-95bd-da1f352ac08c)
### Web 客户端
### 使用 Web 客户端
浏览器访问 [CrossDesk Web Client](https://web.crossdesk.cn/)。
输入 **远程设备 ID****密码**,点击连接即可接入远程设备。如图,**iOS Safari 远程控制 Win11**
<img width="645" height="300" alt="_cgi-bin_mmwebwx-bin_webwxgetmsgimg__ MsgID=932911462648581698 skey=@crypt_1f5153b1_b550ca7462b5009ce03c991cca2a92a7 mmweb_appid=wx_webfilehelper" src="https://github.com/user-attachments/assets/a5109e6f-752c-4654-9f4e-7e161bddf43e" />
### Windows 服务(CrossDesk Service
CrossDesk 在 Windows 平台提供本地辅助服务 **CrossDesk Service**,服务名为 `CrossDeskService`。该服务用于锁屏、登录界面和安全桌面等受保护场景下的远程控制增强能力,包括:
- 上报远端当前是否处于锁屏、登录、凭据输入或安全桌面状态;
- 支持从控制端发送 `Ctrl+Alt+Del`SAS);
- 在锁屏、登录和安全桌面阶段转发键盘、鼠标输入。
@@ -62,13 +78,15 @@ CrossDesk 在 Windows 平台提供本地辅助服务 **CrossDesk Service**,服
Windows 安装包会自动打包 `crossdesk_service.exe``crossdesk_session_helper.exe`,并在安装时注册为按需启动的 Windows 服务。CrossDesk 客户端启动时会尝试启动已安装的服务;当本机没有 CrossDesk 客户端进程运行时,服务会自动退出。卸载客户端时会同步停止并移除该服务。
如果是手动编译或手动部署 Windows 版本,请确保 `CrossDesk.exe``crossdesk_service.exe``crossdesk_session_helper.exe` 位于同一目录。安装或卸载服务需要使用管理员权限打开 PowerShell
```
```powershell
# 安装
.\CrossDesk.exe --service-install
# 启动
.\CrossDesk.exe --service-start
# 查看状态
.\CrossDesk.exe --service-status
# 停止
.\CrossDesk.exe --service-stop
# 卸载
.\CrossDesk.exe --service-uninstall
@@ -76,31 +94,46 @@ Windows 安装包会自动打包 `crossdesk_service.exe` 和 `crossdesk_session_
如果远端 Windows 服务未安装、未启动或暂时不可用,基础远程桌面连接仍可使用,但锁屏、登录界面和安全桌面阶段的控制能力会受限,客户端会提示“远端Windows服务不可用”。
---
## 如何编译
依赖:
### 构建依赖
- [xmake](https://xmake.io/#/guide/installation)
- [cmake](https://cmake.org/download/)
- [cmake](https://cmake.org/download/) 3.21 及以上(系统版本过低时由 xmake 自动安装)
### Linux
Linux 构建目前支持 Ubuntu 22.04 及以上版本的 amd64 和 arm64。先安装基础编译依赖:
Linux 构建支持 Ubuntu 20.04 及以上版本的 amd64 和 arm64。发布安装包以
Ubuntu 20.04/glibc 2.31 为兼容基线。先安装基础编译依赖:
```bash
sudo apt-get update
sudo apt-get install -y \
git curl unzip build-essential cmake pkg-config binutils dpkg-dev \
git curl unzip build-essential gcc-10 g++-10 python3-pip \
pkg-config binutils dpkg-dev \
libx11-dev libxext-dev libxrender-dev libxft-dev libxrandr-dev \
libxinerama-dev libxcursor-dev libxi-dev libxfixes-dev libxv-dev \
libxtst-dev libxcb-randr0-dev libxcb-xtest0-dev libxcb-xinerama0-dev \
libxcb-shape0-dev libxcb-xkb-dev libxcb-xfixes0-dev libxcb-shm0-dev \
libasound2-dev libsndio-dev libpulse-dev
libasound2-dev libsndio-dev libpulse-dev \
libgl1-mesa-dev
```
Ubuntu 20.04 自带的 CMake 3.16 不满足要求时,xmake 会自动下载并使用
CMake 3.21 或更高版本,无需手动替换系统 CMake。
Ubuntu 20.04 默认的 GCC 9 缺少 Slint C++ API 所需的部分 C++20 标准库,
因此上述命令同时安装并在后续配置中指定 GCC 10。
需要启用 Wayland 捕获或 DRM 捕获时,再安装对应依赖:
```bash
sudo apt-get install -y libdbus-1-dev libpipewire-0.3-dev libdrm-dev
sudo apt-get install -y libdbus-1-dev libdrm-dev
# Ubuntu 22.04 及以上也可以直接安装系统开发包
# sudo apt-get install -y libpipewire-0.3-dev libspa-0.2-dev
```
下载子模块并编译 Release 版本:
@@ -112,16 +145,25 @@ cd crossdesk
# 已经克隆过仓库时执行这一行
git submodule update --init --recursive
xmake f -m release --USE_CUDA=false -y
# Ubuntu 20.04 没有 PipeWire 0.3 开发包,安装仅用于编译的头文件 SDK。
# 已安装发行版 PipeWire 0.3 开发包时可跳过此步骤。
sudo ./docker/linux-build/install-pipewire-sdk.sh
xmake f -c -m release --USE_CUDA=false --toolchain=gcc-10 -y
xmake b -vy crossdesk
```
这里应使用 `--toolchain=gcc-10`,以确保 CrossDesk 及 xmake 在配置阶段构建的
libyuv 等第三方包都使用 GCC 10。只指定 `--cc`/`--cxx` 时,依赖安装可能仍回退到
系统默认的 `/bin/cc``/bin/c++``-c` 会清除旧配置,切换编译器时必须保留。
amd64 构建结果位于 `build/linux/x86_64/release/crossdesk`arm64 构建结果位于 `build/linux/arm64/release/crossdesk`
启用 Wayland 和 DRM 的配置示例:
```bash
xmake f -m release --USE_WAYLAND=true --USE_DRM=true --USE_CUDA=false -y
xmake f -c -m release --USE_WAYLAND=true --USE_DRM=true --USE_CUDA=false \
--toolchain=gcc-10 -y
xmake b -vy crossdesk
```
@@ -136,6 +178,8 @@ xmake b -vy crossdesk
```
打包脚本会将 Slint 共享运行库安装到软件包私有目录 `/usr/lib/crossdesk`,无需用户另外安装 `libslint_cpp.so`
PipeWire 不属于强制运行时依赖:程序在运行时检测宿主系统的 PipeWire 0.3
没有该运行库时仍可使用 X11(以及构建时启用的 DRM)捕获。
### 通用编译选项
@@ -158,10 +202,11 @@ xmake r crossdesk
### 无 CUDA 环境下的开发支持
对于**未安装 CUDA 环境的 Linux 开发者,如果希望编译后的成果物拥有硬件编解码能力**,这里提供了预配置的 [Ubuntu 22.04 Docker 镜像](https://hub.docker.com/r/crossdesk/ubuntu22.04)。该镜像内置必要的构建依赖,可在容器中开箱即用,无需额外配置即可直接编译项目
对于**未安装 CUDA 环境的 Linux 开发者,如果希望编译后的成果物拥有硬件编解码能力**,这里提供了预配置的 [Ubuntu 20.04 兼容构建镜像](https://hub.docker.com/r/crossdesk/ubuntu20.04)。该镜像内置必要的构建依赖,可生成兼容 glibc 2.31 的单一 Linux 安装包
进入容器,下载工程后执行:
```
```bash
export CUDA_PATH=/usr/local/cuda
export XMAKE_GLOBALDIR=/data
@@ -170,50 +215,65 @@ xmake b --root -vy crossdesk
```
对于**未安装 CUDA 环境的 Windows 开发者**,执行下面的命令安装 CUDA 编译环境:
```
```powershell
xmake require -vy "cuda 12.6.3"
```
安装完成后执行:
```
```powershell
xmake require --info "cuda 12.6.3"
```
输出如下:
<img width="860" height="226" alt="Image" src="https://github.com/user-attachments/assets/999ac365-581a-4b9a-806e-05eb3e4cf44d" />
根据上述输出获取到 CUDA 的安装目录,即 installdir 指向的位置。将 CUDA_PATH 加入系统环境变量,或在终端中输入:
```
```powershell
set CUDA_PATH=path_to_cuda_installdir
```
重新执行:
```
```powershell
xmake f --USE_CUDA=true
xmake b -vy crossdesk
```
#### 注意
运行时如果客户端状态栏显示 **未连接服务器**,请先在 [CrossDesk 官方网站](https://www.crossdesk.cn/) 安装客户端,以便在环境中安装所需的证书文件。
<img width="256" height="120" alt="image" src="https://github.com/user-attachments/assets/1812f7d6-516b-4b4f-8a3d-98bee505cc5a" />
## 关于 Xmake
### 关于 Xmake
#### 安装 Xmake
使用 curl
```
```bash
curl -fsSL https://xmake.io/shget.text | bash
```
使用 wget
```
```bash
wget https://xmake.io/shget.text -O - | bash
```
使用 powershell
```
```powershell
irm https://xmake.io/psget.text | iex
```
#### 编译选项
```
```text
# 切换编译模式
xmake f -m debug/release
@@ -227,14 +287,22 @@ xmake b -vy crossdesk
```
#### 运行选项
```
```bash
# 使用调试模式运行
xmake r -d crossdesk
```
更多使用方法可参考 [Xmake官方文档](https://xmake.io/guide/quick-start.html) 。
更多使用方法可参考 [Xmake 官方文档](https://xmake.io/guide/quick-start.html)。
---
## 自托管服务器
推荐使用Docker部署CrossDesk Server。
### 服务端
推荐使用 Docker 部署 CrossDesk Server。
```bash
sudo docker run -d \
--name crossdesk_server \
@@ -252,7 +320,8 @@ sudo docker run -d \
上述命令中,用户需注意的参数如下:
**参数**
#### 配置参数
- EXTERNAL_IP:服务器公网 IP , 对应 CrossDesk 客户端**自托管服务器配置**中填写的**服务器地址**
- INTERNAL_IP:服务器内网 IP
- CROSSDESK_SERVER_PORT:自托管服务使用的端口,对应 CrossDesk 客户端**自托管服务器配置**中填写的**服务器端口**
@@ -261,7 +330,8 @@ sudo docker run -d \
- `-v /var/lib/crossdesk:/var/lib/crossdesk`:持久化数据库和证书文件到宿主机
- `-v /var/log/crossdesk:/var/log/crossdesk`:持久化日志文件到宿主机
**示例**
#### 部署示例
```bash
sudo docker run -d \
--name crossdesk_server \
@@ -277,26 +347,31 @@ sudo docker run -d \
crossdesk/crossdesk-server:v1.1.6
```
**注意**
#### 注意事项
- **服务器需开放端口:COTURN_PORT/udpCOTURN_PORT/tcpMIN_PORT-MAX_PORT/udpCROSSDESK_SERVER_PORT/tcp。**
- 如果不挂载 volume,容器删除后数据会丢失
- 证书文件会在首次启动时自动生成并持久化到宿主机的 `/var/lib/crossdesk/certs` 路径下。由于默认使用的是自签证书,无法保障安全性,建议在云服务商申请正式证书放到该目录下并重启服务。
- 数据库文件会自动创建并持久化到宿主机的 `/var/lib/crossdesk/db/crossdesk-server.db` 路径下
- 日志文件会自动创建并持久化到宿主机的 `/var/log/crossdesk/` 路径下
**权限注意**:如果 Docker 自动创建的目录权限不足(属于 root),容器内用户无法写入,会导致:
- 证书生成失败,容器启动脚本会报错退出
- 数据库目录创建失败,程序会抛出异常并崩溃
- 日志目录创建失败,日志文件无法写入(但程序可能继续运行)
**解决方案**:在启动容器前手动设置权限:
#### 目录权限
如果 Docker 自动创建的目录权限不足(属于 root),容器内用户无法写入,会导致:
- 证书生成失败,容器启动脚本会报错退出
- 数据库目录创建失败,程序会抛出异常并崩溃
- 日志目录创建失败,日志文件无法写入(但程序可能继续运行)
在启动容器前手动设置权限:
```bash
sudo mkdir -p /var/lib/crossdesk /var/log/crossdesk
sudo chown -R $(id -u):$(id -g) /var/lib/crossdesk /var/log/crossdesk
```
### 客户端
1. 点击右上角设置进入设置页面。<br><br>
<img width="600" height="210" alt="image" src="https://github.com/user-attachments/assets/6431131d-b32a-4726-8783-6788f47baa3b" /><br>
@@ -304,32 +379,56 @@ sudo chown -R $(id -u):$(id -g) /var/lib/crossdesk /var/log/crossdesk
<img width="600" height="160" alt="image" src="https://github.com/user-attachments/assets/24c761a3-1985-4d7e-84be-787383c2afb8" /><br>
3. 输入`服务器地址`(**EXTERNAL_IP**)、`信令服务端口`(**CROSSDESK_SERVER_PORT**)、`中继服务端口`(**COTURN_PORT**),点击确认按钮。
4. 勾选`自托管服务器配置`选项,点击确认按钮保存设置。如果服务端使用的是正式证书,则到此步骤为止,客户端即可显示已连接服务器。
5. 如果使用默认证书(正式证书忽略此步骤),则需要将服务端`/var/lib/crossdesk/certs/`目录下的`api.crossdesk.cn_root.crt`自签根证书下载到运行客户端的机器,并执行下述命令安装证书:
Windows 平台使用**管理员权限**打开 PowerShell 执行
```
**Windows** 使用管理员权限打开 PowerShell 执行
```powershell
certutil -addstore "Root" "C:\path\to\api.crossdesk.cn_root.crt"
```
Linux
```
**Linux**
```bash
sudo cp /path/to/api.crossdesk.cn_root.crt /usr/local/share/ca-certificates/api.crossdesk.cn_root.crt
sudo update-ca-certificates
```
macOS
```
**macOS**
```bash
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain path/to/api.crossdesk.cn_root.crt
```
### Web 客户端
### 自托管 Web 客户端
详情见项目 [CrossDesk Web Client](https://github.com/kunkundi/crossdesk-web-client)。
# 常见问题
见 [常见问题](https://github.com/kunkundi/crossdesk/blob/self-hosted-server/docs/FAQ.md) 。
---
## 常见问题
见 [常见问题](https://github.com/kunkundi/crossdesk/blob/self-hosted-server/docs/FAQ.md)。
---
## 致谢
# 致谢
- 感谢 [HelloGitHub](https://hellogithub.com/) 的推荐与关注。
- 感谢 [阮一峰的科技爱好者周刊](https://github.com/ruanyf/weekly) 的收录与推荐。
- 感谢 [LinuxDo](https://linux.do) 社区的关注、交流与支持,为 CrossDesk 项目的完善提供了帮助。
---
## Code signing policy
CrossDesk 使用 SignPath.io 为本仓库构建的官方 Windows 发布包提供代码签名。
**Free code signing provided by [SignPath.io](https://signpath.io/), certificate by [SignPath Foundation](https://signpath.org/).**
- **Committers and reviewers(代码提交与审核):** [kunkundi](https://github.com/kunkundi)
- **Approvers(签名审批):** [kunkundi](https://github.com/kunkundi)
- **隐私政策:** [查看 CrossDesk 隐私政策](PRIVACY.md#中文)
+162 -60
View File
@@ -1,39 +1,51 @@
# CrossDesk
<a href="https://hellogithub.com/repository/kunkundi/crossdesk" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=55d41367570345f1838e02fd12be7961&claim_uid=cb0OpZRrBuGVAfL&theme=small" alt="FeaturedHelloGitHub" /></a>
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-brightgreen.svg)]()
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-brightgreen.svg)](https://www.crossdesk.cn/)
[![License: GPL v3](https://img.shields.io/badge/License-GPL%20v3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
[![GitHub last commit](https://img.shields.io/github/last-commit/kunkundi/crossdesk)](https://github.com/kunkundi/crossdesk/commits/web-client)
[![Build Status](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml/badge.svg)](https://github.com/kunkundi/crossdesk/actions)
[![Docker Pulls](https://img.shields.io/docker/pulls/crossdesk/crossdesk-server)](https://hub.docker.com/r/crossdesk/crossdesk-server/tags)
[![GitHub issues](https://img.shields.io/github/issues/kunkundi/crossdesk.svg)]()
[![GitHub stars](https://img.shields.io/github/stars/kunkundi/crossdesk.svg?style=social)]()
[![GitHub forks](https://img.shields.io/github/forks/kunkundi/crossdesk.svg?style=social)]()
[![GitHub issues](https://img.shields.io/github/issues/kunkundi/crossdesk.svg)](https://github.com/kunkundi/crossdesk/issues)
[![GitHub stars](https://img.shields.io/github/stars/kunkundi/crossdesk.svg?style=social)](https://github.com/kunkundi/crossdesk/stargazers)
[![GitHub forks](https://img.shields.io/github/forks/kunkundi/crossdesk.svg?style=social)](https://github.com/kunkundi/crossdesk/forks)
[ [中文](README.md) / English ]
PC Client
---
## Preview
### PC Client
![sup_example](https://github.com/user-attachments/assets/3f17d8f3-7c4a-4b63-bae4-903363628687)
Web Client
### Web Client
<p align="center">
<img width="850" height="550" alt="6bddcbed47ffd4b9988a4037c7f4f524" src="https://github.com/user-attachments/assets/e44f73f9-24ac-46a3-a189-b7f8b6669881" />
</p>
# Intro
---
## Introduction
CrossDesk is a lightweight cross-platform remote desktop software.
CrossDesk is an experimental application of [MiniRTC](https://github.com/kunkundi/minirtc.git), a lightweight cross-platform real-time audio and video transmission library. MiniRTC provides fundamental capabilities including network traversal ([RFC5245](https://datatracker.ietf.org/doc/html/rfc5245)), video software/hardware encoding and decoding (H264/AV1), audio encoding/decoding ([Opus](https://github.com/xiph/opus)), signaling interaction, network congestion control, and transmission encryption ([SRTP](https://tools.ietf.org/html/rfc3711)).
---
## System Requirements
| Platform | Minimum Version |
|-----------|-----------------|
| --- | --- |
| **Windows** | Windows 10 or later (64-bit) |
| **macOS** | macOS Intel 15.0 or later *(versions between 14.0 and 15.0 can be built manually for compatibility)*<br>macOS Apple Silicon 14.0 or later |
| **Linux** | Ubuntu 22.04 or later *(older versions can be built manually for compatibility)* |
| **Linux** | Ubuntu 20.04 or later |
---
## Usage
@@ -49,15 +61,17 @@ Before connecting, you can customize configuration options in the settings, such
![settings](https://github.com/user-attachments/assets/8bc5468d-7bbb-4e30-95bd-da1f352ac08c)
### Web Client
### Using the Web Client
Visit [CrossDesk Web Client](https://web.crossdesk.cn/).
Visit [CrossDesk Web Client](https://web.crossdesk.cn/).
Enter the **Remote Device ID** and **Password**, then click Connect to access the remote device. As shown, **iOS Safari remotely controlling Windows 11**:
<img width="645" height="300" alt="_cgi-bin_mmwebwx-bin_webwxgetmsgimg__ MsgID=932911462648581698 skey=@crypt_1f5153b1_b550ca7462b5009ce03c991cca2a92a7 mmweb_appid=wx_webfilehelper" src="https://github.com/user-attachments/assets/a5109e6f-752c-4654-9f4e-7e161bddf43e" />
### Windows Service (CrossDesk Service)
CrossDesk provides a local helper service on Windows named **CrossDesk Service**. Its service name is `CrossDeskService`. The service improves remote control in protected Windows states such as the lock screen, sign-in UI, credential UI, and secure desktop. It provides:
- Remote status reporting for lock screen, sign-in, credential, and secure desktop states.
- Remote `Ctrl+Alt+Del` (SAS) delivery.
- Keyboard and mouse input forwarding while the remote Windows device is on the lock screen, sign-in UI, or secure desktop.
@@ -65,7 +79,8 @@ CrossDesk provides a local helper service on Windows named **CrossDesk Service**
The Windows installer bundles `crossdesk_service.exe` and `crossdesk_session_helper.exe`, then registers the service as an on-demand Windows service during installation. When the CrossDesk client starts, it tries to start the installed service automatically. When no CrossDesk client process is running on the machine, the service exits automatically. Uninstalling the client also stops and removes the service.
For manual Windows builds or deployments, make sure `CrossDesk.exe`, `crossdesk_service.exe`, and `crossdesk_session_helper.exe` are placed in the same directory. Open PowerShell with administrator privileges to install or uninstall the service:
```
```powershell
# install
.\CrossDesk.exe --service-install
# start
@@ -81,31 +96,49 @@ For manual Windows builds or deployments, make sure `CrossDesk.exe`, `crossdesk_
If the remote Windows service is not installed, not running, or temporarily unavailable, the basic remote desktop connection still works, but remote control on the lock screen, sign-in UI, and secure desktop is limited. The client will show “Remote Windows service unavailable”.
---
## How to build
Requirements:
### Build requirements
- [xmake](https://xmake.io/#/guide/installation)
- [cmake](https://cmake.org/download/)
- [cmake](https://cmake.org/download/) 3.21 or later (installed automatically by xmake when the system version is too old)
### Linux
Linux builds currently support Ubuntu 22.04 or later on amd64 and arm64. Install the base build dependencies first:
Linux builds support Ubuntu 20.04 or later on amd64 and arm64. Release packages
use Ubuntu 20.04/glibc 2.31 as their compatibility baseline. Install the base
build dependencies first:
```bash
sudo apt-get update
sudo apt-get install -y \
git curl unzip build-essential cmake pkg-config binutils dpkg-dev \
git curl unzip build-essential gcc-10 g++-10 python3-pip \
pkg-config binutils dpkg-dev \
libx11-dev libxext-dev libxrender-dev libxft-dev libxrandr-dev \
libxinerama-dev libxcursor-dev libxi-dev libxfixes-dev libxv-dev \
libxtst-dev libxcb-randr0-dev libxcb-xtest0-dev libxcb-xinerama0-dev \
libxcb-shape0-dev libxcb-xkb-dev libxcb-xfixes0-dev libxcb-shm0-dev \
libasound2-dev libsndio-dev libpulse-dev
libasound2-dev libsndio-dev libpulse-dev \
libgl1-mesa-dev
```
When Ubuntu 20.04's bundled CMake 3.16 is too old, xmake automatically
downloads and uses CMake 3.21 or later; replacing the system CMake manually is
not required.
Ubuntu 20.04's default GCC 9 lacks parts of the C++20 standard library needed
by Slint's C++ API, so the commands above install GCC 10 and the configuration
below selects it explicitly.
Install the optional dependencies when Wayland capture or DRM capture is enabled:
```bash
sudo apt-get install -y libdbus-1-dev libpipewire-0.3-dev libdrm-dev
sudo apt-get install -y libdbus-1-dev libdrm-dev
# Ubuntu 22.04 and later can alternatively use the distribution packages.
# sudo apt-get install -y libpipewire-0.3-dev libspa-0.2-dev
```
Clone the submodules and build a release binary:
@@ -117,16 +150,28 @@ cd crossdesk
# Run this when the repository has already been cloned
git submodule update --init --recursive
xmake f -m release --USE_CUDA=false -y
# Ubuntu 20.04 has no PipeWire 0.3 development package. Install the
# header-only SDK used during compilation. Skip this when the distribution's
# PipeWire 0.3 development packages are already installed.
sudo ./docker/linux-build/install-pipewire-sdk.sh
xmake f -c -m release --USE_CUDA=false --toolchain=gcc-10 -y
xmake b -vy crossdesk
```
Use `--toolchain=gcc-10` so that both CrossDesk and third-party packages such
as libyuv, which xmake builds during configuration, use GCC 10. Specifying only
`--cc`/`--cxx` may still let dependency builds fall back to the system
`/bin/cc` and `/bin/c++`. Keep `-c` when switching compilers to clear the old
configuration.
The amd64 binary is written to `build/linux/x86_64/release/crossdesk`; the arm64 binary is written to `build/linux/arm64/release/crossdesk`.
Example configuration with Wayland and DRM capture enabled:
```bash
xmake f -m release --USE_WAYLAND=true --USE_DRM=true --USE_CUDA=false -y
xmake f -c -m release --USE_WAYLAND=true --USE_DRM=true --USE_CUDA=false \
--toolchain=gcc-10 -y
xmake b -vy crossdesk
```
@@ -141,6 +186,9 @@ Build a Debian package after compiling a release binary on the matching architec
```
The package scripts install the shared Slint runtime in the private `/usr/lib/crossdesk` directory, so users do not need to install `libslint_cpp.so` separately.
PipeWire is not a mandatory runtime dependency. CrossDesk detects the host's
PipeWire 0.3 runtime dynamically and can still use X11 capture (and DRM when
enabled at build time) when it is unavailable.
### Common build options
@@ -161,13 +209,14 @@ Run:
xmake r crossdesk
```
#### Development Without CUDA Environment
### Development Without CUDA Environment
For **Linux developers who do not have a CUDA environment installed and want to enable hardware codec feature**, a preconfigured [Ubuntu 22.04 Docker image](https://hub.docker.com/r/crossdesk/ubuntu22.04) is provided.
This image comes with all required build dependencies and allows you to build the project directly inside the container without any additional setup.
For **Linux developers who do not have a CUDA environment installed and want to enable hardware codec feature**, a preconfigured [Ubuntu 20.04 compatibility build image](https://hub.docker.com/r/crossdesk/ubuntu20.04) is provided.
This image contains the required build dependencies and produces a single Linux package compatible with the glibc 2.31 baseline.
After entering the container, download the project and run:
```
```bash
export CUDA_PATH=/usr/local/cuda
export XMAKE_GLOBALDIR=/data
@@ -176,53 +225,68 @@ xmake b --root -vy crossdesk
```
For **Windows developers without a CUDA environment** installed, run the following command to install the CUDA build environment:
```
```powershell
xmake require -vy "cuda 12.6.3"
```
After the installation is complete, execute:
```
```powershell
xmake require --info "cuda 12.6.3"
```
The output will look like this:
<img width="860" height="226" alt="Image" src="https://github.com/user-attachments/assets/999ac365-581a-4b9a-806e-05eb3e4cf44d" />
From the output above, locate the CUDA installation directory — this is the path pointed to by installdir.
Add this path to your system environment variable CUDA_PATH, or set it in the terminal using:
```powershell
set CUDA_PATH=path_to_cuda_installdir
```
set CUDA_PATH=path_to_cuda_installdir:
```
Then re-run:
```
```powershell
xmake f --USE_CUDA=true
xmake b -vy crossdesk
```
#### Notice
If the client status bar shows **Disconnected** during runtime, please first install the client from the [CrossDesk official website](https://www.crossdesk.cn/) to ensure the required certificate files are available in the environment.
<img width="256" height="120" alt="image" src="https://github.com/user-attachments/assets/1812f7d6-516b-4b4f-8a3d-98bee505cc5a" />
## About Xmake
### About Xmake
#### Installing Xmake
You can install Xmake using one of the following methods:
Using curl:
```
```bash
curl -fsSL https://xmake.io/shget.text | bash
```
Using wget:
```
```bash
wget https://xmake.io/shget.text -O - | bash
```
Using powershell:
```
```powershell
irm https://xmake.io/psget.text | iex
```
#### Build Options
```
```text
# Switch build mode
xmake f -m debug/release
@@ -236,16 +300,23 @@ xmake b -vy crossdesk
```
#### Run Options
```
```bash
# Run in debug mode
xmake r -d crossdesk
```
For more information, please refer to the [official Xmake documentation](https://xmake.io/guide/quick-start.html) .
For more information, please refer to the [official Xmake documentation](https://xmake.io/guide/quick-start.html).
---
## Self-Hosted Server
### Server
It is recommended to deploy CrossDesk Server using Docker.
```
```bash
sudo docker run -d \
--name crossdesk_server \
--network host \
@@ -262,7 +333,8 @@ sudo docker run -d \
The parameters you need to pay attention to are as follows:
**Parameters**
#### Parameters
- **EXTERNAL_IP**: The servers public IP. This corresponds to **Server Address** in the CrossDesk clients **Self-Hosted Server Configuration**.
- **INTERNAL_IP**: The servers internal IP.
- **CROSSDESK_SERVER_PORT**: The port used by the self-hosted service. This corresponds to **Server Port** in the CrossDesk clients **Self-Hosted Server Configuration**.
@@ -271,7 +343,8 @@ The parameters you need to pay attention to are as follows:
- `-v /var/lib/crossdesk:/var/lib/crossdesk`: Persists database and certificate files on the host machine.
- `-v /var/log/crossdesk:/var/log/crossdesk`: Persists log files on the host machine.
**Example**:
#### Example
```bash
sudo docker run -d \
--name crossdesk_server \
@@ -287,29 +360,35 @@ sudo docker run -d \
crossdesk/crossdesk-server:v1.1.6
```
**Notes**
#### Notes
- **The server must open the following ports: COTURN_PORT/udp, COTURN_PORT/tcp, MIN_PORTMAX_PORT/udp, and CROSSDESK_SERVER_PORT/tcp.**
- If you dont mount volumes, all data will be lost when the container is removed.
- Certificate files will be automatically generated on first startup and persisted to the host at `/var/lib/crossdesk/certs`.As the default certificates are self-signed and cannot guarantee security, it is strongly recommended to apply for a trusted certificate from a cloud provider, deploy it to this directory, and restart the service.
- Certificate files will be automatically generated on first startup and persisted to the host at `/var/lib/crossdesk/certs`. As the default certificates are self-signed and cannot guarantee security, it is strongly recommended to apply for a trusted certificate from a cloud provider, deploy it to this directory, and restart the service.
- The database file will be automatically created and stored at `/var/lib/crossdesk/db/crossdesk-server.db`.
- Log files will be created and stored at `/var/log/crossdesk/`.
**Permission Notice**
If the directories automatically created by Docker belong to root and have insufficient write permissions, the container user may not be able to write to them. This can cause:
- Certificate generation failure, leading to startup script errors and container exit.
- Database directory creation failure, causing the program to throw exceptions and crash.
- Log directory creation failure, preventing logs from being written (though the program may continue running).
#### Directory permissions
If the directories automatically created by Docker belong to root and have insufficient write permissions, the container user may not be able to write to them. This can cause:
- Certificate generation failure, leading to startup script errors and container exit.
- Database directory creation failure, causing the program to throw exceptions and crash.
- Log directory creation failure, preventing logs from being written (though the program may continue running).
Manually set permissions before starting the container:
**Solution:** Manually set permissions before starting the container:
```bash
sudo mkdir -p /var/lib/crossdesk /var/log/crossdesk
sudo chown -R $(id -u):$(id -g) /var/lib/crossdesk /var/log/crossdesk
```
### Server Side
#### TLS certificate
Place **crossdesk.cn.key** and **crossdesk.cn_bundle.crt** into the **/path/to/your/certs** directory.
### Client Side
### Client
1. Click the settings icon in the top-right corner to enter the settings page.<br><br>
<img width="600" height="210" alt="image" src="https://github.com/user-attachments/assets/6431131d-b32a-4726-8783-6788f47baa3b" /><br>
@@ -322,28 +401,51 @@ Place **crossdesk.cn.key** and **crossdesk.cn_bundle.crt** into the **/path/to/y
5. If the default certificate is used (skip this step if an official certificate is used), download the self-signed root certificate `api.crossdesk.cn_root.crt` from the server directory /var/lib/crossdesk/certs/ to the machine running the client, and install the certificate by executing the following command:
On Windows, open PowerShell with **administrator privileges** and execute:
```
**Windows:** Open PowerShell with administrator privileges and execute:
```powershell
certutil -addstore "Root" "C:\path\to\api.crossdesk.cn_root.crt"
```
Linux
```
**Linux:**
```bash
sudo cp /path/to/api.crossdesk.cn_root.crt /usr/local/share/ca-certificates/api.crossdesk.cn_root.crt
sudo update-ca-certificates
```
macOS
```
**macOS:**
```bash
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain path/to/api.crossdesk.cn_root.crt
```
### Self-Hosted Web Client
### Web Client
See [CrossDesk Web Client](https://github.com/kunkundi/crossdesk-web-client)。
See [CrossDesk Web Client](https://github.com/kunkundi/crossdesk-web-client).
# FAQ
See [FAQ](https://github.com/kunkundi/crosssesk/blob/self-hosted-server/docs/FAQ.md) .
---
## FAQ
See [FAQ](https://github.com/kunkundi/crossdesk/blob/self-hosted-server/docs/FAQ.md).
---
## Acknowledgements
# Acknowledgements
- Thanks to [HelloGitHub](https://hellogithub.com/) for the recommendation and exposure.
- Thanks to [Ruanyf Weekly](https://github.com/ruanyf/weekly) for featuring CrossDesk.
- Thanks to the [LinuxDo](https://linux.do) community for the attention, discussions, and support that helped improve CrossDesk.
---
## Code signing policy
CrossDesk uses SignPath.io to code-sign official Windows releases built from this repository.
**Free code signing provided by [SignPath.io](https://signpath.io/), certificate by [SignPath Foundation](https://signpath.org/).**
- **Committers and reviewers:** [kunkundi](https://github.com/kunkundi)
- **Approvers:** [kunkundi](https://github.com/kunkundi)
- **Privacy policy:** [Read the CrossDesk Privacy Policy](PRIVACY.md#english)
+25 -4
View File
@@ -1,15 +1,16 @@
# syntax=docker/dockerfile:1.7
ARG BASE_IMAGE=nvidia/cuda:12.6.3-devel-ubuntu22.04
ARG BASE_IMAGE=nvidia/cuda:12.6.3-devel-ubuntu20.04
FROM ${BASE_IMAGE}
ARG TARGETARCH
ARG CMAKE_VERSION=3.31.6
ARG XMAKE_VERSION=3.0.9
ARG RUST_VERSION=1.92.0
ARG RUSTUP_VERSION=1.28.2
LABEL org.opencontainers.image.title="CrossDesk Linux build environment" \
org.opencontainers.image.description="Ubuntu 22.04 build environment for CrossDesk" \
org.opencontainers.image.description="Ubuntu 20.04 compatibility build environment for CrossDesk" \
org.opencontainers.image.source="https://github.com/kunkundi/crossdesk" \
org.opencontainers.image.licenses="GPL-3.0-only"
@@ -22,6 +23,8 @@ ENV DEBIAN_FRONTEND=noninteractive \
XMAKE_GLOBALDIR=/data \
XMAKE_ROOT=y \
CUDA_PATH=/usr/local/cuda \
CC=/usr/bin/gcc-10 \
CXX=/usr/bin/g++-10 \
PATH=/root/.local/bin:/opt/cargo/bin:/usr/local/bin:${PATH}
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
@@ -35,13 +38,14 @@ RUN apt-get update \
build-essential \
ca-certificates \
ccache \
cmake \
curl \
dbus \
dpkg-dev \
file \
flex \
gettext \
gcc-10 \
g++-10 \
git \
libasound2-dev \
libdbus-1-dev \
@@ -93,6 +97,22 @@ RUN apt-get update \
zlib1g-dev \
&& rm -rf /var/lib/apt/lists/*
# Slint 1.17 requires CMake 3.21 or newer, while Ubuntu 20.04 provides 3.16.
# PyPI publishes pinned manylinux2014 wheels for both amd64 and arm64.
RUN python3 -m pip install --no-cache-dir "cmake==${CMAKE_VERSION}" \
&& cmake --version | grep -F "cmake version ${CMAKE_VERSION}"
# Ubuntu 20.04 only ships PipeWire 0.2. Install the Ubuntu 22.04-era 0.3.48
# headers as a build-only SDK. CrossDesk loads the host's PipeWire 0.3 runtime
# with dlopen(), so no PipeWire shared library is copied into release packages.
COPY docker/linux-build/install-pipewire-sdk.sh \
/usr/local/bin/install-crossdesk-pipewire-sdk
RUN chmod 0755 /usr/local/bin/install-crossdesk-pipewire-sdk \
&& install-crossdesk-pipewire-sdk \
&& grep -F "#define PW_MICRO 48" \
/opt/crossdesk-pipewire-sdk/include/pipewire-0.3/pipewire/version.h
RUN export GITHUB_ACTIONS=true \
&& curl -fsSL https://xmake.io/shget.text \
| bash -s -- "v${XMAKE_VERSION}" \
@@ -134,7 +154,8 @@ COPY submodules/minirtc/thirdparty ./submodules/minirtc/thirdparty
# CrossDesk's exact dependency graph. Source files are intentionally excluded
# from the Docker context; source-only changes therefore reuse this layer.
RUN xmake repo -u --root \
&& xmake f -m release --USE_CUDA=true --root -y \
&& xmake f -m release --USE_CUDA=true --USE_WAYLAND=true \
--cc=gcc-10 --cxx=g++-10 --root -y \
&& rm -rf /data/.xmake/cache /tmp/crossdesk-dependencies \
&& mkdir -p /workspace
+15 -8
View File
@@ -1,7 +1,9 @@
# CrossDesk Linux 构建镜像
这个目录维护 CrossDesk 专用的 Ubuntu 22.04 构建环境。镜像包含 xmake
Rust、Linux 开发库以及根据项目 `xmake.lua` 提前编译好的依赖。
这个目录维护 CrossDesk 专用的 Ubuntu 20.04 兼容构建环境。镜像包含 GCC 10
CMake 3.31、xmake、Rust、Linux 开发库(包括 D-Bus、DRM,以及仅用于编译的
PipeWire 0.3/SPA 头文件 SDK)以及根据项目
`xmake.lua` 提前编译好的依赖。
## 发布方式
@@ -10,14 +12,19 @@ Rust、Linux 开发库以及根据项目 `xmake.lua` 提前编译好的依赖。
全部通过后再合并为同一个 multi-arch 镜像:
```text
crossdesk/ubuntu22.04:buildenv-cuda12.6.3-xmake3.0.9-rust1.92.0
crossdesk/ubuntu20.04:buildenv-cuda12.6.3-gcc10-cmake3.31.6-pipewire0.3.48-xmake3.0.9-rust1.92.0
```
Docker 会根据运行机器自动选择正确架构。amd64 变体以
`nvidia/cuda:12.6.3-devel-ubuntu22.04` 为基础;arm64 变体不包含当前构建
用不到的 CUDA SDK。两个变体都使用 Ubuntu 22.04/glibc 2.35。
因此该镜像生成的 Linux 安装包最低支持 Ubuntu 22.04,不再保证兼容
Ubuntu 20.04。
`nvidia/cuda:12.6.3-devel-ubuntu20.04` 为基础;arm64 变体不包含当前构建
用不到的 CUDA SDK。两个变体都使用 Ubuntu 20.04/glibc 2.31,因此生成的
单一 Linux 安装包可运行在 Ubuntu 20.04 及更高版本。
Ubuntu 20.04 仓库只提供 PipeWire 0.2。镜像通过
`install-pipewire-sdk.sh` 安装固定版本的 PipeWire 0.3.48/SPA 头文件,但不
安装或打包 PipeWire 动态库。CrossDesk 在运行时通过 `dlopen()` 使用宿主系统
提供的 PipeWire 0.3Ubuntu 20.04 没有该运行库时仍可使用 X11(以及构建时
启用的 DRM),Ubuntu 22.04 及更高版本则可使用 Wayland 捕获。
每次成功发布还会生成 `sha-<完整提交 SHA>`,用于精确回滚;`latest` 和上面的
工具链版本 tag 只会在两个架构都验证成功后更新。
@@ -26,7 +33,7 @@ Ubuntu 20.04。
在仓库的 Actions secrets 中配置:
- `DOCKERHUB_USERNAME`:有权推送 `crossdesk/ubuntu22.04` 的 Docker Hub 用户名
- `DOCKERHUB_USERNAME`:有权推送 `crossdesk/ubuntu20.04` 的 Docker Hub 用户名
- `DOCKERHUB_TOKEN`:该用户的 Docker Hub access token
工作流文件进入默认分支后,也可以从 Actions 页面手动运行。为了避免首次发布
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
set -euo pipefail
PIPEWIRE_VERSION="0.3.48"
PIPEWIRE_ARCHIVE_SHA256="0d218be86b3d2b548c06259c47ad8d110ee1c09f071e17c4393eeef5c880fa6f"
INSTALL_PREFIX="${1:-/opt/crossdesk-pipewire-sdk}"
IFS='.' read -r PIPEWIRE_VERSION_MAJOR PIPEWIRE_VERSION_MINOR \
PIPEWIRE_VERSION_MICRO <<< "${PIPEWIRE_VERSION}"
if [[ "${INSTALL_PREFIX}" != /* ]]; then
echo "Install prefix must be an absolute path: ${INSTALL_PREFIX}" >&2
exit 2
fi
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/crossdesk-pipewire-sdk.XXXXXX")"
trap 'rm -rf "${WORK_DIR}"' EXIT
ARCHIVE_PATH="${WORK_DIR}/pipewire-${PIPEWIRE_VERSION}.tar.gz"
SOURCE_DIR="${WORK_DIR}/pipewire-${PIPEWIRE_VERSION}"
ARCHIVE_URL="https://gitlab.freedesktop.org/pipewire/pipewire/-/archive/${PIPEWIRE_VERSION}/pipewire-${PIPEWIRE_VERSION}.tar.gz"
curl --proto '=https' --tlsv1.2 --fail --silent --show-error --location \
--retry 3 "${ARCHIVE_URL}" -o "${ARCHIVE_PATH}"
echo "${PIPEWIRE_ARCHIVE_SHA256} ${ARCHIVE_PATH}" | sha256sum --check -
tar -xzf "${ARCHIVE_PATH}" -C "${WORK_DIR}"
PIPEWIRE_INCLUDE_DIR="${INSTALL_PREFIX}/include/pipewire-0.3/pipewire"
SPA_INCLUDE_DIR="${INSTALL_PREFIX}/include/spa-0.2/spa"
while IFS= read -r -d '' header_path; do
relative_path="${header_path#${SOURCE_DIR}/src/pipewire/}"
install -D -m 0644 "${header_path}" \
"${PIPEWIRE_INCLUDE_DIR}/${relative_path}"
done < <(find "${SOURCE_DIR}/src/pipewire" -type f -name '*.h' -print0)
while IFS= read -r -d '' header_path; do
relative_path="${header_path#${SOURCE_DIR}/spa/include/spa/}"
install -D -m 0644 "${header_path}" \
"${SPA_INCLUDE_DIR}/${relative_path}"
done < <(find "${SOURCE_DIR}/spa/include/spa" -type f -name '*.h' -print0)
sed \
-e "s/@PIPEWIRE_VERSION_MAJOR@/${PIPEWIRE_VERSION_MAJOR}/g" \
-e "s/@PIPEWIRE_VERSION_MINOR@/${PIPEWIRE_VERSION_MINOR}/g" \
-e "s/@PIPEWIRE_VERSION_MICRO@/${PIPEWIRE_VERSION_MICRO}/g" \
-e 's/@PIPEWIRE_API_VERSION@/"0.3"/g' \
"${SOURCE_DIR}/src/pipewire/version.h.in" \
> "${PIPEWIRE_INCLUDE_DIR}/version.h"
chmod 0644 "${PIPEWIRE_INCLUDE_DIR}/version.h"
test -f "${PIPEWIRE_INCLUDE_DIR}/pipewire.h"
test -f "${PIPEWIRE_INCLUDE_DIR}/version.h"
test -f "${SPA_INCLUDE_DIR}/param/video/format-utils.h"
echo "Installed PipeWire ${PIPEWIRE_VERSION} header SDK under ${INSTALL_PREFIX}"
+49 -7
View File
@@ -2,19 +2,22 @@
set -euo pipefail
EXPECTED_ARCH="${EXPECTED_ARCH:-}"
EXPECTED_CMAKE_VERSION="${EXPECTED_CMAKE_VERSION:-3.31.6}"
EXPECTED_XMAKE_VERSION="${EXPECTED_XMAKE_VERSION:-3.0.9}"
EXPECTED_RUST_VERSION="${EXPECTED_RUST_VERSION:-1.92.0}"
BUILD_CC="${CC:-/usr/bin/gcc-10}"
BUILD_CXX="${CXX:-/usr/bin/g++-10}"
# shellcheck disable=SC1091
source /etc/os-release
if [[ "${ID}" != "ubuntu" || "${VERSION_ID}" != "22.04" ]]; then
echo "Expected Ubuntu 22.04, found ${ID:-unknown} ${VERSION_ID:-unknown}" >&2
if [[ "${ID}" != "ubuntu" || "${VERSION_ID}" != "20.04" ]]; then
echo "Expected Ubuntu 20.04, found ${ID:-unknown} ${VERSION_ID:-unknown}" >&2
exit 1
fi
GLIBC_VERSION="$(getconf GNU_LIBC_VERSION | awk '{print $2}')"
if [[ "${GLIBC_VERSION}" != "2.35" ]]; then
echo "Expected glibc 2.35, found ${GLIBC_VERSION}" >&2
if [[ "${GLIBC_VERSION}" != "2.31" ]]; then
echo "Expected glibc 2.31, found ${GLIBC_VERSION}" >&2
exit 1
fi
@@ -28,9 +31,48 @@ XMAKE_VERSION_OUTPUT="$(xmake --version 2>&1)"
grep -F "v${EXPECTED_XMAKE_VERSION}" <<<"${XMAKE_VERSION_OUTPUT}" >/dev/null
rustc --version | grep -F "rustc ${EXPECTED_RUST_VERSION}" >/dev/null
cargo --version >/dev/null
cmake --version >/dev/null
g++ --version >/dev/null
pkg-config --exists xft
cmake --version | grep -F "cmake version ${EXPECTED_CMAKE_VERSION}" >/dev/null
"${BUILD_CC}" --version | grep -F 'gcc-10' >/dev/null
"${BUILD_CXX}" --version | grep -F 'g++-10' >/dev/null
printf '%s\n' \
'#include <concepts>' \
'#include <span>' \
'template <typename T> concept TestConcept = true;' \
'int main() {' \
' int value = 0;' \
' std::span<int> values(&value, 1);' \
' static_assert(TestConcept<int>);' \
' return values.empty();' \
'}' \
| "${BUILD_CXX}" -std=c++20 -fsyntax-only -x c++ -
for pkg_config_package in \
dbus-1 \
libdrm \
xft; do
if ! pkg-config --exists "${pkg_config_package}"; then
echo "Required pkg-config package is missing: ${pkg_config_package}" >&2
exit 1
fi
done
for sdk_header in \
/opt/crossdesk-pipewire-sdk/include/pipewire-0.3/pipewire/pipewire.h \
/opt/crossdesk-pipewire-sdk/include/pipewire-0.3/pipewire/version.h \
/opt/crossdesk-pipewire-sdk/include/spa-0.2/spa/param/video/format-utils.h; do
if [[ ! -f "${sdk_header}" ]]; then
echo "Required PipeWire build SDK header is missing: ${sdk_header}" >&2
exit 1
fi
done
printf '%s\n' \
'#include <pipewire/pipewire.h>' \
'#include <spa/param/video/format-utils.h>' \
'static_assert(PW_CHECK_VERSION(0, 3, 48));' \
'int main() { return 0; }' \
| "${BUILD_CXX}" -std=c++17 -fsyntax-only -x c++ \
-I/opt/crossdesk-pipewire-sdk/include/pipewire-0.3 \
-I/opt/crossdesk-pipewire-sdk/include/spa-0.2 -
for package_path in \
"s/slint" \
+1 -1
View File
@@ -7,7 +7,7 @@ if [[ $# -lt 1 || $# -gt 2 ]]; then
fi
INPUT_PATH="$1"
MAXIMUM_VERSION="${2:-2.35}"
MAXIMUM_VERSION="${2:-2.31}"
verify_elf() {
local elf_file="$1"
+4 -1
View File
@@ -69,6 +69,8 @@ Render
底层功能模块不应反向依赖具体面板、工具栏或窗口。
桌面端与 iOS Bridge 复用 `src/` 中的协议定义:`device_controller/` 保存控制消息,`common/` 保存数据流名称和鼠标指针类型,`tools/` 保存文件传输线格式。操作系统输入注入、剪贴板、文件读写以及界面状态仍由各自应用实现。
## 核心类型
### Render
@@ -196,7 +198,8 @@ SDL quit / tray exit
| SDL 初始化、事件循环、窗口生命周期 | `application/` |
| 连接、会话、Peer 回调和平台运行时 | `runtime/` |
| 视频、音频、鼠标、键盘设备生命周期 | `features/devices/` |
| 键盘协议和按键状态 | `features/input/` |
| 键盘状态与桌面输入处理 | `features/input/` |
| 控制消息和线格式 | `device_controller/``common/``tools/` |
| 剪贴板同步 | `features/clipboard/` |
| 文件传输 | `features/file_transfer/` |
| 配置持久化 | `features/settings/` |
@@ -0,0 +1,388 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 60;
objects = {
/* Begin PBXBuildFile section */
100000000000000000000001 /* CrossDeskMobileApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000001 /* CrossDeskMobileApp.swift */; };
100000000000000000000002 /* RemoteSessionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000002 /* RemoteSessionModel.swift */; };
100000000000000000000003 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000003 /* ContentView.swift */; };
100000000000000000000004 /* RemoteSessionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000004 /* RemoteSessionView.swift */; };
100000000000000000000005 /* CrossDeskRTCBridge.mm in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000006 /* CrossDeskRTCBridge.mm */; };
100000000000000000000006 /* NativeMetalVideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000009 /* NativeMetalVideoView.swift */; };
100000000000000000000007 /* RemoteTouchInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000000A /* RemoteTouchInputView.swift */; };
100000000000000000000008 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000000D /* Assets.xcassets */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
200000000000000000000001 /* CrossDeskMobileApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossDeskMobileApp.swift; sourceTree = "<group>"; };
200000000000000000000002 /* RemoteSessionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSessionModel.swift; sourceTree = "<group>"; };
200000000000000000000003 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
200000000000000000000004 /* RemoteSessionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSessionView.swift; sourceTree = "<group>"; };
200000000000000000000005 /* CrossDeskRTCBridge.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CrossDeskRTCBridge.h; sourceTree = "<group>"; };
200000000000000000000006 /* CrossDeskRTCBridge.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = CrossDeskRTCBridge.mm; sourceTree = "<group>"; };
200000000000000000000007 /* CrossDeskMobile-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "CrossDeskMobile-Bridging-Header.h"; sourceTree = "<group>"; };
200000000000000000000008 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
200000000000000000000009 /* NativeMetalVideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeMetalVideoView.swift; sourceTree = "<group>"; };
20000000000000000000000A /* RemoteTouchInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTouchInputView.swift; sourceTree = "<group>"; };
20000000000000000000000B /* CrossDeskMobile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CrossDeskMobile.app; sourceTree = BUILT_PRODUCTS_DIR; };
20000000000000000000000C /* build_minirtc_ios.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = build_minirtc_ios.sh; sourceTree = "<group>"; };
20000000000000000000000D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
300000000000000000000001 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
400000000000000000000001 = {
isa = PBXGroup;
children = (
400000000000000000000002 /* CrossDeskMobile */,
400000000000000000000006 /* scripts */,
400000000000000000000007 /* Products */,
);
sourceTree = "<group>";
};
400000000000000000000002 /* CrossDeskMobile */ = {
isa = PBXGroup;
children = (
200000000000000000000001 /* CrossDeskMobileApp.swift */,
200000000000000000000002 /* RemoteSessionModel.swift */,
200000000000000000000003 /* ContentView.swift */,
200000000000000000000004 /* RemoteSessionView.swift */,
400000000000000000000003 /* Bridge */,
400000000000000000000004 /* Rendering */,
400000000000000000000005 /* Input */,
20000000000000000000000D /* Assets.xcassets */,
200000000000000000000007 /* CrossDeskMobile-Bridging-Header.h */,
200000000000000000000008 /* Info.plist */,
);
path = CrossDeskMobile;
sourceTree = "<group>";
};
400000000000000000000003 /* Bridge */ = {
isa = PBXGroup;
children = (
200000000000000000000005 /* CrossDeskRTCBridge.h */,
200000000000000000000006 /* CrossDeskRTCBridge.mm */,
);
path = Bridge;
sourceTree = "<group>";
};
400000000000000000000004 /* Rendering */ = {
isa = PBXGroup;
children = (
200000000000000000000009 /* NativeMetalVideoView.swift */,
);
path = Rendering;
sourceTree = "<group>";
};
400000000000000000000005 /* Input */ = {
isa = PBXGroup;
children = (
20000000000000000000000A /* RemoteTouchInputView.swift */,
);
path = Input;
sourceTree = "<group>";
};
400000000000000000000006 /* scripts */ = {
isa = PBXGroup;
children = (
20000000000000000000000C /* build_minirtc_ios.sh */,
);
path = scripts;
sourceTree = "<group>";
};
400000000000000000000007 /* Products */ = {
isa = PBXGroup;
children = (
20000000000000000000000B /* CrossDeskMobile.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
500000000000000000000001 /* CrossDeskMobile */ = {
isa = PBXNativeTarget;
buildConfigurationList = 900000000000000000000002 /* Build configuration list for PBXNativeTarget "CrossDeskMobile" */;
buildPhases = (
700000000000000000000001 /* Build native MiniRTC */,
600000000000000000000001 /* Sources */,
300000000000000000000001 /* Frameworks */,
600000000000000000000002 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = CrossDeskMobile;
productName = CrossDeskMobile;
productReference = 20000000000000000000000B /* CrossDeskMobile.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
800000000000000000000001 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2660;
LastUpgradeCheck = 2660;
TargetAttributes = {
500000000000000000000001 = {
CreatedOnToolsVersion = 26.6;
};
};
};
buildConfigurationList = 900000000000000000000001 /* Build configuration list for PBXProject "CrossDeskMobile" */;
compatibilityVersion = "Xcode 15.0";
developmentRegion = zh_CN;
hasScannedForEncodings = 0;
knownRegions = (
Base,
en,
zh_CN,
);
mainGroup = 400000000000000000000001;
productRefGroup = 400000000000000000000007 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
500000000000000000000001 /* CrossDeskMobile */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
600000000000000000000002 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
100000000000000000000008 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
700000000000000000000001 /* Build native MiniRTC */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Build native MiniRTC";
outputFileListPaths = (
);
outputPaths = (
"$(PROJECT_DIR)/Vendor/iphoneos/$(CONFIGURATION)/libCrossDeskMiniRTC.a",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/zsh;
shellScript = "\"${PROJECT_DIR}/scripts/build_minirtc_ios.sh\"\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
600000000000000000000001 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
100000000000000000000001 /* CrossDeskMobileApp.swift in Sources */,
100000000000000000000002 /* RemoteSessionModel.swift in Sources */,
100000000000000000000003 /* ContentView.swift in Sources */,
100000000000000000000004 /* RemoteSessionView.swift in Sources */,
100000000000000000000005 /* CrossDeskRTCBridge.mm in Sources */,
100000000000000000000006 /* NativeMetalVideoView.swift in Sources */,
100000000000000000000007 /* RemoteTouchInputView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
A00000000000000000000001 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "c++17";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
A00000000000000000000002 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "c++17";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
GCC_C_LANGUAGE_STANDARD = gnu17;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
A00000000000000000000003 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ARCHS = arm64;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 8DNC3SZ86S;
ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = NO;
HEADER_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)/../submodules/minirtc/src/api",
"$(PROJECT_DIR)/../src/device_controller",
"$(PROJECT_DIR)/../src/common",
"$(PROJECT_DIR)/../src/tools",
);
INFOPLIST_FILE = CrossDeskMobile/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"$(inherited)",
"$(PROJECT_DIR)/Vendor/iphoneos/$(CONFIGURATION)/libCrossDeskMiniRTC.a",
"-liconv",
"-lresolv",
"-framework",
VideoToolbox,
"-framework",
CoreMedia,
"-framework",
CoreVideo,
"-framework",
Security,
"-framework",
SystemConfiguration,
"-framework",
CoreFoundation,
"-framework",
Foundation,
);
PRODUCT_BUNDLE_IDENTIFIER = cn.crossdesk.mobile;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_OBJC_BRIDGING_HEADER = "CrossDeskMobile/CrossDeskMobile-Bridging-Header.h";
SWIFT_STRICT_CONCURRENCY = minimal;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
A00000000000000000000004 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ARCHS = arm64;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 8DNC3SZ86S;
ENABLE_BITCODE = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = NO;
HEADER_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)/../submodules/minirtc/src/api",
"$(PROJECT_DIR)/../src/device_controller",
"$(PROJECT_DIR)/../src/common",
"$(PROJECT_DIR)/../src/tools",
);
INFOPLIST_FILE = CrossDeskMobile/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"$(inherited)",
"$(PROJECT_DIR)/Vendor/iphoneos/$(CONFIGURATION)/libCrossDeskMiniRTC.a",
"-liconv",
"-lresolv",
"-framework",
VideoToolbox,
"-framework",
CoreMedia,
"-framework",
CoreVideo,
"-framework",
Security,
"-framework",
SystemConfiguration,
"-framework",
CoreFoundation,
"-framework",
Foundation,
);
PRODUCT_BUNDLE_IDENTIFIER = cn.crossdesk.mobile;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_OBJC_BRIDGING_HEADER = "CrossDeskMobile/CrossDeskMobile-Bridging-Header.h";
SWIFT_STRICT_CONCURRENCY = minimal;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
900000000000000000000001 /* Build configuration list for PBXProject "CrossDeskMobile" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A00000000000000000000001 /* Debug */,
A00000000000000000000002 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
900000000000000000000002 /* Build configuration list for PBXNativeTarget "CrossDeskMobile" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A00000000000000000000003 /* Debug */,
A00000000000000000000004 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 800000000000000000000001 /* Project object */;
}
@@ -0,0 +1,76 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2660"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "500000000000000000000001"
BuildableName = "CrossDeskMobile.app"
BlueprintName = "CrossDeskMobile"
ReferencedContainer = "container:CrossDeskMobile.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "500000000000000000000001"
BuildableName = "CrossDeskMobile.app"
BlueprintName = "CrossDeskMobile"
ReferencedContainer = "container:CrossDeskMobile.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "500000000000000000000001"
BuildableName = "CrossDeskMobile.app"
BlueprintName = "CrossDeskMobile"
ReferencedContainer = "container:CrossDeskMobile.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "CrossDeskAppIcon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,121 @@
#import <CoreVideo/CoreVideo.h>
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
typedef NS_ENUM(NSInteger, CrossDeskSignalState) {
CrossDeskSignalStateConnecting = 0,
CrossDeskSignalStateConnected,
CrossDeskSignalStateFailed,
CrossDeskSignalStateClosed,
CrossDeskSignalStateReconnecting,
CrossDeskSignalStateServerClosed,
CrossDeskSignalStateTLSCertificateError,
};
typedef NS_ENUM(NSInteger, CrossDeskConnectionState) {
CrossDeskConnectionStateConnecting = 0,
CrossDeskConnectionStateConnected,
CrossDeskConnectionStateGathering,
CrossDeskConnectionStateDisconnected,
CrossDeskConnectionStateFailed,
CrossDeskConnectionStateClosed,
CrossDeskConnectionStateIncorrectPassword,
CrossDeskConnectionStateNoSuchID,
CrossDeskConnectionStateRemoteUnavailable,
};
typedef NS_ENUM(NSInteger, CrossDeskPointerAction) {
CrossDeskPointerActionMove = 0,
CrossDeskPointerActionLeftDown,
CrossDeskPointerActionLeftUp,
CrossDeskPointerActionRightDown,
CrossDeskPointerActionRightUp,
CrossDeskPointerActionMiddleDown,
CrossDeskPointerActionMiddleUp,
};
@class CrossDeskRTCBridge;
@protocol CrossDeskRTCBridgeDelegate <NSObject>
@optional
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didChangeSignalState:(CrossDeskSignalState)state;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didChangeConnectionState:(CrossDeskConnectionState)state
remoteID:(NSString *)remoteID;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didProvisionIdentity:(NSString *)identity;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceivePixelBuffer:(CVPixelBufferRef)pixelBuffer
width:(NSInteger)width
height:(NSInteger)height;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceiveHostName:(NSString *)hostName
displayNames:(NSArray<NSString *> *)displayNames
displaySizes:(NSArray<NSValue *> *)displaySizes;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceivePresence:(NSDictionary<NSString *, NSNumber *> *)presence;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceiveCursorVisible:(BOOL)visible
shape:(NSInteger)shape
positionUpdate:(BOOL)positionUpdate
positionValid:(BOOL)positionValid
x:(float)x
y:(float)y
visualOffsetX:(float)visualOffsetX
visualOffsetY:(float)visualOffsetY
displayIndex:(NSInteger)displayIndex
sequence:(uint32_t)sequence;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceiveAudioPCM:(NSData *)pcmData;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceiveClipboardText:(NSString *)text;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didUpdateFileTransfer:(NSString *)fileName
progress:(double)progress
sending:(BOOL)sending;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didReceiveFileAtURL:(NSURL *)fileURL;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didUpdateBitrate:(NSUInteger)bitsPerSecond
lossRate:(float)lossRate
usingTURN:(BOOL)usingTURN;
@end
/// Objective-C++ boundary around the native MiniRTC C API.
///
/// All MiniRTC ownership and calls are serialized internally. Delegate methods
/// are delivered on the main queue so SwiftUI never touches callback threads.
@interface CrossDeskRTCBridge : NSObject
@property(nonatomic, weak, nullable) id<CrossDeskRTCBridgeDelegate> delegate;
- (void)configureWithSignalHost:(NSString *)host
signalPort:(NSInteger)signalPort
turnPort:(NSInteger)turnPort
enableSRTP:(BOOL)enableSRTP;
- (void)setHardwareAccelerationEnabled:(BOOL)enabled;
- (void)requestPresenceForRemoteIDs:(NSArray<NSString *> *)remoteIDs
NS_SWIFT_NAME(requestPresence(remoteIDs:));
- (void)connectToRemoteID:(NSString *)remoteID password:(NSString *)password;
- (void)disconnect;
- (void)sendPointerAtX:(float)x
y:(float)y
action:(CrossDeskPointerAction)action
NS_SWIFT_NAME(sendPointer(x:y:action:));
- (void)sendScrollX:(float)x y:(float)y deltaX:(NSInteger)deltaX
deltaY:(NSInteger)deltaY;
- (void)sendWindowsKeyCode:(NSUInteger)keyCode isDown:(BOOL)isDown;
- (void)sendText:(NSString *)text;
- (void)switchToDisplay:(NSInteger)displayIndex;
- (void)setAudioEnabled:(BOOL)enabled;
- (void)sendClipboardText:(NSString *)text;
- (void)sendFileAtURL:(NSURL *)fileURL;
- (void)sendSecureAttentionSequence;
@end
NS_ASSUME_NONNULL_END
File diff suppressed because it is too large Load Diff
+731
View File
@@ -0,0 +1,731 @@
import SwiftUI
import UIKit
struct ContentView: View {
@Environment(\.scenePhase) private var scenePhase
@ObservedObject var session: RemoteSessionModel
var body: some View {
Group {
if session.sessionVisible {
RemoteSessionView(session: session)
.transition(.opacity)
} else {
ConnectionHomeView(session: session)
.transition(.opacity)
}
}
.animation(.easeInOut(duration: 0.2), value: session.sessionVisible)
.preferredColorScheme(session.sessionVisible ? .dark : .light)
.onAppear {
if !session.sessionVisible {
AppOrientation.update(to: .portrait)
}
if scenePhase == .active {
session.refreshRecentConnectionPresenceAfterForeground()
}
}
.onChange(of: scenePhase) { phase in
if phase == .active {
session.refreshRecentConnectionPresenceAfterForeground()
}
}
}
}
private struct KeyboardDismissTapView: UIViewRepresentable {
let onDismiss: () -> Void
func makeCoordinator() -> Coordinator {
Coordinator(onDismiss: onDismiss)
}
func makeUIView(context: Context) -> UIView {
let view = UIView(frame: .zero)
view.backgroundColor = .clear
view.isUserInteractionEnabled = false
installRecognizer(for: view, coordinator: context.coordinator)
return view
}
func updateUIView(_ view: UIView, context: Context) {
context.coordinator.onDismiss = onDismiss
installRecognizer(for: view, coordinator: context.coordinator)
}
static func dismantleUIView(_ view: UIView, coordinator: Coordinator) {
coordinator.uninstall()
}
private func installRecognizer(for view: UIView, coordinator: Coordinator) {
DispatchQueue.main.async { [weak view, weak coordinator] in
guard let window = view?.window else { return }
coordinator?.install(in: window)
}
}
final class Coordinator: NSObject, UIGestureRecognizerDelegate {
var onDismiss: () -> Void
private weak var window: UIWindow?
private var recognizer: UITapGestureRecognizer?
init(onDismiss: @escaping () -> Void) {
self.onDismiss = onDismiss
}
func install(in window: UIWindow) {
guard self.window !== window || recognizer == nil else { return }
uninstall()
let recognizer = UITapGestureRecognizer(target: self,
action: #selector(didTapOutsideInput))
recognizer.cancelsTouchesInView = false
recognizer.delegate = self
window.addGestureRecognizer(recognizer)
self.window = window
self.recognizer = recognizer
}
func uninstall() {
if let recognizer {
window?.removeGestureRecognizer(recognizer)
}
recognizer = nil
window = nil
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
shouldReceive touch: UITouch) -> Bool {
var touchedView = touch.view
while let view = touchedView {
if view is UITextField || view is UITextView {
return false
}
touchedView = view.superview
}
return true
}
@objc private func didTapOutsideInput() {
onDismiss()
window?.endEditing(true)
}
}
}
private struct ConnectionHomeView: View {
@ObservedObject var session: RemoteSessionModel
@FocusState private var remoteIDFocused: Bool
@State private var passwordPromptVisible = false
@State private var settingsVisible = false
@State private var promptRemoteID = ""
@State private var promptPassword = ""
@State private var promptRememberPassword = false
private let connectionAccent = Color(red: 0.18, green: 0.48, blue: 0.86)
private let connectionAccentEnd = Color(red: 0.12, green: 0.38, blue: 0.78)
private var trimmedRemoteID: String {
session.remoteID.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var groupedRemoteID: Binding<String> {
Binding(
get: { formatRemoteID(session.remoteID) },
set: { value in
session.remoteID = String(value.filter { $0.isNumber }.prefix(9))
}
)
}
private var signalIsConnected: Bool {
session.signalStatus == "已连接服务器"
}
private var signalHasError: Bool {
session.signalStatus.contains("失败") ||
session.signalStatus.contains("无效") ||
session.signalStatus.contains("关闭") ||
session.signalStatus.contains("未知")
}
private var signalTint: Color {
if signalIsConnected { return .green }
if signalHasError { return .red }
return .orange
}
private var signalSymbol: String {
if signalIsConnected { return "checkmark.circle.fill" }
if signalHasError { return "exclamationmark.triangle.fill" }
return "arrow.triangle.2.circlepath"
}
private var recentConnectionColumns: [GridItem] {
[
GridItem(.flexible(), spacing: 12, alignment: .top),
GridItem(.flexible(), spacing: 12, alignment: .top)
]
}
private var orderedRecentConnections: [RecentConnection] {
session.recentConnections.enumerated()
.sorted { lhs, rhs in
let lhsOnline = session.isRecentConnectionOnline(lhs.element)
let rhsOnline = session.isRecentConnectionOnline(rhs.element)
if lhsOnline != rhsOnline { return lhsOnline }
return lhs.offset < rhs.offset
}
.map(\.element)
}
var body: some View {
ZStack {
Color(.systemGroupedBackground).ignoresSafeArea()
VStack(spacing: 0) {
header
remoteConnectionPanel
.padding(.top, 24)
recentConnectionsPanel
.padding(.top, 20)
.frame(maxHeight: .infinity)
}
.padding(.horizontal, 20)
.padding(.bottom, 12)
if session.isConnecting {
connectionProgressOverlay
}
}
.background {
KeyboardDismissTapView {
remoteIDFocused = false
}
.allowsHitTesting(false)
}
.sheet(isPresented: $passwordPromptVisible) {
PasswordPromptView(remoteID: promptRemoteID,
password: $promptPassword,
rememberPassword: $promptRememberPassword) {
passwordPromptVisible = false
session.remoteID = promptRemoteID
session.connect(password: promptPassword,
rememberPassword: promptRememberPassword)
}
.presentationDetents([.height(340)])
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $settingsVisible) {
ServerSettingsView(session: session)
.presentationDetents([.fraction(0.80)])
.presentationDragIndicator(.visible)
}
.alert("设备离线", isPresented: Binding(
get: { session.deviceOfflineAlertVisible },
set: { visible in
if !visible { session.dismissDeviceOfflineAlert() }
}
)) {
Button("确定") {
session.dismissDeviceOfflineAlert()
}
}
.onChange(of: session.remoteID) { value in
let formatted = String(value.filter { $0.isNumber }.prefix(9))
if formatted != value {
session.remoteID = formatted
}
}
}
private var header: some View {
HStack(spacing: 8) {
Label {
Text(session.signalStatus)
.font(.caption.weight(.semibold))
.lineLimit(1)
} icon: {
Image(systemName: signalSymbol)
.font(.caption.weight(.bold))
}
.foregroundStyle(signalTint)
.padding(.horizontal, 11)
.frame(height: 34)
.background(signalTint.opacity(0.11), in: Capsule())
.overlay {
Capsule()
.stroke(signalTint.opacity(0.22), lineWidth: 1)
}
Spacer()
Button {
settingsVisible = true
} label: {
Image(systemName: "gearshape")
.font(.system(size: 18, weight: .semibold))
.frame(width: 40, height: 40)
.background(Color(.secondarySystemGroupedBackground),
in: RoundedRectangle(cornerRadius: 10,
style: .continuous))
}
.foregroundStyle(.primary)
.accessibilityLabel("设置")
}
.padding(.top, 8)
}
private var remoteConnectionPanel: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("远程桌面")
.font(.title3.weight(.bold))
Spacer()
}
HStack(spacing: 10) {
TextField("对端 ID", text: groupedRemoteID)
.keyboardType(.numberPad)
.textContentType(.username)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.font(.system(.title3, design: .rounded).weight(.semibold))
.monospacedDigit()
.focused($remoteIDFocused)
.padding(.horizontal, 15)
.frame(height: 54)
.background(Color(.systemGroupedBackground),
in: RoundedRectangle(cornerRadius: 12,
style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(remoteIDFocused
? connectionAccent
: Color(.separator).opacity(0.4),
lineWidth: remoteIDFocused ? 2 : 1)
}
.shadow(color: remoteIDFocused
? connectionAccent.opacity(0.12) : .clear,
radius: 8, y: 2)
Button {
presentPasswordPrompt(for: trimmedRemoteID)
} label: {
HStack(spacing: 6) {
Text("连接")
Image(systemName: "arrow.right")
}
.font(.system(size: 16, weight: .bold))
.frame(width: 96, height: 54)
.foregroundStyle(.white)
.background(LinearGradient(colors: [
connectionAccent,
connectionAccentEnd
], startPoint: .topLeading, endPoint: .bottomTrailing))
.clipShape(RoundedRectangle(cornerRadius: 12,
style: .continuous))
.shadow(color: connectionAccent.opacity(0.24),
radius: 8, y: 4)
}
.buttonStyle(.plain)
.disabled(trimmedRemoteID.isEmpty || session.isConnecting)
.opacity(trimmedRemoteID.isEmpty || session.isConnecting ? 0.45 : 1)
.accessibilityLabel("连接")
}
}
.padding(18)
.background(Color(.secondarySystemGroupedBackground),
in: RoundedRectangle(cornerRadius: 20, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 20, style: .continuous)
.stroke(Color(.separator).opacity(0.28), lineWidth: 1)
}
.shadow(color: .black.opacity(0.055), radius: 14, y: 5)
}
@ViewBuilder
private var recentConnectionsPanel: some View {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .firstTextBaseline) {
Text("最近连接")
.font(.title3.bold())
Spacer()
if !session.recentConnections.isEmpty {
Text("\(session.recentConnections.count) 台设备")
.font(.caption)
.foregroundStyle(.secondary)
}
}
if session.recentConnections.isEmpty {
VStack(spacing: 10) {
Image(systemName: "clock.arrow.circlepath")
.font(.system(size: 30, weight: .medium))
.foregroundStyle(.tertiary)
Text("还没有连接记录")
.font(.headline)
Text("成功连接后,这里会显示远端桌面的缩略图。")
.font(.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView(.vertical) {
LazyVGrid(columns: recentConnectionColumns, spacing: 12) {
ForEach(orderedRecentConnections) { connection in
RecentConnectionCard(
connection: connection,
thumbnail: session.thumbnailImage(for: connection),
online: session.isRecentConnectionOnline(connection),
connect: {
connectRecent(connection)
},
delete: {
session.removeRecentConnection(connection)
}
)
}
}
.padding(.bottom, 2)
}
}
}
.padding(16)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.background(Color(.secondarySystemGroupedBackground),
in: RoundedRectangle(cornerRadius: 18, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.stroke(Color(.separator).opacity(0.35), lineWidth: 1)
}
}
private var connectionProgressOverlay: some View {
ZStack {
Color.clear
.ignoresSafeArea()
.contentShape(Rectangle())
.onTapGesture { }
VStack(spacing: 0) {
VStack(spacing: 14) {
ProgressView()
.controlSize(.large)
.tint(connectionAccent)
VStack(spacing: 6) {
Text("正在连接")
.font(.title3.weight(.semibold))
.foregroundStyle(.primary)
Text(session.connectionStatus)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.lineLimit(2)
.frame(minHeight: 36)
}
}
.padding(.horizontal, 26)
.padding(.top, 24)
.padding(.bottom, 20)
Divider()
Button(role: .cancel, action: session.disconnect) {
Text("取消连接")
.font(.subheadline.weight(.semibold))
.foregroundStyle(Color.red)
.frame(maxWidth: .infinity)
.frame(height: 46)
}
.buttonStyle(.plain)
}
.frame(width: 292)
.background(Color.white,
in: RoundedRectangle(cornerRadius: 24,
style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.stroke(Color(.separator).opacity(0.24), lineWidth: 0.8)
}
.shadow(color: .black.opacity(0.22), radius: 28, y: 10)
}
.transition(.opacity.combined(with: .scale(scale: 0.96)))
}
private func presentPasswordPrompt(for identifier: String) {
let trimmed = identifier.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
remoteIDFocused = true
return
}
remoteIDFocused = false
promptRemoteID = trimmed
promptPassword = session.savedPassword(for: trimmed)
promptRememberPassword = session.remembersPassword(for: trimmed)
passwordPromptVisible = true
}
private func formatRemoteID(_ value: String) -> String {
let digits = Array(value.filter { $0.isNumber }.prefix(9))
return stride(from: 0, to: digits.count, by: 3)
.map { start in
String(digits[start..<min(start + 3, digits.count)])
}
.joined(separator: " ")
}
private func connectRecent(_ connection: RecentConnection) {
session.remoteID = connection.remoteID
remoteIDFocused = false
if connection.remembersPassword,
let savedPassword = session.savedCredential(for: connection.remoteID) {
session.connect(password: savedPassword, rememberPassword: true)
} else {
presentPasswordPrompt(for: connection.remoteID)
}
}
}
private struct RecentConnectionCard: View {
let connection: RecentConnection
let thumbnail: UIImage?
let online: Bool
let connect: () -> Void
let delete: () -> Void
var body: some View {
Button(action: connect) {
VStack(alignment: .leading, spacing: 0) {
ZStack {
if let thumbnail {
Image(uiImage: thumbnail)
.resizable()
.scaledToFill()
} else {
LinearGradient(colors: [
Color(red: 0.18, green: 0.48, blue: 0.86),
Color(red: 0.38, green: 0.68, blue: 0.96)
], startPoint: .topLeading, endPoint: .bottomTrailing)
Image(systemName: "display")
.font(.system(size: 34, weight: .medium))
.foregroundStyle(.white.opacity(0.9))
}
}
.frame(maxWidth: .infinity)
.aspectRatio(16 / 9, contentMode: .fit)
.clipped()
VStack(alignment: .leading, spacing: 3) {
Text(connection.displayName)
.font(.caption.weight(.semibold))
.foregroundStyle(.primary)
.lineLimit(1)
HStack(spacing: 5) {
Text("ID \(connection.remoteID)")
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer(minLength: 4)
if connection.remembersPassword {
Image(systemName: "key.fill")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(.secondary)
.accessibilityLabel("已保存密码")
}
Text(online ? "在线" : "离线")
.font(.caption2.weight(.semibold))
.foregroundStyle(online ? Color.green : Color.secondary)
.accessibilityLabel(online ? "在线" : "离线")
}
}
.padding(9)
}
.background(Color(.secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(Color(.separator).opacity(0.35), lineWidth: 1)
}
}
.buttonStyle(.plain)
.contextMenu {
Button(action: connect) {
Label("连接", systemImage: "arrow.right.circle")
}
Button(role: .destructive, action: delete) {
Label("删除记录", systemImage: "trash")
}
}
}
}
private struct PasswordPromptView: View {
let remoteID: String
@Binding var password: String
@Binding var rememberPassword: Bool
let connect: () -> Void
@Environment(\.dismiss) private var dismiss
@FocusState private var passwordFocused: Bool
@State private var passwordVisible = false
var body: some View {
VStack(alignment: .leading, spacing: 18) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("连接远程桌面")
.font(.title2.bold())
Text("对端 ID \(remoteID)")
.font(.subheadline.monospacedDigit())
.foregroundStyle(.secondary)
}
Spacer()
Button {
dismiss()
} label: {
Image(systemName: "xmark.circle.fill")
.font(.title2)
.foregroundStyle(.secondary)
}
.accessibilityLabel("关闭")
}
HStack(spacing: 10) {
Group {
if passwordVisible {
TextField("访问密码", text: $password)
} else {
SecureField("访问密码", text: $password)
}
}
.keyboardType(.numberPad)
.textContentType(.password)
.focused($passwordFocused)
Button {
passwordVisible.toggle()
} label: {
Image(systemName: passwordVisible ? "eye.slash" : "eye")
.foregroundStyle(.secondary)
}
.accessibilityLabel(passwordVisible ? "隐藏密码" : "显示密码")
}
.padding(.horizontal, 13)
.frame(height: 50)
.background(Color(.secondarySystemGroupedBackground),
in: RoundedRectangle(cornerRadius: 10,
style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(Color(.separator).opacity(0.5), lineWidth: 1)
}
Toggle(isOn: $rememberPassword) {
VStack(alignment: .leading, spacing: 2) {
Text("保存密码")
.font(.subheadline.weight(.semibold))
Text("密码将安全保存在本机 Keychain 中")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Button(action: connect) {
Label("连接", systemImage: "arrow.right")
.font(.headline)
.frame(maxWidth: .infinity)
.frame(height: 48)
}
.buttonStyle(.borderedProminent)
.buttonBorderShape(.roundedRectangle(radius: 10))
}
.padding(22)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
passwordFocused = true
}
}
}
}
private struct ServerSettingsView: View {
@ObservedObject var session: RemoteSessionModel
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
Form {
Section("鼠标控制") {
Picker("控制模式", selection: $session.mouseControlMode) {
ForEach(MouseControlMode.allCases) { mode in
Text(mode.title).tag(mode)
}
}
.pickerStyle(.segmented)
Text(session.mouseControlMode.detail)
.font(.footnote)
.foregroundStyle(.secondary)
}
Section("视频编解码") {
Picker("处理方式", selection: $session.videoCodecMode) {
ForEach(VideoCodecMode.allCases) { mode in
Text(mode.title).tag(mode)
}
}
.pickerStyle(.segmented)
Text(session.videoCodecMode.detail)
.font(.footnote)
.foregroundStyle(.secondary)
Text("修改后从下一次连接开始生效。")
.font(.caption)
.foregroundStyle(.tertiary)
}
Section("服务器") {
TextField("信令服务器", text: $session.signalHost)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
TextField("信令端口", text: $session.signalPort)
.keyboardType(.numberPad)
TextField("STUN/TURN 端口", text: $session.turnPort)
.keyboardType(.numberPad)
}
Section {
Toggle("启用 SRTP", isOn: $session.enableSRTP)
} header: {
Text("传输")
} footer: {
Text(session.localIdentity.isEmpty
? "正在获取本机 ID…"
: "本机 ID \(session.localIdentity)")
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.top, 16)
.textSelection(.enabled)
}
}
.navigationTitle("设置")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("应用") {
session.configureBridge()
dismiss()
}
}
ToolbarItem(placement: .cancellationAction) {
Button("取消") { dismiss() }
}
}
}
}
}
@@ -0,0 +1 @@
#import "Bridge/CrossDeskRTCBridge.h"
@@ -0,0 +1,43 @@
import SwiftUI
import UIKit
final class CrossDeskAppDelegate: NSObject, UIApplicationDelegate {
static var supportedOrientations: UIInterfaceOrientationMask = .portrait
func application(_ application: UIApplication,
supportedInterfaceOrientationsFor window: UIWindow?)
-> UIInterfaceOrientationMask {
Self.supportedOrientations
}
}
enum AppOrientation {
static func update(to orientations: UIInterfaceOrientationMask) {
DispatchQueue.main.async {
CrossDeskAppDelegate.supportedOrientations = orientations
for case let scene as UIWindowScene in UIApplication.shared.connectedScenes {
scene.windows.forEach {
$0.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
}
scene.requestGeometryUpdate(
.iOS(interfaceOrientations: orientations)
) { error in
NSLog("CrossDesk orientation update failed: %@", error.localizedDescription)
}
}
}
}
}
@main
struct CrossDeskMobileApp: App {
@UIApplicationDelegateAdaptor(CrossDeskAppDelegate.self) private var appDelegate
@StateObject private var session = RemoteSessionModel()
var body: some Scene {
WindowGroup {
ContentView(session: session)
}
}
}
+53
View File
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>CrossDesk</string>
<key>CFBundleDevelopmentRegion</key>
<string>zh_CN</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>2</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSLocalNetworkUsageDescription</key>
<string>CrossDesk 使用局域网发现并建立原生 P2P 远程控制连接。</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIFileSharingEnabled</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>UILaunchScreen</key>
<dict/>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
@@ -0,0 +1,502 @@
import SwiftUI
import UIKit
enum RemoteVideoGeometry {
private static func pixelAligned(_ rect: CGRect) -> CGRect {
let scale = max(UIScreen.main.scale, 1)
let minX = (rect.minX * scale).rounded() / scale
let minY = (rect.minY * scale).rounded() / scale
let maxX = (rect.maxX * scale).rounded() / scale
let maxY = (rect.maxY * scale).rounded() / scale
return CGRect(x: minX, y: minY,
width: max(0, maxX - minX),
height: max(0, maxY - minY))
}
static func aspectFitRect(containerSize: CGSize,
videoSize: CGSize) -> CGRect? {
guard containerSize.width > 0, containerSize.height > 0,
videoSize.width > 0, videoSize.height > 0 else { return nil }
let videoAspect = videoSize.width / videoSize.height
let containerAspect = containerSize.width / containerSize.height
if videoAspect > containerAspect {
let height = containerSize.width / videoAspect
return pixelAligned(
CGRect(x: 0, y: (containerSize.height - height) / 2,
width: containerSize.width, height: height)
)
}
let width = containerSize.height * videoAspect
return pixelAligned(
CGRect(x: (containerSize.width - width) / 2, y: 0,
width: width, height: containerSize.height)
)
}
static func transformedRect(containerSize: CGSize,
videoSize: CGSize,
scale: CGFloat,
offset: CGSize) -> CGRect? {
guard let baseRect = aspectFitRect(containerSize: containerSize,
videoSize: videoSize) else {
return nil
}
// SwiftUI scales the video around the center of its own pixel-aligned
// frame. That center can differ fractionally from the container center
// after edge alignment, and the difference is amplified at 10x.
let center = CGPoint(x: baseRect.midX, y: baseRect.midY)
return CGRect(
x: center.x + (baseRect.minX - center.x) * scale + offset.width,
y: center.y + (baseRect.minY - center.y) * scale + offset.height,
width: baseRect.width * scale,
height: baseRect.height * scale
)
}
}
struct RemoteTouchInputView: UIViewRepresentable {
let videoSize: CGSize
let controlMode: MouseControlMode
let remoteCursorPosition: CGPoint?
let viewportScale: CGFloat
let viewportOffset: CGSize
let viewportChanged: (CGFloat, CGSize) -> Void
let move: (Float, Float) -> Void
let leftDown: (Float, Float) -> Void
let leftUp: (Float, Float) -> Void
let rightClick: (Float, Float) -> Void
let scroll: (Float, Float, Int, Int) -> Void
func makeUIView(context: Context) -> RemoteTouchSurface {
RemoteTouchSurface()
}
func updateUIView(_ view: RemoteTouchSurface, context: Context) {
view.videoSize = videoSize
view.controlMode = controlMode
view.setViewport(scale: viewportScale, offset: viewportOffset)
view.onViewportChanged = viewportChanged
view.onMove = move
view.onLeftDown = leftDown
view.onLeftUp = leftUp
view.onRightClick = rightClick
view.onScroll = scroll
view.synchronizeRemoteCursor(remoteCursorPosition)
}
}
final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
var videoSize = CGSize.zero {
didSet { setNeedsLayout() }
}
var controlMode: MouseControlMode = .absolute {
didSet {
guard oldValue != controlMode else { return }
relativeCursorPoint = lastRemoteCursorPoint ?? (0.5, 0.5)
relativePanActive = false
heldDragPoint = nil
heldDragLastLocation = nil
hoverLastLocation = nil
}
}
var onMove: ((Float, Float) -> Void)?
var onLeftDown: ((Float, Float) -> Void)?
var onLeftUp: ((Float, Float) -> Void)?
var onRightClick: ((Float, Float) -> Void)?
var onScroll: ((Float, Float, Int, Int) -> Void)?
var onViewportChanged: ((CGFloat, CGSize) -> Void)?
private var relativeCursorPoint: (Float, Float) = (0.5, 0.5)
private var lastRemoteCursorPoint: (Float, Float)?
private var relativePanActive = false
private var heldDragPoint: (Float, Float)?
private var heldDragLastLocation: CGPoint?
private var hoverLastLocation: CGPoint?
private var viewportScale: CGFloat = 1
private var viewportOffset = CGSize.zero
private var pinchActive = false
private var viewportPanActive = false
private let relativePointerSpeed: CGFloat = 1.35
private let maximumViewportScale: CGFloat = 10
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
isMultipleTouchEnabled = true
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
tap.numberOfTouchesRequired = 1
addGestureRecognizer(tap)
let rightTap = UITapGestureRecognizer(target: self, action: #selector(handleRightTap(_:)))
rightTap.numberOfTouchesRequired = 2
tap.require(toFail: rightTap)
addGestureRecognizer(rightTap)
let drag = UIPanGestureRecognizer(target: self, action: #selector(handleDrag(_:)))
drag.minimumNumberOfTouches = 1
drag.maximumNumberOfTouches = 1
drag.delegate = self
addGestureRecognizer(drag)
let heldDrag = UILongPressGestureRecognizer(
target: self,
action: #selector(handleHeldDrag(_:))
)
heldDrag.minimumPressDuration = 0.35
heldDrag.allowableMovement = 14
heldDrag.numberOfTouchesRequired = 1
heldDrag.delegate = self
addGestureRecognizer(heldDrag)
let pinch = UIPinchGestureRecognizer(target: self,
action: #selector(handlePinch(_:)))
pinch.delegate = self
addGestureRecognizer(pinch)
let wheel = UIPanGestureRecognizer(target: self, action: #selector(handleWheel(_:)))
wheel.minimumNumberOfTouches = 2
wheel.maximumNumberOfTouches = 2
wheel.delegate = self
addGestureRecognizer(wheel)
let hover = UIHoverGestureRecognizer(target: self,
action: #selector(handleHover(_:)))
addGestureRecognizer(hover)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
let constrained = constrainedViewportOffset(viewportOffset,
scale: viewportScale)
guard constrained != viewportOffset else { return }
viewportOffset = constrained
publishViewport()
}
func setViewport(scale: CGFloat, offset: CGSize) {
let nextScale = min(max(scale, 1), maximumViewportScale)
viewportScale = nextScale
viewportOffset = constrainedViewportOffset(offset, scale: nextScale)
}
func synchronizeRemoteCursor(_ position: CGPoint?) {
guard let position else {
lastRemoteCursorPoint = nil
return
}
let point = (
Float(min(max(position.x, 0), 1)),
Float(min(max(position.y, 0), 1))
)
lastRemoteCursorPoint = point
applyRemoteCursorIfIdle()
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
let recognizers = [gestureRecognizer, otherGestureRecognizer]
let hasPinch = recognizers.contains { $0 is UIPinchGestureRecognizer }
let hasTwoFingerPan = recognizers.contains { recognizer in
guard let pan = recognizer as? UIPanGestureRecognizer else {
return false
}
return pan.minimumNumberOfTouches == 2
}
return hasPinch && hasTwoFingerPan
}
@objc private func handleTap(_ recognizer: UITapGestureRecognizer) {
let location = recognizer.location(in: self)
guard recognizer.state == .ended,
let point = pointerPoint(for: location) else { return }
#if DEBUG
NSLog("CrossDesk tap location=%@ normalized=(%.5f, %.5f) " +
"render=%@ scale=%.4f offset=%@ mode=%@",
String(describing: location), point.0, point.1,
renderRect().map { String(describing: $0) } ?? "nil",
viewportScale, String(describing: viewportOffset),
controlMode == .relative ? "relative" : "absolute")
#endif
onMove?(point.0, point.1)
onLeftDown?(point.0, point.1)
onLeftUp?(point.0, point.1)
}
@objc private func handleRightTap(_ recognizer: UITapGestureRecognizer) {
guard recognizer.state == .ended,
let point = pointerPoint(for: recognizer.location(in: self)) else { return }
onMove?(point.0, point.1)
onRightClick?(point.0, point.1)
}
@objc private func handleDrag(_ recognizer: UIPanGestureRecognizer) {
if controlMode == .relative {
handleRelativeDrag(recognizer)
return
}
switch recognizer.state {
case .began, .changed:
guard let point = normalized(recognizer.location(in: self)) else { return }
onMove?(point.0, point.1)
default:
break
}
}
@objc private func handleHeldDrag(_ recognizer: UILongPressGestureRecognizer) {
if controlMode == .relative {
handleRelativeHeldDrag(recognizer)
return
}
let point = normalized(recognizer.location(in: self)) ?? heldDragPoint
switch recognizer.state {
case .began:
guard let point else { return }
heldDragPoint = point
onMove?(point.0, point.1)
onLeftDown?(point.0, point.1)
case .changed:
guard let point else { return }
heldDragPoint = point
onMove?(point.0, point.1)
case .ended, .cancelled, .failed:
if let point {
onLeftUp?(point.0, point.1)
}
heldDragPoint = nil
default:
break
}
}
@objc private func handlePinch(_ recognizer: UIPinchGestureRecognizer) {
let focus = recognizer.location(in: self)
switch recognizer.state {
case .began:
pinchActive = renderRect()?.contains(focus) == true
recognizer.scale = 1
case .changed:
guard pinchActive else { return }
let oldScale = viewportScale
let newScale = min(max(oldScale * recognizer.scale, 1),
maximumViewportScale)
recognizer.scale = 1
guard abs(newScale - oldScale) > 0.0001 else { return }
let center = CGPoint(x: bounds.midX, y: bounds.midY)
let ratio = newScale / oldScale
let proposedOffset = CGSize(
width: focus.x - center.x -
(focus.x - center.x - viewportOffset.width) * ratio,
height: focus.y - center.y -
(focus.y - center.y - viewportOffset.height) * ratio
)
viewportScale = newScale
viewportOffset = constrainedViewportOffset(proposedOffset,
scale: newScale)
publishViewport()
case .ended, .cancelled, .failed:
pinchActive = false
if viewportScale <= 1.001 {
viewportScale = 1
viewportOffset = .zero
publishViewport()
}
default:
break
}
}
@objc private func handleWheel(_ recognizer: UIPanGestureRecognizer) {
if viewportScale > 1.001 || pinchActive {
switch recognizer.state {
case .began:
viewportPanActive = renderRect()?.contains(
recognizer.location(in: self)
) == true
recognizer.setTranslation(.zero, in: self)
case .changed:
if !viewportPanActive {
viewportPanActive = renderRect()?.contains(
recognizer.location(in: self)
) == true
}
guard viewportPanActive else { return }
let translation = recognizer.translation(in: self)
recognizer.setTranslation(.zero, in: self)
let proposedOffset = CGSize(
width: viewportOffset.width + translation.x,
height: viewportOffset.height + translation.y
)
viewportOffset = constrainedViewportOffset(proposedOffset,
scale: viewportScale)
publishViewport()
default:
viewportPanActive = false
}
return
}
guard let point = pointerPoint(for: recognizer.location(in: self)) else { return }
let translation = recognizer.translation(in: self)
let deltaX = Int(-translation.x / 12)
let deltaY = Int(-translation.y / 12)
if deltaX != 0 || deltaY != 0 {
onScroll?(point.0, point.1, deltaX, deltaY)
recognizer.setTranslation(.zero, in: self)
}
}
@objc private func handleHover(_ recognizer: UIHoverGestureRecognizer) {
let location = recognizer.location(in: self)
if controlMode == .relative {
switch recognizer.state {
case .began:
guard renderRect()?.contains(location) == true else { return }
hoverLastLocation = location
onMove?(relativeCursorPoint.0, relativeCursorPoint.1)
case .changed:
guard let previous = hoverLastLocation else { return }
hoverLastLocation = location
if let point = updateRelativeCursor(
by: CGPoint(x: location.x - previous.x,
y: location.y - previous.y)
) {
onMove?(point.0, point.1)
}
default:
hoverLastLocation = nil
}
return
}
guard recognizer.state == .began || recognizer.state == .changed,
let point = normalized(location) else { return }
onMove?(point.0, point.1)
}
private func handleRelativeDrag(_ recognizer: UIPanGestureRecognizer) {
switch recognizer.state {
case .began:
let location = recognizer.location(in: self)
relativePanActive = renderRect()?.contains(location) == true
recognizer.setTranslation(.zero, in: self)
if relativePanActive {
onMove?(relativeCursorPoint.0, relativeCursorPoint.1)
}
case .changed:
guard relativePanActive else { return }
let translation = recognizer.translation(in: self)
recognizer.setTranslation(.zero, in: self)
if let point = updateRelativeCursor(by: translation) {
onMove?(point.0, point.1)
}
default:
relativePanActive = false
applyRemoteCursorIfIdle()
}
}
private func handleRelativeHeldDrag(_ recognizer: UILongPressGestureRecognizer) {
let location = recognizer.location(in: self)
switch recognizer.state {
case .began:
guard renderRect()?.contains(location) == true else { return }
heldDragLastLocation = location
heldDragPoint = relativeCursorPoint
onMove?(relativeCursorPoint.0, relativeCursorPoint.1)
onLeftDown?(relativeCursorPoint.0, relativeCursorPoint.1)
case .changed:
guard let previous = heldDragLastLocation else { return }
heldDragLastLocation = location
if let point = updateRelativeCursor(
by: CGPoint(x: location.x - previous.x,
y: location.y - previous.y)
) {
heldDragPoint = point
onMove?(point.0, point.1)
}
case .ended, .cancelled, .failed:
if let point = heldDragPoint {
onLeftUp?(point.0, point.1)
}
heldDragPoint = nil
heldDragLastLocation = nil
applyRemoteCursorIfIdle()
default:
break
}
}
private func pointerPoint(for location: CGPoint) -> (Float, Float)? {
if controlMode == .relative {
guard renderRect()?.contains(location) == true else { return nil }
return relativeCursorPoint
}
return normalized(location)
}
private func updateRelativeCursor(by translation: CGPoint) -> (Float, Float)? {
guard let renderRect = renderRect(),
renderRect.width > 0, renderRect.height > 0 else { return nil }
let nextX = CGFloat(relativeCursorPoint.0) +
translation.x / renderRect.width * relativePointerSpeed
let nextY = CGFloat(relativeCursorPoint.1) +
translation.y / renderRect.height * relativePointerSpeed
relativeCursorPoint = (
Float(min(max(nextX, 0), 1)),
Float(min(max(nextY, 0), 1))
)
return relativeCursorPoint
}
private func applyRemoteCursorIfIdle() {
guard controlMode == .relative,
!relativePanActive,
heldDragPoint == nil,
let point = lastRemoteCursorPoint else { return }
relativeCursorPoint = point
}
private func normalized(_ point: CGPoint) -> (Float, Float)? {
guard let renderRect = renderRect(), renderRect.contains(point) else { return nil }
return (Float((point.x - renderRect.minX) / renderRect.width),
Float((point.y - renderRect.minY) / renderRect.height))
}
private func renderRect() -> CGRect? {
RemoteVideoGeometry.transformedRect(
containerSize: bounds.size,
videoSize: videoSize,
scale: viewportScale,
offset: viewportOffset
)
}
private func baseRenderRect() -> CGRect? {
RemoteVideoGeometry.aspectFitRect(containerSize: bounds.size,
videoSize: videoSize)
}
private func constrainedViewportOffset(_ offset: CGSize,
scale: CGFloat) -> CGSize {
guard let baseRect = baseRenderRect() else { return offset }
let maximumX = max(0, baseRect.width * (scale - 1) / 2)
let maximumY = max(0, baseRect.height * (scale - 1) / 2)
return CGSize(
width: min(max(offset.width, -maximumX), maximumX),
height: min(max(offset.height, -maximumY), maximumY)
)
}
private func publishViewport() {
onViewportChanged?(viewportScale, viewportOffset)
}
}
@@ -0,0 +1,875 @@
import AVFoundation
import CoreImage
import CoreVideo
import Foundation
import Security
import UIKit
enum MouseControlMode: String, CaseIterable, Identifiable {
case relative
case absolute
private static let defaultsKey = "crossdesk.mobile.mouse-control-mode"
var id: String { rawValue }
var title: String {
switch self {
case .absolute: return "绝对位置"
case .relative: return "相对位置"
}
}
var detail: String {
switch self {
case .absolute:
return "触摸位置直接对应远端屏幕位置,适合快速定位。"
case .relative:
return "像触控板一样滑动光标,点击时操作当前光标位置。"
}
}
static var saved: MouseControlMode {
guard let rawValue = UserDefaults.standard.string(forKey: defaultsKey),
let mode = MouseControlMode(rawValue: rawValue) else {
return .relative
}
return mode
}
func save() {
UserDefaults.standard.set(rawValue, forKey: Self.defaultsKey)
}
}
enum VideoCodecMode: String, CaseIterable, Identifiable {
case hardware
case software
private static let defaultsKey = "crossdesk.mobile.video-codec-mode"
var id: String { rawValue }
var title: String {
switch self {
case .hardware: return "硬件"
case .software: return "软件"
}
}
var detail: String {
switch self {
case .hardware:
return "使用 VideoToolbox,延迟和耗电更低,推荐日常使用。"
case .software:
return "使用 OpenH264 软件编解码,适合兼容性测试。"
}
}
static var saved: VideoCodecMode {
guard let rawValue = UserDefaults.standard.string(forKey: defaultsKey),
let mode = VideoCodecMode(rawValue: rawValue) else {
return .hardware
}
return mode
}
func save() {
UserDefaults.standard.set(rawValue, forKey: Self.defaultsKey)
}
}
private struct RemoteVideoFrame {
let pixelBuffer: CVPixelBuffer
let encodedSize: CGSize
}
struct RecentConnection: Codable, Identifiable, Equatable {
let remoteID: String
var displayName: String
var lastConnectedAt: Date
var remembersPassword: Bool
var thumbnailFileName: String?
var id: String { remoteID }
}
private enum RecentConnectionStore {
private static let defaultsKey = "crossdesk.mobile.recent-connections.v1"
private static let thumbnailQueue =
DispatchQueue(label: "cn.crossdesk.mobile.thumbnails", qos: .utility)
private static let context = CIContext(options: [.cacheIntermediates: false])
static func load() -> [RecentConnection] {
guard let data = UserDefaults.standard.data(forKey: defaultsKey),
let connections = try? JSONDecoder().decode([RecentConnection].self,
from: data) else {
return []
}
return connections.sorted { $0.lastConnectedAt > $1.lastConnectedAt }
}
static func save(_ connections: [RecentConnection]) {
guard let data = try? JSONEncoder().encode(connections) else { return }
UserDefaults.standard.set(data, forKey: defaultsKey)
}
static func thumbnailURL(fileName: String) -> URL? {
guard let directory = thumbnailDirectory() else { return nil }
return directory.appendingPathComponent(fileName, isDirectory: false)
}
static func captureThumbnail(from pixelBuffer: CVPixelBuffer,
remoteID: String,
completion: @escaping (String?) -> Void) {
thumbnailQueue.async {
let fileName: String? = autoreleasepool {
let image = CIImage(cvPixelBuffer: pixelBuffer)
let targetSize = CGSize(width: 640, height: 360)
guard image.extent.width > 0, image.extent.height > 0 else {
return nil
}
// Scale in Core Image before materializing a CGImage. Creating
// a full 4K bitmap and then drawing it into a thumbnail can add
// tens of megabytes to the first-frame memory peak.
let scale = max(targetSize.width / image.extent.width,
targetSize.height / image.extent.height)
let scaled = image.transformed(by: CGAffineTransform(
scaleX: scale,
y: scale
))
let cropRect = CGRect(
x: scaled.extent.midX - targetSize.width / 2,
y: scaled.extent.midY - targetSize.height / 2,
width: targetSize.width,
height: targetSize.height
).integral
guard let thumbnailImage = context.createCGImage(
scaled.cropped(to: cropRect),
from: cropRect
) else {
return nil
}
let safeID = remoteID.filter {
$0.isLetter || $0.isNumber || $0 == "-"
}
let name = "\(safeID.isEmpty ? "remote" : safeID).jpg"
guard let url = thumbnailURL(fileName: name),
let data = UIImage(cgImage: thumbnailImage)
.jpegData(compressionQuality: 0.78) else {
return nil
}
do {
try data.write(to: url, options: .atomic)
return name
} catch {
return nil
}
}
DispatchQueue.main.async { completion(fileName) }
}
}
static func removeThumbnail(fileName: String?) {
guard let fileName, let url = thumbnailURL(fileName: fileName) else { return }
try? FileManager.default.removeItem(at: url)
}
private static func thumbnailDirectory() -> URL? {
guard let base = FileManager.default.urls(for: .applicationSupportDirectory,
in: .userDomainMask).first else {
return nil
}
let directory = base.appendingPathComponent("RecentConnectionThumbnails",
isDirectory: true)
do {
try FileManager.default.createDirectory(at: directory,
withIntermediateDirectories: true)
return directory
} catch {
return nil
}
}
}
private enum ConnectionCredentialStore {
private static let service = "cn.crossdesk.mobile.saved-password"
static func password(for remoteID: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: remoteID,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let data = result as? Data else {
return nil
}
return String(data: data, encoding: .utf8)
}
static func save(password: String, for remoteID: String) {
removePassword(for: remoteID)
let attributes: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: remoteID,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
kSecValueData as String: Data(password.utf8)
]
SecItemAdd(attributes as CFDictionary, nil)
}
static func removePassword(for remoteID: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: remoteID
]
SecItemDelete(query as CFDictionary)
}
}
private final class RemoteAudioPlayer {
private let engine = AVAudioEngine()
private let player = AVAudioPlayerNode()
private let queue = DispatchQueue(label: "cn.crossdesk.mobile.audio")
private let format = AVAudioFormat(commonFormat: .pcmFormatInt16,
sampleRate: 48_000,
channels: 1,
interleaved: false)!
private var enabled = true
private var queuedBuffers = 0
init() {
engine.attach(player)
engine.connect(player, to: engine.mainMixerNode, format: format)
}
func setEnabled(_ value: Bool) {
queue.async {
self.enabled = value
if value {
self.startIfNeeded()
} else {
self.player.stop()
self.engine.stop()
self.queuedBuffers = 0
}
}
}
func enqueue(_ data: Data) {
guard !data.isEmpty, data.count.isMultiple(of: MemoryLayout<Int16>.size) else { return }
queue.async {
guard self.enabled, self.queuedBuffers < 80 else { return }
self.startIfNeeded()
guard self.engine.isRunning,
let buffer = AVAudioPCMBuffer(pcmFormat: self.format,
frameCapacity: AVAudioFrameCount(data.count / 2)),
let samples = buffer.int16ChannelData?[0] else { return }
buffer.frameLength = buffer.frameCapacity
data.withUnsafeBytes { bytes in
guard let source = bytes.baseAddress else { return }
memcpy(samples, source, data.count)
}
self.queuedBuffers += 1
self.player.scheduleBuffer(buffer) {
self.queue.async {
self.queuedBuffers = max(0, self.queuedBuffers - 1)
}
}
if !self.player.isPlaying {
self.player.play()
}
}
}
private func startIfNeeded() {
guard enabled, !engine.isRunning else { return }
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, mode: .moviePlayback,
options: [.mixWithOthers])
try session.setActive(true)
try engine.start()
player.play()
} catch {
// The next PCM packet retries activation after route changes.
}
}
}
final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDelegate {
@Published var signalHost = "api.crossdesk.cn"
@Published var signalPort = "9099"
@Published var turnPort = "3478"
@Published var enableSRTP = false
@Published var mouseControlMode = MouseControlMode.saved {
didSet { mouseControlMode.save() }
}
@Published var videoCodecMode = VideoCodecMode.saved {
didSet { videoCodecMode.save() }
}
@Published var remoteID = ""
@Published var password = ""
@Published private(set) var signalStatus = "正在连接信令服务"
@Published private(set) var connectionStatus = "未连接"
@Published private(set) var localIdentity = ""
@Published private(set) var isConnecting = false
@Published private(set) var isConnected = false
@Published private(set) var sessionVisible = false
@Published private var videoFrame: RemoteVideoFrame?
@Published private(set) var remoteCursorVisible = true
@Published private(set) var remoteCursorShape = 0
@Published private(set) var remoteCursorPosition: CGPoint?
@Published private(set) var remoteCursorVisualOffset = CGPoint.zero
@Published private(set) var hasRemoteCursorState = false
@Published private(set) var bitrate: UInt = 0
@Published private(set) var lossRate: Float = 0
@Published private(set) var usingTURN = false
@Published private(set) var displays: [String] = ["显示器 1"]
@Published private(set) var displaySizes: [CGSize] = []
@Published var selectedDisplay = 0
@Published var audioEnabled = true
@Published private(set) var clipboardStatus = ""
@Published private(set) var transferStatus = ""
@Published private(set) var transferProgress = 0.0
@Published private(set) var receivedFileURL: URL?
@Published private(set) var frameCount: UInt64 = 0
@Published private(set) var recentConnections = RecentConnectionStore.load()
@Published private(set) var recentConnectionPresence: [String: Bool] = [:]
@Published private(set) var deviceOfflineAlertVisible = false
let bridge = CrossDeskRTCBridge()
private let audioPlayer = RemoteAudioPlayer()
private var activeRemoteID = ""
private var pendingRememberPassword = false
private var connectionRecorded = false
private var shouldCaptureThumbnail = false
private var activeDisplayName = ""
private var remoteCursorSequence: UInt32?
private var pendingPresenceRemoteID: String?
private var presenceProbeGeneration: UInt64 = 0
var pixelBuffer: CVPixelBuffer? { videoFrame?.pixelBuffer }
var frameSize: CGSize { videoFrame?.encodedSize ?? .zero }
var displayGeometrySize: CGSize {
guard displaySizes.indices.contains(selectedDisplay) else {
return frameSize
}
let size = displaySizes[selectedDisplay]
return size.width > 0 && size.height > 0 ? size : frameSize
}
override init() {
super.init()
bridge.delegate = self
configureBridge()
}
func configureBridge() {
guard let signal = Int(signalPort), let turn = Int(turnPort),
!signalHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
signalStatus = "服务器配置无效"
return
}
bridge.setHardwareAccelerationEnabled(videoCodecMode == .hardware)
bridge.configure(withSignalHost: signalHost,
signalPort: signal,
turnPort: turn,
enableSRTP: enableSRTP)
}
func connect(password: String, rememberPassword: Bool) {
self.password = password
pendingRememberPassword = rememberPassword
connect()
}
func connect() {
let identifier = remoteID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !identifier.isEmpty else {
connectionStatus = "请输入远程设备 ID"
return
}
remoteID = identifier
configureBridge()
if recentConnectionPresence[identifier] == true {
beginRemoteConnection(identifier)
} else {
beginPresenceProbe(identifier)
}
}
private func beginRemoteConnection(_ identifier: String) {
cancelPresenceProbe()
activeRemoteID = identifier
activeDisplayName = ""
displaySizes = []
connectionRecorded = false
shouldCaptureThumbnail = true
isConnecting = true
isConnected = false
sessionVisible = false
resetRemoteCursorState()
connectionStatus = "正在准备连接…"
bridge.connect(toRemoteID: identifier, password: password)
}
private func beginPresenceProbe(_ identifier: String) {
guard signalStatus == "已连接服务器" else {
showDeviceOffline()
return
}
presenceProbeGeneration &+= 1
let generation = presenceProbeGeneration
pendingPresenceRemoteID = identifier
isConnecting = true
isConnected = false
sessionVisible = false
connectionStatus = "正在确认设备状态…"
bridge.requestPresence(remoteIDs: [identifier])
DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [weak self] in
guard let self,
self.presenceProbeGeneration == generation,
self.pendingPresenceRemoteID == identifier else {
return
}
self.cancelPresenceProbe()
self.refreshRecentConnectionPresence()
self.showDeviceOffline()
}
}
private func cancelPresenceProbe() {
presenceProbeGeneration &+= 1
pendingPresenceRemoteID = nil
}
private func showDeviceOffline() {
isConnecting = false
isConnected = false
sessionVisible = false
connectionStatus = "设备离线"
deviceOfflineAlertVisible = true
}
func dismissDeviceOfflineAlert() {
deviceOfflineAlertVisible = false
}
func disconnect() {
let wasCheckingPresence = pendingPresenceRemoteID != nil
cancelPresenceProbe()
if wasCheckingPresence {
refreshRecentConnectionPresence()
}
bridge.disconnect()
isConnecting = false
isConnected = false
sessionVisible = false
videoFrame = nil
resetRemoteCursorState()
frameCount = 0
bitrate = 0
displays = ["显示器 1"]
displaySizes = []
selectedDisplay = 0
connectionStatus = "未连接"
audioPlayer.setEnabled(false)
AppOrientation.update(to: .portrait)
}
func retry() {
bridge.disconnect()
connect()
}
func savedPassword(for remoteID: String) -> String {
ConnectionCredentialStore.password(for: remoteID) ?? ""
}
func savedCredential(for remoteID: String) -> String? {
ConnectionCredentialStore.password(for: remoteID)
}
func remembersPassword(for remoteID: String) -> Bool {
recentConnections.first(where: { $0.remoteID == remoteID })?
.remembersPassword == true
}
func thumbnailImage(for connection: RecentConnection) -> UIImage? {
guard let fileName = connection.thumbnailFileName,
let url = RecentConnectionStore.thumbnailURL(fileName: fileName) else {
return nil
}
return UIImage(contentsOfFile: url.path)
}
func isRecentConnectionOnline(_ connection: RecentConnection) -> Bool {
recentConnectionPresence[connection.remoteID] == true
}
private func refreshRecentConnectionPresence() {
bridge.requestPresence(remoteIDs: recentConnections.map(\.remoteID))
}
func refreshRecentConnectionPresenceAfterForeground() {
// iOS can suspend the process while it is in the background, so presence
// updates received during that time are missed. Treat cached values as
// unknown (and therefore offline, like the desktop client) until the
// signaling server returns a fresh snapshot.
recentConnectionPresence = [:]
if let pendingPresenceRemoteID {
bridge.requestPresence(remoteIDs: [pendingPresenceRemoteID])
} else {
refreshRecentConnectionPresence()
}
}
func removeRecentConnection(_ connection: RecentConnection) {
recentConnections.removeAll { $0.remoteID == connection.remoteID }
recentConnectionPresence.removeValue(forKey: connection.remoteID)
RecentConnectionStore.save(recentConnections)
RecentConnectionStore.removeThumbnail(fileName: connection.thumbnailFileName)
ConnectionCredentialStore.removePassword(for: connection.remoteID)
refreshRecentConnectionPresence()
}
private func recordSuccessfulConnectionIfNeeded() {
guard !connectionRecorded, !activeRemoteID.isEmpty else { return }
connectionRecorded = true
if pendingRememberPassword {
ConnectionCredentialStore.save(password: password, for: activeRemoteID)
} else {
ConnectionCredentialStore.removePassword(for: activeRemoteID)
}
let previous = recentConnections.first { $0.remoteID == activeRemoteID }
let title = activeDisplayName.isEmpty
? (previous?.displayName ?? activeRemoteID)
: activeDisplayName
let connection = RecentConnection(
remoteID: activeRemoteID,
displayName: title,
lastConnectedAt: Date(),
remembersPassword: pendingRememberPassword,
thumbnailFileName: previous?.thumbnailFileName
)
recentConnections.removeAll { $0.remoteID == activeRemoteID }
recentConnections.insert(connection, at: 0)
recentConnectionPresence[activeRemoteID] = true
RecentConnectionStore.save(recentConnections)
refreshRecentConnectionPresence()
}
private func updateActiveConnectionName(_ name: String) {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, !activeRemoteID.isEmpty else { return }
activeDisplayName = trimmed
guard let index = recentConnections.firstIndex(where: {
$0.remoteID == activeRemoteID
}) else { return }
recentConnections[index].displayName = trimmed
RecentConnectionStore.save(recentConnections)
}
private func captureRecentThumbnailIfNeeded(_ pixelBuffer: CVPixelBuffer) {
guard shouldCaptureThumbnail, !activeRemoteID.isEmpty else { return }
shouldCaptureThumbnail = false
let identifier = activeRemoteID
RecentConnectionStore.captureThumbnail(from: pixelBuffer,
remoteID: identifier) { [weak self] fileName in
guard let self, let fileName,
let index = self.recentConnections.firstIndex(where: {
$0.remoteID == identifier
}) else { return }
self.recentConnections[index].thumbnailFileName = fileName
RecentConnectionStore.save(self.recentConnections)
}
}
func selectDisplay(_ index: Int) {
guard displays.indices.contains(index) else { return }
selectedDisplay = index
videoFrame = nil
resetRemoteCursorState()
frameCount = 0
bridge.switch(toDisplay: index)
}
private func resetRemoteCursorState() {
remoteCursorVisible = true
remoteCursorShape = 0
remoteCursorPosition = nil
remoteCursorVisualOffset = .zero
hasRemoteCursorState = false
remoteCursorSequence = nil
}
func toggleAudio() {
audioEnabled.toggle()
audioPlayer.setEnabled(audioEnabled)
bridge.setAudioEnabled(audioEnabled)
}
func sendClipboard() {
guard let text = UIPasteboard.general.string, !text.isEmpty else {
clipboardStatus = "剪贴板中没有文本"
return
}
guard text.lengthOfBytes(using: .utf8) <= 128 * 1024 else {
clipboardStatus = "剪贴板文本超过 128 KiB"
return
}
bridge.sendClipboardText(text)
clipboardStatus = "已发送本机剪贴板"
}
func sendFile(_ url: URL) {
transferStatus = "正在发送 \(url.lastPathComponent)"
transferProgress = 0
bridge.sendFile(at: url)
}
func sendKeyStroke(_ keyCode: UInt) {
bridge.sendWindowsKeyCode(keyCode, isDown: true)
bridge.sendWindowsKeyCode(keyCode, isDown: false)
}
func sendKeyState(_ keyCode: UInt, isDown: Bool) {
bridge.sendWindowsKeyCode(keyCode, isDown: isDown)
}
func rtcBridge(_ bridge: CrossDeskRTCBridge,
didChange state: CrossDeskSignalState) {
switch state.rawValue {
case 0: signalStatus = "正在连接信令服务"
case 1: signalStatus = "已连接服务器"
case 2: signalStatus = "信令连接失败"
case 3: signalStatus = "信令连接已关闭"
case 4: signalStatus = "信令服务重连中"
case 5: signalStatus = "信令服务器已关闭连接"
case 6: signalStatus = "TLS 证书校验失败"
default: signalStatus = "未知信令状态"
}
if state.rawValue == 1 {
if let pendingPresenceRemoteID {
bridge.requestPresence(remoteIDs: [pendingPresenceRemoteID])
} else {
refreshRecentConnectionPresence()
}
}
}
func rtcBridge(_ bridge: CrossDeskRTCBridge,
didChange state: CrossDeskConnectionState,
remoteID: String) {
isConnected = state.rawValue == 1
isConnecting = [0, 2].contains(state.rawValue)
switch state.rawValue {
case 0: connectionStatus = "正在建立远程连接"
case 1:
connectionStatus = "已连接"
if !remoteID.isEmpty {
recentConnectionPresence[remoteID] = true
}
sessionVisible = true
recordSuccessfulConnectionIfNeeded()
audioPlayer.setEnabled(audioEnabled)
bridge.setAudioEnabled(audioEnabled)
AppOrientation.update(to: .allButUpsideDown)
case 2: connectionStatus = "正在收集 ICE 候选"
case 3: connectionStatus = "网络连接已中断"
case 4:
connectionStatus = "P2P/TURN 建链失败"
sessionVisible = false
AppOrientation.update(to: .portrait)
case 5:
connectionStatus = "未连接"
sessionVisible = false
AppOrientation.update(to: .portrait)
case 6:
connectionStatus = "访问密码错误"
sessionVisible = false
AppOrientation.update(to: .portrait)
case 7:
connectionStatus = "远程设备 ID 不存在"
if !remoteID.isEmpty {
recentConnectionPresence[remoteID] = false
}
sessionVisible = false
AppOrientation.update(to: .portrait)
case 8:
connectionStatus = "远程设备当前不可用"
if !remoteID.isEmpty {
recentConnectionPresence[remoteID] = false
}
sessionVisible = false
AppOrientation.update(to: .portrait)
default: connectionStatus = "未知连接状态"
}
}
func rtcBridge(_ bridge: CrossDeskRTCBridge, didProvisionIdentity identity: String) {
localIdentity = identity.split(separator: "@").first.map(String.init) ?? identity
if let pendingPresenceRemoteID {
bridge.requestPresence(remoteIDs: [pendingPresenceRemoteID])
} else {
refreshRecentConnectionPresence()
}
}
func rtcBridge(_ bridge: CrossDeskRTCBridge,
didReceivePresence presence: [String: NSNumber]) {
var updated = recentConnectionPresence
for (remoteID, online) in presence {
updated[remoteID] = online.boolValue
}
recentConnectionPresence = updated
guard let pendingRemoteID = pendingPresenceRemoteID,
let online = presence[pendingRemoteID]?.boolValue else {
return
}
cancelPresenceProbe()
refreshRecentConnectionPresence()
if online {
beginRemoteConnection(pendingRemoteID)
} else {
showDeviceOffline()
}
}
func rtcBridge(_ bridge: CrossDeskRTCBridge,
didReceive pixelBuffer: CVPixelBuffer,
width: Int,
height: Int) {
// Buffer and encoded dimensions must be one observable value. Adaptive
// resolution changes must never expose a new frame with the previous
// frame's geometry to SwiftUI.
videoFrame = RemoteVideoFrame(
pixelBuffer: pixelBuffer,
encodedSize: CGSize(width: width, height: height)
)
frameCount &+= 1
captureRecentThumbnailIfNeeded(pixelBuffer)
}
func rtcBridge(_ bridge: CrossDeskRTCBridge,
didReceiveHostName hostName: String,
displayNames remoteDisplayNames: [String],
displaySizes remoteDisplaySizes: [NSValue]) {
updateActiveConnectionName(hostName)
let names = remoteDisplayNames.enumerated().map { index, displayName in
let name = displayName.trimmingCharacters(in: .whitespacesAndNewlines)
return name.isEmpty ? "显示器 \(index + 1)" : name
}
if !names.isEmpty {
displays = names
displaySizes = remoteDisplaySizes.map(\.cgSizeValue)
selectedDisplay = min(selectedDisplay, names.count - 1)
bridge.switch(toDisplay: selectedDisplay)
}
}
func rtcBridge(_ bridge: CrossDeskRTCBridge,
didReceiveCursorVisible visible: Bool,
shape: Int,
positionUpdate: Bool,
positionValid: Bool,
x: Float,
y: Float,
visualOffsetX: Float,
visualOffsetY: Float,
display displayIndex: Int,
sequence: UInt32) {
if let previous = remoteCursorSequence,
sequence != 0,
Int32(bitPattern: sequence &- previous) <= 0 {
return
}
remoteCursorSequence = sequence
remoteCursorVisible = visible
remoteCursorShape = min(max(shape, 0), 28)
remoteCursorVisualOffset = CGPoint(x: CGFloat(visualOffsetX),
y: CGFloat(visualOffsetY))
if positionUpdate {
if positionValid, displayIndex == selectedDisplay {
remoteCursorPosition = CGPoint(
x: CGFloat(min(max(x, 0), 1)),
y: CGFloat(min(max(y, 0), 1))
)
} else {
remoteCursorPosition = nil
}
}
hasRemoteCursorState = true
}
func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceiveAudioPCM pcmData: Data) {
audioPlayer.enqueue(pcmData)
}
func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceiveClipboardText text: String) {
UIPasteboard.general.string = text
clipboardStatus = "已接收远端剪贴板"
}
func rtcBridge(_ bridge: CrossDeskRTCBridge,
didUpdateFileTransfer fileName: String,
progress: Double,
sending: Bool) {
if progress < 0 {
transferStatus = "\(fileName) 传输失败"
transferProgress = 0
return
}
transferProgress = progress
let percent = Int((progress * 100).rounded())
transferStatus = "\(sending ? "发送" : "接收") \(fileName) · \(percent)%"
}
func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceiveFileAt fileURL: URL) {
receivedFileURL = fileURL
transferStatus = "已接收 \(fileURL.lastPathComponent)"
transferProgress = 1
}
func rtcBridge(_ bridge: CrossDeskRTCBridge,
didUpdateBitrate bitsPerSecond: UInt,
lossRate: Float,
usingTURN: Bool) {
bitrate = bitsPerSecond
self.lossRate = lossRate
self.usingTURN = usingTURN
}
var formattedBitrate: String {
if bitrate >= 1_000_000 {
return String(format: "%.1f Mbps", Double(bitrate) / 1_000_000)
}
if bitrate >= 1_000 {
return String(format: "%.0f Kbps", Double(bitrate) / 1_000)
}
return "\(bitrate) bps"
}
var videoStatus: String {
if frameCount > 0 {
return "视频 \(Int(frameSize.width))×\(Int(frameSize.height)) · \(frameCount)"
}
return isConnected ? "正在取回远程画面…" : "等待连接"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,151 @@
import AVFoundation
import CoreMedia
import CoreVideo
import SwiftUI
import UIKit
/// Presents decoded NV12 frames through iOS's native video display path.
///
/// `AVSampleBufferDisplayLayer` handles YUV conversion and drawable scheduling
/// itself. This avoids the Core Image -> MTKView path, which can remain black
/// on a physical device even though VideoToolbox is producing valid frames.
struct NativeVideoView: UIViewRepresentable {
let pixelBuffer: CVPixelBuffer?
func makeUIView(context: Context) -> SampleBufferVideoView {
SampleBufferVideoView(frame: .zero)
}
func updateUIView(_ uiView: SampleBufferVideoView, context: Context) {
uiView.display(pixelBuffer)
}
}
final class SampleBufferVideoView: UIView {
override class var layerClass: AnyClass {
AVSampleBufferDisplayLayer.self
}
private var videoLayer: AVSampleBufferDisplayLayer {
layer as! AVSampleBufferDisplayLayer
}
private var lastPixelBuffer: CVPixelBuffer?
private var submittedFrames: UInt64 = 0
private var droppedFrames: UInt64 = 0
override init(frame: CGRect) {
super.init(frame: frame)
configureLayer()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
configureLayer()
}
private func configureLayer() {
backgroundColor = .black
isOpaque = true
videoLayer.backgroundColor = UIColor.black.cgColor
// The SwiftUI host gives this view the exact aspect-fit video rect.
// Filling that rect avoids a second, independently rounded aspect-fit
// calculation inside AVSampleBufferDisplayLayer. That rounding becomes
// visibly amplified when the remote desktop is zoomed up to 10x.
videoLayer.videoGravity = .resize
}
func display(_ pixelBuffer: CVPixelBuffer?) {
guard let pixelBuffer else {
lastPixelBuffer = nil
submittedFrames = 0
droppedFrames = 0
videoLayer.flushAndRemoveImage()
return
}
// SwiftUI can refresh for unrelated status fields. Do not enqueue the
// same retained CVPixelBuffer more than once.
guard lastPixelBuffer !== pixelBuffer else { return }
lastPixelBuffer = pixelBuffer
if videoLayer.status == .failed {
NSLog("CrossDesk video layer failed: %@",
videoLayer.error?.localizedDescription ?? "unknown error")
videoLayer.flush()
}
// Never let AVSampleBufferDisplayLayer turn temporary rendering
// pressure into seconds of latency. Its queued buffers are already
// stale by the time it stops accepting more data, so discard them and
// present the newest decoded IOSurface instead.
if !videoLayer.isReadyForMoreMediaData {
droppedFrames &+= 1
videoLayer.flush()
if droppedFrames == 1 || droppedFrames.isMultiple(of: 300) {
NSLog("CrossDesk dropped stale display frames: %llu",
droppedFrames)
}
}
guard videoLayer.isReadyForMoreMediaData else { return }
var formatDescription: CMVideoFormatDescription?
guard CMVideoFormatDescriptionCreateForImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: pixelBuffer,
formatDescriptionOut: &formatDescription
) == noErr, let formatDescription else {
NSLog("CrossDesk could not create a video format description")
return
}
var timing = CMSampleTimingInfo(
duration: .invalid,
presentationTimeStamp: .invalid,
decodeTimeStamp: .invalid
)
var sampleBuffer: CMSampleBuffer?
guard CMSampleBufferCreateReadyWithImageBuffer(
allocator: kCFAllocatorDefault,
imageBuffer: pixelBuffer,
formatDescription: formatDescription,
sampleTiming: &timing,
sampleBufferOut: &sampleBuffer
) == noErr, let sampleBuffer else {
NSLog("CrossDesk could not create a video sample buffer")
return
}
if let attachments = CMSampleBufferGetSampleAttachmentsArray(
sampleBuffer,
createIfNecessary: true
), CFArrayGetCount(attachments) > 0 {
let attachment = unsafeBitCast(
CFArrayGetValueAtIndex(attachments, 0),
to: CFMutableDictionary.self
)
CFDictionarySetValue(
attachment,
Unmanaged.passUnretained(kCMSampleAttachmentKey_DisplayImmediately).toOpaque(),
Unmanaged.passUnretained(kCFBooleanTrue).toOpaque()
)
}
videoLayer.enqueue(sampleBuffer)
submittedFrames &+= 1
if submittedFrames == 1 || submittedFrames.isMultiple(of: 300) {
NSLog("CrossDesk presented video frame %llu (%zu x %zu)",
submittedFrames,
CVPixelBufferGetWidth(pixelBuffer),
CVPixelBufferGetHeight(pixelBuffer))
}
if submittedFrames == 1 {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
guard let self else { return }
NSLog("CrossDesk video layer status=%ld error=%@",
self.videoLayer.status.rawValue,
self.videoLayer.error?.localizedDescription ?? "none")
}
}
}
}
+70
View File
@@ -0,0 +1,70 @@
# CrossDesk Mobile (native iOS)
This target is a native MiniRTC controller. It uses the same WebSocket
signaling, libnice ICE, SRTP/RTP and data-stream protocol as the desktop app.
There is no `WKWebView` or browser runtime.
## Requirements
- Xcode 16 or newer
- Xmake available on `PATH`
- A physical arm64 iPhone or iPad running iOS 16 or newer
## Build
Open `CrossDeskMobile.xcodeproj`, select the `CrossDeskMobile` scheme and a
physical device, then build. The first build phase compiles MiniRTC and the
shared CrossDesk protocol, then merges their iPhoneOS static dependencies into
a local archive under `Vendor/`.
You can also verify the target without code signing:
```sh
xcodebuild -project ios/CrossDeskMobile.xcodeproj \
-scheme CrossDeskMobile \
-configuration Debug \
-destination 'generic/platform=iOS' \
CODE_SIGNING_ALLOWED=NO build
```
The first connection to a signaling server provisions and stores an identity
for that server. Remote control sessions then log in as `C-<identity>` and use
the desktop-compatible `DisplayN`, `control_audio`, `mouse`, `keyboard`,
`control_data`, `clipboard`, `file`, and `file_feedback` streams.
## Video codecs
Every iOS build includes VideoToolbox, OpenH264, dav1d, libaom, and SVT-AV1. H.264 uses
VideoToolbox by default; the in-app **Settings > Video Codec** selector switches
H.264 software processing to OpenH264. AV1 encoding uses SVT-AV1 and AV1 decoding
uses dav1d; VideoToolbox AV1 hardware decoding is currently unsupported.
## Physical-device test checklist
Run the app from Xcode on a physical device and connect to a current desktop
build. Test the features in this order so a media problem is not confused with
a data-channel problem:
1. **Video:** after the connection turns green, the waiting panel should be
replaced by the remote desktop. The status bar reports the decoded size and
frame count.
2. **Audio:** play continuous sound on the remote computer, then toggle the
speaker button. Audio is Opus-decoded by MiniRTC and played as 48 kHz mono
16-bit PCM through `AVAudioEngine`.
3. **Clipboard:** copy a short text value on the phone and choose **Send local
clipboard**. Copy a different value on the desktop and confirm that it is
written to the iOS pasteboard. Text is limited to 128 KiB.
4. **Displays:** open the display menu, switch every listed monitor, and check
that the frame counter restarts and the selected monitor appears after a
new key frame.
5. **Files:** use the folder button to pick a small file, then send a file back
from the desktop. Progress is ACK-driven. Received files are stored in the
app's `Documents/Received` directory and can be exported with the share
button or Finder's Files tab.
For black-screen diagnosis, keep Xcode's device console open and filter for
`CrossDesk`. A working pipeline prints both `VideoToolbox decoded frame` from
MiniRTC and `CrossDesk received decoded frame` from the iOS bridge. If only the
first message appears, verify that the selected stream is named `Display1`,
`Display2`, and so on. If neither appears, update and rebuild the desktop side
and request a key frame.
+147
View File
@@ -0,0 +1,147 @@
#!/bin/zsh
set -euo pipefail
SCRIPT_DIR="${0:A:h}"
IOS_DIR="${SCRIPT_DIR:h}"
REPO_DIR="${IOS_DIR:h}"
MINIRTC_DIR="${REPO_DIR}/submodules/minirtc"
CONFIG_NAME="${CONFIGURATION:-Debug}"
MODE="${CONFIG_NAME:l}"
# Keep iOS dependencies isolated from desktop Xmake packages. Besides avoiding
# cross-project cache collisions, this guarantees every archive is compiled
# with the iOS 16 deployment target instead of the active SDK version.
export XMAKE_PKG_INSTALLDIR="${IOS_DIR}/.xmake/packages"
if [[ "${MODE}" != "debug" && "${MODE}" != "release" ]]; then
MODE="release"
fi
ARCH_NAME="${CURRENT_ARCH:-arm64}"
if [[ "${ARCH_NAME}" == "undefined_arch" ]]; then
ARCH_NAME="arm64"
fi
if [[ "${ARCH_NAME}" != "arm64" ]]; then
print -u2 "CrossDesk Mobile currently supports physical iOS arm64 builds only."
exit 64
fi
XMAKE_BIN="${XMAKE_BIN:-}"
if [[ -z "${XMAKE_BIN}" ]]; then
XMAKE_BIN="$(command -v xmake 2>/dev/null || true)"
fi
if [[ -z "${XMAKE_BIN}" ]]; then
for candidate in /opt/homebrew/bin/xmake /usr/local/bin/xmake "${HOME}/.local/bin/xmake"; do
if [[ -x "${candidate}" ]]; then
XMAKE_BIN="${candidate}"
break
fi
done
fi
if [[ -z "${XMAKE_BIN}" || ! -x "${XMAKE_BIN}" ]]; then
print -u2 "xmake is required. Install it from https://xmake.io first."
exit 69
fi
OUTPUT_DIR="${IOS_DIR}/Vendor/iphoneos/${CONFIG_NAME}"
OUTPUT_LIBRARY="${OUTPUT_DIR}/libCrossDeskMiniRTC.a"
MINIRTC_BUILD_DIR="${IOS_DIR}/.xmake/minirtc-build"
MINIRTC_LIBRARY="${MINIRTC_BUILD_DIR}/iphoneos/arm64/${MODE}/libminirtc.a"
CROSSDESK_SOURCE_DIR="${REPO_DIR}/src"
CROSSDESK_BUILD_DIR="${IOS_DIR}/.xmake/crossdesk-build/iphoneos/arm64/${MODE}"
mkdir -p "${OUTPUT_DIR}"
# Xmake stores the configured build directory relative to the process working
# directory. Xcode does not guarantee that directory for build phases, so keep
# configuration, compilation and inspection anchored to the MiniRTC project.
(
cd "${MINIRTC_DIR}"
"${XMAKE_BIN}" f -P "${MINIRTC_DIR}" -c -o "${MINIRTC_BUILD_DIR}" \
-p iphoneos -a arm64 -m "${MODE}" \
--target_minver=16.0 --USE_CUDA=false -y
"${XMAKE_BIN}" b -P "${MINIRTC_DIR}" minirtc
)
if [[ ! -f "${MINIRTC_LIBRARY}" ]]; then
print -u2 "MiniRTC archive was not produced at ${MINIRTC_LIBRARY}."
exit 66
fi
# Xmake owns the package hashes, so query its resolved target instead of
# embedding machine-specific ~/.xmake paths in the Xcode project.
TARGET_INFO="$(cd "${MINIRTC_DIR}" && TERM=dumb NO_COLOR=1 \
"${XMAKE_BIN}" show -P "${MINIRTC_DIR}" -t minirtc)"
CLEAN_INFO="$(print -r -- "${TARGET_INFO}" | sed $'s/\033\\[[0-9;]*[[:alpha:]]//g')"
LINK_DIRS=("${(@f)$(print -r -- "${CLEAN_INFO}" | sed -nE 's|.*-> (/.*)/lib -> package.*|\1/lib|p' | sort -u)}")
REQUIRED_LINKS=(
nice glib-2.0 gobject-2.0 gmodule-2.0 gio-2.0 gthread-2.0 intl
ffi pcre2-8 pcre2-posix z ssl crypto srtp2 openfec opus yuv kcp
datachannel usrsctp openh264 dav1d aom SvtAv1Enc
)
DEPENDENCY_ARCHIVES=()
for link_name in "${REQUIRED_LINKS[@]}"; do
archive_path=""
for link_dir in "${LINK_DIRS[@]}"; do
candidate="${link_dir}/lib${link_name}.a"
if [[ -f "${candidate}" ]]; then
archive_path="${candidate}"
break
fi
done
if [[ -z "${archive_path}" ]]; then
print -u2 "Unable to resolve static dependency lib${link_name}.a from Xmake."
exit 66
fi
DEPENDENCY_ARCHIVES+=("${archive_path}")
done
# Build the CrossDesk message and file-transfer codecs for the same iOS
# architecture, then merge them into the aggregate archive consumed by Xcode.
NLOHMANN_HEADER="$(find "${XMAKE_PKG_INSTALLDIR}/n/nlohmann_json" \
-path '*/include/nlohmann/json.hpp' -print -quit 2>/dev/null || true)"
if [[ -z "${NLOHMANN_HEADER}" ]]; then
print -u2 "Unable to locate the nlohmann_json headers installed by Xmake."
exit 66
fi
NLOHMANN_INCLUDE="${NLOHMANN_HEADER:h:h}"
IOS_SDK_PATH="$(xcrun --sdk iphoneos --show-sdk-path)"
mkdir -p "${CROSSDESK_BUILD_DIR}"
CROSSDESK_CXXFLAGS=(
-std=c++17 -arch arm64 -isysroot "${IOS_SDK_PATH}"
-miphoneos-version-min=16.0
-I"${CROSSDESK_SOURCE_DIR}/device_controller"
-I"${CROSSDESK_SOURCE_DIR}/common"
-I"${CROSSDESK_SOURCE_DIR}/tools"
-I"${NLOHMANN_INCLUDE}"
)
if [[ "${MODE}" == "debug" ]]; then
CROSSDESK_CXXFLAGS+=(-O0 -g)
else
CROSSDESK_CXXFLAGS+=(-O2 -DNDEBUG)
fi
CROSSDESK_SOURCES=(
"${CROSSDESK_SOURCE_DIR}/device_controller/remote_action.cpp"
"${CROSSDESK_SOURCE_DIR}/tools/file_transfer_protocol.cpp"
)
CROSSDESK_OBJECTS=()
for source_path in "${CROSSDESK_SOURCES[@]}"; do
object_path="${CROSSDESK_BUILD_DIR}/${source_path:t:r}.o"
xcrun --sdk iphoneos clang++ "${CROSSDESK_CXXFLAGS[@]}" \
-c "${source_path}" -o "${object_path}"
CROSSDESK_OBJECTS+=("${object_path}")
done
TEMP_LIBRARY="${OUTPUT_LIBRARY}.tmp"
rm -f "${TEMP_LIBRARY}"
/usr/bin/libtool -static -o "${TEMP_LIBRARY}" \
"${MINIRTC_LIBRARY}" "${DEPENDENCY_ARCHIVES[@]}" \
"${CROSSDESK_OBJECTS[@]}"
mv -f "${TEMP_LIBRARY}" "${OUTPUT_LIBRARY}"
print "Created ${OUTPUT_LIBRARY}"
+14 -4
View File
@@ -17,6 +17,7 @@ MAINTAINER="Junkun Di <junkun.di@hotmail.com>"
DESCRIPTION="A simple cross-platform remote desktop client."
ALSA_RUNTIME_DEP="libasound2 | libasound2t64"
PORTAL_RUNTIME_RECOMMENDS="xdg-desktop-portal, xdg-desktop-portal-gtk | xdg-desktop-portal-kde | xdg-desktop-portal-wlr"
TRAY_RUNTIME_RECOMMENDS="libayatana-appindicator3-1 | libappindicator3-1"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
@@ -104,6 +105,15 @@ OUTPUT_FILE="$PROJECT_ROOT/${PKG_NAME}-linux-${DEBIAN_ARCH}-${APP_VERSION}.deb"
install -d "$DEBIAN_DIR" "$BIN_DIR" "$PRIVATE_DIR" "$DESKTOP_DIR"
install -m 0755 "$BUILD_BINARY" "$PRIVATE_DIR/crossdesk-bin"
# PipeWire is deliberately optional. The Wayland capturer resolves PipeWire
# 0.3 with dlopen() so the same package can run on Ubuntu 20.04 without the
# runtime while using the host runtime on newer Ubuntu releases.
if readelf -d "$BUILD_BINARY" \
| grep -qE 'Shared library: \[libpipewire-0\.3\.so'; then
echo "CrossDesk must not link directly to the optional PipeWire runtime" >&2
exit 1
fi
# Slint is intentionally built as a shared library. Keep that runtime private
# to CrossDesk instead of relying on a non-existent distribution package.
SLINT_NEEDED="$(readelf -d "$BUILD_BINARY" | sed -n \
@@ -136,7 +146,7 @@ for size in 16 24 32 48 64 96 128 256; do
"$ICON_BASE_DIR/${size}x${size}/apps/${PKG_NAME}.png"
done
RECOMMENDS="$PORTAL_RUNTIME_RECOMMENDS"
RECOMMENDS="$PORTAL_RUNTIME_RECOMMENDS, $TRAY_RUNTIME_RECOMMENDS"
if [[ -n "$EXTRA_RECOMMENDATION" ]]; then
RECOMMENDS="$RECOMMENDS, $EXTRA_RECOMMENDATION"
fi
@@ -147,11 +157,11 @@ Version: $DEB_VERSION
Architecture: $DEBIAN_ARCH
Maintainer: $MAINTAINER
Description: $DESCRIPTION
Depends: libc6 (>= 2.35), libstdc++6 (>= 9), libx11-6, libxext6,
libxrender1, libxft2, libxrandr2, libxfixes3, libxcb1, libxcb-randr0,
Depends: libc6 (>= 2.31), libstdc++6 (>= 10), libx11-6, libxext6,
libxrender1, libxft2, libxrandr2, libxfixes3, libxcursor1, libxcb1, libxcb-randr0,
libxcb-xtest0, libxcb-xinerama0, libxcb-shape0, libxcb-xkb1,
libxcb-xfixes0, libxv1, libxtst6, $ALSA_RUNTIME_DEP, libsndio7.0,
libxcb-shm0, libpulse0, libdrm2, libdbus-1-3
libxcb-shm0, libpulse0, libdrm2, libdbus-1-3, libgl1
Recommends: $RECOMMENDS
Priority: optional
Section: utils
+7 -4
View File
@@ -1,16 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
APP_NAME="crossdesk"
APP_NAME_UPPER="CrossDesk"
EXECUTABLE_PATH="./build/macosx/arm64/release/crossdesk"
EXECUTABLE_PATH="${PROJECT_ROOT}/build/macosx/arm64/release/crossdesk"
PLATFORM="macos"
ARCH="arm64"
BINARY_ARCH="arm64"
IDENTIFIER="cn.crossdesk.app"
ICON_PATH="icons/macos/crossdesk.icns"
ICON_PATH="${PROJECT_ROOT}/icons/macos/crossdesk.icns"
MACOS_MIN_VERSION="14.0"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$PROJECT_ROOT"
normalize_app_version() {
local input="$1"
@@ -103,7 +106,7 @@ find "${APP_BUNDLE}" -name '._*' -delete
echo ".app created successfully."
mkdir -p build_pkg_scripts
cp scripts/macosx/tcc_postinstall.sh build_pkg_scripts/postinstall
cp "${SCRIPT_DIR}/tcc_postinstall.sh" build_pkg_scripts/postinstall
chmod +x build_pkg_scripts/postinstall
mkdir -p build_pkg_resources
+7 -4
View File
@@ -1,16 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
APP_NAME="crossdesk"
APP_NAME_UPPER="CrossDesk"
EXECUTABLE_PATH="build/macosx/x86_64/release/crossdesk"
EXECUTABLE_PATH="${PROJECT_ROOT}/build/macosx/x86_64/release/crossdesk"
PLATFORM="macos"
ARCH="x64"
BINARY_ARCH="x86_64"
IDENTIFIER="cn.crossdesk.app"
ICON_PATH="icons/macos/crossdesk.icns"
ICON_PATH="${PROJECT_ROOT}/icons/macos/crossdesk.icns"
MACOS_MIN_VERSION="14.0"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$PROJECT_ROOT"
normalize_app_version() {
local input="$1"
@@ -103,7 +106,7 @@ find "${APP_BUNDLE}" -name '._*' -delete
echo ".app created successfully."
mkdir -p build_pkg_scripts
cp scripts/macosx/tcc_postinstall.sh build_pkg_scripts/postinstall
cp "${SCRIPT_DIR}/tcc_postinstall.sh" build_pkg_scripts/postinstall
chmod +x build_pkg_scripts/postinstall
mkdir -p build_pkg_resources
+20 -2
View File
@@ -1,13 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 2 ]]; then
echo "Usage: $0 <app-bundle> <expected-architecture>" >&2
if [[ $# -lt 2 || $# -gt 3 ]]; then
echo "Usage: $0 <app-bundle> <expected-architecture> [expected-sdk-version]" >&2
exit 2
fi
APP_BUNDLE="$1"
EXPECTED_ARCH="$2"
EXPECTED_SDK_VERSION="${3:-}"
APP_EXECUTABLE="$APP_BUNDLE/Contents/MacOS/CrossDesk"
FRAMEWORKS_DIR="$APP_BUNDLE/Contents/Frameworks"
INFO_PLIST="$APP_BUNDLE/Contents/Info.plist"
@@ -94,6 +95,23 @@ if [[ -z "$BINARY_MIN_VERSION" || "$PLIST_MIN_VERSION" != "$BINARY_MIN_VERSION"
exit 1
fi
if [[ -n "$EXPECTED_SDK_VERSION" ]]; then
verify_sdk_version() {
local macho="$1"
local actual_sdk=""
actual_sdk="$(otool -l "$macho" | awk '
$1 == "cmd" { build_version = ($2 == "LC_BUILD_VERSION"); next }
build_version && $1 == "sdk" { print $2; exit }')"
if [[ "$actual_sdk" != "$EXPECTED_SDK_VERSION" ]]; then
echo "Unexpected macOS SDK in $macho: expected $EXPECTED_SDK_VERSION, got ${actual_sdk:-unknown}" >&2
return 1
fi
}
verify_sdk_version "$APP_EXECUTABLE"
verify_sdk_version "$SLINT_LIBRARY"
fi
codesign --verify --deep --strict --verbose=2 "$APP_BUNDLE"
echo "Verified $APP_BUNDLE ($EXPECTED_ARCH, macOS $BINARY_MIN_VERSION+)"
+5
View File
@@ -9,3 +9,8 @@ CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "crossdesk_debug.manifest"
#else
CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "crossdesk.manifest"
#endif
#define CROSSDESK_FILE_DESCRIPTION "CrossDesk"
#define CROSSDESK_ORIGINAL_FILENAME "CrossDesk.exe"
#include "version_info.rcinc"
+5
View File
@@ -6,3 +6,8 @@ IDI_ICON1 ICON "..\\..\\icons\\windows\\crossdesk.ico"
#define RT_MANIFEST 24
CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "crossdesk_portable.manifest"
#define CROSSDESK_FILE_DESCRIPTION "CrossDesk Portable"
#define CROSSDESK_ORIGINAL_FILENAME "CrossDesk.exe"
#include "version_info.rcinc"
+4
View File
@@ -0,0 +1,4 @@
#define CROSSDESK_FILE_DESCRIPTION "CrossDesk Service"
#define CROSSDESK_ORIGINAL_FILENAME "crossdesk_service.exe"
#include "version_info.rcinc"
@@ -0,0 +1,18 @@
// Session helper icon and manifest resources.
IDI_ICON1 ICON "..\\..\\icons\\windows\\crossdesk.ico"
#define CREATEPROCESS_MANIFEST_RESOURCE_ID 1
#define RT_MANIFEST 24
#ifdef CROSSDESK_PORTABLE
CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "crossdesk_portable.manifest"
#elif defined(CROSSDESK_DEBUG)
CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "crossdesk_debug.manifest"
#else
CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "crossdesk.manifest"
#endif
#define CROSSDESK_FILE_DESCRIPTION "CrossDesk Session Helper"
#define CROSSDESK_ORIGINAL_FILENAME "crossdesk_session_helper.exe"
#include "version_info.rcinc"
+9
View File
@@ -4,6 +4,7 @@
; Installer initial constants
!define PRODUCT_NAME "CrossDesk"
!define PRODUCT_VERSION "${VERSION}"
!define PRODUCT_VERSION_NUMERIC "${VERSION_NUMERIC}"
!define PRODUCT_PUBLISHER "CrossDesk"
!define PRODUCT_WEB_SITE "https://www.crossdesk.cn/"
!define APP_NAME "CrossDesk"
@@ -41,6 +42,14 @@ RequestExecutionLevel admin
Name "${PRODUCT_NAME} ${PRODUCT_VERSION}"
OutFile "crossdesk-win-x64-${PRODUCT_VERSION}.exe"
VIProductVersion "${PRODUCT_VERSION_NUMERIC}"
VIAddVersionKey /LANG=2052 "CompanyName" "CrossDesk"
VIAddVersionKey /LANG=2052 "FileDescription" "CrossDesk Installer"
VIAddVersionKey /LANG=2052 "FileVersion" "${PRODUCT_VERSION}"
VIAddVersionKey /LANG=2052 "LegalCopyright" "Copyright (C) CrossDesk contributors"
VIAddVersionKey /LANG=2052 "OriginalFilename" "crossdesk-win-x64-${PRODUCT_VERSION}.exe"
VIAddVersionKey /LANG=2052 "ProductName" "${PRODUCT_NAME}"
VIAddVersionKey /LANG=2052 "ProductVersion" "${PRODUCT_VERSION}"
InstallDir "$PROGRAMFILES\CrossDesk"
InstallDirRegKey HKCU "Software\${PRODUCT_NAME}" "InstallDir"
ShowInstDetails show
+29 -1
View File
@@ -26,12 +26,40 @@ function Normalize-AppVersion {
return $InputVersion
}
function Convert-To-WindowsVersion {
param(
[Parameter(Mandatory = $true)]
[string]$InputVersion
)
$body = $InputVersion -replace '^v', ''
$baseMatch = [regex]::Match($body, '^[0-9]+(?:\.[0-9]+){0,3}')
if (!$baseMatch.Success) {
return "0.0.0.0"
}
$parts = [System.Collections.Generic.List[int]]::new()
foreach ($part in $baseMatch.Value.Split('.')) {
$value = [int]$part
if ($value -gt 65535) {
$value = 0
}
$parts.Add($value)
}
while ($parts.Count -lt 4) {
$parts.Add(0)
}
return ($parts -join '.')
}
$normalizedVersion = Normalize-AppVersion -InputVersion $Version
$windowsVersion = Convert-To-WindowsVersion -InputVersion $normalizedVersion
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
Push-Location $scriptDir
try {
& makensis "/DVERSION=$normalizedVersion" "nsis_script.nsi"
& makensis "/DVERSION=$normalizedVersion" "/DVERSION_NUMERIC=$windowsVersion" "nsis_script.nsi"
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
<parameters>
<parameter name="version" required="true" />
</parameters>
<pe-file path="crossdesk-win-x64-${version}.exe"
product-name="CrossDesk"
product-version="${version}"
file-version="${version}"
company-name="CrossDesk"
copyright="Copyright (C) CrossDesk contributors"
original-filename="crossdesk-win-x64-${version}.exe">
<authenticode-sign />
</pe-file>
</artifact-configuration>
+54
View File
@@ -0,0 +1,54 @@
#include <winver.h>
#ifndef CROSSDESK_VERSION_STRING
#define CROSSDESK_VERSION_STRING "0.0.0"
#endif
#ifndef CROSSDESK_VERSION_NUMERIC
#define CROSSDESK_VERSION_NUMERIC 0,0,0,0
#endif
#ifndef CROSSDESK_FILE_DESCRIPTION
#define CROSSDESK_FILE_DESCRIPTION "CrossDesk"
#endif
#ifndef CROSSDESK_ORIGINAL_FILENAME
#define CROSSDESK_ORIGINAL_FILENAME "CrossDesk.exe"
#endif
#ifndef CROSSDESK_FILE_TYPE
#define CROSSDESK_FILE_TYPE VFT_APP
#endif
VS_VERSION_INFO VERSIONINFO
FILEVERSION CROSSDESK_VERSION_NUMERIC
PRODUCTVERSION CROSSDESK_VERSION_NUMERIC
FILEFLAGSMASK VS_FFI_FILEFLAGSMASK
#ifdef CROSSDESK_DEBUG
FILEFLAGS VS_FF_DEBUG
#else
FILEFLAGS 0x0L
#endif
FILEOS VOS_NT_WINDOWS32
FILETYPE CROSSDESK_FILE_TYPE
FILESUBTYPE VFT2_UNKNOWN
BEGIN
BLOCK "StringFileInfo"
BEGIN
BLOCK "040904B0"
BEGIN
VALUE "CompanyName", "CrossDesk\0"
VALUE "FileDescription", CROSSDESK_FILE_DESCRIPTION "\0"
VALUE "FileVersion", CROSSDESK_VERSION_STRING "\0"
VALUE "InternalName", CROSSDESK_ORIGINAL_FILENAME "\0"
VALUE "LegalCopyright", "Copyright (C) CrossDesk contributors\0"
VALUE "OriginalFilename", CROSSDESK_ORIGINAL_FILENAME "\0"
VALUE "ProductName", "CrossDesk\0"
VALUE "ProductVersion", CROSSDESK_VERSION_STRING "\0"
END
END
BLOCK "VarFileInfo"
BEGIN
VALUE "Translation", 0x0409, 1200
END
END
+5
View File
@@ -0,0 +1,5 @@
#define CROSSDESK_FILE_DESCRIPTION "CrossDesk Windows Graphics Capture Plugin"
#define CROSSDESK_ORIGINAL_FILENAME "wgc_plugin.dll"
#define CROSSDESK_FILE_TYPE VFT_DLL
#include "version_info.rcinc"
+59
View File
@@ -0,0 +1,59 @@
#ifndef CROSSDESK_DISPLAY_STREAM_ID_H_
#define CROSSDESK_DISPLAY_STREAM_ID_H_
#include <cstddef>
#include <string>
namespace crossdesk {
// MiniRTC stream identifiers are protocol-facing logical identifiers. Keep
// them independent from platform display handles and user-visible names, both
// of which may change or contain duplicate/Unicode text.
inline std::string MakeDisplayStreamId(size_t display_index) {
return "Display" + std::to_string(display_index + 1);
}
inline bool IsRegisteredDisplayStreamId(const std::string& stream_id,
size_t display_count) {
if (stream_id.empty()) {
return false;
}
for (size_t index = 0; index < display_count; ++index) {
if (stream_id == MakeDisplayStreamId(index)) {
return true;
}
}
return false;
}
// Resolves a backend-reported identifier to a stream registered with MiniRTC.
// Backends should report MakeDisplayStreamId(index); the fallbacks keep older
// capture plugins and hotplug transitions safe.
inline std::string ResolveDisplayStreamId(
const char* reported_id, size_t display_count, int preferred_index = -1,
const std::string& previous_id = {}) {
const std::string candidate = reported_id ? reported_id : "";
if (IsRegisteredDisplayStreamId(candidate, display_count)) {
return candidate;
}
if (preferred_index >= 0 &&
preferred_index < static_cast<int>(display_count)) {
return MakeDisplayStreamId(static_cast<size_t>(preferred_index));
}
if (IsRegisteredDisplayStreamId(previous_id, display_count)) {
return previous_id;
}
if (display_count == 1) {
return MakeDisplayStreamId(0);
}
return {};
}
} // namespace crossdesk
#endif // CROSSDESK_DISPLAY_STREAM_ID_H_
+402
View File
@@ -0,0 +1,402 @@
#include "linux_cursor_shape.h"
#include <algorithm>
#include <array>
#include <cctype>
#include <cmath>
#include <cstring>
#include <limits>
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>
#if defined(__linux__) && !defined(__APPLE__)
#include <X11/Xcursor/Xcursor.h>
#include <X11/Xlib.h>
#endif
namespace crossdesk {
namespace {
constexpr LinuxCursorAlias kCursorAliases[] = {
{"help", RemoteCursorShape::help},
{"question-arrow", RemoteCursorShape::help},
{"left-ptr-help", RemoteCursorShape::help},
{"whats-this", RemoteCursorShape::help},
{"dnd-ask", RemoteCursorShape::help},
{"pointer", RemoteCursorShape::pointer},
{"hand", RemoteCursorShape::pointer},
{"hand1", RemoteCursorShape::pointer},
{"hand2", RemoteCursorShape::pointer},
{"link", RemoteCursorShape::pointer},
{"pointing-hand", RemoteCursorShape::pointer},
{"progress", RemoteCursorShape::progress},
{"left-ptr-watch", RemoteCursorShape::progress},
{"half-busy", RemoteCursorShape::progress},
{"wait", RemoteCursorShape::wait},
{"watch", RemoteCursorShape::wait},
{"crosshair", RemoteCursorShape::crosshair},
{"cross", RemoteCursorShape::crosshair},
{"cross-reverse", RemoteCursorShape::crosshair},
{"tcross", RemoteCursorShape::crosshair},
{"cell", RemoteCursorShape::crosshair},
{"plus", RemoteCursorShape::crosshair},
{"target", RemoteCursorShape::crosshair},
{"dotbox", RemoteCursorShape::crosshair},
{"dot-box-mask", RemoteCursorShape::crosshair},
{"text", RemoteCursorShape::text},
{"xterm", RemoteCursorShape::text},
{"ibeam", RemoteCursorShape::text},
{"vertical-text", RemoteCursorShape::text},
{"alias", RemoteCursorShape::alias},
{"dnd-link", RemoteCursorShape::alias},
{"copy", RemoteCursorShape::copy},
{"dnd-copy", RemoteCursorShape::copy},
{"move", RemoteCursorShape::move},
{"fleur", RemoteCursorShape::move},
{"size-all", RemoteCursorShape::move},
{"all-scroll", RemoteCursorShape::move},
{"dnd-move", RemoteCursorShape::move},
{"no-drop", RemoteCursorShape::no_drop},
{"dnd-no-drop", RemoteCursorShape::no_drop},
{"dnd-none", RemoteCursorShape::no_drop},
{"not-allowed", RemoteCursorShape::not_allowed},
{"forbidden", RemoteCursorShape::not_allowed},
{"crossed-circle", RemoteCursorShape::not_allowed},
{"circle", RemoteCursorShape::not_allowed},
{"pirate", RemoteCursorShape::not_allowed},
{"grab", RemoteCursorShape::grab},
{"openhand", RemoteCursorShape::grab},
{"open-hand", RemoteCursorShape::grab},
{"grabbing", RemoteCursorShape::grabbing},
{"closedhand", RemoteCursorShape::grabbing},
{"closed-hand", RemoteCursorShape::grabbing},
{"col-resize", RemoteCursorShape::col_resize},
{"row-resize", RemoteCursorShape::row_resize},
{"n-resize", RemoteCursorShape::n_resize},
{"top-side", RemoteCursorShape::n_resize},
{"top-tee", RemoteCursorShape::n_resize},
{"sb-up-arrow", RemoteCursorShape::n_resize},
{"e-resize", RemoteCursorShape::e_resize},
{"right-side", RemoteCursorShape::e_resize},
{"right-tee", RemoteCursorShape::e_resize},
{"sb-right-arrow", RemoteCursorShape::e_resize},
{"s-resize", RemoteCursorShape::s_resize},
{"bottom-side", RemoteCursorShape::s_resize},
{"bottom-tee", RemoteCursorShape::s_resize},
{"sb-down-arrow", RemoteCursorShape::s_resize},
{"w-resize", RemoteCursorShape::w_resize},
{"left-side", RemoteCursorShape::w_resize},
{"left-tee", RemoteCursorShape::w_resize},
{"sb-left-arrow", RemoteCursorShape::w_resize},
{"ne-resize", RemoteCursorShape::ne_resize},
{"top-right-corner", RemoteCursorShape::ne_resize},
{"ur-angle", RemoteCursorShape::ne_resize},
{"nw-resize", RemoteCursorShape::nw_resize},
{"top-left-corner", RemoteCursorShape::nw_resize},
{"ul-angle", RemoteCursorShape::nw_resize},
{"se-resize", RemoteCursorShape::se_resize},
{"bottom-right-corner", RemoteCursorShape::se_resize},
{"lr-angle", RemoteCursorShape::se_resize},
{"sw-resize", RemoteCursorShape::sw_resize},
{"bottom-left-corner", RemoteCursorShape::sw_resize},
{"ll-angle", RemoteCursorShape::sw_resize},
{"ew-resize", RemoteCursorShape::ew_resize},
{"sb-h-double-arrow", RemoteCursorShape::ew_resize},
{"h-double-arrow", RemoteCursorShape::ew_resize},
{"size-hor", RemoteCursorShape::ew_resize},
{"split-h", RemoteCursorShape::ew_resize},
{"exchange", RemoteCursorShape::ew_resize},
{"ns-resize", RemoteCursorShape::ns_resize},
{"sb-v-double-arrow", RemoteCursorShape::ns_resize},
{"v-double-arrow", RemoteCursorShape::ns_resize},
{"double-arrow", RemoteCursorShape::ns_resize},
{"size-ver", RemoteCursorShape::ns_resize},
{"split-v", RemoteCursorShape::ns_resize},
{"nesw-resize", RemoteCursorShape::nesw_resize},
{"fd-double-arrow", RemoteCursorShape::nesw_resize},
{"size-bdiag", RemoteCursorShape::nesw_resize},
{"nwse-resize", RemoteCursorShape::nwse_resize},
{"bd-double-arrow", RemoteCursorShape::nwse_resize},
{"size-fdiag", RemoteCursorShape::nwse_resize},
{"default", RemoteCursorShape::default_cursor},
{"left-ptr", RemoteCursorShape::default_cursor},
{"left-arrow", RemoteCursorShape::default_cursor},
{"arrow", RemoteCursorShape::default_cursor},
{"top-left-arrow", RemoteCursorShape::default_cursor},
{"right-ptr", RemoteCursorShape::default_cursor},
{"center-ptr", RemoteCursorShape::default_cursor},
{"context-menu", RemoteCursorShape::default_cursor},
};
std::string NormalizeCursorName(const std::string& raw_name) {
std::string normalized;
normalized.reserve(raw_name.size());
for (unsigned char ch : raw_name) {
if (ch == '_' || std::isspace(ch)) {
normalized.push_back('-');
} else {
normalized.push_back(static_cast<char>(std::tolower(ch)));
}
}
return normalized;
}
#if defined(__linux__) && !defined(__APPLE__)
struct CursorCandidate {
RemoteCursorShape shape = RemoteCursorShape::default_cursor;
uint32_t width = 0;
uint32_t height = 0;
uint32_t xhot = 0;
uint32_t yhot = 0;
std::vector<uint32_t> argb;
};
uint8_t Alpha(uint32_t argb) {
return static_cast<uint8_t>((argb >> 24U) & 0xffU);
}
double BilinearAlpha(const CursorCandidate& candidate, double x, double y) {
if (candidate.width == 0 || candidate.height == 0) {
return 0.0;
}
x = std::max(0.0, std::min(x, static_cast<double>(candidate.width - 1)));
y = std::max(0.0, std::min(y, static_cast<double>(candidate.height - 1)));
const uint32_t x0 = static_cast<uint32_t>(std::floor(x));
const uint32_t y0 = static_cast<uint32_t>(std::floor(y));
const uint32_t x1 = std::min(x0 + 1, candidate.width - 1);
const uint32_t y1 = std::min(y0 + 1, candidate.height - 1);
const double fx = x - x0;
const double fy = y - y0;
const auto sample = [&](uint32_t px, uint32_t py) {
return static_cast<double>(
Alpha(candidate.argb[static_cast<size_t>(py) * candidate.width + px]));
};
const double top = sample(x0, y0) * (1.0 - fx) + sample(x1, y0) * fx;
const double bottom = sample(x0, y1) * (1.0 - fx) + sample(x1, y1) * fx;
return top * (1.0 - fy) + bottom * fy;
}
double SimilarityError(const LinuxCursorImageView& image,
const CursorCandidate& candidate) {
if (!image.argb || image.width == 0 || image.height == 0 ||
candidate.argb.empty()) {
return std::numeric_limits<double>::infinity();
}
double alpha_error = 0.0;
for (uint32_t y = 0; y < image.height; ++y) {
const double source_y =
(static_cast<double>(y) + 0.5) * candidate.height / image.height - 0.5;
for (uint32_t x = 0; x < image.width; ++x) {
const double source_x = (static_cast<double>(x) + 0.5) *
candidate.width / image.width -
0.5;
const double expected = BilinearAlpha(candidate, source_x, source_y);
const double actual = static_cast<double>(
Alpha(image.argb[static_cast<size_t>(y) * image.width + x]));
alpha_error += std::abs(actual - expected) / 255.0;
}
}
alpha_error /= static_cast<double>(image.width) * image.height;
const double observed_hot_x =
static_cast<double>(image.xhot) / std::max(1u, image.width);
const double observed_hot_y =
static_cast<double>(image.yhot) / std::max(1u, image.height);
const double candidate_hot_x =
static_cast<double>(candidate.xhot) / std::max(1u, candidate.width);
const double candidate_hot_y =
static_cast<double>(candidate.yhot) / std::max(1u, candidate.height);
const double hotspot_error =
std::abs(observed_hot_x - candidate_hot_x) +
std::abs(observed_hot_y - candidate_hot_y);
const double observed_aspect =
static_cast<double>(image.width) / image.height;
const double candidate_aspect =
static_cast<double>(candidate.width) / candidate.height;
const double aspect_error =
std::abs(std::log(observed_aspect / candidate_aspect));
return alpha_error + hotspot_error * 0.12 + aspect_error * 0.08;
}
bool SameImage(const LinuxCursorImageView& image,
const CursorCandidate& candidate) {
if (image.width != candidate.width || image.height != candidate.height ||
image.xhot != candidate.xhot || image.yhot != candidate.yhot) {
return false;
}
const size_t pixel_count =
static_cast<size_t>(image.width) * static_cast<size_t>(image.height);
return std::equal(image.argb, image.argb + pixel_count,
candidate.argb.begin());
}
#endif
} // namespace
const LinuxCursorAlias* LinuxCursorAliases(size_t* count) {
if (count) {
*count = sizeof(kCursorAliases) / sizeof(kCursorAliases[0]);
}
return kCursorAliases;
}
RemoteCursorShape ShapeFromLinuxCursorName(const std::string& raw_name) {
const std::string name = NormalizeCursorName(raw_name);
for (const auto& alias : kCursorAliases) {
if (name == alias.name) {
return alias.shape;
}
}
// Some toolkits prefix a theme or toolkit name to the standard cursor name.
// Restrict the fallback to separators so names such as "pointer-progress"
// still prefer their exact semantic alias above.
for (const auto& alias : kCursorAliases) {
const std::string suffix = std::string("-") + alias.name;
if (name.size() > suffix.size() &&
name.compare(name.size() - suffix.size(), suffix.size(), suffix) == 0) {
return alias.shape;
}
}
return RemoteCursorShape::default_cursor;
}
#if defined(__linux__) && !defined(__APPLE__)
struct LinuxCursorThemeMatcher::Impl {
Display* display = XOpenDisplay(nullptr);
std::string theme;
int default_size = 24;
std::unordered_map<int, std::vector<CursorCandidate>> candidates_by_size;
Impl() {
if (!display) {
return;
}
if (const char* active_theme = XcursorGetTheme(display)) {
theme = active_theme;
}
const int active_size = XcursorGetDefaultSize(display);
if (active_size > 0) {
default_size = active_size;
}
}
~Impl() {
if (display) {
XCloseDisplay(display);
}
}
const std::vector<CursorCandidate>& Candidates(int requested_size) {
requested_size = std::max(1, requested_size);
auto existing = candidates_by_size.find(requested_size);
if (existing != candidates_by_size.end()) {
return existing->second;
}
std::vector<CursorCandidate> candidates;
const char* theme_name = theme.empty() ? nullptr : theme.c_str();
for (const auto& alias : kCursorAliases) {
XcursorImages* images =
XcursorLibraryLoadImages(alias.name, theme_name, requested_size);
if (!images) {
continue;
}
for (int index = 0; index < images->nimage; ++index) {
const XcursorImage* image = images->images[index];
if (!image || !image->pixels || image->width == 0 ||
image->height == 0) {
continue;
}
CursorCandidate candidate;
candidate.shape = alias.shape;
candidate.width = image->width;
candidate.height = image->height;
candidate.xhot = image->xhot;
candidate.yhot = image->yhot;
const size_t pixel_count = static_cast<size_t>(image->width) *
static_cast<size_t>(image->height);
candidate.argb.assign(image->pixels, image->pixels + pixel_count);
const bool duplicate =
std::any_of(candidates.begin(), candidates.end(),
[&](const CursorCandidate& current) {
return current.shape == candidate.shape &&
current.width == candidate.width &&
current.height == candidate.height &&
current.xhot == candidate.xhot &&
current.yhot == candidate.yhot &&
current.argb == candidate.argb;
});
if (!duplicate) {
candidates.push_back(std::move(candidate));
}
}
XcursorImagesDestroy(images);
}
return candidates_by_size
.emplace(requested_size, std::move(candidates))
.first->second;
}
};
#else
struct LinuxCursorThemeMatcher::Impl {};
#endif
LinuxCursorThemeMatcher::LinuxCursorThemeMatcher()
: impl_(std::make_unique<Impl>()) {}
LinuxCursorThemeMatcher::~LinuxCursorThemeMatcher() = default;
RemoteCursorShape LinuxCursorThemeMatcher::Match(
const LinuxCursorImageView& image) {
#if defined(__linux__) && !defined(__APPLE__)
if (!impl_ || !image.argb || image.width == 0 || image.height == 0) {
return RemoteCursorShape::default_cursor;
}
const int requested_size =
static_cast<int>(std::max(image.width, image.height));
const auto& candidates = impl_->Candidates(requested_size);
for (const auto& candidate : candidates) {
if (SameImage(image, candidate)) {
return candidate.shape;
}
}
double best_error = std::numeric_limits<double>::infinity();
RemoteCursorShape best_shape = RemoteCursorShape::default_cursor;
for (const auto& candidate : candidates) {
const double error = SimilarityError(image, candidate);
if (error < best_error) {
best_error = error;
best_shape = candidate.shape;
}
}
// Scaled theme cursors normally remain well below this threshold, while an
// unrelated custom application cursor should fall back to the default.
return best_error <= 0.20 ? best_shape
: RemoteCursorShape::default_cursor;
#else
(void)image;
return RemoteCursorShape::default_cursor;
#endif
}
} // namespace crossdesk
+49
View File
@@ -0,0 +1,49 @@
#ifndef CROSSDESK_COMMON_LINUX_CURSOR_SHAPE_H_
#define CROSSDESK_COMMON_LINUX_CURSOR_SHAPE_H_
#include <cstddef>
#include <cstdint>
#include <memory>
#include <string>
#include "remote_cursor_shape.h"
namespace crossdesk {
struct LinuxCursorAlias {
const char* name;
RemoteCursorShape shape;
};
const LinuxCursorAlias* LinuxCursorAliases(size_t* count);
RemoteCursorShape ShapeFromLinuxCursorName(const std::string& name);
struct LinuxCursorImageView {
uint32_t width = 0;
uint32_t height = 0;
uint32_t xhot = 0;
uint32_t yhot = 0;
const uint32_t* argb = nullptr;
};
// Matches a compositor-provided cursor bitmap against the active Xcursor
// theme. This is needed on Wayland, where XFixes only observes XWayland
// cursors and cannot report cursors owned by native Wayland surfaces.
class LinuxCursorThemeMatcher {
public:
LinuxCursorThemeMatcher();
~LinuxCursorThemeMatcher();
LinuxCursorThemeMatcher(const LinuxCursorThemeMatcher&) = delete;
LinuxCursorThemeMatcher& operator=(const LinuxCursorThemeMatcher&) = delete;
RemoteCursorShape Match(const LinuxCursorImageView& image);
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
} // namespace crossdesk
#endif // CROSSDESK_COMMON_LINUX_CURSOR_SHAPE_H_
+62
View File
@@ -0,0 +1,62 @@
#include "nv12_scaler.h"
#include <cstddef>
#include <limits>
#include "libyuv/planar_functions.h"
namespace crossdesk {
int ScaleNv12ViaI420(const uint8_t* src_y, int src_stride_y,
const uint8_t* src_uv, int src_stride_uv, int src_width,
int src_height, uint8_t* dst_y, int dst_stride_y,
uint8_t* dst_uv, int dst_stride_uv, int dst_width,
int dst_height, libyuv::FilterMode filtering,
std::vector<uint8_t>* scratch_buffer) {
if (!src_y || !src_uv || !dst_y || !dst_uv || !scratch_buffer ||
src_width <= 0 || src_height <= 0 || dst_width <= 0 || dst_height <= 0 ||
(src_width & 1) != 0 || (src_height & 1) != 0 || (dst_width & 1) != 0 ||
(dst_height & 1) != 0) {
return -1;
}
const int src_chroma_width = src_width / 2;
const int src_chroma_height = src_height / 2;
const int dst_chroma_width = dst_width / 2;
const int dst_chroma_height = dst_height / 2;
const size_t src_chroma_size =
static_cast<size_t>(src_chroma_width) * src_chroma_height;
const size_t dst_chroma_size =
static_cast<size_t>(dst_chroma_width) * dst_chroma_height;
const size_t max_size = std::numeric_limits<size_t>::max();
if (src_chroma_size > max_size / 2 || dst_chroma_size > max_size / 2) {
return -1;
}
const size_t src_planes_size = 2 * src_chroma_size;
const size_t dst_planes_size = 2 * dst_chroma_size;
if (src_planes_size > max_size - dst_planes_size) {
return -1;
}
scratch_buffer->resize(src_planes_size + dst_planes_size);
uint8_t* src_u = scratch_buffer->data();
uint8_t* src_v = src_u + src_chroma_size;
uint8_t* dst_u = src_v + src_chroma_size;
uint8_t* dst_v = dst_u + dst_chroma_size;
libyuv::SplitUVPlane(src_uv, src_stride_uv, src_u, src_chroma_width, src_v,
src_chroma_width, src_chroma_width, src_chroma_height);
const int result = libyuv::I420Scale(
src_y, src_stride_y, src_u, src_chroma_width, src_v, src_chroma_width,
src_width, src_height, dst_y, dst_stride_y, dst_u, dst_chroma_width,
dst_v, dst_chroma_width, dst_width, dst_height, filtering);
if (result != 0) {
return result;
}
libyuv::MergeUVPlane(dst_u, dst_chroma_width, dst_v, dst_chroma_width, dst_uv,
dst_stride_uv, dst_chroma_width, dst_chroma_height);
return 0;
}
} // namespace crossdesk
+23
View File
@@ -0,0 +1,23 @@
#ifndef CROSSDESK_COMMON_NV12_SCALER_H_
#define CROSSDESK_COMMON_NV12_SCALER_H_
#include <cstdint>
#include <vector>
#include "libyuv/scale.h"
namespace crossdesk {
// Scales NV12 by separating the interleaved chroma plane before filtering.
// This avoids the downward chroma rounding bias in libyuv::NV12Scale while
// preserving the requested filtering quality.
int ScaleNv12ViaI420(const uint8_t* src_y, int src_stride_y,
const uint8_t* src_uv, int src_stride_uv, int src_width,
int src_height, uint8_t* dst_y, int dst_stride_y,
uint8_t* dst_uv, int dst_stride_uv, int dst_width,
int dst_height, libyuv::FilterMode filtering,
std::vector<uint8_t>* scratch_buffer);
} // namespace crossdesk
#endif // CROSSDESK_COMMON_NV12_SCALER_H_
+45
View File
@@ -0,0 +1,45 @@
#ifndef CROSSDESK_COMMON_REMOTE_CURSOR_SHAPE_H_
#define CROSSDESK_COMMON_REMOTE_CURSOR_SHAPE_H_
#include <cstdint>
namespace crossdesk {
// Keep these values aligned with Slint's MouseCursor enum. The wire protocol
// intentionally carries a semantic cursor instead of a platform handle so a
// Windows, macOS or Linux host can control a different desktop platform.
enum class RemoteCursorShape : uint8_t {
default_cursor = 0,
none,
help,
pointer,
progress,
wait,
crosshair,
text,
alias,
copy,
move,
no_drop,
not_allowed,
grab,
grabbing,
col_resize,
row_resize,
n_resize,
e_resize,
s_resize,
w_resize,
ne_resize,
nw_resize,
se_resize,
sw_resize,
ew_resize,
ns_resize,
nesw_resize,
nwse_resize,
};
} // namespace crossdesk
#endif // CROSSDESK_COMMON_REMOTE_CURSOR_SHAPE_H_
+42
View File
@@ -0,0 +1,42 @@
#include "shared_cursor_state.h"
#include <mutex>
namespace crossdesk {
namespace {
std::mutex g_cursor_state_mutex;
SharedCursorState g_cursor_state;
bool g_cursor_state_available = false;
} // namespace
void PublishSharedCursorState(bool visible, RemoteCursorShape shape) {
std::lock_guard<std::mutex> lock(g_cursor_state_mutex);
++g_cursor_state.generation;
g_cursor_state.visible = visible;
g_cursor_state.shape = visible ? shape : RemoteCursorShape::none;
g_cursor_state_available = true;
}
bool GetSharedCursorState(SharedCursorState* state) {
if (!state) {
return false;
}
std::lock_guard<std::mutex> lock(g_cursor_state_mutex);
if (!g_cursor_state_available) {
return false;
}
*state = g_cursor_state;
return true;
}
void ClearSharedCursorState() {
std::lock_guard<std::mutex> lock(g_cursor_state_mutex);
++g_cursor_state.generation;
g_cursor_state.visible = false;
g_cursor_state.shape = RemoteCursorShape::none;
g_cursor_state_available = false;
}
} // namespace crossdesk
+22
View File
@@ -0,0 +1,22 @@
#ifndef CROSSDESK_COMMON_SHARED_CURSOR_STATE_H_
#define CROSSDESK_COMMON_SHARED_CURSOR_STATE_H_
#include <cstdint>
#include "remote_cursor_shape.h"
namespace crossdesk {
struct SharedCursorState {
uint64_t generation = 0;
bool visible = false;
RemoteCursorShape shape = RemoteCursorShape::default_cursor;
};
void PublishSharedCursorState(bool visible, RemoteCursorShape shape);
bool GetSharedCursorState(SharedCursorState* state);
void ClearSharedCursorState();
} // namespace crossdesk
#endif // CROSSDESK_COMMON_SHARED_CURSOR_STATE_H_
+22
View File
@@ -0,0 +1,22 @@
#ifndef _STREAM_NAMES_H_
#define _STREAM_NAMES_H_
#include <cstddef>
namespace crossdesk::protocol {
inline constexpr char kAudioStream[] = "control_audio";
inline constexpr char kDataStream[] = "data";
inline constexpr char kMouseStream[] = "mouse";
inline constexpr char kKeyboardStream[] = "keyboard";
inline constexpr char kControlStream[] = "control_data";
inline constexpr char kFileStream[] = "file";
inline constexpr char kFileFeedbackStream[] = "file_feedback";
inline constexpr char kClipboardStream[] = "clipboard";
inline constexpr std::size_t kFileChunkSize = 64 * 1024;
inline constexpr std::size_t kMaxClipboardBytes = 128 * 1024;
} // namespace crossdesk::protocol
#endif
+15
View File
@@ -0,0 +1,15 @@
#ifndef CROSSDESK_COMMON_WINDOWS_INPUT_MARKER_H_
#define CROSSDESK_COMMON_WINDOWS_INPUT_MARKER_H_
#include <cstdint>
namespace crossdesk {
// SendInput copies dwExtraInfo into KBDLLHOOKSTRUCT. Tag CrossDesk-generated
// keyboard input so the controller hook can ignore only its own injections
// while still accepting input from accessibility tools or remote sessions.
inline constexpr std::uintptr_t kInjectedKeyboardInputMarker = 0x4353444B;
} // namespace crossdesk
#endif // CROSSDESK_COMMON_WINDOWS_INPUT_MARKER_H_
+9 -22
View File
@@ -29,7 +29,13 @@ int ConfigCenter::Load() {
return -1;
}
bool persist_turn_mode_migration = false;
bool persist_config_migration = false;
persist_config_migration |= ini_.Delete(section_, "video_content_type");
persist_config_migration |= ini_.Delete(section_, "screen_content");
persist_config_migration |=
ini_.Delete(section_, "enable_desktop_quality_optimization");
persist_config_migration |=
ini_.Delete(section_, "enable_minimize_to_tray");
const long language_value =
ini_.GetLongValue(section_, "language", static_cast<long>(language_));
@@ -52,7 +58,6 @@ int ConfigCenter::Load() {
hardware_video_codec_ = ini_.GetBoolValue(section_, "hardware_video_codec",
hardware_video_codec_);
const char* turn_mode_value = ini_.GetValue(section_, "turn_mode", nullptr);
if (turn_mode_value != nullptr && strlen(turn_mode_value) > 0) {
const long parsed_turn_mode = ini_.GetLongValue(
@@ -70,7 +75,7 @@ int ConfigCenter::Load() {
turn_mode_ = legacy_enable_turn ? TURN_MODE::AUTO_UDP_TCP
: TURN_MODE::DISABLED;
ini_.SetLongValue(section_, "turn_mode", static_cast<long>(turn_mode_));
persist_turn_mode_migration = true;
persist_config_migration = true;
}
enable_srtp_ = ini_.GetBoolValue(section_, "enable_srtp", enable_srtp_);
enable_self_hosted_ =
@@ -106,8 +111,6 @@ int ConfigCenter::Load() {
enable_autostart_ =
ini_.GetBoolValue(section_, "enable_autostart", enable_autostart_);
enable_daemon_ = ini_.GetBoolValue(section_, "enable_daemon", enable_daemon_);
enable_minimize_to_tray_ = ini_.GetBoolValue(
section_, "enable_minimize_to_tray", enable_minimize_to_tray_);
portable_service_prompt_suppressed_ =
ini_.GetBoolValue(section_, "portable_service_prompt_suppressed",
portable_service_prompt_suppressed_);
@@ -121,8 +124,7 @@ int ConfigCenter::Load() {
file_transfer_save_path_ = "";
}
if (persist_turn_mode_migration &&
ini_.SaveFile(config_path_.c_str()) < 0) {
if (persist_config_migration && ini_.SaveFile(config_path_.c_str()) < 0) {
return -1;
}
@@ -155,8 +157,6 @@ int ConfigCenter::Save() {
ini_.SetBoolValue(section_, "enable_autostart", enable_autostart_);
ini_.SetBoolValue(section_, "enable_daemon", enable_daemon_);
ini_.SetBoolValue(section_, "enable_minimize_to_tray",
enable_minimize_to_tray_);
ini_.SetBoolValue(section_, "portable_service_prompt_suppressed",
portable_service_prompt_suppressed_);
@@ -336,17 +336,6 @@ int ConfigCenter::SetSelfHosted(bool enable_self_hosted) {
return 0;
}
int ConfigCenter::SetMinimizeToTray(bool enable_minimize_to_tray) {
enable_minimize_to_tray_ = enable_minimize_to_tray;
ini_.SetBoolValue(section_, "enable_minimize_to_tray",
enable_minimize_to_tray_);
SI_Error rc = ini_.SaveFile(config_path_.c_str());
if (rc < 0) {
return -1;
}
return 0;
}
int ConfigCenter::SetAutostart(bool enable_autostart) {
enable_autostart_ = enable_autostart;
bool success = false;
@@ -446,8 +435,6 @@ int ConfigCenter::GetDefaultCoturnServerPort() const {
bool ConfigCenter::IsSelfHosted() const { return enable_self_hosted_; }
bool ConfigCenter::IsMinimizeToTray() const { return enable_minimize_to_tray_; }
bool ConfigCenter::IsEnableAutostart() const { return enable_autostart_; }
bool ConfigCenter::IsEnableDaemon() const { return enable_daemon_; }
-3
View File
@@ -43,7 +43,6 @@ class ConfigCenter {
int SetServerPort(int signal_server_port);
int SetCoturnServerPort(int coturn_server_port);
int SetSelfHosted(bool enable_self_hosted);
int SetMinimizeToTray(bool enable_minimize_to_tray);
int SetAutostart(bool enable_autostart);
int SetDaemon(bool enable_daemon);
int SetPortableServicePromptSuppressed(bool suppressed);
@@ -66,7 +65,6 @@ class ConfigCenter {
int GetDefaultSignalServerPort() const;
int GetDefaultCoturnServerPort() const;
bool IsSelfHosted() const;
bool IsMinimizeToTray() const;
bool IsEnableAutostart() const;
bool IsEnableDaemon() const;
bool IsPortableServicePromptSuppressed() const;
@@ -94,7 +92,6 @@ class ConfigCenter {
int coturn_server_port_ = 0;
int coturn_server_port_default_ = 3478;
bool enable_self_hosted_ = false;
bool enable_minimize_to_tray_ = false;
bool enable_autostart_ = false;
bool enable_daemon_ = false;
bool portable_service_prompt_suppressed_ = false;
+66 -214
View File
@@ -7,19 +7,16 @@
#ifndef _DEVICE_CONTROLLER_H_
#define _DEVICE_CONTROLLER_H_
#include <stdio.h>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <nlohmann/json.hpp>
#include <string>
#include "display_info.h"
using json = nlohmann::json;
#include "remote_cursor_shape.h"
namespace crossdesk {
typedef enum {
enum ControlType {
invalid = -1,
mouse = 0,
keyboard = 1,
audio_capture = 2,
@@ -28,8 +25,10 @@ typedef enum {
service_status = 5,
service_command = 6,
keyboard_state = 7,
} ControlType;
typedef enum {
cursor_state = 8,
};
enum MouseFlag {
move = 0,
left_down,
left_up,
@@ -38,64 +37,85 @@ typedef enum {
middle_down,
middle_up,
wheel_vertical,
wheel_horizontal
} MouseFlag;
typedef enum { key_down = 0, key_up } KeyFlag;
typedef enum { send_sas = 0, lock_workstation } ServiceCommandFlag;
typedef struct {
wheel_horizontal,
};
enum KeyFlag { key_down = 0, key_up };
enum ServiceCommandFlag { send_sas = 0, lock_workstation };
struct Mouse {
float x;
float y;
int s;
MouseFlag flag;
} Mouse;
};
typedef struct {
size_t key_value;
struct Key {
std::size_t key_value;
uint32_t scan_code;
bool extended;
KeyFlag flag;
} Key;
};
inline constexpr size_t kMaxKeyboardStateKeys = 32;
inline constexpr std::size_t kMaxKeyboardStateKeys = 32;
typedef struct {
size_t key_value;
struct KeyboardStateKey {
std::size_t key_value;
uint32_t scan_code;
bool extended;
} KeyboardStateKey;
};
typedef struct {
struct KeyboardState {
uint32_t seq;
size_t pressed_count;
std::size_t pressed_count;
KeyboardStateKey pressed_keys[kMaxKeyboardStateKeys];
} KeyboardState;
};
typedef struct {
struct CursorState {
uint32_t seq;
bool visible;
RemoteCursorShape shape;
bool position_valid;
float x;
float y;
// Normalized displacement from the input hotspot to the visible cursor
// anchor. This is presentation metadata and must not affect input mapping.
float visual_offset_x;
float visual_offset_y;
int display_id;
// Whether receivers should apply the position fields in this message.
// Shape-only updates set this to false so cursor appearance can remain
// responsive while position feedback to the input source is suppressed.
bool position_update;
};
struct HostInfo {
char host_name[64];
size_t host_name_size;
std::size_t host_name_size;
char** display_list;
size_t display_num;
std::size_t display_num;
int* left;
int* top;
int* right;
int* bottom;
} HostInfo;
};
typedef struct {
struct ServiceStatus {
bool available;
char interactive_stage[32];
} ServiceStatus;
};
typedef struct {
struct ServiceCommand {
ServiceCommandFlag flag;
} ServiceCommand;
};
struct RemoteAction {
ControlType type;
ControlType type = ControlType::invalid;
union {
Mouse m;
Key k;
KeyboardState ks;
CursorState cs;
HostInfo i;
bool a;
int d;
@@ -103,193 +123,25 @@ struct RemoteAction {
ServiceCommand c;
};
// parse
std::string to_json() const { return ToJson(*this); }
std::string to_json() const;
bool from_json(const std::string& json_string);
bool from_json(const std::string& json_str) {
RemoteAction temp;
if (!FromJson(json_str, temp)) return false;
*this = temp;
return true;
}
static std::string ToJson(const RemoteAction& a) {
json j;
j["type"] = a.type;
switch (a.type) {
case ControlType::mouse:
j["mouse"] = {
{"x", a.m.x}, {"y", a.m.y}, {"s", a.m.s}, {"flag", a.m.flag}};
break;
case ControlType::keyboard:
j["keyboard"] = {{"key_value", a.k.key_value},
{"scan_code", a.k.scan_code},
{"extended", a.k.extended},
{"flag", a.k.flag}};
break;
case ControlType::keyboard_state: {
json keys = json::array();
const size_t pressed_count =
a.ks.pressed_count < kMaxKeyboardStateKeys
? a.ks.pressed_count
: kMaxKeyboardStateKeys;
for (size_t idx = 0; idx < pressed_count; ++idx) {
keys.push_back({{"key_value", a.ks.pressed_keys[idx].key_value},
{"scan_code", a.ks.pressed_keys[idx].scan_code},
{"extended", a.ks.pressed_keys[idx].extended}});
}
j["keyboard_state"] = {{"seq", a.ks.seq}, {"pressed_keys", keys}};
break;
}
case ControlType::audio_capture:
j["audio_capture"] = a.a;
break;
case ControlType::display_id:
j["display_id"] = a.d;
break;
case ControlType::service_status:
j["service_status"] = {{"available", a.ss.available},
{"interactive_stage", a.ss.interactive_stage}};
break;
case ControlType::service_command:
j["service_command"] = {{"flag", a.c.flag}};
break;
case ControlType::host_infomation: {
json displays = json::array();
for (size_t idx = 0; idx < a.i.display_num; idx++) {
displays.push_back(
{{"name", a.i.display_list ? a.i.display_list[idx] : ""},
{"left", a.i.left ? a.i.left[idx] : 0},
{"top", a.i.top ? a.i.top[idx] : 0},
{"right", a.i.right ? a.i.right[idx] : 0},
{"bottom", a.i.bottom ? a.i.bottom[idx] : 0}});
}
j["host_info"] = {{"host_name", a.i.host_name},
{"display_num", a.i.display_num},
{"displays", displays}};
break;
}
}
return j.dump();
}
static bool FromJson(const std::string& json_str, RemoteAction& out) {
try {
json j = json::parse(json_str);
out.type = (ControlType)j.at("type").get<int>();
switch (out.type) {
case ControlType::mouse:
out.m.x = j.at("mouse").at("x").get<float>();
out.m.y = j.at("mouse").at("y").get<float>();
out.m.s = j.at("mouse").at("s").get<int>();
out.m.flag = (MouseFlag)j.at("mouse").at("flag").get<int>();
break;
case ControlType::keyboard:
out.k.key_value = j.at("keyboard").at("key_value").get<size_t>();
out.k.scan_code =
j.at("keyboard").value("scan_code", static_cast<uint32_t>(0));
out.k.extended = j.at("keyboard").value("extended", false);
out.k.flag = (KeyFlag)j.at("keyboard").at("flag").get<int>();
break;
case ControlType::keyboard_state: {
const auto& keyboard_state_json = j.at("keyboard_state");
out.ks.seq = keyboard_state_json.value("seq", 0u);
out.ks.pressed_count = 0;
const auto keys_json =
keyboard_state_json.value("pressed_keys", json::array());
if (!keys_json.is_array()) {
break;
}
const size_t count =
keys_json.size() < kMaxKeyboardStateKeys
? keys_json.size()
: kMaxKeyboardStateKeys;
for (size_t idx = 0; idx < count; ++idx) {
const auto& key_json = keys_json[idx];
out.ks.pressed_keys[idx].key_value =
key_json.at("key_value").get<size_t>();
out.ks.pressed_keys[idx].scan_code =
key_json.value("scan_code", static_cast<uint32_t>(0));
out.ks.pressed_keys[idx].extended =
key_json.value("extended", false);
}
out.ks.pressed_count = count;
break;
}
case ControlType::audio_capture:
out.a = j.at("audio_capture").get<bool>();
break;
case ControlType::display_id:
out.d = j.at("display_id").get<int>();
break;
case ControlType::service_status: {
const auto& service_status_json = j.at("service_status");
out.ss.available = service_status_json.value("available", false);
std::string interactive_stage =
service_status_json.value("interactive_stage", std::string());
std::strncpy(out.ss.interactive_stage, interactive_stage.c_str(),
sizeof(out.ss.interactive_stage) - 1);
out.ss.interactive_stage[sizeof(out.ss.interactive_stage) - 1] = '\0';
break;
}
case ControlType::service_command:
out.c.flag = static_cast<ServiceCommandFlag>(
j.at("service_command").at("flag").get<int>());
break;
case ControlType::host_infomation: {
std::string host_name =
j.at("host_info").at("host_name").get<std::string>();
strncpy(out.i.host_name, host_name.c_str(), sizeof(out.i.host_name));
out.i.host_name[sizeof(out.i.host_name) - 1] = '\0';
out.i.host_name_size = host_name.size();
out.i.display_num = j.at("host_info").at("display_num").get<size_t>();
auto displays = j.at("host_info").at("displays");
out.i.display_list =
(char**)malloc(out.i.display_num * sizeof(char*));
out.i.left = (int*)malloc(out.i.display_num * sizeof(int));
out.i.top = (int*)malloc(out.i.display_num * sizeof(int));
out.i.right = (int*)malloc(out.i.display_num * sizeof(int));
out.i.bottom = (int*)malloc(out.i.display_num * sizeof(int));
for (size_t idx = 0; idx < out.i.display_num; idx++) {
std::string name = displays[idx].at("name").get<std::string>();
out.i.display_list[idx] = (char*)malloc(name.size() + 1);
strcpy(out.i.display_list[idx], name.c_str());
out.i.left[idx] = displays[idx].at("left").get<int>();
out.i.top[idx] = displays[idx].at("top").get<int>();
out.i.right[idx] = displays[idx].at("right").get<int>();
out.i.bottom[idx] = displays[idx].at("bottom").get<int>();
}
break;
}
}
return true;
} catch (const std::exception& e) {
printf("Failed to parse RemoteAction JSON: %s\n", e.what());
return false;
}
}
static std::string ToJson(const RemoteAction& action);
static bool FromJson(const std::string& json_string, RemoteAction& output);
};
// Releases the dynamically allocated display arrays held by host information.
// Other RemoteAction variants do not own memory and are left unchanged.
void FreeRemoteAction(RemoteAction& action);
// int key_code, bool is_down, uint32_t scan_code, bool extended
typedef void (*OnKeyAction)(int, bool, uint32_t, bool, void*);
using OnKeyAction = void (*)(int, bool, uint32_t, bool, void*);
class DeviceController {
public:
virtual ~DeviceController() {}
public:
// virtual int Init(int screen_width, int screen_height);
// virtual int Destroy();
// virtual int SendMouseCommand(RemoteAction remote_action);
// virtual int Hook();
// virtual int Unhook();
virtual ~DeviceController() = default;
};
} // namespace crossdesk
#endif
@@ -8,6 +8,7 @@
#include <poll.h>
#include "keyboard_converter.h"
#include "linux_evdev_keycode.h"
#include "platform.h"
#include "rd_log.h"
#include "windows_key_metadata.h"
@@ -17,6 +18,40 @@ namespace crossdesk {
static OnKeyAction g_on_key_action = nullptr;
static void* g_user_ptr = nullptr;
static KeyCode ResolveX11Keycode(Display* display, int key_code,
uint32_t scan_code, bool extended) {
if (!display) {
return 0;
}
const auto key_it = vkCodeToX11KeySym.find(key_code);
if (key_it != vkCodeToX11KeySym.end()) {
const KeyCode x11_keycode =
XKeysymToKeycode(display, static_cast<KeySym>(key_it->second));
if (x11_keycode != 0) {
return x11_keycode;
}
}
// Some controllers can preserve the physical Windows scan code even when
// they cannot resolve a Windows virtual-key value. Xorg's evdev keycodes use
// the Linux input code plus the protocol-reserved offset of 8.
const int evdev_keycode =
ResolveLinuxEvdevKeycodeFromWindowsKey(key_code, scan_code, extended);
if (evdev_keycode < 0) {
return 0;
}
int min_keycode = 0;
int max_keycode = 0;
XDisplayKeycodes(display, &min_keycode, &max_keycode);
const int x11_keycode = evdev_keycode + 8;
if (x11_keycode < min_keycode || x11_keycode > max_keycode) {
return 0;
}
return static_cast<KeyCode>(x11_keycode);
}
static KeySym NormalizeKeySym(KeySym key_sym) {
if (key_sym >= XK_a && key_sym <= XK_z) {
return key_sym - XK_a + XK_A;
@@ -62,7 +97,16 @@ KeyboardCapturer::KeyboardCapturer()
display_ = XOpenDisplay(nullptr);
if (!display_) {
LOG_ERROR("Failed to open X display.");
return;
}
int event_base = 0;
int error_base = 0;
int major_version = 0;
int minor_version = 0;
x11_xtest_available_ =
XTestQueryExtension(display_, &event_base, &error_base, &major_version,
&minor_version) != 0;
}
KeyboardCapturer::~KeyboardCapturer() {
@@ -70,8 +114,10 @@ KeyboardCapturer::~KeyboardCapturer() {
CleanupWaylandPortal();
if (display_) {
std::lock_guard<std::mutex> lock(x11_injection_mutex_);
XCloseDisplay(display_);
display_ = nullptr;
x11_xtest_available_ = false;
}
}
@@ -156,8 +202,6 @@ int KeyboardCapturer::Unhook() {
int KeyboardCapturer::SendKeyboardCommand(int key_code, bool is_down,
uint32_t scan_code, bool extended) {
(void)scan_code;
(void)extended;
if (IsWaylandSession()) {
if (!use_wayland_portal_ && !wayland_init_attempted_) {
wayland_init_attempted_ = true;
@@ -181,12 +225,33 @@ int KeyboardCapturer::SendKeyboardCommand(int key_code, bool is_down,
return -1;
}
if (vkCodeToX11KeySym.find(key_code) != vkCodeToX11KeySym.end()) {
int x11_key_code = vkCodeToX11KeySym[key_code];
KeyCode keycode = XKeysymToKeycode(display_, x11_key_code);
XTestFakeKeyEvent(display_, keycode, is_down, CurrentTime);
XFlush(display_);
std::lock_guard<std::mutex> lock(x11_injection_mutex_);
if (!x11_xtest_available_) {
LOG_ERROR("XTest extension not available for keyboard injection");
return -2;
}
const KeyCode x11_keycode =
ResolveX11Keycode(display_, key_code, scan_code, extended);
if (x11_keycode == 0) {
LOG_WARN(
"Cannot map remote keyboard event to X11 keycode, vk_code={}, "
"scan_code={}, extended={}",
key_code, scan_code, extended);
return -3;
}
if (!XTestFakeKeyEvent(display_, x11_keycode, is_down, CurrentTime)) {
LOG_ERROR(
"XTest keyboard injection failed, vk_code={}, scan_code={}, "
"extended={}, x11_keycode={}, is_down={}",
key_code, scan_code, extended, static_cast<int>(x11_keycode), is_down);
return -4;
}
// Complete the request before reporting success to the keyboard-state
// reconciler. This also makes X11 protocol errors observable immediately.
XSync(display_, False);
return 0;
}
} // namespace crossdesk
@@ -10,6 +10,7 @@
#include <atomic>
#include <cstdint>
#include <functional>
#include <mutex>
#include <string>
#include <thread>
@@ -49,6 +50,8 @@ class KeyboardCapturer : public DeviceController {
unsigned long root_;
std::atomic<bool> running_;
std::thread event_thread_;
std::mutex x11_injection_mutex_;
bool x11_xtest_available_ = false;
bool use_wayland_portal_ = false;
bool wayland_init_attempted_ = false;
DBusConnection* dbus_connection_ = nullptr;
@@ -611,6 +611,7 @@ int KeyboardCapturer::SendWaylandKeyboardCommand(int key_code, bool is_down,
// Prefer keycode injection to preserve physical-key semantics and avoid
// implicit Shift interpretation for uppercase keysyms.
if (display_) {
std::lock_guard<std::mutex> lock(x11_injection_mutex_);
const int keysym = key_it->second;
const KeyCode x11_keycode =
XKeysymToKeycode(display_, static_cast<KeySym>(keysym));
@@ -1,5 +1,6 @@
#include "keyboard_capturer.h"
#include <cstdint>
#include <unordered_map>
#include "keyboard_converter.h"
@@ -10,6 +11,7 @@ namespace crossdesk {
static OnKeyAction g_on_key_action = nullptr;
static void* g_user_ptr = nullptr;
static std::unordered_map<int, int> g_unmapped_keycode_to_vk;
constexpr int64_t kCrossDeskInjectedKeyboardEvent = 0x43524f5353444553;
static int VkCodeFromUnicode(UniChar ch) {
if (ch >= 'a' && ch <= 'z') {
@@ -103,6 +105,10 @@ static int ResolveVkCodeFromMacEvent(CGEventRef event, CGKeyCode key_code,
CGEventRef eventCallback(CGEventTapProxy proxy, CGEventType type,
CGEventRef event, void* userInfo) {
(void)proxy;
if (CGEventGetIntegerValueField(event, kCGEventSourceUserData) ==
kCrossDeskInjectedKeyboardEvent) {
return event;
}
if (!g_on_key_action) {
return event;
}
@@ -302,6 +308,8 @@ int KeyboardCapturer::SendKeyboardCommand(int key_code, bool is_down,
}
CGEventSetFlags(event, ToCGEventFlags(injected_flags));
CGEventSetIntegerValueField(event, kCGEventSourceUserData,
kCrossDeskInjectedKeyboardEvent);
CGEventPost(kCGHIDEventTap, event);
CFRelease(event);
@@ -314,6 +322,8 @@ int KeyboardCapturer::SendKeyboardCommand(int key_code, bool is_down,
LOG_ERROR("CGEventCreateKeyboardEvent failed for fn release");
return -1;
}
CGEventSetIntegerValueField(fn_release_event, kCGEventSourceUserData,
kCrossDeskInjectedKeyboardEvent);
CGEventPost(kCGHIDEventTap, fn_release_event);
CFRelease(fn_release_event);
}
@@ -1,36 +1,17 @@
#include "keyboard_capturer.h"
#include <hidusage.h>
#include "rd_log.h"
#include "windows_input_marker.h"
namespace crossdesk {
namespace {
static OnKeyAction g_on_key_action = nullptr;
static void* g_user_ptr = nullptr;
constexpr wchar_t kRawInputWindowClassName[] =
L"CrossDeskKeyboardRawInputWindow";
static int NormalizeModifierVkCode(const KBDLLHOOKSTRUCT* kb_data) {
if (kb_data == nullptr) {
return -1;
}
if (kb_data->vkCode != VK_SHIFT && kb_data->vkCode != VK_CONTROL &&
kb_data->vkCode != VK_MENU) {
return static_cast<int>(kb_data->vkCode);
}
UINT scan_code = static_cast<UINT>(kb_data->scanCode & 0xFF);
if ((kb_data->flags & LLKHF_EXTENDED) != 0) {
scan_code |= 0xE000;
}
const UINT normalized_vk = MapVirtualKeyW(scan_code, MAPVK_VSC_TO_VK_EX);
if (normalized_vk != 0) {
return static_cast<int>(normalized_vk);
}
return static_cast<int>(kb_data->vkCode);
}
static bool PreferSideSpecificVkInjection(int key_code) {
bool PreferSideSpecificVkInjection(int key_code) {
switch (key_code) {
case VK_LSHIFT:
case VK_RSHIFT:
@@ -46,55 +27,247 @@ static bool PreferSideSpecificVkInjection(int key_code) {
}
}
LRESULT CALLBACK KeyboardProc(int nCode, WPARAM wParam, LPARAM lParam) {
if (nCode == HC_ACTION && g_on_key_action) {
KBDLLHOOKSTRUCT* kbData = reinterpret_cast<KBDLLHOOKSTRUCT*>(lParam);
const int key_code = NormalizeModifierVkCode(kbData);
if (wParam == WM_KEYDOWN || wParam == WM_SYSKEYDOWN) {
g_on_key_action(key_code, true, kbData->scanCode,
(kbData->flags & LLKHF_EXTENDED) != 0, g_user_ptr);
} else if (wParam == WM_KEYUP || wParam == WM_SYSKEYUP) {
g_on_key_action(key_code, false, kbData->scanCode,
(kbData->flags & LLKHF_EXTENDED) != 0, g_user_ptr);
}
return 1;
}
return CallNextHookEx(NULL, nCode, wParam, lParam);
}
} // namespace
KeyboardCapturer::KeyboardCapturer() {}
KeyboardCapturer::~KeyboardCapturer() {}
KeyboardCapturer::~KeyboardCapturer() { Unhook(); }
int KeyboardCapturer::Hook(OnKeyAction on_key_action, void* user_ptr) {
g_on_key_action = on_key_action;
g_user_ptr = user_ptr;
if (capture_thread_.joinable()) {
return 0;
}
keyboard_hook_ = SetWindowsHookEx(WH_KEYBOARD_LL, KeyboardProc, NULL, 0);
if (!keyboard_hook_) {
LOG_ERROR("Failed to install keyboard hook");
on_key_action_ = on_key_action;
user_ptr_ = user_ptr;
{
std::lock_guard<std::mutex> lock(capture_state_mutex_);
capture_thread_id_ = 0;
capture_start_complete_ = false;
capture_start_succeeded_ = false;
}
capture_thread_ = std::thread(&KeyboardCapturer::RawInputThreadMain, this);
std::unique_lock<std::mutex> lock(capture_state_mutex_);
capture_start_condition_.wait(
lock, [this] { return capture_start_complete_; });
const bool capture_started = capture_start_succeeded_;
lock.unlock();
if (!capture_started) {
if (capture_thread_.joinable()) {
capture_thread_.join();
}
on_key_action_ = nullptr;
user_ptr_ = nullptr;
return -1;
}
return 0;
}
int KeyboardCapturer::Unhook() {
if (keyboard_hook_) {
g_on_key_action = nullptr;
g_user_ptr = nullptr;
UnhookWindowsHookEx(keyboard_hook_);
keyboard_hook_ = nullptr;
DWORD capture_thread_id = 0;
{
std::lock_guard<std::mutex> lock(capture_state_mutex_);
capture_thread_id = capture_thread_id_;
}
if (capture_thread_id != 0 &&
!PostThreadMessageW(capture_thread_id, WM_QUIT, 0, 0)) {
LOG_WARN("Failed to stop keyboard raw input thread, thread_id={}, error={}",
capture_thread_id, GetLastError());
}
if (capture_thread_.joinable()) {
capture_thread_.join();
}
on_key_action_ = nullptr;
user_ptr_ = nullptr;
return 0;
}
// apply remote keyboard commands to the local machine
void KeyboardCapturer::RawInputThreadMain() {
const DWORD thread_id = GetCurrentThreadId();
MSG message{};
PeekMessageW(&message, nullptr, WM_USER, WM_USER, PM_NOREMOVE);
const bool capture_started = CreateRawInputWindow();
{
std::lock_guard<std::mutex> lock(capture_state_mutex_);
capture_thread_id_ = thread_id;
capture_start_succeeded_ = capture_started;
capture_start_complete_ = true;
}
capture_start_condition_.notify_one();
if (!capture_started) {
std::lock_guard<std::mutex> lock(capture_state_mutex_);
capture_thread_id_ = 0;
return;
}
LOG_INFO("Keyboard raw input capture started, thread_id={}", thread_id);
while (true) {
const BOOL get_message_result = GetMessageW(&message, nullptr, 0, 0);
if (get_message_result <= 0) {
if (get_message_result < 0) {
LOG_WARN("Keyboard raw input message loop failed, thread_id={}, "
"error={}",
thread_id, GetLastError());
}
break;
}
TranslateMessage(&message);
DispatchMessageW(&message);
}
DestroyRawInputWindow();
{
std::lock_guard<std::mutex> lock(capture_state_mutex_);
capture_thread_id_ = 0;
}
LOG_INFO("Keyboard raw input capture stopped, thread_id={}", thread_id);
}
bool KeyboardCapturer::CreateRawInputWindow() {
const HINSTANCE instance = GetModuleHandleW(nullptr);
WNDCLASSEXW window_class{};
window_class.cbSize = sizeof(window_class);
window_class.lpfnWndProc = &KeyboardCapturer::RawInputWindowProc;
window_class.hInstance = instance;
window_class.lpszClassName = kRawInputWindowClassName;
if (RegisterClassExW(&window_class) == 0) {
const DWORD error = GetLastError();
if (error != ERROR_CLASS_ALREADY_EXISTS) {
LOG_WARN("Failed to register keyboard raw input window class, error={}",
error);
return false;
}
}
raw_input_window_ = CreateWindowExW(
0, kRawInputWindowClassName, L"", 0, 0, 0, 0, 0, HWND_MESSAGE, nullptr,
instance, this);
if (!raw_input_window_) {
LOG_WARN("Failed to create keyboard raw input window, error={}",
GetLastError());
return false;
}
RAWINPUTDEVICE keyboard_device{};
keyboard_device.usUsagePage = HID_USAGE_PAGE_GENERIC;
keyboard_device.usUsage = HID_USAGE_GENERIC_KEYBOARD;
keyboard_device.dwFlags = RIDEV_DEVNOTIFY | RIDEV_INPUTSINK;
keyboard_device.hwndTarget = raw_input_window_;
if (!RegisterRawInputDevices(&keyboard_device, 1,
sizeof(keyboard_device))) {
LOG_WARN("Failed to register keyboard raw input, error={}", GetLastError());
DestroyWindow(raw_input_window_);
raw_input_window_ = nullptr;
return false;
}
raw_input_registered_ = true;
return true;
}
void KeyboardCapturer::DestroyRawInputWindow() {
if (raw_input_registered_) {
RAWINPUTDEVICE keyboard_device{};
keyboard_device.usUsagePage = HID_USAGE_PAGE_GENERIC;
keyboard_device.usUsage = HID_USAGE_GENERIC_KEYBOARD;
keyboard_device.dwFlags = RIDEV_REMOVE;
keyboard_device.hwndTarget = nullptr;
if (!RegisterRawInputDevices(&keyboard_device, 1,
sizeof(keyboard_device))) {
LOG_WARN("Failed to unregister keyboard raw input, error={}",
GetLastError());
}
raw_input_registered_ = false;
}
if (raw_input_window_) {
DestroyWindow(raw_input_window_);
raw_input_window_ = nullptr;
}
}
LRESULT CALLBACK KeyboardCapturer::RawInputWindowProc(
HWND window, UINT message, WPARAM w_param, LPARAM l_param) {
KeyboardCapturer* capturer = reinterpret_cast<KeyboardCapturer*>(
GetWindowLongPtrW(window, GWLP_USERDATA));
if (message == WM_NCCREATE) {
auto* create = reinterpret_cast<CREATESTRUCTW*>(l_param);
capturer = static_cast<KeyboardCapturer*>(create->lpCreateParams);
SetWindowLongPtrW(window, GWLP_USERDATA,
reinterpret_cast<LONG_PTR>(capturer));
} else if (message == WM_INPUT && capturer) {
capturer->HandleRawInput(reinterpret_cast<HRAWINPUT>(l_param));
} else if (message == WM_NCDESTROY) {
SetWindowLongPtrW(window, GWLP_USERDATA, 0);
}
return DefWindowProcW(window, message, w_param, l_param);
}
void KeyboardCapturer::HandleRawInput(HRAWINPUT raw_input_handle) {
RAWINPUT input{};
UINT input_size = sizeof(input);
const UINT bytes_read =
GetRawInputData(raw_input_handle, RID_INPUT, &input, &input_size,
sizeof(RAWINPUTHEADER));
if (bytes_read == static_cast<UINT>(-1) ||
bytes_read < sizeof(RAWINPUTHEADER) ||
input.header.dwType != RIM_TYPEKEYBOARD) {
return;
}
const RAWKEYBOARD& keyboard = input.data.keyboard;
if (keyboard.VKey == 0xFF ||
keyboard.ExtraInformation ==
static_cast<ULONG>(kInjectedKeyboardInputMarker)) {
return;
}
bool is_down = false;
if (keyboard.Message == WM_KEYDOWN || keyboard.Message == WM_SYSKEYDOWN) {
is_down = true;
} else if (keyboard.Message != WM_KEYUP &&
keyboard.Message != WM_SYSKEYUP) {
return;
}
const bool extended = (keyboard.Flags & (RI_KEY_E0 | RI_KEY_E1)) != 0;
UINT mapped_scan_code = keyboard.MakeCode;
if ((keyboard.Flags & RI_KEY_E0) != 0) {
mapped_scan_code |= 0xE000;
} else if ((keyboard.Flags & RI_KEY_E1) != 0) {
mapped_scan_code |= 0xE100;
}
if (mapped_scan_code == 0xE11D || mapped_scan_code == 0xE02A) {
return;
}
int key_code = static_cast<int>(keyboard.VKey);
if (key_code == VK_SHIFT || key_code == VK_CONTROL || key_code == VK_MENU) {
const UINT normalized =
MapVirtualKeyW(mapped_scan_code, MAPVK_VSC_TO_VK_EX);
if (normalized != 0) {
key_code = static_cast<int>(normalized);
}
}
if (on_key_action_) {
on_key_action_(key_code, is_down, keyboard.MakeCode, extended, user_ptr_);
}
}
// Apply remote keyboard commands to the local machine.
int KeyboardCapturer::SendKeyboardCommand(int key_code, bool is_down,
uint32_t scan_code, bool extended) {
INPUT input = {0};
input.type = INPUT_KEYBOARD;
input.ki.dwExtraInfo =
static_cast<ULONG_PTR>(kInjectedKeyboardInputMarker);
const bool prefer_vk = PreferSideSpecificVkInjection(key_code);
const UINT resolved_scan_code =
@@ -110,7 +283,7 @@ int KeyboardCapturer::SendKeyboardCommand(int key_code, bool is_down,
input.ki.dwFlags |= KEYEVENTF_EXTENDEDKEY;
}
} else {
input.ki.wVk = (WORD)key_code;
input.ki.wVk = static_cast<WORD>(key_code);
if (prefer_vk && resolved_scan_code != 0) {
input.ki.wScan = static_cast<WORD>(resolved_scan_code & 0xFF);
@@ -131,7 +304,7 @@ int KeyboardCapturer::SendKeyboardCommand(int key_code, bool is_down,
input.ki.dwFlags |= KEYEVENTF_KEYUP;
}
UINT sent = SendInput(1, &input, sizeof(INPUT));
const UINT sent = SendInput(1, &input, sizeof(INPUT));
if (sent != 1) {
LOG_WARN("SendInput failed for key_code={}, is_down={}, err={}", key_code,
is_down, GetLastError());
@@ -9,6 +9,10 @@
#include <Windows.h>
#include <condition_variable>
#include <mutex>
#include <thread>
#include "device_controller.h"
namespace crossdesk {
@@ -26,8 +30,25 @@ class KeyboardCapturer : public DeviceController {
bool extended = false);
private:
HHOOK keyboard_hook_ = nullptr;
static LRESULT CALLBACK RawInputWindowProc(HWND window, UINT message,
WPARAM w_param, LPARAM l_param);
void RawInputThreadMain();
bool CreateRawInputWindow();
void DestroyRawInputWindow();
void HandleRawInput(HRAWINPUT raw_input_handle);
OnKeyAction on_key_action_ = nullptr;
void* user_ptr_ = nullptr;
HWND raw_input_window_ = nullptr;
bool raw_input_registered_ = false;
std::thread capture_thread_;
DWORD capture_thread_id_ = 0;
bool capture_start_complete_ = false;
bool capture_start_succeeded_ = false;
std::mutex capture_state_mutex_;
std::condition_variable capture_start_condition_;
};
} // namespace crossdesk
#endif
#endif
@@ -13,6 +13,7 @@
#include <vector>
#include "device_controller.h"
#include "display_info.h"
struct DBusConnection;
struct DBusMessageIter;
@@ -4,6 +4,7 @@
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdlib>
#include "rd_log.h"
@@ -44,6 +45,13 @@ int MouseController::Init(std::vector<DisplayInfo> display_info_list) {
int MouseController::Destroy() { return 0; }
void MouseController::UpdateDisplayInfoList(
const std::vector<DisplayInfo>& display_info_list) {
if (!display_info_list.empty()) {
display_info_list_ = display_info_list;
}
}
int MouseController::BeginClick(ClickTracker& tracker, int x, int y) {
const auto now = std::chrono::steady_clock::now();
const bool continues_previous_click =
@@ -94,10 +102,26 @@ int MouseController::SendMouseCommand(RemoteAction remote_action,
return -1;
}
const float normalized_x = std::clamp(remote_action.m.x, 0.0f, 1.0f);
const float normalized_y = std::clamp(remote_action.m.y, 0.0f, 1.0f);
int mouse_pos_x = normalized_x * display_info.width + display_info.left;
int mouse_pos_y = normalized_y * display_info.height + display_info.top;
const double normalized_x =
std::clamp(static_cast<double>(remote_action.m.x), 0.0, 1.0);
const double normalized_y =
std::clamp(static_cast<double>(remote_action.m.y), 0.0, 1.0);
// Keep the coordinate continuous until it reaches Core Graphics. This
// avoids turning a one-pixel rounding error into a visible offset when the
// client zooms the remote image. The upper bound remains just inside the
// display because CG display rectangles use an exclusive right/bottom edge.
const double max_x = std::nextafter(static_cast<double>(display_info.right),
static_cast<double>(display_info.left));
const double max_y = std::nextafter(static_cast<double>(display_info.bottom),
static_cast<double>(display_info.top));
const double mouse_pos_x = std::clamp(
display_info.left + normalized_x * display_info.width,
static_cast<double>(display_info.left), max_x);
const double mouse_pos_y = std::clamp(
display_info.top + normalized_y * display_info.height,
static_cast<double>(display_info.top), max_y);
const int tracked_mouse_pos_x = static_cast<int>(std::lround(mouse_pos_x));
const int tracked_mouse_pos_y = static_cast<int>(std::lround(mouse_pos_y));
CGEventRef mouse_event = nullptr;
CGEventType mouse_type;
@@ -109,7 +133,8 @@ int MouseController::SendMouseCommand(RemoteAction remote_action,
case MouseFlag::left_down:
mouse_type = kCGEventLeftMouseDown;
left_dragging_ = true;
click_state = BeginClick(left_click_tracker_, mouse_pos_x, mouse_pos_y);
click_state = BeginClick(left_click_tracker_, tracked_mouse_pos_x,
tracked_mouse_pos_y);
mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point,
kCGMouseButtonLeft);
SetClickState(mouse_event, click_state);
@@ -117,7 +142,8 @@ int MouseController::SendMouseCommand(RemoteAction remote_action,
case MouseFlag::left_up:
mouse_type = kCGEventLeftMouseUp;
left_dragging_ = false;
click_state = EndClick(left_click_tracker_, mouse_pos_x, mouse_pos_y);
click_state = EndClick(left_click_tracker_, tracked_mouse_pos_x,
tracked_mouse_pos_y);
mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point,
kCGMouseButtonLeft);
SetClickState(mouse_event, click_state);
@@ -125,7 +151,8 @@ int MouseController::SendMouseCommand(RemoteAction remote_action,
case MouseFlag::right_down:
mouse_type = kCGEventRightMouseDown;
right_dragging_ = true;
click_state = BeginClick(right_click_tracker_, mouse_pos_x, mouse_pos_y);
click_state = BeginClick(right_click_tracker_, tracked_mouse_pos_x,
tracked_mouse_pos_y);
mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point,
kCGMouseButtonRight);
SetClickState(mouse_event, click_state);
@@ -133,21 +160,24 @@ int MouseController::SendMouseCommand(RemoteAction remote_action,
case MouseFlag::right_up:
mouse_type = kCGEventRightMouseUp;
right_dragging_ = false;
click_state = EndClick(right_click_tracker_, mouse_pos_x, mouse_pos_y);
click_state = EndClick(right_click_tracker_, tracked_mouse_pos_x,
tracked_mouse_pos_y);
mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point,
kCGMouseButtonRight);
SetClickState(mouse_event, click_state);
break;
case MouseFlag::middle_down:
mouse_type = kCGEventOtherMouseDown;
click_state = BeginClick(middle_click_tracker_, mouse_pos_x, mouse_pos_y);
click_state = BeginClick(middle_click_tracker_, tracked_mouse_pos_x,
tracked_mouse_pos_y);
mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point,
kCGMouseButtonCenter);
SetClickState(mouse_event, click_state);
break;
case MouseFlag::middle_up:
mouse_type = kCGEventOtherMouseUp;
click_state = EndClick(middle_click_tracker_, mouse_pos_x, mouse_pos_y);
click_state = EndClick(middle_click_tracker_, tracked_mouse_pos_x,
tracked_mouse_pos_y);
mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point,
kCGMouseButtonCenter);
SetClickState(mouse_event, click_state);
@@ -11,6 +11,7 @@
#include <vector>
#include "device_controller.h"
#include "display_info.h"
namespace crossdesk {
@@ -23,6 +24,8 @@ class MouseController : public DeviceController {
virtual int Init(std::vector<DisplayInfo> display_info_list);
virtual int Destroy();
virtual int SendMouseCommand(RemoteAction remote_action, int display_index);
void UpdateDisplayInfoList(
const std::vector<DisplayInfo>& display_info_list);
private:
struct ClickTracker {
@@ -10,6 +10,7 @@
#include <vector>
#include "device_controller.h"
#include "display_info.h"
namespace crossdesk {
@@ -27,4 +28,4 @@ class MouseController : public DeviceController {
std::vector<DisplayInfo> display_info_list_;
};
} // namespace crossdesk
#endif
#endif
+300
View File
@@ -0,0 +1,300 @@
#include "device_controller.h"
#include <algorithm>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <cmath>
#include <nlohmann/json.hpp>
namespace crossdesk {
namespace {
using json = nlohmann::json;
void ResetHostInfo(HostInfo& info) {
info.host_name[0] = '\0';
info.host_name_size = 0;
info.display_list = nullptr;
info.display_num = 0;
info.left = nullptr;
info.top = nullptr;
info.right = nullptr;
info.bottom = nullptr;
}
bool AllocateHostDisplays(HostInfo& info, std::size_t count) {
if (count == 0) return true;
info.display_list = static_cast<char**>(std::calloc(count, sizeof(char*)));
info.left = static_cast<int*>(std::malloc(count * sizeof(int)));
info.top = static_cast<int*>(std::malloc(count * sizeof(int)));
info.right = static_cast<int*>(std::malloc(count * sizeof(int)));
info.bottom = static_cast<int*>(std::malloc(count * sizeof(int)));
return info.display_list && info.left && info.top && info.right &&
info.bottom;
}
} // namespace
std::string RemoteAction::to_json() const { return ToJson(*this); }
bool RemoteAction::from_json(const std::string& json_string) {
RemoteAction temporary{};
if (!FromJson(json_string, temporary)) return false;
*this = temporary;
return true;
}
std::string RemoteAction::ToJson(const RemoteAction& action) {
if (action.type == ControlType::invalid) return {};
json object;
object["type"] = action.type;
switch (action.type) {
case ControlType::mouse:
object["mouse"] = {{"x", action.m.x},
{"y", action.m.y},
{"s", action.m.s},
{"flag", action.m.flag}};
break;
case ControlType::keyboard:
object["keyboard"] = {{"key_value", action.k.key_value},
{"scan_code", action.k.scan_code},
{"extended", action.k.extended},
{"flag", action.k.flag}};
break;
case ControlType::keyboard_state: {
json keys = json::array();
const std::size_t pressed_count =
std::min(action.ks.pressed_count, kMaxKeyboardStateKeys);
for (std::size_t index = 0; index < pressed_count; ++index) {
keys.push_back(
{{"key_value", action.ks.pressed_keys[index].key_value},
{"scan_code", action.ks.pressed_keys[index].scan_code},
{"extended", action.ks.pressed_keys[index].extended}});
}
object["keyboard_state"] =
{{"seq", action.ks.seq}, {"pressed_keys", keys}};
break;
}
case ControlType::cursor_state:
object["cursor_state"] =
{{"seq", action.cs.seq},
{"visible", action.cs.visible},
{"shape", static_cast<int>(action.cs.shape)},
{"position_valid", action.cs.position_valid},
{"x", action.cs.x},
{"y", action.cs.y},
{"visual_offset_x", action.cs.visual_offset_x},
{"visual_offset_y", action.cs.visual_offset_y},
{"display_id", action.cs.display_id},
{"position_update", action.cs.position_update}};
break;
case ControlType::audio_capture:
object["audio_capture"] = action.a;
break;
case ControlType::display_id:
object["display_id"] = action.d;
break;
case ControlType::service_status:
object["service_status"] =
{{"available", action.ss.available},
{"interactive_stage", action.ss.interactive_stage}};
break;
case ControlType::service_command:
object["service_command"] = {{"flag", action.c.flag}};
break;
case ControlType::host_infomation: {
json displays = json::array();
for (std::size_t index = 0; index < action.i.display_num; ++index) {
displays.push_back(
{{"name", action.i.display_list ? action.i.display_list[index]
: ""},
{"left", action.i.left ? action.i.left[index] : 0},
{"top", action.i.top ? action.i.top[index] : 0},
{"right", action.i.right ? action.i.right[index] : 0},
{"bottom", action.i.bottom ? action.i.bottom[index] : 0}});
}
object["host_info"] = {{"host_name", action.i.host_name},
{"display_num", action.i.display_num},
{"displays", displays}};
break;
}
case ControlType::invalid:
default:
return {};
}
return object.dump();
}
bool RemoteAction::FromJson(const std::string& json_string,
RemoteAction& output) {
bool owns_host_info = false;
try {
const json object = json::parse(json_string);
output.type = static_cast<ControlType>(object.at("type").get<int>());
switch (output.type) {
case ControlType::mouse:
output.m.x = object.at("mouse").at("x").get<float>();
output.m.y = object.at("mouse").at("y").get<float>();
output.m.s = object.at("mouse").at("s").get<int>();
output.m.flag = static_cast<MouseFlag>(
object.at("mouse").at("flag").get<int>());
break;
case ControlType::keyboard:
output.k.key_value =
object.at("keyboard").at("key_value").get<std::size_t>();
output.k.scan_code = object.at("keyboard").value("scan_code", 0u);
output.k.extended = object.at("keyboard").value("extended", false);
output.k.flag = static_cast<KeyFlag>(
object.at("keyboard").at("flag").get<int>());
break;
case ControlType::keyboard_state: {
const auto& keyboard_state_object = object.at("keyboard_state");
output.ks.seq = keyboard_state_object.value("seq", 0u);
output.ks.pressed_count = 0;
const auto keys =
keyboard_state_object.value("pressed_keys", json::array());
if (!keys.is_array()) break;
const std::size_t count =
std::min(keys.size(), kMaxKeyboardStateKeys);
for (std::size_t index = 0; index < count; ++index) {
output.ks.pressed_keys[index].key_value =
keys[index].at("key_value").get<std::size_t>();
output.ks.pressed_keys[index].scan_code =
keys[index].value("scan_code", 0u);
output.ks.pressed_keys[index].extended =
keys[index].value("extended", false);
}
output.ks.pressed_count = count;
break;
}
case ControlType::cursor_state: {
const auto& cursor_state_object = object.at("cursor_state");
const int shape = cursor_state_object.at("shape").get<int>();
if (shape < static_cast<int>(RemoteCursorShape::default_cursor) ||
shape > static_cast<int>(RemoteCursorShape::nwse_resize)) {
return false;
}
output.cs.seq = cursor_state_object.at("seq").get<uint32_t>();
output.cs.visible = cursor_state_object.at("visible").get<bool>();
output.cs.shape = static_cast<RemoteCursorShape>(shape);
output.cs.position_valid =
cursor_state_object.value("position_valid", false);
output.cs.x = cursor_state_object.value("x", 0.5f);
output.cs.y = cursor_state_object.value("y", 0.5f);
output.cs.visual_offset_x =
cursor_state_object.value("visual_offset_x", 0.0f);
output.cs.visual_offset_y =
cursor_state_object.value("visual_offset_y", 0.0f);
output.cs.display_id = cursor_state_object.value("display_id", -1);
// Cursor state messages predating position-only echo suppression
// always carried an authoritative position update.
output.cs.position_update =
cursor_state_object.value("position_update", true);
if (!std::isfinite(output.cs.x) || !std::isfinite(output.cs.y) ||
!std::isfinite(output.cs.visual_offset_x) ||
!std::isfinite(output.cs.visual_offset_y)) {
return false;
}
output.cs.x = std::clamp(output.cs.x, 0.0f, 1.0f);
output.cs.y = std::clamp(output.cs.y, 0.0f, 1.0f);
output.cs.visual_offset_x =
std::clamp(output.cs.visual_offset_x, -1.0f, 1.0f);
output.cs.visual_offset_y =
std::clamp(output.cs.visual_offset_y, -1.0f, 1.0f);
break;
}
case ControlType::audio_capture:
output.a = object.at("audio_capture").get<bool>();
break;
case ControlType::display_id:
output.d = object.at("display_id").get<int>();
break;
case ControlType::service_status: {
const auto& service_status_object = object.at("service_status");
output.ss.available = service_status_object.value("available", false);
const std::string stage = service_status_object.value(
"interactive_stage", std::string());
std::strncpy(output.ss.interactive_stage, stage.c_str(),
sizeof(output.ss.interactive_stage) - 1);
output.ss.interactive_stage[sizeof(output.ss.interactive_stage) - 1] =
'\0';
break;
}
case ControlType::service_command:
output.c.flag = static_cast<ServiceCommandFlag>(
object.at("service_command").at("flag").get<int>());
break;
case ControlType::host_infomation: {
ResetHostInfo(output.i);
owns_host_info = true;
const auto& host_info_object = object.at("host_info");
const std::string host_name =
host_info_object.at("host_name").get<std::string>();
std::strncpy(output.i.host_name, host_name.c_str(),
sizeof(output.i.host_name) - 1);
output.i.host_name[sizeof(output.i.host_name) - 1] = '\0';
output.i.host_name_size = std::strlen(output.i.host_name);
const auto& displays = host_info_object.at("displays");
if (!displays.is_array()) return false;
output.i.display_num =
host_info_object.at("display_num").get<std::size_t>();
if (output.i.display_num != displays.size()) return false;
if (!AllocateHostDisplays(output.i, output.i.display_num)) {
FreeRemoteAction(output);
return false;
}
for (std::size_t index = 0; index < output.i.display_num; ++index) {
const std::string name =
displays[index].at("name").get<std::string>();
output.i.display_list[index] =
static_cast<char*>(std::malloc(name.size() + 1));
if (!output.i.display_list[index]) {
FreeRemoteAction(output);
return false;
}
std::memcpy(output.i.display_list[index], name.c_str(),
name.size() + 1);
output.i.left[index] = displays[index].at("left").get<int>();
output.i.top[index] = displays[index].at("top").get<int>();
output.i.right[index] = displays[index].at("right").get<int>();
output.i.bottom[index] = displays[index].at("bottom").get<int>();
}
break;
}
default:
return false;
}
return true;
} catch (const std::exception& exception) {
if (owns_host_info) {
FreeRemoteAction(output);
}
std::fprintf(stderr, "Failed to parse RemoteAction JSON: %s\n",
exception.what());
return false;
}
}
void FreeRemoteAction(RemoteAction& action) {
if (action.type != ControlType::host_infomation) return;
if (action.i.display_list) {
for (std::size_t index = 0; index < action.i.display_num; ++index) {
std::free(action.i.display_list[index]);
}
}
std::free(action.i.display_list);
std::free(action.i.left);
std::free(action.i.top);
std::free(action.i.right);
std::free(action.i.bottom);
ResetHostInfo(action.i);
}
} // namespace crossdesk
+1 -1
View File
@@ -65,7 +65,7 @@ struct InteractionState {
bool start_keyboard_capturer_ = false;
bool show_cursor_ = false;
bool keyboard_capturer_is_started_ = false;
bool keyboard_capturer_uses_sdl_events_ = false;
bool keyboard_capturer_uses_window_events_ = false;
bool foucs_on_main_window_ = false;
bool focus_on_stream_window_ = false;
bool audio_capture_ = false;
File diff suppressed because it is too large Load Diff
+26 -3
View File
@@ -1,9 +1,12 @@
#ifndef CROSSDESK_GUI_APPLICATION_H_
#define CROSSDESK_GUI_APPLICATION_H_
#ifndef _GUI_APPLICATION_H_
#define _GUI_APPLICATION_H_
#include <chrono>
#include <memory>
#include <string>
#include <unordered_map>
#include "runtime/cursor_state_provider.h"
#include "runtime/gui_runtime.h"
namespace crossdesk {
@@ -19,6 +22,12 @@ public:
private:
struct SlintUi;
struct CursorDeliveryState {
CursorState last_sent{};
bool has_sent = false;
bool feedback_pending = false;
std::chrono::steady_clock::time_point last_sent_time{};
};
void InitializeLogger();
void InitializeSettings();
@@ -31,10 +40,18 @@ private:
void BindStreamCallbacks();
void BindServerCallbacks();
void Tick();
void ShareLocalCursorState();
void HandlePasswordChangeResult();
void HandleCredentialRecovery();
void SyncMainWindow();
void SyncConnectionDialog();
void SyncPlatformDialogs();
void SyncStreamWindow();
void SyncStreamVideoFrame();
void ScheduleNextVideoFrame();
void ConfigureStreamVideoRenderer();
void SyncStreamKeyboardFocus();
void SetStreamKeyboardFocus(bool focused);
void SyncServerWindow();
#if defined(__linux__) && !defined(__APPLE__)
void SyncXWaylandWindowActivation();
@@ -62,11 +79,17 @@ private:
bool OpenUrl(const std::string &url);
std::unique_ptr<SlintUi> ui_;
CursorStateProvider cursor_state_provider_;
std::unordered_map<std::string, CursorDeliveryState>
cursor_delivery_states_;
uint32_t cursor_state_sequence_ = 0;
std::chrono::steady_clock::time_point next_video_frame_time_{};
#if defined(__linux__) && !defined(__APPLE__)
bool use_xwayland_gui_ = false;
bool use_x11_custom_titlebar_ = false;
#endif
};
} // namespace crossdesk
#endif // CROSSDESK_GUI_APPLICATION_H_
#endif
+6 -3
View File
@@ -134,8 +134,10 @@ void GuiApplication::ProcessSdlEvent(const SDL_Event &event) {
}
if (main_window_ &&
event.window.windowID == SDL_GetWindowID(main_window_) &&
MinimizeMainWindowToTray()) {
event.window.windowID == SDL_GetWindowID(main_window_)) {
if (!MinimizeMainWindowToTray()) {
SDL_HideWindow(main_window_);
}
break;
}
@@ -200,7 +202,8 @@ void GuiApplication::ProcessSdlEvent(const SDL_Event &event) {
case SDL_EVENT_KEY_DOWN:
case SDL_EVENT_KEY_UP:
if (keyboard_capturer_is_started_ && keyboard_capturer_uses_sdl_events_ &&
if (keyboard_capturer_is_started_ &&
keyboard_capturer_uses_window_events_ &&
focus_on_stream_window_ && stream_window_ &&
SDL_GetWindowID(stream_window_) == event.key.windowID) {
ProcessKeyboardEvent(event);
+9
View File
@@ -233,6 +233,15 @@ int GuiApplication::ProcessKeyboardEvent(const SDL_Event &event) {
int GuiApplication::ProcessMouseEvent(const SDL_Event &event) {
controlled_remote_id_ = "";
RemoteAction remote_action{};
if ((event.type == SDL_EVENT_MOUSE_BUTTON_DOWN ||
event.type == SDL_EVENT_MOUSE_BUTTON_UP) &&
event.button.button != SDL_BUTTON_LEFT &&
event.button.button != SDL_BUTTON_RIGHT &&
event.button.button != SDL_BUTTON_MIDDLE) {
return 0;
}
float cursor_x = last_mouse_event.motion.x;
float cursor_y = last_mouse_event.motion.y;
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include <algorithm>
#include <iterator>
#include <string>
#include <vector>
namespace crossdesk::server_window_state {
inline int ReconcileSelectedController(const std::vector<std::string>& ids,
std::string* selected_id) {
if (!selected_id) {
return 0;
}
if (ids.empty()) {
selected_id->clear();
return 0;
}
const auto selected = std::find(ids.begin(), ids.end(), *selected_id);
if (selected == ids.end()) {
*selected_id = ids.front();
return 0;
}
return static_cast<int>(std::distance(ids.begin(), selected));
}
} // namespace crossdesk::server_window_state
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include <algorithm>
namespace crossdesk::window_geometry {
struct PhysicalRect {
int x = 0;
int y = 0;
int width = 0;
int height = 0;
};
struct PhysicalSize {
int width = 0;
int height = 0;
};
struct PhysicalPosition {
int x = 0;
int y = 0;
};
constexpr PhysicalPosition CenteredPosition(const PhysicalRect& bounds,
const PhysicalSize& size) {
return {
bounds.x + (bounds.width - std::min(bounds.width, size.width)) / 2,
bounds.y + (bounds.height - std::min(bounds.height, size.height)) / 2,
};
}
constexpr PhysicalPosition BottomRightPosition(const PhysicalRect& bounds,
const PhysicalSize& size) {
return {
bounds.x + std::max(0, bounds.width - size.width),
bounds.y + std::max(0, bounds.height - size.height),
};
}
} // namespace crossdesk::window_geometry
+1 -1
View File
@@ -150,7 +150,7 @@ int GuiApplication::DrawServerWindow() {
}
bool GuiApplication::MinimizeMainWindowToTray() {
if (!enable_minimize_to_tray_ || !main_window_) {
if (!main_window_) {
return false;
}
+72 -34
View File
@@ -6,6 +6,7 @@
#ifndef _LOCALIZATION_H_
#define _LOCALIZATION_H_
#include <cstddef>
#include <string>
#include <unordered_map>
#include <vector>
@@ -110,45 +111,82 @@ inline const std::string& GetTranslatedText(const std::string& key,
} // namespace detail
inline const std::string& LocalizedString::operator[](
int language_index) const {
return detail::GetTranslatedText(key_, language_index);
}
#define CROSSDESK_DECLARE_LOCALIZED_STRING(name, zh, en, ru) \
inline const std::string& LocalizedString::operator[](
int language_index) const {
return detail::GetTranslatedText(key_, language_index);
}
inline std::string FormatDisplayLabel(size_t display_index,
const std::string& display_name,
int language_index) {
const std::string number = std::to_string(display_index + 1);
std::string label =
detail::GetTranslatedText("display_screen", language_index) + " " +
number;
const std::string fallback_name = "Display" + number;
if (!display_name.empty() && display_name != fallback_name) {
label += " (" + display_name + ")";
}
return label;
}
#define CROSSDESK_DECLARE_LOCALIZED_STRING(name, zh, en, ru) \
inline const LocalizedString name(#name);
CROSSDESK_LOCALIZATION_ALL(CROSSDESK_DECLARE_LOCALIZED_STRING)
#undef CROSSDESK_DECLARE_LOCALIZED_STRING
#if _WIN32
inline const wchar_t* GetExitProgramLabel(int language_index) {
static std::vector<std::wstring> cache(GetSupportedLanguages().size());
#if _WIN32
inline const wchar_t* GetWindowsLocalizedLabel(
int language_index, const char* key, const wchar_t* fallback,
std::vector<std::wstring>& cache) {
const int normalized_index = detail::ClampLanguageIndex(language_index);
std::wstring& cached_text = cache[normalized_index];
if (!cached_text.empty()) {
return cached_text.c_str();
}
const std::string& utf8_text =
detail::GetTranslatedText("exit_program", normalized_index);
if (utf8_text.empty()) {
cached_text = L"Exit";
return cached_text.c_str();
}
int wide_length =
MultiByteToWideChar(CP_UTF8, 0, utf8_text.c_str(), -1, nullptr, 0);
if (wide_length <= 0) {
cached_text = L"Exit";
return cached_text.c_str();
}
cached_text.resize(static_cast<size_t>(wide_length - 1));
MultiByteToWideChar(CP_UTF8, 0, utf8_text.c_str(), -1, cached_text.data(),
wide_length);
return cached_text.c_str();
}
#endif
std::wstring& cached_text = cache[normalized_index];
if (!cached_text.empty()) {
return cached_text.c_str();
}
const std::string& utf8_text =
detail::GetTranslatedText(key, normalized_index);
if (utf8_text.empty()) {
cached_text = fallback;
return cached_text.c_str();
}
int wide_length =
MultiByteToWideChar(CP_UTF8, 0, utf8_text.c_str(), -1, nullptr, 0);
if (wide_length <= 0) {
cached_text = fallback;
return cached_text.c_str();
}
cached_text.resize(static_cast<size_t>(wide_length));
if (MultiByteToWideChar(CP_UTF8, 0, utf8_text.c_str(), -1,
cached_text.data(), wide_length) <= 0) {
cached_text = fallback;
return cached_text.c_str();
}
cached_text.pop_back();
return cached_text.c_str();
}
inline const wchar_t* GetShowMainWindowLabel(int language_index) {
static std::vector<std::wstring> cache(GetSupportedLanguages().size());
return GetWindowsLocalizedLabel(language_index, "show_main_window",
L"Show Main Window", cache);
}
inline const wchar_t* GetSettingsLabel(int language_index) {
static std::vector<std::wstring> cache(GetSupportedLanguages().size());
return GetWindowsLocalizedLabel(language_index, "settings", L"Settings",
cache);
}
inline const wchar_t* GetExitProgramLabel(int language_index) {
static std::vector<std::wstring> cache(GetSupportedLanguages().size());
return GetWindowsLocalizedLabel(language_index, "exit_program", L"Exit",
cache);
}
#endif
} // namespace localization
} // namespace crossdesk
@@ -35,6 +35,7 @@ struct TranslationRow {
u8"Недавние подключения") \
X(disconnect, u8"断开连接", "Disconnect", u8"Отключить") \
X(select_display, u8"选择显示器", "Select Display", u8"Выбрать дисплей") \
X(display_screen, u8"显示屏", "Display", u8"Экран") \
X(expand_control_bar, u8"展开控制栏", "Expand Control Bar", \
u8"Развернуть панель управления") \
X(collapse_control_bar, u8"收起控制栏", "Collapse Control Bar", \
@@ -214,8 +215,6 @@ struct TranslationRow {
X(file_transfer_save_path, u8"文件接收保存路径:", \
"File Transfer Save Path:", u8"Путь сохранения файлов:") \
X(default_desktop, u8"桌面", "Desktop", u8"Рабочий стол") \
X(minimize_to_tray, u8"退出时最小化到系统托盘:", \
"Minimize on Exit:", u8"Сворачивать в трей при выходе:") \
X(resolution, u8"分辨率", "Res", u8"Разрешение") \
X(connection_mode, u8"连接模式", "Mode", u8"Режим") \
X(connection_mode_direct, u8"直连", "Direct", u8"Прямой") \
@@ -232,6 +231,8 @@ struct TranslationRow {
X(permission_required_message, u8"该应用需要授权以下权限:", \
"The application requires the following permissions:", \
u8"Для работы приложения требуются следующие разрешения:") \
X(show_main_window, u8"显示主界面", "Show Main Window", \
u8"Показать главное окно") \
X(exit_program, u8"退出", "Exit", u8"Выход")
inline constexpr TranslationRow kTranslationRows[] = {
@@ -3,6 +3,7 @@
#include <chrono>
#include <cstdlib>
#include "display_stream_id.h"
#include "platform.h"
#include "rd_log.h"
#include "runtime/gui_runtime.h"
@@ -11,11 +12,30 @@ namespace crossdesk {
namespace {
constexpr uint64_t kCaptureResumeKeyFrameGapMs = 500;
constexpr size_t kMaxCapturedKeyboardInputs = 512;
constexpr auto kFrameDeadlineTolerance = std::chrono::milliseconds(1);
} // namespace
SessionDeviceManager::SessionDeviceManager(GuiRuntime &owner) : owner_(owner) {}
bool SessionDeviceManager::ShouldSendCapturedFrame(
std::chrono::steady_clock::time_point now, int fps) {
const auto interval =
std::chrono::nanoseconds(std::chrono::seconds(1)) / (fps > 0 ? fps : 1);
if (next_frame_deadline_ == std::chrono::steady_clock::time_point{}) {
next_frame_deadline_ = now;
}
if (now + kFrameDeadlineTolerance < next_frame_deadline_) {
return false;
}
next_frame_deadline_ += interval;
if (next_frame_deadline_ <= now) {
next_frame_deadline_ = now + interval;
}
return true;
}
void SessionDeviceManager::Initialize() {
InitializeAudioOutput();
screen_capturer_factory_ = new ScreenCapturerFactory();
@@ -38,37 +58,79 @@ int SessionDeviceManager::InitializeScreenCapturer() {
static_cast<ScreenCapturer *>(screen_capturer_factory_->Create());
}
last_frame_time_ = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch())
.count();
last_frame_time_ = {};
next_frame_deadline_ = {};
const int fps = owner_.config_center_->GetVideoFrameRate() ==
ConfigCenter::VIDEO_FRAME_RATE::FPS_30
? 30
: 60;
LOG_INFO("Init screen capturer with {} fps", fps);
display_info_list_.clear();
registered_display_stream_count_ = 0;
last_video_frame_stream_id_.clear();
invalid_video_stream_id_logged_ = false;
const int init_ret = screen_capturer_->Init(
fps, [this, fps](unsigned char *data, int size, int width, int height,
const char *display_name) {
const auto now_time = static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch())
.count());
const auto duration = now_time - last_frame_time_;
if (duration * fps < 1000) {
const auto now_time = std::chrono::steady_clock::now();
if (!ShouldSendCapturedFrame(now_time, fps)) {
return;
}
const std::string stream_id = display_name ? display_name : "";
const bool has_previous_frame =
last_frame_time_.time_since_epoch().count() != 0;
const auto duration_ms =
has_previous_frame
? std::chrono::duration_cast<std::chrono::milliseconds>(
now_time - last_frame_time_)
.count()
: 0;
std::vector<std::string> connected_remote_ids;
{
std::shared_lock lock(owner_.connection_status_mutex_);
connected_remote_ids.reserve(owner_.connection_status_.size());
for (const auto &[remote_id, status] : owner_.connection_status_) {
if (status == ConnectionStatus::Connected) {
connected_remote_ids.push_back(remote_id);
}
}
}
// Capture can still deliver frames while ICE is gathering or after
// the final controller disconnects. Do not broadcast those frames to
// MiniRTC: a broadcast also reaches newly joining peers whose ICE
// transport is not ready yet.
if (connected_remote_ids.empty()) {
last_frame_time_ = now_time;
return;
}
const std::string stream_id = ResolveDisplayStreamId(
display_name, registered_display_stream_count_, -1,
last_video_frame_stream_id_);
if (stream_id.empty()) {
if (!invalid_video_stream_id_logged_) {
LOG_ERROR(
"Drop captured frames with an empty or unregistered video "
"stream id, reported='{}', registered_streams={}",
display_name ? display_name : "",
registered_display_stream_count_);
invalid_video_stream_id_logged_ = true;
}
return;
}
invalid_video_stream_id_logged_ = false;
const bool resumed_after_gap =
last_frame_time_ != 0 && duration >= kCaptureResumeKeyFrameGapMs;
has_previous_frame && duration_ms >= kCaptureResumeKeyFrameGapMs;
const bool stream_changed = !last_video_frame_stream_id_.empty() &&
last_video_frame_stream_id_ != stream_id;
if (resumed_after_gap || stream_changed) {
if (RequestVideoKeyFrame(owner_.peer_, stream_id.c_str()) == 0) {
LOG_INFO("Request video key frame before sending captured frame, "
"stream='{}', gap_ms={}, stream_changed={}",
stream_id, duration, stream_changed);
stream_id, duration_ms, stream_changed);
}
}
@@ -78,7 +140,10 @@ int SessionDeviceManager::InitializeScreenCapturer() {
frame.width = width;
frame.height = height;
frame.captured_timestamp = GetSystemTimeMicros(owner_.peer_);
SendVideoFrame(owner_.peer_, &frame, stream_id.c_str());
for (const std::string &remote_id : connected_remote_ids) {
SendVideoFrameToPeer(owner_.peer_, &frame, stream_id.c_str(),
remote_id.data(), remote_id.size());
}
last_video_frame_stream_id_ = stream_id;
last_frame_time_ = now_time;
});
@@ -89,6 +154,7 @@ int SessionDeviceManager::InitializeScreenCapturer() {
if (!latest_display_info.empty()) {
display_info_list_ = latest_display_info;
}
registered_display_stream_count_ = display_info_list_.size();
return 0;
}
@@ -228,27 +294,27 @@ int SessionDeviceManager::StopMouseController() {
}
int SessionDeviceManager::StartKeyboardCapturer() {
owner_.keyboard_capturer_uses_sdl_events_ = false;
owner_.keyboard_capturer_uses_window_events_ = false;
#ifdef __APPLE__
if (!owner_.EnsureMacAccessibilityPermission()) {
owner_.keyboard_capturer_uses_sdl_events_ = true;
owner_.keyboard_capturer_uses_window_events_ = true;
return 0;
}
#endif
#if defined(__linux__) && !defined(__APPLE__)
if (IsWaylandSession()) {
owner_.keyboard_capturer_uses_sdl_events_ = true;
LOG_INFO("Start keyboard capturer with SDL Wayland backend");
owner_.keyboard_capturer_uses_window_events_ = true;
LOG_INFO("Start keyboard capturer with Slint Wayland backend");
return 0;
}
#endif
if (!keyboard_capturer_) {
owner_.keyboard_capturer_uses_sdl_events_ = true;
owner_.keyboard_capturer_uses_window_events_ = true;
LOG_WARN(
"keyboard capturer is nullptr, falling back to SDL keyboard events");
"keyboard capturer is nullptr, falling back to Slint keyboard events");
return 0;
}
@@ -256,31 +322,32 @@ int SessionDeviceManager::StartKeyboardCapturer() {
[](int key_code, bool is_down, uint32_t scan_code, bool extended,
void *user_ptr) {
if (user_ptr) {
auto *runtime = static_cast<GuiRuntime *>(user_ptr);
runtime->keyboard_.SendKeyCommand(key_code, is_down, scan_code,
extended);
auto *devices = static_cast<SessionDeviceManager *>(user_ptr);
devices->QueueCapturedKeyboardInput(key_code, is_down, scan_code,
extended);
}
},
&owner_);
this);
if (hook_ret != 0) {
owner_.keyboard_capturer_uses_sdl_events_ = true;
owner_.keyboard_capturer_uses_window_events_ = true;
LOG_WARN(
"Start keyboard capturer failed, falling back to SDL keyboard events");
"Start keyboard capturer failed, falling back to Slint keyboard events");
} else {
LOG_INFO("Start keyboard capturer with native hook");
LOG_INFO("Start keyboard capturer with native input");
}
return 0;
}
int SessionDeviceManager::StopKeyboardCapturer() {
if (owner_.keyboard_capturer_uses_sdl_events_) {
owner_.keyboard_capturer_uses_sdl_events_ = false;
LOG_INFO("Stop keyboard capturer with SDL keyboard backend");
if (owner_.keyboard_capturer_uses_window_events_) {
owner_.keyboard_capturer_uses_window_events_ = false;
LOG_INFO("Stop keyboard capturer with Slint keyboard backend");
return 0;
}
if (keyboard_capturer_) {
keyboard_capturer_->Unhook();
ClearCapturedKeyboardInput();
LOG_INFO("Stop keyboard capturer");
}
return 0;
@@ -394,7 +461,7 @@ void SessionDeviceManager::UpdateInteractions() {
owner_.mouse_controller_is_started_ = false;
}
#if defined(__linux__) && !defined(__APPLE__)
#if defined(__linux__) || defined(__APPLE__)
if (owner_.screen_capturer_is_started_ && screen_capturer_ &&
mouse_controller_) {
const auto latest_display_info = screen_capturer_->GetDisplayInfoList();
@@ -410,6 +477,7 @@ void SessionDeviceManager::UpdateInteractions() {
owner_.keyboard_capturer_is_started_ = true;
}
if (owner_.keyboard_capturer_is_started_) {
DrainCapturedKeyboardInput();
owner_.keyboard_.SendHeartbeat(false);
}
} else if (owner_.keyboard_capturer_is_started_) {
@@ -421,6 +489,36 @@ void SessionDeviceManager::UpdateInteractions() {
owner_.keyboard_.CheckRemoteTimeouts();
}
void SessionDeviceManager::QueueCapturedKeyboardInput(int key_code,
bool is_down,
uint32_t scan_code,
bool extended) {
std::lock_guard<std::mutex> lock(captured_keyboard_inputs_mutex_);
if (captured_keyboard_inputs_.size() >= kMaxCapturedKeyboardInputs) {
captured_keyboard_inputs_.pop_front();
LOG_WARN("Captured keyboard input queue overflow, dropping oldest event");
}
captured_keyboard_inputs_.push_back(
CapturedKeyboardInput{key_code, is_down, scan_code, extended});
}
void SessionDeviceManager::DrainCapturedKeyboardInput() {
std::deque<CapturedKeyboardInput> inputs;
{
std::lock_guard<std::mutex> lock(captured_keyboard_inputs_mutex_);
inputs.swap(captured_keyboard_inputs_);
}
for (const CapturedKeyboardInput &input : inputs) {
owner_.keyboard_.SendKeyCommand(input.key_code, input.is_down,
input.scan_code, input.extended);
}
}
void SessionDeviceManager::ClearCapturedKeyboardInput() {
std::lock_guard<std::mutex> lock(captured_keyboard_inputs_mutex_);
captured_keyboard_inputs_.clear();
}
bool SessionDeviceManager::SendKeyboardCommand(int key_code, bool is_down,
uint32_t scan_code,
bool extended) {
@@ -3,8 +3,11 @@
#include <SDL3/SDL.h>
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <deque>
#include <mutex>
#include <string>
#include <vector>
@@ -53,6 +56,20 @@ public:
const std::vector<DisplayInfo> &display_info_list() const;
private:
struct CapturedKeyboardInput {
int key_code = 0;
bool is_down = false;
uint32_t scan_code = 0;
bool extended = false;
};
void QueueCapturedKeyboardInput(int key_code, bool is_down,
uint32_t scan_code, bool extended);
void DrainCapturedKeyboardInput();
void ClearCapturedKeyboardInput();
bool ShouldSendCapturedFrame(std::chrono::steady_clock::time_point now,
int fps);
GuiRuntime &owner_;
SDL_AudioStream *output_stream_ = nullptr;
ScreenCapturerFactory *screen_capturer_factory_ = nullptr;
@@ -63,8 +80,13 @@ private:
MouseController *mouse_controller_ = nullptr;
KeyboardCapturer *keyboard_capturer_ = nullptr;
std::vector<DisplayInfo> display_info_list_;
uint64_t last_frame_time_ = 0;
size_t registered_display_stream_count_ = 0;
std::deque<CapturedKeyboardInput> captured_keyboard_inputs_;
std::mutex captured_keyboard_inputs_mutex_;
std::chrono::steady_clock::time_point last_frame_time_{};
std::chrono::steady_clock::time_point next_frame_deadline_{};
std::string last_video_frame_stream_id_;
bool invalid_video_stream_id_logged_ = false;
};
} // namespace crossdesk
@@ -2,7 +2,6 @@
#include <algorithm>
#include <chrono>
#include <cstring>
#include <filesystem>
#include <limits>
#include <memory>
@@ -236,15 +235,9 @@ void FileTransferManager::Unregister(uint32_t file_id, bool per_peer) {
}
void FileTransferManager::HandleAck(const char *data, size_t size) {
if (size < sizeof(FileTransferAck)) {
LOG_ERROR("FileTransferAck: buffer too small, size={}", size);
return;
}
FileTransferAck ack{};
std::memcpy(&ack, data, sizeof(ack));
if (ack.magic != kFileAckMagic) {
LOG_ERROR("FileTransferAck: invalid magic, got 0x{:08X}", ack.magic);
if (!protocol::DecodeFileTransferAck(data, size, &ack)) {
LOG_ERROR("FileTransferAck: invalid payload, size={}", size);
return;
}
+35 -34
View File
@@ -1,14 +1,14 @@
#include "features/input/keyboard_controller.h"
#include <SDL3/SDL.h>
#include <cstdint>
#include <mutex>
#include <nlohmann/json.hpp>
#include <string>
#include <unordered_map>
#include <vector>
#include <SDL3/SDL.h>
#include <nlohmann/json.hpp>
#include "minirtc.h"
#include "rd_log.h"
#include "runtime/gui_runtime.h"
@@ -45,8 +45,8 @@ int NormalizeWindowsModifierVk(int key_code, uint32_t scan_code,
#endif
}
void PopulateWindowsKeyMetadataFromVk(int key_code, uint32_t *scan_code_out,
bool *extended_out) {
void PopulateWindowsKeyMetadataFromVk(int key_code, uint32_t* scan_code_out,
bool* extended_out) {
if (!scan_code_out || !extended_out) {
return;
}
@@ -65,7 +65,7 @@ void PopulateWindowsKeyMetadataFromVk(int key_code, uint32_t *scan_code_out,
#if _WIN32
constexpr uint32_t kSecureDesktopInputLogIntervalMs = 2000;
void LogSecureDesktopInputBlocked(uint32_t *last_tick, const char *stage) {
void LogSecureDesktopInputBlocked(uint32_t* last_tick, const char* stage) {
const uint32_t now = static_cast<uint32_t>(SDL_GetTicks());
if (*last_tick != 0 && now - *last_tick < kSecureDesktopInputLogIntervalMs) {
return;
@@ -77,8 +77,8 @@ void LogSecureDesktopInputBlocked(uint32_t *last_tick, const char *stage) {
stage ? stage : "");
}
bool IsTransientSecureDesktopInputFailure(const nlohmann::json &response,
const RemoteAction &action) {
bool IsTransientSecureDesktopInputFailure(const nlohmann::json& response,
const RemoteAction& action) {
return response.is_object() &&
response.value("error", std::string()) == "send_input_failed" &&
response.value("code", 0u) == ERROR_ACCESS_DENIED &&
@@ -87,9 +87,9 @@ bool IsTransientSecureDesktopInputFailure(const nlohmann::json &response,
}
#endif
} // namespace
} // namespace
KeyboardController::KeyboardController(GuiRuntime &owner) : owner_(owner) {}
KeyboardController::KeyboardController(GuiRuntime& owner) : owner_(owner) {}
void KeyboardController::TrackPressedKey(int key_code, bool is_down,
uint32_t scan_code, bool extended) {
@@ -106,13 +106,13 @@ void KeyboardController::ForceReleasePressedKeys() {
{
std::lock_guard<std::mutex> lock(pressed_keys_mutex_);
pressed_keys.reserve(pressed_keys_.size());
for (const auto &[_, key] : pressed_keys_) {
for (const auto& [_, key] : pressed_keys_) {
pressed_keys.push_back(key);
}
pressed_keys_.clear();
}
for (const PressedKey &key : pressed_keys) {
for (const PressedKey& key : pressed_keys) {
SendKeyCommand(key.key_code, false, key.scan_code, key.extended);
}
SendHeartbeat(true);
@@ -130,7 +130,7 @@ void KeyboardController::SendHeartbeat(bool force) {
{
std::lock_guard<std::mutex> lock(pressed_keys_mutex_);
size_t index = 0;
for (const auto &[_, key] : pressed_keys_) {
for (const auto& [_, key] : pressed_keys_) {
if (index >= kMaxKeyboardStateKeys) {
LOG_WARN("Keyboard heartbeat truncated, pressed_keys={}",
pressed_keys_.size());
@@ -223,9 +223,10 @@ bool KeyboardController::InjectRemoteKey(int key_code, bool is_down,
action.k.flag = is_down ? KeyFlag::key_down : KeyFlag::key_up;
if (!json.is_discarded() &&
IsTransientSecureDesktopInputFailure(json, action)) {
LOG_INFO("Secure desktop keyboard injection transient failure, "
"key_code={}, is_down={}, response={}",
key_code, is_down, response);
LOG_INFO(
"Secure desktop keyboard injection transient failure, "
"key_code={}, is_down={}, response={}",
key_code, is_down, response);
return true;
}
@@ -245,15 +246,15 @@ bool KeyboardController::InjectRemoteKey(int key_code, bool is_down,
extended);
}
void KeyboardController::ApplyRemoteEvent(const std::string &remote_id,
const RemoteAction &action) {
void KeyboardController::ApplyRemoteEvent(const std::string& remote_id,
const RemoteAction& action) {
const int key_code = static_cast<int>(action.k.key_value);
const bool is_down = action.k.flag == KeyFlag::key_down;
const bool injected =
InjectRemoteKey(key_code, is_down, action.k.scan_code, action.k.extended);
std::lock_guard<std::mutex> lock(remote_states_mutex_);
auto &state = remote_states_[remote_id];
auto& state = remote_states_[remote_id];
state.last_seen_tick = static_cast<uint32_t>(SDL_GetTicks());
if (is_down && injected) {
state.pressed_keys[key_code] =
@@ -263,13 +264,13 @@ void KeyboardController::ApplyRemoteEvent(const std::string &remote_id,
}
}
void KeyboardController::ApplyRemoteState(const std::string &remote_id,
const RemoteAction &action) {
void KeyboardController::ApplyRemoteState(const std::string& remote_id,
const RemoteAction& action) {
std::vector<PressedKey> keys_to_release;
std::vector<PressedKey> keys_to_press;
{
std::lock_guard<std::mutex> lock(remote_states_mutex_);
auto &state = remote_states_[remote_id];
auto& state = remote_states_[remote_id];
if (action.ks.seq != 0 && state.last_seq != 0 &&
static_cast<int32_t>(action.ks.seq - state.last_seq) <= 0) {
return;
@@ -283,24 +284,24 @@ void KeyboardController::ApplyRemoteState(const std::string &remote_id,
const size_t count =
(std::min)(action.ks.pressed_count, kMaxKeyboardStateKeys);
for (size_t index = 0; index < count; ++index) {
const auto &key = action.ks.pressed_keys[index];
const auto& key = action.ks.pressed_keys[index];
const int key_code = static_cast<int>(key.key_value);
desired_keys[key_code] =
PressedKey{key_code, key.scan_code, key.extended};
}
for (const auto &[key_code, key] : state.pressed_keys) {
for (const auto& [key_code, key] : state.pressed_keys) {
if (desired_keys.find(key_code) == desired_keys.end()) {
keys_to_release.push_back(key);
}
}
for (const auto &[key_code, key] : desired_keys) {
for (const auto& [key_code, key] : desired_keys) {
if (state.pressed_keys.find(key_code) == state.pressed_keys.end()) {
keys_to_press.push_back(key);
}
}
}
for (const PressedKey &key : keys_to_release) {
for (const PressedKey& key : keys_to_release) {
if (InjectRemoteKey(key.key_code, false, key.scan_code, key.extended)) {
std::lock_guard<std::mutex> lock(remote_states_mutex_);
const auto state_it = remote_states_.find(remote_id);
@@ -309,7 +310,7 @@ void KeyboardController::ApplyRemoteState(const std::string &remote_id,
}
}
}
for (const PressedKey &key : keys_to_press) {
for (const PressedKey& key : keys_to_press) {
if (InjectRemoteKey(key.key_code, true, key.scan_code, key.extended)) {
std::lock_guard<std::mutex> lock(remote_states_mutex_);
remote_states_[remote_id].pressed_keys[key.key_code] = key;
@@ -317,8 +318,8 @@ void KeyboardController::ApplyRemoteState(const std::string &remote_id,
}
}
void KeyboardController::ReleaseRemotePressedKeys(const std::string &remote_id,
const char *reason) {
void KeyboardController::ReleaseRemotePressedKeys(const std::string& remote_id,
const char* reason) {
std::vector<PressedKey> keys_to_release;
{
std::lock_guard<std::mutex> lock(remote_states_mutex_);
@@ -326,7 +327,7 @@ void KeyboardController::ReleaseRemotePressedKeys(const std::string &remote_id,
if (state_it == remote_states_.end()) {
return;
}
for (const auto &[_, key] : state_it->second.pressed_keys) {
for (const auto& [_, key] : state_it->second.pressed_keys) {
keys_to_release.push_back(key);
}
remote_states_.erase(state_it);
@@ -336,7 +337,7 @@ void KeyboardController::ReleaseRemotePressedKeys(const std::string &remote_id,
LOG_WARN("Releasing {} remote keyboard keys for remote_id={}, reason={}",
keys_to_release.size(), remote_id, reason ? reason : "unknown");
}
for (const PressedKey &key : keys_to_release) {
for (const PressedKey& key : keys_to_release) {
InjectRemoteKey(key.key_code, false, key.scan_code, key.extended);
}
}
@@ -346,7 +347,7 @@ void KeyboardController::CheckRemoteTimeouts() {
std::vector<std::string> timed_out_remotes;
{
std::lock_guard<std::mutex> lock(remote_states_mutex_);
for (const auto &[remote_id, state] : remote_states_) {
for (const auto& [remote_id, state] : remote_states_) {
if (state.keyboard_state_seen && !state.pressed_keys.empty() &&
state.last_seen_tick != 0 &&
now - state.last_seen_tick > kRemoteReleaseTimeoutMs) {
@@ -354,9 +355,9 @@ void KeyboardController::CheckRemoteTimeouts() {
}
}
}
for (const std::string &remote_id : timed_out_remotes) {
for (const std::string& remote_id : timed_out_remotes) {
ReleaseRemotePressedKeys(remote_id, "keyboard_heartbeat_timeout");
}
}
} // namespace crossdesk
} // namespace crossdesk
+323 -46
View File
@@ -1,11 +1,24 @@
#include "features/settings/settings_manager.h"
#include <chrono>
#include <cstddef>
#include <cstdio>
#include <cstring>
#include <exception>
#include <filesystem>
#include <fstream>
#include <functional>
#include <memory>
#include <string>
#include <thread>
#if defined(_WIN32)
#include <io.h>
#include <windows.h>
#else
#include <fcntl.h>
#include <unistd.h>
#endif
#include "localization.h"
#include "rd_log.h"
@@ -14,6 +27,9 @@
namespace crossdesk {
namespace {
constexpr uint32_t kCacheV3Magic = 0x33444358; // "XCD3"
constexpr uint32_t kCacheV3Version = 3;
template <size_t Size>
void CopyString(char (&destination)[Size], const char *source) {
static_assert(Size > 0);
@@ -23,6 +39,99 @@ void CopyString(char (&destination)[Size], const char *source) {
}
}
uint32_t CacheChecksum(const void *data, size_t size) {
const auto *bytes = static_cast<const unsigned char *>(data);
uint32_t hash = 2166136261u;
for (size_t i = 0; i < size; ++i) {
hash ^= bytes[i];
hash *= 16777619u;
}
return hash;
}
std::filesystem::path TemporaryPathFor(
const std::filesystem::path &target) {
const auto timestamp = std::chrono::steady_clock::now()
.time_since_epoch()
.count();
const auto thread_id =
std::hash<std::thread::id>{}(std::this_thread::get_id());
std::filesystem::path temporary = target;
temporary += ".tmp-" + std::to_string(timestamp) + "-" +
std::to_string(thread_id);
return temporary;
}
bool FlushFileToDisk(FILE *file) {
if (!file || std::fflush(file) != 0) {
return false;
}
#if defined(_WIN32)
return _commit(_fileno(file)) == 0;
#else
return fsync(fileno(file)) == 0;
#endif
}
bool ReplaceFileAtomically(const std::filesystem::path &temporary,
const std::filesystem::path &target) {
#if defined(_WIN32)
return MoveFileExW(temporary.c_str(), target.c_str(),
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0;
#else
if (::rename(temporary.c_str(), target.c_str()) != 0) {
return false;
}
const std::filesystem::path parent = target.parent_path().empty()
? std::filesystem::path(".")
: target.parent_path();
int directory_flags = O_RDONLY;
#if defined(O_DIRECTORY)
directory_flags |= O_DIRECTORY;
#endif
const int directory = open(parent.c_str(), directory_flags);
if (directory >= 0) {
const bool synced = fsync(directory) == 0;
close(directory);
return synced;
}
return false;
#endif
}
bool WriteFileAtomically(const std::filesystem::path &target,
const void *data, size_t size) {
std::error_code ec;
const std::filesystem::path parent = target.parent_path();
if (!parent.empty()) {
std::filesystem::create_directories(parent, ec);
if (ec) {
return false;
}
}
const std::filesystem::path temporary = TemporaryPathFor(target);
#if defined(_WIN32)
FILE *file = _wfopen(temporary.c_str(), L"wb");
#else
FILE *file = std::fopen(temporary.c_str(), "wb");
#endif
if (!file) {
return false;
}
const bool written = std::fwrite(data, 1, size, file) == size;
const bool flushed = written && FlushFileToDisk(file);
const bool closed = std::fclose(file) == 0;
if (!written || !flushed || !closed ||
!ReplaceFileAtomically(temporary, target)) {
std::filesystem::remove(temporary, ec);
return false;
}
return true;
}
} // namespace
SettingsManager::SettingsManager(GuiRuntime &owner) : owner_(owner) {}
@@ -33,36 +142,74 @@ int SettingsManager::Save() {
}
int SettingsManager::SaveLocked() {
std::ofstream cache_v2_file(owner_.cache_path_ + "/secure_cache_v2.enc",
std::ios::binary);
if (!cache_v2_file) {
return -1;
}
CopyString(cache_v2_.client_id_with_password,
owner_.client_id_with_password_);
std::memcpy(cache_v2_.key, owner_.aes128_key_, sizeof(owner_.aes128_key_));
std::memcpy(cache_v2_.iv, owner_.aes128_iv_, sizeof(owner_.aes128_iv_));
CopyString(cache_v2_.self_hosted_id, owner_.self_hosted_id_);
cache_v2_file.write(reinterpret_cast<const char *>(&cache_v2_),
sizeof(cache_v2_));
cache_v3_.magic = kCacheV3Magic;
cache_v3_.version = kCacheV3Version;
cache_v3_.base = cache_v2_;
cache_v3_.pending_identity[sizeof(cache_v3_.pending_identity) - 1] = '\0';
cache_v3_.pending_server_host[sizeof(cache_v3_.pending_server_host) - 1] =
'\0';
cache_v3_.pending_request_id[sizeof(cache_v3_.pending_request_id) - 1] =
'\0';
cache_v3_.checksum =
CacheChecksum(&cache_v3_, offsetof(CacheV3, checksum));
if (!WriteFileAtomically(owner_.cache_path_ + "/secure_cache_v3.enc",
&cache_v3_, sizeof(cache_v3_))) {
return -1;
}
if (!WriteFileAtomically(owner_.cache_path_ + "/secure_cache_v2.enc",
&cache_v2_, sizeof(cache_v2_))) {
LOG_WARN("Failed to update legacy v2 credential cache");
}
// Keep writing the legacy cache while older installations may still read it.
std::ofstream cache_v1_file(owner_.cache_path_ + "/secure_cache.enc",
std::ios::binary);
if (cache_v1_file) {
CopyString(cache_v1_.client_id_with_password,
owner_.client_id_with_password_);
std::memcpy(cache_v1_.key, owner_.aes128_key_, sizeof(owner_.aes128_key_));
std::memcpy(cache_v1_.iv, owner_.aes128_iv_, sizeof(owner_.aes128_iv_));
cache_v1_file.write(reinterpret_cast<const char *>(&cache_v1_),
sizeof(cache_v1_));
CopyString(cache_v1_.client_id_with_password,
owner_.client_id_with_password_);
std::memcpy(cache_v1_.key, owner_.aes128_key_, sizeof(owner_.aes128_key_));
std::memcpy(cache_v1_.iv, owner_.aes128_iv_, sizeof(owner_.aes128_iv_));
if (!WriteFileAtomically(owner_.cache_path_ + "/secure_cache.enc",
&cache_v1_, sizeof(cache_v1_))) {
LOG_WARN("Failed to update legacy v1 credential cache");
}
return 0;
}
bool SettingsManager::ReadV3Locked() {
std::ifstream cache_file(owner_.cache_path_ + "/secure_cache_v3.enc",
std::ios::binary);
if (!cache_file) {
return false;
}
CacheV3 loaded{};
cache_file.read(reinterpret_cast<char *>(&loaded), sizeof(loaded));
if (cache_file.gcount() != static_cast<std::streamsize>(sizeof(loaded)) ||
loaded.magic != kCacheV3Magic ||
loaded.version != kCacheV3Version ||
loaded.checksum != CacheChecksum(&loaded, offsetof(CacheV3, checksum))) {
LOG_WARN("Ignore invalid v3 credential cache");
return false;
}
loaded.base.client_id_with_password
[sizeof(loaded.base.client_id_with_password) - 1] = '\0';
loaded.base.self_hosted_id[sizeof(loaded.base.self_hosted_id) - 1] = '\0';
loaded.pending_identity[sizeof(loaded.pending_identity) - 1] = '\0';
loaded.pending_server_host[sizeof(loaded.pending_server_host) - 1] = '\0';
loaded.pending_request_id[sizeof(loaded.pending_request_id) - 1] = '\0';
cache_v3_ = loaded;
cache_v2_ = loaded.base;
return true;
}
bool SettingsManager::ReadV2Locked() {
std::ifstream cache_v2_file(owner_.cache_path_ + "/secure_cache_v2.enc",
std::ios::binary);
@@ -84,13 +231,20 @@ bool SettingsManager::ReadV2Locked() {
int SettingsManager::Load() {
std::unique_lock<std::mutex> lock(cache_mutex_);
if (ReadV2Locked()) {
const bool loaded_v3 = ReadV3Locked();
if (loaded_v3 || ReadV2Locked()) {
CopyString(owner_.client_id_with_password_,
cache_v2_.client_id_with_password);
CopyString(owner_.self_hosted_id_, cache_v2_.self_hosted_id);
std::memcpy(owner_.aes128_key_, cache_v2_.key, sizeof(cache_v2_.key));
std::memcpy(owner_.aes128_iv_, cache_v2_.iv, sizeof(cache_v2_.iv));
LOG_INFO("Load settings from v2 cache file");
if (loaded_v3) {
LOG_INFO("Load settings from v3 cache file");
} else {
cache_v3_ = {};
SaveLocked();
LOG_INFO("Migrated settings from v2 to v3 cache file");
}
} else {
std::ifstream cache_v1_file(owner_.cache_path_ + "/secure_cache.enc",
std::ios::binary);
@@ -128,20 +282,14 @@ int SettingsManager::Load() {
std::memcpy(owner_.aes128_key_, cache_v1_.key, sizeof(cache_v1_.key));
std::memcpy(owner_.aes128_iv_, cache_v1_.iv, sizeof(cache_v1_.iv));
cache_v3_ = {};
SaveLocked();
LOG_INFO("Migrated settings from v1 to v2 cache file");
LOG_INFO("Migrated settings from v1 to v3 cache file");
}
lock.unlock();
const char *at_pos = std::strchr(owner_.client_id_with_password_, '@');
if (at_pos != nullptr) {
const std::string id(owner_.client_id_with_password_,
at_pos - owner_.client_id_with_password_);
const std::string password(at_pos + 1);
CopyString(owner_.client_id_, id.c_str());
CopyString(owner_.password_saved_, password.c_str());
}
ActivateCachedPublicIdentity();
owner_.thumbnail_ =
std::make_shared<Thumbnail>(owner_.cache_path_ + "/thumbnails/",
@@ -162,7 +310,6 @@ int SettingsManager::Load() {
owner_.enable_self_hosted_ = owner_.config_center_->IsSelfHosted();
owner_.enable_autostart_ = owner_.config_center_->IsEnableAutostart();
owner_.enable_daemon_ = owner_.config_center_->IsEnableDaemon();
owner_.enable_minimize_to_tray_ = owner_.config_center_->IsMinimizeToTray();
#if _WIN32 && CROSSDESK_PORTABLE
owner_.portable_service_prompt_suppressed_ =
owner_.config_center_->IsPortableServicePromptSuppressed();
@@ -185,7 +332,6 @@ int SettingsManager::Load() {
owner_.enable_srtp_last_ = owner_.enable_srtp_;
owner_.enable_self_hosted_last_ = owner_.enable_self_hosted_;
owner_.enable_autostart_last_ = owner_.enable_autostart_;
owner_.enable_minimize_to_tray_last_ = owner_.enable_minimize_to_tray_;
LOG_INFO("Load settings from cache file");
return 0;
@@ -193,8 +339,11 @@ int SettingsManager::Load() {
bool SettingsManager::LoadCachedSelfHostedIdentity() {
std::lock_guard<std::mutex> lock(cache_mutex_);
if (!ReadV2Locked() || cache_v2_.self_hosted_id[0] == '\0') {
if ((!ReadV3Locked() && !ReadV2Locked()) ||
cache_v2_.self_hosted_id[0] == '\0') {
std::memset(owner_.self_hosted_id_, 0, sizeof(owner_.self_hosted_id_));
std::memset(owner_.client_id_, 0, sizeof(owner_.client_id_));
std::memset(owner_.password_saved_, 0, sizeof(owner_.password_saved_));
return false;
}
@@ -212,27 +361,155 @@ bool SettingsManager::LoadCachedSelfHostedIdentity() {
return true;
}
bool SettingsManager::ActivateCachedPublicIdentity() {
if (owner_.client_id_with_password_[0] == '\0') {
std::memset(owner_.client_id_, 0, sizeof(owner_.client_id_));
std::memset(owner_.password_saved_, 0, sizeof(owner_.password_saved_));
return false;
}
const char *at_pos = std::strchr(owner_.client_id_with_password_, '@');
if (at_pos == nullptr) {
CopyString(owner_.client_id_, owner_.client_id_with_password_);
std::memset(owner_.password_saved_, 0, sizeof(owner_.password_saved_));
} else {
const std::string id(owner_.client_id_with_password_,
at_pos - owner_.client_id_with_password_);
CopyString(owner_.client_id_, id.c_str());
CopyString(owner_.password_saved_, at_pos + 1);
}
return owner_.client_id_[0] != '\0';
}
void SettingsManager::ActivateIdentity(const char *identity,
bool self_hosted) {
if (!identity) {
return;
}
if (self_hosted) {
CopyString(owner_.self_hosted_id_, identity);
} else {
CopyString(owner_.client_id_with_password_, identity);
}
const char *at_pos = std::strchr(identity, '@');
if (at_pos == nullptr) {
CopyString(owner_.client_id_, identity);
std::memset(owner_.password_saved_, 0, sizeof(owner_.password_saved_));
return;
}
const std::string id(identity, at_pos - identity);
CopyString(owner_.client_id_, id.c_str());
CopyString(owner_.password_saved_, at_pos + 1);
}
bool SettingsManager::StagePendingPasswordChange(
const std::string &identity, bool self_hosted,
const std::string &server_host, int server_port,
const std::string &request_id) {
if (identity.empty() || identity.size() >= sizeof(cache_v3_.pending_identity) ||
server_host.empty() ||
server_host.size() >= sizeof(cache_v3_.pending_server_host) ||
server_port <= 0 || request_id.empty() ||
request_id.size() >= sizeof(cache_v3_.pending_request_id)) {
return false;
}
std::lock_guard<std::mutex> lock(cache_mutex_);
if (cache_v3_.pending_identity[0] != '\0' &&
(cache_v3_.pending_self_hosted != self_hosted ||
cache_v3_.pending_server_port != server_port ||
server_host != cache_v3_.pending_server_host ||
request_id != cache_v3_.pending_request_id)) {
LOG_WARN("Refuse to overwrite an unresolved password change");
return false;
}
const CacheV3 previous = cache_v3_;
CopyString(cache_v3_.pending_identity, identity.c_str());
CopyString(cache_v3_.pending_server_host, server_host.c_str());
cache_v3_.pending_server_port = server_port;
cache_v3_.pending_self_hosted = self_hosted;
CopyString(cache_v3_.pending_request_id, request_id.c_str());
if (SaveLocked() != 0) {
cache_v3_ = previous;
return false;
}
return true;
}
std::string SettingsManager::PendingPasswordChangeIdentity(
bool self_hosted, const std::string &server_host, int server_port) const {
std::lock_guard<std::mutex> lock(cache_mutex_);
if (cache_v3_.pending_identity[0] == '\0' ||
cache_v3_.pending_self_hosted != self_hosted ||
cache_v3_.pending_server_port != server_port ||
server_host != cache_v3_.pending_server_host) {
return {};
}
return cache_v3_.pending_identity;
}
bool SettingsManager::PromotePendingPasswordChange() {
std::lock_guard<std::mutex> lock(cache_mutex_);
if (cache_v3_.pending_identity[0] == '\0') {
return true;
}
const CacheV3 previous = cache_v3_;
const std::string identity = cache_v3_.pending_identity;
const bool self_hosted = cache_v3_.pending_self_hosted;
ActivateIdentity(identity.c_str(), self_hosted);
std::memset(cache_v3_.pending_identity, 0,
sizeof(cache_v3_.pending_identity));
std::memset(cache_v3_.pending_server_host, 0,
sizeof(cache_v3_.pending_server_host));
cache_v3_.pending_server_port = 0;
cache_v3_.pending_self_hosted = false;
std::memset(cache_v3_.pending_request_id, 0,
sizeof(cache_v3_.pending_request_id));
if (SaveLocked() != 0) {
// The already-durable pending credential remains the recovery source on
// disk. Keep it in memory as well so a reconnect in this process retries
// the credential that the server has accepted.
cache_v3_ = previous;
return false;
}
return true;
}
bool SettingsManager::ClearPendingPasswordChange() {
std::lock_guard<std::mutex> lock(cache_mutex_);
if (cache_v3_.pending_identity[0] == '\0') {
return true;
}
const CacheV3 previous = cache_v3_;
std::memset(cache_v3_.pending_identity, 0,
sizeof(cache_v3_.pending_identity));
std::memset(cache_v3_.pending_server_host, 0,
sizeof(cache_v3_.pending_server_host));
cache_v3_.pending_server_port = 0;
cache_v3_.pending_self_hosted = false;
std::memset(cache_v3_.pending_request_id, 0,
sizeof(cache_v3_.pending_request_id));
if (SaveLocked() != 0) {
cache_v3_ = previous;
return false;
}
return true;
}
void SettingsManager::PersistSelfHostedIdentity(const char *client_id) {
if (!client_id) {
return;
}
std::lock_guard<std::mutex> lock(cache_mutex_);
if (!ReadV2Locked()) {
cache_v2_ = {};
}
CopyString(cache_v2_.self_hosted_id, client_id);
CopyString(cache_v2_.client_id_with_password,
owner_.client_id_with_password_);
std::memcpy(cache_v2_.key, owner_.aes128_key_, sizeof(owner_.aes128_key_));
std::memcpy(cache_v2_.iv, owner_.aes128_iv_, sizeof(owner_.aes128_iv_));
std::ofstream cache_v2_file(owner_.cache_path_ + "/secure_cache_v2.enc",
std::ios::binary);
if (cache_v2_file) {
cache_v2_file.write(reinterpret_cast<const char *>(&cache_v2_),
sizeof(cache_v2_));
CopyString(owner_.self_hosted_id_, client_id);
if (SaveLocked() != 0) {
LOG_ERROR("Failed to persist self-hosted identity atomically");
}
}
@@ -1,6 +1,7 @@
#ifndef CROSSDESK_GUI_SETTINGS_MANAGER_H_
#define CROSSDESK_GUI_SETTINGS_MANAGER_H_
#include <cstdint>
#include <mutex>
#include <string>
#include <unordered_map>
@@ -33,8 +34,28 @@ public:
// Loads the cached self-hosted identity into the owner's active connection
// fields. Returns true only when a non-empty identity was restored.
bool LoadCachedSelfHostedIdentity();
// Restores the public-server identity kept in the in-memory cache fields.
// This must run when switching away from a self-hosted server so signal
// callbacks are matched against the identity used by the replacement peer.
bool ActivateCachedPublicIdentity();
void PersistSelfHostedIdentity(const char *client_id);
// Password rotation is deliberately persisted in two phases. The active
// credential remains usable while the pending credential records the value
// that may already have been committed by the server. On the next launch the
// caller can try the pending credential first and safely fall back to the
// active credential when the request never reached the server.
bool StagePendingPasswordChange(const std::string &identity,
bool self_hosted,
const std::string &server_host,
int server_port,
const std::string &request_id);
std::string PendingPasswordChangeIdentity(
bool self_hosted, const std::string &server_host,
int server_port) const;
bool PromotePendingPasswordChange();
bool ClearPendingPasswordChange();
private:
struct CacheV1 {
char client_id_with_password[17];
@@ -63,12 +84,27 @@ private:
char self_hosted_id[17];
};
struct CacheV3 {
uint32_t magic;
uint32_t version;
CacheV2 base;
char pending_identity[17];
char pending_server_host[256];
int pending_server_port;
bool pending_self_hosted;
char pending_request_id[64];
uint32_t checksum;
};
int SaveLocked();
bool ReadV3Locked();
bool ReadV2Locked();
void ActivateIdentity(const char *identity, bool self_hosted);
GuiRuntime &owner_;
CacheV1 cache_v1_{};
CacheV2 cache_v2_{};
CacheV3 cache_v3_{};
mutable std::mutex cache_mutex_;
std::unordered_map<std::string, std::string> recent_connection_aliases_;
};
+82
View File
@@ -0,0 +1,82 @@
#ifndef CROSSDESK_GUI_PLATFORM_METAL_VIDEO_RENDERER_H_
#define CROSSDESK_GUI_PLATFORM_METAL_VIDEO_RENDERER_H_
#include <cstddef>
#include <cstdint>
#include <memory>
#include <string>
#include <string_view>
#include <vector>
#include "platform/video_renderer.h"
namespace crossdesk {
// macOS video compositor used by CrossDesk only. MiniRTC continues to expose
// tightly packed CPU NV12 frames; this renderer performs the single required
// copy into shared Metal buffers and converts NV12 to RGB in the fragment
// shader. AppKit attachment and rendering are main-thread operations, while
// SubmitNv12() is safe to call from MiniRTC's decode callback thread.
class MacMetalVideoRenderer final : public VideoRenderer {
public:
MacMetalVideoRenderer();
~MacMetalVideoRenderer() override;
MacMetalVideoRenderer(const MacMetalVideoRenderer&) = delete;
MacMetalVideoRenderer& operator=(const MacMetalVideoRenderer&) = delete;
bool IsReady() const override;
bool IsActive() const override;
// The selected stream is the only stream uploaded. Frames for background
// tabs are intentionally dropped to avoid wasting memory bandwidth.
bool SetSelectedStream(std::string remote_id) override;
void DiscardStream(std::string_view remote_id) override;
// Copies one tightly packed NV12 frame into a free shared Metal slot.
SubmitResult SubmitNv12(std::string_view remote_id, const uint8_t* data,
size_t size, int width, int height) override;
// Seeds a newly selected stream from its retained CPU snapshot without
// replacing a newer decoded frame that is already queued or rendering.
SubmitResult SubmitCachedNv12(std::string_view remote_id,
const uint8_t* data, size_t size, int width,
int height) override;
// Attaches a native CAMetalLayer-backed sibling below Slint's NSView and
// restores the containing AppKit window's ordinary opaque titlebar.
// |slint_view| is an NSView* kept opaque so this header remains valid C++.
bool Attach(void* slint_view);
void Detach();
bool IsAttached() const;
// Cheap main-thread geometry check used by the UI frame timer. New decoded
// frames are tracked by GuiRuntime's dirty flag; this covers native resize,
// scale, stream selection, and retry work without a full session sync.
bool NeedsSurfaceRedraw() const;
// Draws the newest queued frame for |remote_id|. top_inset_points reserves
// Slint's tab strip above the native video surface. A rendered outcome
// carries metadata from the exact slot submitted to the command buffer.
RenderOutcome RenderLatest(std::string_view remote_id,
double top_inset_points,
bool native_titlebar_visible);
// Used only when a session closes, so thumbnail generation does not require
// a second per-frame CPU copy during normal playback.
bool CopyLatestNv12(std::string_view remote_id,
std::vector<unsigned char>* output, int* width,
int* height) const override;
private:
SubmitResult SubmitNv12Internal(std::string_view remote_id,
const uint8_t* data, size_t size, int width,
int height, bool replace_pending);
struct Impl;
std::unique_ptr<Impl> impl_;
};
} // namespace crossdesk
#endif // CROSSDESK_GUI_PLATFORM_METAL_VIDEO_RENDERER_H_
File diff suppressed because it is too large Load Diff
+928
View File
@@ -0,0 +1,928 @@
#include "platform/opengl_video_renderer.h"
#if !defined(_WIN32) && !defined(__linux__)
#error "OpenGlVideoRenderer is only available on Windows and Linux"
#endif
#if defined(_WIN32)
#include <windows.h>
#elif defined(__linux__)
#include <dlfcn.h>
#endif
#include <GL/gl.h>
#include <GL/glext.h>
#if defined(__linux__)
#include <GL/glx.h>
#endif
#include <algorithm>
#include <array>
#include <atomic>
#include <cstring>
#include <limits>
#include <mutex>
#include <string>
#include <utility>
#include "rd_log.h"
namespace crossdesk {
namespace {
constexpr size_t kFrameSlotCount = 3;
enum class SlotUse {
available,
pending,
uploading,
};
struct FrameSlot {
std::vector<unsigned char> bytes;
int width = 0;
int height = 0;
std::string remote_id;
uint64_t sequence = 0;
SlotUse use = SlotUse::available;
bool valid = false;
};
struct SharedFrameState {
mutable std::mutex mutex;
std::array<FrameSlot, kFrameSlotCount> slots;
std::string selected_stream;
uint64_t next_sequence = 1;
};
template <typename T> bool LoadOpenGlFunction(T *function, const char *name) {
#if defined(_WIN32)
PROC address = wglGetProcAddress(name);
if (address == nullptr || address == reinterpret_cast<PROC>(1) ||
address == reinterpret_cast<PROC>(2) ||
address == reinterpret_cast<PROC>(3) ||
address == reinterpret_cast<PROC>(-1)) {
HMODULE module = GetModuleHandleW(L"opengl32.dll");
address = module ? GetProcAddress(module, name) : nullptr;
}
*function = reinterpret_cast<T>(address);
#elif defined(__linux__)
void *address = dlsym(RTLD_DEFAULT, name);
if (address == nullptr) {
address = reinterpret_cast<void *>(
glXGetProcAddressARB(reinterpret_cast<const GLubyte *>(name)));
}
*function = reinterpret_cast<T>(address);
#endif
return *function != nullptr;
}
struct OpenGlFunctions {
PFNGLACTIVETEXTUREPROC active_texture = nullptr;
PFNGLATTACHSHADERPROC attach_shader = nullptr;
PFNGLBINDBUFFERPROC bind_buffer = nullptr;
PFNGLBUFFERDATAPROC buffer_data = nullptr;
PFNGLCOMPILESHADERPROC compile_shader = nullptr;
PFNGLCREATEPROGRAMPROC create_program = nullptr;
PFNGLCREATESHADERPROC create_shader = nullptr;
PFNGLDELETEBUFFERSPROC delete_buffers = nullptr;
PFNGLDELETEPROGRAMPROC delete_program = nullptr;
PFNGLDELETESHADERPROC delete_shader = nullptr;
PFNGLDISABLEVERTEXATTRIBARRAYPROC disable_vertex_attrib_array = nullptr;
PFNGLENABLEVERTEXATTRIBARRAYPROC enable_vertex_attrib_array = nullptr;
PFNGLGENBUFFERSPROC gen_buffers = nullptr;
PFNGLGETATTRIBLOCATIONPROC get_attrib_location = nullptr;
PFNGLGETPROGRAMINFOLOGPROC get_program_info_log = nullptr;
PFNGLGETPROGRAMIVPROC get_program_iv = nullptr;
PFNGLGETSHADERINFOLOGPROC get_shader_info_log = nullptr;
PFNGLGETSHADERIVPROC get_shader_iv = nullptr;
PFNGLGETUNIFORMLOCATIONPROC get_uniform_location = nullptr;
PFNGLGETVERTEXATTRIBIVPROC get_vertex_attrib_iv = nullptr;
PFNGLGETVERTEXATTRIBPOINTERVPROC get_vertex_attrib_pointer_v = nullptr;
PFNGLLINKPROGRAMPROC link_program = nullptr;
PFNGLSHADERSOURCEPROC shader_source = nullptr;
PFNGLUNIFORM1FPROC uniform_1f = nullptr;
PFNGLUNIFORM1IPROC uniform_1i = nullptr;
PFNGLUNIFORM2FPROC uniform_2f = nullptr;
PFNGLUNIFORM4FPROC uniform_4f = nullptr;
PFNGLUSEPROGRAMPROC use_program = nullptr;
PFNGLVERTEXATTRIBPOINTERPROC vertex_attrib_pointer = nullptr;
bool Load() {
return LoadOpenGlFunction(&active_texture, "glActiveTexture") &&
LoadOpenGlFunction(&attach_shader, "glAttachShader") &&
LoadOpenGlFunction(&bind_buffer, "glBindBuffer") &&
LoadOpenGlFunction(&buffer_data, "glBufferData") &&
LoadOpenGlFunction(&compile_shader, "glCompileShader") &&
LoadOpenGlFunction(&create_program, "glCreateProgram") &&
LoadOpenGlFunction(&create_shader, "glCreateShader") &&
LoadOpenGlFunction(&delete_buffers, "glDeleteBuffers") &&
LoadOpenGlFunction(&delete_program, "glDeleteProgram") &&
LoadOpenGlFunction(&delete_shader, "glDeleteShader") &&
LoadOpenGlFunction(&disable_vertex_attrib_array,
"glDisableVertexAttribArray") &&
LoadOpenGlFunction(&enable_vertex_attrib_array,
"glEnableVertexAttribArray") &&
LoadOpenGlFunction(&gen_buffers, "glGenBuffers") &&
LoadOpenGlFunction(&get_attrib_location, "glGetAttribLocation") &&
LoadOpenGlFunction(&get_program_info_log, "glGetProgramInfoLog") &&
LoadOpenGlFunction(&get_program_iv, "glGetProgramiv") &&
LoadOpenGlFunction(&get_shader_info_log, "glGetShaderInfoLog") &&
LoadOpenGlFunction(&get_shader_iv, "glGetShaderiv") &&
LoadOpenGlFunction(&get_uniform_location, "glGetUniformLocation") &&
LoadOpenGlFunction(&get_vertex_attrib_iv, "glGetVertexAttribiv") &&
LoadOpenGlFunction(&get_vertex_attrib_pointer_v,
"glGetVertexAttribPointerv") &&
LoadOpenGlFunction(&link_program, "glLinkProgram") &&
LoadOpenGlFunction(&shader_source, "glShaderSource") &&
LoadOpenGlFunction(&uniform_1f, "glUniform1f") &&
LoadOpenGlFunction(&uniform_1i, "glUniform1i") &&
LoadOpenGlFunction(&uniform_2f, "glUniform2f") &&
LoadOpenGlFunction(&uniform_4f, "glUniform4f") &&
LoadOpenGlFunction(&use_program, "glUseProgram") &&
LoadOpenGlFunction(&vertex_attrib_pointer, "glVertexAttribPointer");
}
};
GLuint CompileShader(const OpenGlFunctions &gl, GLenum type,
const char *source) {
const GLuint shader = gl.create_shader(type);
if (shader == 0) {
return 0;
}
gl.shader_source(shader, 1, &source, nullptr);
gl.compile_shader(shader);
GLint compiled = GL_FALSE;
gl.get_shader_iv(shader, GL_COMPILE_STATUS, &compiled);
if (compiled == GL_TRUE) {
return shader;
}
GLint log_length = 0;
gl.get_shader_iv(shader, GL_INFO_LOG_LENGTH, &log_length);
std::string log(static_cast<size_t>(std::max(log_length, 1)), '\0');
GLsizei written = 0;
gl.get_shader_info_log(shader, static_cast<GLsizei>(log.size()), &written,
log.data());
if (written >= 0 && static_cast<size_t>(written) < log.size()) {
log.resize(static_cast<size_t>(written));
}
LOG_ERROR("OpenGL NV12 {} shader compilation failed: {}",
type == GL_VERTEX_SHADER ? "vertex" : "fragment", log);
gl.delete_shader(shader);
return 0;
}
bool IsEnabled(GLenum capability) { return glIsEnabled(capability) == GL_TRUE; }
void RestoreCapability(GLenum capability, bool enabled) {
if (enabled) {
glEnable(capability);
} else {
glDisable(capability);
}
}
struct VertexAttribState {
GLint enabled = GL_FALSE;
GLint size = 4;
GLint stride = 0;
GLint type = GL_FLOAT;
GLint normalized = GL_FALSE;
GLint buffer = 0;
void *pointer = nullptr;
};
VertexAttribState CaptureVertexAttrib(const OpenGlFunctions &gl, GLuint index) {
VertexAttribState state;
gl.get_vertex_attrib_iv(index, GL_VERTEX_ATTRIB_ARRAY_ENABLED,
&state.enabled);
gl.get_vertex_attrib_iv(index, GL_VERTEX_ATTRIB_ARRAY_SIZE, &state.size);
gl.get_vertex_attrib_iv(index, GL_VERTEX_ATTRIB_ARRAY_STRIDE, &state.stride);
gl.get_vertex_attrib_iv(index, GL_VERTEX_ATTRIB_ARRAY_TYPE, &state.type);
gl.get_vertex_attrib_iv(index, GL_VERTEX_ATTRIB_ARRAY_NORMALIZED,
&state.normalized);
gl.get_vertex_attrib_iv(index, GL_VERTEX_ATTRIB_ARRAY_BUFFER_BINDING,
&state.buffer);
gl.get_vertex_attrib_pointer_v(index, GL_VERTEX_ATTRIB_ARRAY_POINTER,
&state.pointer);
return state;
}
void RestoreVertexAttrib(const OpenGlFunctions &gl, GLuint index,
const VertexAttribState &state) {
if (state.buffer != 0 || state.pointer != nullptr) {
gl.bind_buffer(GL_ARRAY_BUFFER, static_cast<GLuint>(state.buffer));
gl.vertex_attrib_pointer(index, state.size, static_cast<GLenum>(state.type),
static_cast<GLboolean>(state.normalized),
state.stride, state.pointer);
}
if (state.enabled == GL_TRUE) {
gl.enable_vertex_attrib_array(index);
} else {
gl.disable_vertex_attrib_array(index);
}
}
} // namespace
struct OpenGlVideoRenderer::Impl {
OpenGlFunctions gl;
std::shared_ptr<SharedFrameState> frames =
std::make_shared<SharedFrameState>();
std::atomic<bool> ready{false};
GLuint program = 0;
GLuint vertex_buffer = 0;
GLuint y_texture = 0;
GLuint uv_texture = 0;
GLint position_location = -1;
GLint texcoord_location = -1;
GLint y_texture_location = -1;
GLint uv_texture_location = -1;
GLint target_size_location = -1;
GLint video_rect_location = -1;
GLint corner_radius_location = -1;
GLint video_enabled_location = -1;
int texture_width = 0;
int texture_height = 0;
std::string uploaded_stream;
uint64_t uploaded_sequence = 0;
void ResetGlHandles() {
program = 0;
vertex_buffer = 0;
y_texture = 0;
uv_texture = 0;
position_location = -1;
texcoord_location = -1;
y_texture_location = -1;
uv_texture_location = -1;
target_size_location = -1;
video_rect_location = -1;
corner_radius_location = -1;
video_enabled_location = -1;
texture_width = 0;
texture_height = 0;
uploaded_stream.clear();
uploaded_sequence = 0;
}
};
OpenGlVideoRenderer::OpenGlVideoRenderer()
: impl_(std::make_unique<Impl>()) {}
OpenGlVideoRenderer::~OpenGlVideoRenderer() = default;
bool OpenGlVideoRenderer::Setup() {
if (IsReady()) {
return true;
}
impl_->ready.store(false, std::memory_order_release);
impl_->ResetGlHandles();
if (!impl_->gl.Load()) {
LOG_WARN("OpenGL NV12 underlay unavailable: required functions missing");
return false;
}
static constexpr char kGlesVertexShader[] = R"glsl(
#version 100
attribute vec2 position;
attribute vec2 texcoord;
varying vec2 video_texcoord;
void main() {
video_texcoord = texcoord;
gl_Position = vec4(position, 0.0, 1.0);
}
)glsl";
static constexpr char kGlesFragmentShader[] = R"glsl(
#version 100
#ifdef GL_FRAGMENT_PRECISION_HIGH
precision highp float;
#else
precision mediump float;
#endif
uniform sampler2D y_texture;
uniform sampler2D uv_texture;
uniform vec2 target_size;
uniform vec4 video_rect;
uniform float corner_radius;
uniform float video_enabled;
varying vec2 video_texcoord;
void main() {
vec2 surface_point = video_texcoord * target_size;
vec3 rgb = vec3(0.0);
if (video_enabled > 0.5 &&
surface_point.x >= video_rect.x &&
surface_point.x <= video_rect.x + video_rect.z &&
surface_point.y >= video_rect.y &&
surface_point.y <= video_rect.y + video_rect.w) {
vec2 frame_texcoord =
(surface_point - video_rect.xy) / video_rect.zw;
float y = 1.16438356 *
(texture2D(y_texture, frame_texcoord).r - 16.0 / 255.0);
vec2 uv =
texture2D(uv_texture, frame_texcoord).ra - vec2(0.5, 0.5);
rgb = clamp(vec3(y + 1.59602678 * uv.y,
y - 0.39176229 * uv.x - 0.81296764 * uv.y,
y + 2.01723214 * uv.x),
0.0, 1.0);
}
float coverage = 1.0;
if (corner_radius > 0.0) {
vec2 half_size = target_size * 0.5;
vec2 corner = abs(surface_point - half_size) -
(half_size - vec2(corner_radius));
float distance_to_edge =
length(max(corner, vec2(0.0))) +
min(max(corner.x, corner.y), 0.0) - corner_radius;
coverage = clamp(0.5 - distance_to_edge, 0.0, 1.0);
}
gl_FragColor = vec4(rgb * coverage, coverage);
}
)glsl";
static constexpr char kDesktopVertexShader[] = R"glsl(
#version 110
attribute vec2 position;
attribute vec2 texcoord;
varying vec2 video_texcoord;
void main() {
video_texcoord = texcoord;
gl_Position = vec4(position, 0.0, 1.0);
}
)glsl";
static constexpr char kDesktopFragmentShader[] = R"glsl(
#version 110
uniform sampler2D y_texture;
uniform sampler2D uv_texture;
uniform vec2 target_size;
uniform vec4 video_rect;
uniform float corner_radius;
uniform float video_enabled;
varying vec2 video_texcoord;
void main() {
vec2 surface_point = video_texcoord * target_size;
vec3 rgb = vec3(0.0);
if (video_enabled > 0.5 &&
surface_point.x >= video_rect.x &&
surface_point.x <= video_rect.x + video_rect.z &&
surface_point.y >= video_rect.y &&
surface_point.y <= video_rect.y + video_rect.w) {
vec2 frame_texcoord =
(surface_point - video_rect.xy) / video_rect.zw;
float y = 1.16438356 *
(texture2D(y_texture, frame_texcoord).r - 16.0 / 255.0);
vec2 uv =
texture2D(uv_texture, frame_texcoord).ra - vec2(0.5, 0.5);
rgb = clamp(vec3(y + 1.59602678 * uv.y,
y - 0.39176229 * uv.x - 0.81296764 * uv.y,
y + 2.01723214 * uv.x),
0.0, 1.0);
}
float coverage = 1.0;
if (corner_radius > 0.0) {
vec2 half_size = target_size * 0.5;
vec2 corner = abs(surface_point - half_size) -
(half_size - vec2(corner_radius));
float distance_to_edge =
length(max(corner, vec2(0.0))) +
min(max(corner.x, corner.y), 0.0) - corner_radius;
coverage = clamp(0.5 - distance_to_edge, 0.0, 1.0);
}
gl_FragColor = vec4(rgb * coverage, coverage);
}
)glsl";
const auto *version = reinterpret_cast<const char *>(glGetString(GL_VERSION));
const bool is_gles = version && std::strstr(version, "OpenGL ES") != nullptr;
const char *vertex_source =
is_gles ? kGlesVertexShader : kDesktopVertexShader;
const char *fragment_source =
is_gles ? kGlesFragmentShader : kDesktopFragmentShader;
const GLuint vertex_shader =
CompileShader(impl_->gl, GL_VERTEX_SHADER, vertex_source);
const GLuint fragment_shader =
CompileShader(impl_->gl, GL_FRAGMENT_SHADER, fragment_source);
if (vertex_shader == 0 || fragment_shader == 0) {
if (vertex_shader != 0)
impl_->gl.delete_shader(vertex_shader);
if (fragment_shader != 0)
impl_->gl.delete_shader(fragment_shader);
return false;
}
impl_->program = impl_->gl.create_program();
impl_->gl.attach_shader(impl_->program, vertex_shader);
impl_->gl.attach_shader(impl_->program, fragment_shader);
impl_->gl.link_program(impl_->program);
impl_->gl.delete_shader(vertex_shader);
impl_->gl.delete_shader(fragment_shader);
GLint linked = GL_FALSE;
impl_->gl.get_program_iv(impl_->program, GL_LINK_STATUS, &linked);
if (linked != GL_TRUE) {
GLint log_length = 0;
impl_->gl.get_program_iv(impl_->program, GL_INFO_LOG_LENGTH, &log_length);
std::string log(static_cast<size_t>(std::max(log_length, 1)), '\0');
GLsizei written = 0;
impl_->gl.get_program_info_log(
impl_->program, static_cast<GLsizei>(log.size()), &written, log.data());
if (written >= 0 && static_cast<size_t>(written) < log.size()) {
log.resize(static_cast<size_t>(written));
}
LOG_ERROR("OpenGL NV12 program link failed: {}", log);
impl_->gl.delete_program(impl_->program);
impl_->ResetGlHandles();
return false;
}
impl_->position_location =
impl_->gl.get_attrib_location(impl_->program, "position");
impl_->texcoord_location =
impl_->gl.get_attrib_location(impl_->program, "texcoord");
impl_->y_texture_location =
impl_->gl.get_uniform_location(impl_->program, "y_texture");
impl_->uv_texture_location =
impl_->gl.get_uniform_location(impl_->program, "uv_texture");
impl_->target_size_location =
impl_->gl.get_uniform_location(impl_->program, "target_size");
impl_->video_rect_location =
impl_->gl.get_uniform_location(impl_->program, "video_rect");
impl_->corner_radius_location =
impl_->gl.get_uniform_location(impl_->program, "corner_radius");
impl_->video_enabled_location =
impl_->gl.get_uniform_location(impl_->program, "video_enabled");
if (impl_->position_location < 0 || impl_->texcoord_location < 0 ||
impl_->y_texture_location < 0 || impl_->uv_texture_location < 0 ||
impl_->target_size_location < 0 || impl_->video_rect_location < 0 ||
impl_->corner_radius_location < 0 ||
impl_->video_enabled_location < 0) {
LOG_ERROR("OpenGL NV12 program is missing required shader bindings");
impl_->gl.delete_program(impl_->program);
impl_->ResetGlHandles();
return false;
}
static constexpr GLfloat kVertices[] = {
-1.0f, 1.0f, 0.0f, 0.0f, // top-left
-1.0f, -1.0f, 0.0f, 1.0f, // bottom-left
1.0f, 1.0f, 1.0f, 0.0f, // top-right
1.0f, -1.0f, 1.0f, 1.0f, // bottom-right
};
GLint previous_array_buffer = 0;
glGetIntegerv(GL_ARRAY_BUFFER_BINDING, &previous_array_buffer);
impl_->gl.gen_buffers(1, &impl_->vertex_buffer);
impl_->gl.bind_buffer(GL_ARRAY_BUFFER, impl_->vertex_buffer);
impl_->gl.buffer_data(GL_ARRAY_BUFFER, sizeof(kVertices), kVertices,
GL_STATIC_DRAW);
impl_->gl.bind_buffer(GL_ARRAY_BUFFER,
static_cast<GLuint>(previous_array_buffer));
GLint previous_active_texture = GL_TEXTURE0;
glGetIntegerv(GL_ACTIVE_TEXTURE, &previous_active_texture);
impl_->gl.active_texture(GL_TEXTURE0);
GLint previous_texture_0 = 0;
glGetIntegerv(GL_TEXTURE_BINDING_2D, &previous_texture_0);
impl_->gl.active_texture(GL_TEXTURE1);
GLint previous_texture_1 = 0;
glGetIntegerv(GL_TEXTURE_BINDING_2D, &previous_texture_1);
while (glGetError() != GL_NO_ERROR) {
}
glGenTextures(1, &impl_->y_texture);
glGenTextures(1, &impl_->uv_texture);
for (const auto [unit, texture] :
{std::pair{GL_TEXTURE0, impl_->y_texture},
std::pair{GL_TEXTURE1, impl_->uv_texture}}) {
impl_->gl.active_texture(unit);
glBindTexture(GL_TEXTURE_2D, texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
}
impl_->gl.active_texture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, static_cast<GLuint>(previous_texture_0));
impl_->gl.active_texture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, static_cast<GLuint>(previous_texture_1));
impl_->gl.active_texture(static_cast<GLenum>(previous_active_texture));
const GLenum setup_error = glGetError();
if (impl_->program == 0 || impl_->vertex_buffer == 0 ||
impl_->y_texture == 0 || impl_->uv_texture == 0 ||
setup_error != GL_NO_ERROR) {
LOG_ERROR("OpenGL NV12 resource setup failed, error={}", setup_error);
Teardown();
return false;
}
impl_->ready.store(true, std::memory_order_release);
LOG_INFO("OpenGL NV12 underlay initialized ({})",
version ? version : "unknown OpenGL version");
return true;
}
void OpenGlVideoRenderer::Teardown() {
impl_->ready.store(false, std::memory_order_release);
if (impl_->y_texture != 0)
glDeleteTextures(1, &impl_->y_texture);
if (impl_->uv_texture != 0)
glDeleteTextures(1, &impl_->uv_texture);
if (impl_->vertex_buffer != 0 && impl_->gl.delete_buffers) {
impl_->gl.delete_buffers(1, &impl_->vertex_buffer);
}
if (impl_->program != 0 && impl_->gl.delete_program) {
impl_->gl.delete_program(impl_->program);
}
impl_->ResetGlHandles();
}
bool OpenGlVideoRenderer::IsReady() const {
return impl_->ready.load(std::memory_order_acquire);
}
bool OpenGlVideoRenderer::IsActive() const { return IsReady(); }
bool OpenGlVideoRenderer::SetSelectedStream(std::string remote_id) {
std::lock_guard lock(impl_->frames->mutex);
if (impl_->frames->selected_stream == remote_id) {
return false;
}
impl_->frames->selected_stream = std::move(remote_id);
for (auto &slot : impl_->frames->slots) {
if (slot.use == SlotUse::pending &&
slot.remote_id != impl_->frames->selected_stream) {
slot.use = SlotUse::available;
}
}
return true;
}
void OpenGlVideoRenderer::DiscardStream(std::string_view remote_id) {
std::lock_guard lock(impl_->frames->mutex);
for (auto &slot : impl_->frames->slots) {
if (slot.remote_id == remote_id && slot.use != SlotUse::uploading) {
slot.valid = false;
slot.use = SlotUse::available;
slot.remote_id.clear();
}
}
if (impl_->frames->selected_stream == remote_id) {
impl_->frames->selected_stream.clear();
}
}
OpenGlVideoRenderer::SubmitResult
OpenGlVideoRenderer::SubmitNv12(std::string_view remote_id,
const uint8_t *data, size_t size,
int width, int height) {
return SubmitNv12Internal(remote_id, data, size, width, height, true);
}
OpenGlVideoRenderer::SubmitResult
OpenGlVideoRenderer::SubmitCachedNv12(std::string_view remote_id,
const uint8_t *data, size_t size,
int width, int height) {
return SubmitNv12Internal(remote_id, data, size, width, height, false);
}
OpenGlVideoRenderer::SubmitResult
OpenGlVideoRenderer::SubmitNv12Internal(std::string_view remote_id,
const uint8_t *data, size_t size,
int width, int height,
bool replace_pending) {
if (!IsReady()) {
return SubmitResult::failed;
}
if (data == nullptr || width <= 0 || height <= 0 || (width & 1) != 0 ||
(height & 1) != 0) {
return SubmitResult::failed;
}
const size_t y_size = static_cast<size_t>(width) * height;
const size_t required_size = y_size + y_size / 2;
if (size < required_size) {
return SubmitResult::failed;
}
auto &frames = *impl_->frames;
std::lock_guard lock(frames.mutex);
if (frames.selected_stream != remote_id) {
return SubmitResult::not_selected;
}
FrameSlot *target = nullptr;
if (replace_pending) {
for (auto &slot : frames.slots) {
if (slot.use == SlotUse::pending) {
target = &slot;
break;
}
}
} else {
for (const auto &slot : frames.slots) {
if (slot.valid && slot.remote_id == remote_id &&
(slot.use == SlotUse::pending || slot.use == SlotUse::uploading)) {
return SubmitResult::dropped;
}
}
}
if (target == nullptr) {
uint64_t oldest_sequence = std::numeric_limits<uint64_t>::max();
for (auto &slot : frames.slots) {
if (slot.use != SlotUse::available) {
continue;
}
if (!slot.valid) {
target = &slot;
break;
}
if (slot.sequence < oldest_sequence) {
oldest_sequence = slot.sequence;
target = &slot;
}
}
}
if (target == nullptr) {
return SubmitResult::dropped;
}
target->bytes.resize(required_size);
std::memcpy(target->bytes.data(), data, required_size);
target->width = width;
target->height = height;
target->remote_id.assign(remote_id);
target->sequence = frames.next_sequence++;
target->use = SlotUse::pending;
target->valid = true;
return SubmitResult::submitted;
}
OpenGlVideoRenderer::RenderOutcome
OpenGlVideoRenderer::RenderLatest(std::string_view remote_id,
int target_width, int target_height,
int top_inset_pixels,
int corner_radius_pixels) {
if (!IsReady() || target_width <= 0 || target_height <= 0) {
return {RenderResult::failed};
}
size_t slot_index = kFrameSlotCount;
uint64_t sequence = 0;
int source_width = 0;
int source_height = 0;
const unsigned char *frame_data = nullptr;
{
std::lock_guard lock(impl_->frames->mutex);
for (size_t index = 0; index < impl_->frames->slots.size(); ++index) {
const auto &slot = impl_->frames->slots[index];
if (slot.valid && slot.use == SlotUse::pending &&
slot.remote_id == remote_id &&
(slot_index == kFrameSlotCount || slot.sequence > sequence)) {
slot_index = index;
sequence = slot.sequence;
}
}
if (slot_index != kFrameSlotCount) {
auto &selected = impl_->frames->slots[slot_index];
selected.use = SlotUse::uploading;
source_width = selected.width;
source_height = selected.height;
frame_data = selected.bytes.data();
for (size_t index = 0; index < impl_->frames->slots.size(); ++index) {
auto &slot = impl_->frames->slots[index];
if (index != slot_index && slot.use == SlotUse::pending &&
slot.remote_id == remote_id) {
slot.use = SlotUse::available;
}
}
}
}
GLint previous_program = 0;
GLint previous_active_texture = GL_TEXTURE0;
GLint previous_array_buffer = 0;
GLint previous_unpack_alignment = 4;
GLint previous_viewport[4] = {};
GLint previous_scissor_box[4] = {};
GLfloat previous_clear_color[4] = {};
GLboolean previous_color_mask[4] = {};
glGetIntegerv(GL_CURRENT_PROGRAM, &previous_program);
glGetIntegerv(GL_ACTIVE_TEXTURE, &previous_active_texture);
glGetIntegerv(GL_ARRAY_BUFFER_BINDING, &previous_array_buffer);
glGetIntegerv(GL_UNPACK_ALIGNMENT, &previous_unpack_alignment);
glGetIntegerv(GL_VIEWPORT, previous_viewport);
glGetIntegerv(GL_SCISSOR_BOX, previous_scissor_box);
glGetFloatv(GL_COLOR_CLEAR_VALUE, previous_clear_color);
glGetBooleanv(GL_COLOR_WRITEMASK, previous_color_mask);
const bool blend_enabled = IsEnabled(GL_BLEND);
const bool cull_enabled = IsEnabled(GL_CULL_FACE);
const bool depth_enabled = IsEnabled(GL_DEPTH_TEST);
const bool scissor_enabled = IsEnabled(GL_SCISSOR_TEST);
const bool stencil_enabled = IsEnabled(GL_STENCIL_TEST);
impl_->gl.active_texture(GL_TEXTURE0);
GLint previous_texture_0 = 0;
glGetIntegerv(GL_TEXTURE_BINDING_2D, &previous_texture_0);
impl_->gl.active_texture(GL_TEXTURE1);
GLint previous_texture_1 = 0;
glGetIntegerv(GL_TEXTURE_BINDING_2D, &previous_texture_1);
const auto position_state = CaptureVertexAttrib(
impl_->gl, static_cast<GLuint>(impl_->position_location));
const auto texcoord_state = CaptureVertexAttrib(
impl_->gl, static_cast<GLuint>(impl_->texcoord_location));
glDisable(GL_BLEND);
glDisable(GL_CULL_FACE);
glDisable(GL_DEPTH_TEST);
glDisable(GL_SCISSOR_TEST);
glDisable(GL_STENCIL_TEST);
glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
glViewport(0, 0, target_width, target_height);
// The Slint items above this underlay are transparent around the custom
// window corners. Keep those pixels transparent so the compositor can show
// the desktop instead of an opaque black framebuffer clear.
glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
glClear(GL_COLOR_BUFFER_BIT);
bool upload_succeeded = true;
if (slot_index != kFrameSlotCount) {
while (glGetError() != GL_NO_ERROR) {
}
const size_t y_size = static_cast<size_t>(source_width) * source_height;
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
impl_->gl.active_texture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, impl_->y_texture);
impl_->gl.active_texture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, impl_->uv_texture);
if (impl_->texture_width != source_width ||
impl_->texture_height != source_height) {
impl_->gl.active_texture(GL_TEXTURE0);
glTexImage2D(GL_TEXTURE_2D, 0, GL_LUMINANCE, source_width, source_height,
0, GL_LUMINANCE, GL_UNSIGNED_BYTE, frame_data);
impl_->gl.active_texture(GL_TEXTURE1);
glTexImage2D(GL_TEXTURE_2D, 0, GL_LUMINANCE_ALPHA, source_width / 2,
source_height / 2, 0, GL_LUMINANCE_ALPHA, GL_UNSIGNED_BYTE,
frame_data + y_size);
} else {
impl_->gl.active_texture(GL_TEXTURE0);
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, source_width, source_height,
GL_LUMINANCE, GL_UNSIGNED_BYTE, frame_data);
impl_->gl.active_texture(GL_TEXTURE1);
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, source_width / 2,
source_height / 2, GL_LUMINANCE_ALPHA, GL_UNSIGNED_BYTE,
frame_data + y_size);
}
const GLenum upload_error = glGetError();
upload_succeeded = upload_error == GL_NO_ERROR;
if (upload_succeeded) {
impl_->texture_width = source_width;
impl_->texture_height = source_height;
impl_->uploaded_stream.assign(remote_id);
impl_->uploaded_sequence = sequence;
} else {
LOG_WARN("OpenGL NV12 texture upload failed, error={}", upload_error);
}
}
{
std::lock_guard lock(impl_->frames->mutex);
if (slot_index != kFrameSlotCount) {
auto &slot = impl_->frames->slots[slot_index];
if (slot.sequence == sequence && slot.use == SlotUse::uploading) {
slot.use = SlotUse::available;
}
}
}
const int content_y = std::clamp(top_inset_pixels, 0, target_height);
const int content_height = target_height - content_y;
const bool has_video =
upload_succeeded && impl_->uploaded_stream == remote_id &&
impl_->texture_width > 0 && impl_->texture_height > 0 &&
content_height > 0;
int video_x = 0;
int video_y = content_y;
int video_width = target_width;
int video_height = content_height;
if (has_video) {
source_width = impl_->texture_width;
source_height = impl_->texture_height;
sequence = impl_->uploaded_sequence;
const double source_aspect =
static_cast<double>(source_width) / source_height;
const double target_aspect =
static_cast<double>(target_width) / content_height;
if (source_aspect > target_aspect) {
video_height =
std::max(1, static_cast<int>(target_width / source_aspect + 0.5));
video_y = content_y + (content_height - video_height) / 2;
} else {
video_width =
std::max(1, static_cast<int>(content_height * source_aspect + 0.5));
video_x = (target_width - video_width) / 2;
}
}
while (glGetError() != GL_NO_ERROR) {
}
impl_->gl.use_program(impl_->program);
impl_->gl.active_texture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, impl_->y_texture);
impl_->gl.uniform_1i(impl_->y_texture_location, 0);
impl_->gl.active_texture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, impl_->uv_texture);
impl_->gl.uniform_1i(impl_->uv_texture_location, 1);
impl_->gl.uniform_2f(impl_->target_size_location,
static_cast<GLfloat>(target_width),
static_cast<GLfloat>(target_height));
impl_->gl.uniform_4f(
impl_->video_rect_location, static_cast<GLfloat>(video_x),
static_cast<GLfloat>(video_y), static_cast<GLfloat>(video_width),
static_cast<GLfloat>(video_height));
impl_->gl.uniform_1f(
impl_->corner_radius_location,
static_cast<GLfloat>(std::clamp(
corner_radius_pixels, 0, std::min(target_width, target_height) / 2)));
impl_->gl.uniform_1f(impl_->video_enabled_location, has_video ? 1.0f : 0.0f);
impl_->gl.bind_buffer(GL_ARRAY_BUFFER, impl_->vertex_buffer);
impl_->gl.vertex_attrib_pointer(
static_cast<GLuint>(impl_->position_location), 2, GL_FLOAT, GL_FALSE,
4 * static_cast<GLsizei>(sizeof(GLfloat)), nullptr);
impl_->gl.vertex_attrib_pointer(
static_cast<GLuint>(impl_->texcoord_location), 2, GL_FLOAT, GL_FALSE,
4 * static_cast<GLsizei>(sizeof(GLfloat)),
reinterpret_cast<const void *>(2 * sizeof(GLfloat)));
impl_->gl.enable_vertex_attrib_array(
static_cast<GLuint>(impl_->position_location));
impl_->gl.enable_vertex_attrib_array(
static_cast<GLuint>(impl_->texcoord_location));
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
const GLenum draw_error = glGetError();
RenderOutcome outcome{
!upload_succeeded || draw_error != GL_NO_ERROR
? RenderResult::failed
: has_video ? RenderResult::rendered : RenderResult::empty,
has_video ? source_width : 0, has_video ? source_height : 0,
has_video ? sequence : 0};
RestoreVertexAttrib(impl_->gl, static_cast<GLuint>(impl_->position_location),
position_state);
RestoreVertexAttrib(impl_->gl, static_cast<GLuint>(impl_->texcoord_location),
texcoord_state);
impl_->gl.bind_buffer(GL_ARRAY_BUFFER,
static_cast<GLuint>(previous_array_buffer));
impl_->gl.active_texture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, static_cast<GLuint>(previous_texture_0));
impl_->gl.active_texture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, static_cast<GLuint>(previous_texture_1));
impl_->gl.active_texture(static_cast<GLenum>(previous_active_texture));
impl_->gl.use_program(static_cast<GLuint>(previous_program));
glPixelStorei(GL_UNPACK_ALIGNMENT, previous_unpack_alignment);
glViewport(previous_viewport[0], previous_viewport[1], previous_viewport[2],
previous_viewport[3]);
glScissor(previous_scissor_box[0], previous_scissor_box[1],
previous_scissor_box[2], previous_scissor_box[3]);
glClearColor(previous_clear_color[0], previous_clear_color[1],
previous_clear_color[2], previous_clear_color[3]);
glColorMask(previous_color_mask[0], previous_color_mask[1],
previous_color_mask[2], previous_color_mask[3]);
RestoreCapability(GL_BLEND, blend_enabled);
RestoreCapability(GL_CULL_FACE, cull_enabled);
RestoreCapability(GL_DEPTH_TEST, depth_enabled);
RestoreCapability(GL_SCISSOR_TEST, scissor_enabled);
RestoreCapability(GL_STENCIL_TEST, stencil_enabled);
const GLenum restore_error = glGetError();
if (restore_error != GL_NO_ERROR) {
LOG_WARN("OpenGL NV12 state restoration failed, error={}", restore_error);
if (outcome.result == RenderResult::rendered) {
outcome.result = RenderResult::failed;
}
}
return outcome;
}
bool OpenGlVideoRenderer::CopyLatestNv12(
std::string_view remote_id, std::vector<unsigned char> *output, int *width,
int *height) const {
if (!output || !width || !height) {
return false;
}
std::lock_guard lock(impl_->frames->mutex);
const FrameSlot *newest = nullptr;
for (const auto &slot : impl_->frames->slots) {
if (slot.valid && slot.remote_id == remote_id &&
(!newest || slot.sequence > newest->sequence)) {
newest = &slot;
}
}
if (!newest || newest->bytes.empty()) {
return false;
}
*output = newest->bytes;
*width = newest->width;
*height = newest->height;
return true;
}
} // namespace crossdesk
+70
View File
@@ -0,0 +1,70 @@
#ifndef CROSSDESK_GUI_PLATFORM_OPENGL_VIDEO_RENDERER_H_
#define CROSSDESK_GUI_PLATFORM_OPENGL_VIDEO_RENDERER_H_
#include <cstddef>
#include <cstdint>
#include <memory>
#include <string>
#include <string_view>
#include <vector>
#include "platform/video_renderer.h"
namespace crossdesk {
// NV12 underlay for Slint's FemtoVG OpenGL renderer on Windows and Linux.
// MiniRTC submits tightly packed CPU NV12 frames from its decode callback
// thread. The Slint UI thread uploads the newest frame as Y and UV textures and
// performs color conversion and scaling in a fragment shader before Slint
// draws its overlay.
class OpenGlVideoRenderer final : public VideoRenderer {
public:
OpenGlVideoRenderer();
~OpenGlVideoRenderer() override;
OpenGlVideoRenderer(const OpenGlVideoRenderer &) = delete;
OpenGlVideoRenderer &operator=(const OpenGlVideoRenderer &) = delete;
// These methods must run while Slint's OpenGL context is current, from the
// RenderingSetup and RenderingTeardown notifier states respectively.
bool Setup();
void Teardown();
bool IsReady() const override;
bool IsActive() const override;
// Only the selected stream is queued for upload. Frames for other tabs are
// retained by GuiRuntime at a throttled rate for thumbnail/tab restoration.
bool SetSelectedStream(std::string remote_id) override;
void DiscardStream(std::string_view remote_id) override;
// Thread-safe; called from MiniRTC's decode callback thread.
SubmitResult SubmitNv12(std::string_view remote_id, const uint8_t *data,
size_t size, int width, int height) override;
SubmitResult SubmitCachedNv12(std::string_view remote_id, const uint8_t *data,
size_t size, int width, int height) override;
// Draws below Slint while its OpenGL context is current. All dimensions are
// physical pixels in the window client area. corner_radius_pixels clips the
// underlay to Slint's custom rounded window surface.
RenderOutcome RenderLatest(std::string_view remote_id, int target_width,
int target_height, int top_inset_pixels,
int corner_radius_pixels);
// Used during disconnect cleanup so thumbnail generation does not require a
// second per-frame CPU copy during normal playback.
bool CopyLatestNv12(std::string_view remote_id,
std::vector<unsigned char> *output, int *width,
int *height) const override;
private:
SubmitResult SubmitNv12Internal(std::string_view remote_id,
const uint8_t *data, size_t size, int width,
int height, bool replace_pending);
struct Impl;
std::unique_ptr<Impl> impl_;
};
} // namespace crossdesk
#endif // CROSSDESK_GUI_PLATFORM_OPENGL_VIDEO_RENDERER_H_
+556 -68
View File
@@ -9,6 +9,7 @@
#include <X11/Xutil.h>
#include <X11/extensions/shape.h>
#include <X11/extensions/Xrender.h>
#include <dlfcn.h>
#include <algorithm>
#include <chrono>
@@ -32,14 +33,121 @@ namespace crossdesk {
namespace {
constexpr int kTrayIconSize = 24;
constexpr int kMenuHeight = 28;
constexpr int kMenuItemHeight = 28;
constexpr int kMenuItemCount = 3;
constexpr int kMenuVerticalPadding = 4;
constexpr int kMenuHeight =
kMenuItemHeight * kMenuItemCount + kMenuVerticalPadding * 2;
constexpr int kMenuHorizontalPadding = 12;
constexpr int kMenuCornerRadius = 8;
constexpr int kMenuBorderWidth = 1;
constexpr int kMenuItemHorizontalInset = 4;
constexpr int kMenuItemVerticalInset = 2;
constexpr int kMenuItemCornerRadius = 5;
constexpr int kMenuSeparatorHorizontalInset = 8;
constexpr int kDockTimeoutMs = 800;
constexpr int kMaxTrayEventsPerTick = 32;
constexpr int kMaxTrayEventsDuringEmbed = 16;
constexpr long kSystemTrayRequestDock = 0;
constexpr long kXEmbedMapped = 1;
constexpr long kXEmbedEmbeddedNotify = 0;
constexpr int kAppIndicatorCategoryApplicationStatus = 0;
constexpr int kAppIndicatorStatusPassive = 0;
constexpr int kAppIndicatorStatusActive = 1;
// AppIndicator exports the menu to the desktop shell, which lets GNOME, KDE,
// and compatible panels render it with their own theme. Load either maintained
// implementation dynamically so the legacy XEmbed path remains available.
struct DesktopIndicatorApi {
using SignalCallback = void (*)(void*, void*);
using SignalDestroyNotify = void (*)(void*, void*);
void* library = nullptr;
void* (*indicator_new_with_path)(const char*, const char*, int,
const char*) = nullptr;
void (*indicator_set_status)(void*, int) = nullptr;
void (*indicator_set_menu)(void*, void*) = nullptr;
void (*indicator_set_title)(void*, const char*) = nullptr;
int (*gtk_init_check)(int*, char***) = nullptr;
void* (*gtk_menu_new)() = nullptr;
void* (*gtk_menu_item_new_with_label)(const char*) = nullptr;
void* (*gtk_separator_menu_item_new)() = nullptr;
void (*gtk_menu_shell_append)(void*, void*) = nullptr;
void (*gtk_widget_show_all)(void*) = nullptr;
void (*gtk_widget_destroy)(void*) = nullptr;
int (*gtk_events_pending)() = nullptr;
int (*gtk_main_iteration_do)(int) = nullptr;
unsigned long (*signal_connect_data)(void*, const char*, SignalCallback,
void*, SignalDestroyNotify,
int) = nullptr;
void (*object_unref)(void*) = nullptr;
};
template <typename T>
bool LoadDesktopIndicatorSymbol(void* library, T* function,
const char* symbol_name) {
*function = reinterpret_cast<T>(dlsym(library, symbol_name));
return *function != nullptr;
}
void UnloadDesktopIndicatorApi(DesktopIndicatorApi* api) {
if (api->library) {
dlclose(api->library);
}
*api = DesktopIndicatorApi{};
}
bool LoadDesktopIndicatorApi(DesktopIndicatorApi* api) {
for (const char* library_name : {"libayatana-appindicator3.so.1",
"libappindicator3.so.1"}) {
api->library = dlopen(library_name, RTLD_NOW | RTLD_LOCAL);
if (api->library) {
break;
}
}
if (!api->library) {
return false;
}
const bool loaded =
LoadDesktopIndicatorSymbol(api->library,
&api->indicator_new_with_path,
"app_indicator_new_with_path") &&
LoadDesktopIndicatorSymbol(api->library, &api->indicator_set_status,
"app_indicator_set_status") &&
LoadDesktopIndicatorSymbol(api->library, &api->indicator_set_menu,
"app_indicator_set_menu") &&
LoadDesktopIndicatorSymbol(api->library, &api->indicator_set_title,
"app_indicator_set_title") &&
LoadDesktopIndicatorSymbol(api->library, &api->gtk_init_check,
"gtk_init_check") &&
LoadDesktopIndicatorSymbol(api->library, &api->gtk_menu_new,
"gtk_menu_new") &&
LoadDesktopIndicatorSymbol(api->library,
&api->gtk_menu_item_new_with_label,
"gtk_menu_item_new_with_label") &&
LoadDesktopIndicatorSymbol(api->library,
&api->gtk_separator_menu_item_new,
"gtk_separator_menu_item_new") &&
LoadDesktopIndicatorSymbol(api->library, &api->gtk_menu_shell_append,
"gtk_menu_shell_append") &&
LoadDesktopIndicatorSymbol(api->library, &api->gtk_widget_show_all,
"gtk_widget_show_all") &&
LoadDesktopIndicatorSymbol(api->library, &api->gtk_widget_destroy,
"gtk_widget_destroy") &&
LoadDesktopIndicatorSymbol(api->library, &api->gtk_events_pending,
"gtk_events_pending") &&
LoadDesktopIndicatorSymbol(api->library, &api->gtk_main_iteration_do,
"gtk_main_iteration_do") &&
LoadDesktopIndicatorSymbol(api->library, &api->signal_connect_data,
"g_signal_connect_data") &&
LoadDesktopIndicatorSymbol(api->library, &api->object_unref,
"g_object_unref");
if (!loaded) {
UnloadDesktopIndicatorApi(api);
}
return loaded;
}
bool IsAsciiPrintable(const std::string& text) {
for (unsigned char ch : text) {
@@ -50,7 +158,21 @@ bool IsAsciiPrintable(const std::string& text) {
return true;
}
std::string GetMenuLabel(int language_index) {
std::string GetShowMainWindowMenuLabel(int language_index) {
const int normalized_index = localization::detail::ClampLanguageIndex(
language_index);
const std::string& label = localization::show_main_window[normalized_index];
return label.empty() ? "Show Main Window" : label;
}
std::string GetSettingsMenuLabel(int language_index) {
const int normalized_index = localization::detail::ClampLanguageIndex(
language_index);
const std::string& label = localization::settings[normalized_index];
return label.empty() ? "Settings" : label;
}
std::string GetExitMenuLabel(int language_index) {
const int normalized_index = localization::detail::ClampLanguageIndex(
language_index);
const std::string& label = localization::exit_program[normalized_index];
@@ -73,6 +195,62 @@ unsigned long AllocateColor(Display* display, int screen, const char* name,
return fallback;
}
void FillRoundedRectangle(Display* display, Drawable drawable, GC gc, int x,
int y, int width, int height, int radius) {
if (!display || !drawable || !gc || width <= 0 || height <= 0) {
return;
}
const int clamped_radius =
std::clamp(radius, 0, std::min(width, height) / 2);
if (clamped_radius == 0) {
XFillRectangle(display, drawable, gc, x, y, width, height);
return;
}
const int diameter = clamped_radius * 2;
XFillRectangle(display, drawable, gc, x + clamped_radius, y,
width - diameter, height);
XFillRectangle(display, drawable, gc, x, y + clamped_radius, width,
height - diameter);
XFillArc(display, drawable, gc, x, y, diameter, diameter, 0, 360 * 64);
XFillArc(display, drawable, gc, x + width - diameter, y, diameter, diameter,
0, 360 * 64);
XFillArc(display, drawable, gc, x, y + height - diameter, diameter, diameter,
0, 360 * 64);
XFillArc(display, drawable, gc, x + width - diameter,
y + height - diameter, diameter, diameter, 0, 360 * 64);
}
bool IsPointInsideRoundedRectangle(int point_x, int point_y, int x, int y,
int width, int height, int radius) {
if (point_x < x || point_y < y || point_x >= x + width ||
point_y >= y + height) {
return false;
}
const int clamped_radius =
std::clamp(radius, 0, std::min(width, height) / 2);
if (clamped_radius == 0 ||
(point_x >= x + clamped_radius &&
point_x < x + width - clamped_radius) ||
(point_y >= y + clamped_radius &&
point_y < y + height - clamped_radius)) {
return true;
}
const int center_x = point_x < x + clamped_radius
? x + clamped_radius
: x + width - clamped_radius - 1;
const int center_y = point_y < y + clamped_radius
? y + clamped_radius
: y + height - clamped_radius - 1;
const int delta_x = point_x - center_x;
const int delta_y = point_y - center_y;
return delta_x * delta_x + delta_y * delta_y <=
clamped_radius * clamped_radius;
}
std::vector<std::filesystem::path> BuildIconCandidatePaths() {
std::vector<std::filesystem::path> paths = {
"icons/linux/crossdesk_32x32.png",
@@ -117,6 +295,22 @@ std::vector<std::filesystem::path> BuildIconCandidatePaths() {
return paths;
}
std::pair<std::string, std::string> ResolveDesktopIndicatorIcon() {
for (const auto& candidate : BuildIconCandidatePaths()) {
std::error_code error;
if (!std::filesystem::exists(candidate, error) || error) {
continue;
}
const auto absolute_path = std::filesystem::absolute(candidate, error);
if (!error) {
return {absolute_path.stem().string(),
absolute_path.parent_path().string()};
}
}
return {"crossdesk", {}};
}
int CountMaskBits(unsigned long mask) {
int count = 0;
while (mask) {
@@ -187,20 +381,42 @@ struct LinuxTrayImpl {
tooltip(std::move(tray_tooltip)),
language_index(language_index_value),
exit_event_type(tray_exit_event_type),
menu_label(GetMenuLabel(language_index_value)),
menu_ascii_label(IsAsciiPrintable(menu_label) ? menu_label : "Exit") {}
show_menu_label(GetShowMainWindowMenuLabel(language_index_value)),
show_menu_ascii_label(IsAsciiPrintable(show_menu_label)
? show_menu_label
: "Show Main Window"),
settings_menu_label(GetSettingsMenuLabel(language_index_value)),
settings_menu_ascii_label(IsAsciiPrintable(settings_menu_label)
? settings_menu_label
: "Settings"),
exit_menu_label(GetExitMenuLabel(language_index_value)),
exit_menu_ascii_label(IsAsciiPrintable(exit_menu_label)
? exit_menu_label
: "Exit") {}
explicit LinuxTrayImpl(std::function<void()> show_window_callback,
std::function<void()> hide_window_callback,
std::function<void()> open_settings_callback,
std::function<void()> exit_callback,
std::string tray_tooltip, int language_index_value)
: show_window(std::move(show_window_callback)),
hide_window(std::move(hide_window_callback)),
open_settings(std::move(open_settings_callback)),
exit_app(std::move(exit_callback)),
tooltip(std::move(tray_tooltip)),
language_index(language_index_value),
menu_label(GetMenuLabel(language_index_value)),
menu_ascii_label(IsAsciiPrintable(menu_label) ? menu_label : "Exit") {}
show_menu_label(GetShowMainWindowMenuLabel(language_index_value)),
show_menu_ascii_label(IsAsciiPrintable(show_menu_label)
? show_menu_label
: "Show Main Window"),
settings_menu_label(GetSettingsMenuLabel(language_index_value)),
settings_menu_ascii_label(IsAsciiPrintable(settings_menu_label)
? settings_menu_label
: "Settings"),
exit_menu_label(GetExitMenuLabel(language_index_value)),
exit_menu_ascii_label(IsAsciiPrintable(exit_menu_label)
? exit_menu_label
: "Exit") {}
~LinuxTrayImpl() { RemoveTrayIcon(); }
@@ -218,6 +434,7 @@ struct LinuxTrayImpl {
}
void RemoveTrayIcon() {
RemoveDesktopIndicator();
HideMenu();
if (display && icon_window) {
@@ -264,9 +481,137 @@ struct LinuxTrayImpl {
tray_manager_window = 0;
}
void ProcessEvents() { ProcessPendingEvents(kMaxTrayEventsPerTick); }
void ProcessEvents() {
ProcessDesktopIndicatorEvents();
ProcessPendingEvents(kMaxTrayEventsPerTick);
}
private:
static void OnDesktopShowWindow(void*, void* user_data) {
auto* self = static_cast<LinuxTrayImpl*>(user_data);
if (self) {
self->ShowWindow();
}
}
static void OnDesktopOpenSettings(void*, void* user_data) {
auto* self = static_cast<LinuxTrayImpl*>(user_data);
if (self) {
self->OpenSettings();
}
}
static void OnDesktopExit(void*, void* user_data) {
auto* self = static_cast<LinuxTrayImpl*>(user_data);
if (self) {
self->RequestExit();
}
}
bool EnsureDesktopIndicator() {
if (desktop_indicator) {
return true;
}
if (desktop_indicator_attempted) {
return false;
}
desktop_indicator_attempted = true;
if (!LoadDesktopIndicatorApi(&desktop_indicator_api)) {
LOG_INFO(
"Desktop AppIndicator runtime unavailable; using XEmbed tray "
"fallback");
return false;
}
if (!desktop_indicator_api.gtk_init_check(nullptr, nullptr)) {
LOG_INFO(
"GTK could not connect to the desktop; using XEmbed tray fallback");
return false;
}
desktop_menu = desktop_indicator_api.gtk_menu_new();
void* show_item = desktop_indicator_api.gtk_menu_item_new_with_label(
show_menu_label.c_str());
void* settings_item = desktop_indicator_api.gtk_menu_item_new_with_label(
settings_menu_label.c_str());
void* separator = desktop_indicator_api.gtk_separator_menu_item_new();
void* exit_item = desktop_indicator_api.gtk_menu_item_new_with_label(
exit_menu_label.c_str());
if (!desktop_menu || !show_item || !settings_item || !separator ||
!exit_item) {
if (desktop_menu) {
desktop_indicator_api.gtk_widget_destroy(desktop_menu);
desktop_menu = nullptr;
}
return false;
}
desktop_indicator_api.signal_connect_data(
show_item, "activate", &LinuxTrayImpl::OnDesktopShowWindow, this,
nullptr, 0);
desktop_indicator_api.signal_connect_data(
settings_item, "activate", &LinuxTrayImpl::OnDesktopOpenSettings, this,
nullptr, 0);
desktop_indicator_api.signal_connect_data(
exit_item, "activate", &LinuxTrayImpl::OnDesktopExit, this, nullptr, 0);
desktop_indicator_api.gtk_menu_shell_append(desktop_menu, show_item);
desktop_indicator_api.gtk_menu_shell_append(desktop_menu, settings_item);
desktop_indicator_api.gtk_menu_shell_append(desktop_menu, separator);
desktop_indicator_api.gtk_menu_shell_append(desktop_menu, exit_item);
desktop_indicator_api.gtk_widget_show_all(desktop_menu);
const auto [icon_name, icon_path] = ResolveDesktopIndicatorIcon();
desktop_indicator = desktop_indicator_api.indicator_new_with_path(
"crossdesk", icon_name.c_str(),
kAppIndicatorCategoryApplicationStatus,
icon_path.empty() ? nullptr : icon_path.c_str());
if (!desktop_indicator) {
desktop_indicator_api.gtk_widget_destroy(desktop_menu);
desktop_menu = nullptr;
return false;
}
desktop_indicator_api.indicator_set_title(desktop_indicator,
tooltip.c_str());
desktop_indicator_api.indicator_set_menu(desktop_indicator, desktop_menu);
desktop_indicator_api.indicator_set_status(desktop_indicator,
kAppIndicatorStatusActive);
ProcessDesktopIndicatorEvents();
LOG_INFO("Linux tray registered through the desktop AppIndicator service");
return true;
}
void RemoveDesktopIndicator() {
if (desktop_indicator) {
desktop_indicator_api.indicator_set_status(
desktop_indicator, kAppIndicatorStatusPassive);
}
if (desktop_menu) {
desktop_indicator_api.gtk_widget_destroy(desktop_menu);
desktop_menu = nullptr;
}
if (desktop_indicator) {
desktop_indicator_api.object_unref(desktop_indicator);
desktop_indicator = nullptr;
}
// GTK registers process-wide types and callbacks. Keep the dynamically
// loaded module resident after initialization and let process teardown
// release it, instead of invalidating those registrations with dlclose().
}
void ProcessDesktopIndicatorEvents() {
if (!desktop_indicator) {
return;
}
int processed = 0;
while (processed < kMaxTrayEventsPerTick &&
desktop_indicator_api.gtk_events_pending()) {
desktop_indicator_api.gtk_main_iteration_do(0);
++processed;
}
}
int ProcessPendingEvents(int max_events) {
if (!display) {
return 0;
@@ -314,6 +659,8 @@ struct LinuxTrayImpl {
SelectArgbVisual();
brand_pixel = AllocateColor(display, screen, "#2563eb", black_pixel);
hover_pixel = AllocateColor(display, screen, "#e5e7eb", white_pixel);
menu_border_pixel =
AllocateColor(display, screen, "#d1d5db", black_pixel);
int shape_event_base = 0;
int shape_error_base = 0;
shape_available =
@@ -328,6 +675,10 @@ struct LinuxTrayImpl {
xembed_info_atom = XInternAtom(display, "_XEMBED_INFO", False);
utf8_string_atom = XInternAtom(display, "UTF8_STRING", False);
net_wm_name_atom = XInternAtom(display, "_NET_WM_NAME", False);
net_wm_window_type_atom =
XInternAtom(display, "_NET_WM_WINDOW_TYPE", False);
net_wm_window_type_popup_menu_atom =
XInternAtom(display, "_NET_WM_WINDOW_TYPE_POPUP_MENU", False);
char** missing_charset_list = nullptr;
int missing_charset_count = 0;
@@ -386,6 +737,9 @@ struct LinuxTrayImpl {
}
bool EnsureTrayIcon() {
if (EnsureDesktopIndicator()) {
return true;
}
if (docked && icon_window) {
return true;
}
@@ -540,6 +894,17 @@ struct LinuxTrayImpl {
HandleButtonRelease(event.xbutton);
break;
case MotionNotify:
if (event.xmotion.window == menu_window) {
const int hovered_item =
MenuItemAt(event.xmotion.x, event.xmotion.y);
if (hovered_item != hovered_menu_item) {
hovered_menu_item = hovered_item;
DrawMenu();
}
}
break;
case LeaveNotify:
if (event.xcrossing.window == menu_window) {
HideMenu();
@@ -553,12 +918,14 @@ struct LinuxTrayImpl {
void HandleButtonRelease(const XButtonEvent& event) {
if (menu_window && event.window == menu_window) {
const bool inside_menu =
event.x >= 0 && event.y >= 0 && event.x < menu_width &&
event.y < kMenuHeight;
const bool activate_exit = inside_menu && event.button == Button1;
const int selected_item =
event.button == Button1 ? MenuItemAt(event.x, event.y) : -1;
HideMenu();
if (activate_exit) {
if (selected_item == 0) {
ShowWindow();
} else if (selected_item == 1) {
OpenSettings();
} else if (selected_item == 2) {
RequestExit();
}
return;
@@ -571,9 +938,7 @@ struct LinuxTrayImpl {
return;
}
if (event.button == Button1) {
ShowWindow();
} else if (event.button == Button3) {
if (event.button == Button1 || event.button == Button3) {
ShowMenu(event.x_root, event.y_root);
}
}
@@ -824,29 +1189,76 @@ struct LinuxTrayImpl {
return false;
}
int MenuTextWidth() const {
int MenuTextWidth(const std::string& label,
const std::string& ascii_label) const {
if (menu_font) {
XGlyphInfo extents{};
XftTextExtentsUtf8(display, menu_font,
reinterpret_cast<const FcChar8*>(menu_label.c_str()),
static_cast<int>(menu_label.size()), &extents);
reinterpret_cast<const FcChar8*>(label.c_str()),
static_cast<int>(label.size()), &extents);
return extents.xOff;
}
if (font_set) {
XRectangle ink{};
XRectangle logical{};
Xutf8TextExtents(font_set, menu_label.c_str(),
static_cast<int>(menu_label.size()), &ink, &logical);
Xutf8TextExtents(font_set, label.c_str(), static_cast<int>(label.size()),
&ink, &logical);
return logical.width;
}
if (fallback_font) {
return XTextWidth(fallback_font, menu_ascii_label.c_str(),
static_cast<int>(menu_ascii_label.size()));
return XTextWidth(fallback_font, ascii_label.c_str(),
static_cast<int>(ascii_label.size()));
}
return static_cast<int>(menu_ascii_label.size()) * 8;
return static_cast<int>(ascii_label.size()) * 8;
}
int MenuItemAt(int x, int y) const {
if (!IsPointInsideRoundedRectangle(x, y, 0, 0, menu_width, kMenuHeight,
kMenuCornerRadius)) {
return -1;
}
const int content_y = y - kMenuVerticalPadding;
if (content_y < 0 ||
content_y >= kMenuItemHeight * kMenuItemCount) {
return -1;
}
return content_y / kMenuItemHeight;
}
void ApplyMenuShape() {
if (!shape_available || !display || !menu_window) {
return;
}
Pixmap shape_mask =
XCreatePixmap(display, menu_window, menu_width, kMenuHeight, 1);
if (!shape_mask) {
return;
}
GC shape_gc = XCreateGC(display, shape_mask, 0, nullptr);
if (!shape_gc) {
XFreePixmap(display, shape_mask);
return;
}
XSetForeground(display, shape_gc, 0);
XFillRectangle(display, shape_mask, shape_gc, 0, 0, menu_width,
kMenuHeight);
XSetForeground(display, shape_gc, 1);
FillRoundedRectangle(display, shape_mask, shape_gc, 0, 0, menu_width,
kMenuHeight, kMenuCornerRadius);
XShapeCombineMask(display, menu_window, ShapeBounding, 0, 0, shape_mask,
ShapeSet);
XShapeCombineMask(display, menu_window, ShapeInput, 0, 0, shape_mask,
ShapeSet);
XFreeGC(display, shape_gc);
XFreePixmap(display, shape_mask);
}
void ShowMenu(int root_x, int root_y) {
@@ -856,7 +1268,15 @@ struct LinuxTrayImpl {
HideMenu();
menu_width = std::max(72, MenuTextWidth() + kMenuHorizontalPadding * 2);
menu_width =
std::max({72,
MenuTextWidth(show_menu_label, show_menu_ascii_label) +
kMenuHorizontalPadding * 2,
MenuTextWidth(settings_menu_label,
settings_menu_ascii_label) +
kMenuHorizontalPadding * 2,
MenuTextWidth(exit_menu_label, exit_menu_ascii_label) +
kMenuHorizontalPadding * 2});
const int display_width = DisplayWidth(display, screen);
const int display_height = DisplayHeight(display, screen);
const int x = std::clamp(root_x, 0, std::max(0, display_width - menu_width));
@@ -865,23 +1285,31 @@ struct LinuxTrayImpl {
XSetWindowAttributes attrs{};
attrs.override_redirect = True;
attrs.background_pixel = white_pixel;
attrs.border_pixel = black_pixel;
attrs.background_pixel = menu_border_pixel;
attrs.event_mask = ExposureMask | ButtonPressMask | ButtonReleaseMask |
LeaveWindowMask;
PointerMotionMask | LeaveWindowMask;
menu_window = XCreateWindow(
display, root_window, x, y, menu_width, kMenuHeight, 1, CopyFromParent,
display, root_window, x, y, menu_width, kMenuHeight, 0, CopyFromParent,
InputOutput, CopyFromParent,
CWOverrideRedirect | CWBackPixel | CWBorderPixel | CWEventMask, &attrs);
CWOverrideRedirect | CWBackPixel | CWEventMask, &attrs);
if (!menu_window) {
return;
}
if (net_wm_window_type_atom && net_wm_window_type_popup_menu_atom) {
const Atom window_type = net_wm_window_type_popup_menu_atom;
XChangeProperty(display, menu_window, net_wm_window_type_atom, XA_ATOM,
32, PropModeReplace,
reinterpret_cast<const unsigned char*>(&window_type), 1);
}
ApplyMenuShape();
menu_visible = true;
hovered_menu_item = -1;
XMapRaised(display, menu_window);
XGrabPointer(display, menu_window, False,
ButtonPressMask | ButtonReleaseMask, GrabModeAsync,
GrabModeAsync, None, None, CurrentTime);
ButtonPressMask | ButtonReleaseMask | PointerMotionMask,
GrabModeAsync, GrabModeAsync, None, None, CurrentTime);
DrawMenu();
XFlush(display);
}
@@ -889,6 +1317,7 @@ struct LinuxTrayImpl {
void HideMenu() {
if (!display || !menu_window) {
menu_visible = false;
hovered_menu_item = -1;
return;
}
@@ -896,6 +1325,7 @@ struct LinuxTrayImpl {
XDestroyWindow(display, menu_window);
menu_window = 0;
menu_visible = false;
hovered_menu_item = -1;
XFlush(display);
}
@@ -905,47 +1335,82 @@ struct LinuxTrayImpl {
}
GC gc = XCreateGC(display, menu_window, 0, nullptr);
XSetForeground(display, gc, menu_border_pixel);
FillRoundedRectangle(display, menu_window, gc, 0, 0, menu_width,
kMenuHeight, kMenuCornerRadius);
XSetForeground(display, gc, white_pixel);
XFillRectangle(display, menu_window, gc, 0, 0, menu_width, kMenuHeight);
FillRoundedRectangle(
display, menu_window, gc, kMenuBorderWidth, kMenuBorderWidth,
menu_width - kMenuBorderWidth * 2,
kMenuHeight - kMenuBorderWidth * 2,
kMenuCornerRadius - kMenuBorderWidth);
if (hovered_menu_item >= 0 && hovered_menu_item < kMenuItemCount) {
XSetForeground(display, gc, hover_pixel);
FillRoundedRectangle(
display, menu_window, gc, kMenuItemHorizontalInset,
kMenuVerticalPadding + hovered_menu_item * kMenuItemHeight +
kMenuItemVerticalInset,
menu_width - kMenuItemHorizontalInset * 2,
kMenuItemHeight - kMenuItemVerticalInset * 2,
kMenuItemCornerRadius);
}
XSetForeground(display, gc, hover_pixel);
XFillRectangle(display, menu_window, gc, 1, 1, menu_width - 2,
kMenuHeight - 2);
XDrawLine(display, menu_window, gc, kMenuSeparatorHorizontalInset,
kMenuVerticalPadding + kMenuItemHeight * 2,
menu_width - kMenuSeparatorHorizontalInset,
kMenuVerticalPadding + kMenuItemHeight * 2);
XSetForeground(display, gc, black_pixel);
int baseline = kMenuHeight / 2 + 5;
if (menu_font && menu_text_color_allocated) {
XftDraw* draw = XftDrawCreate(display, menu_window,
DefaultVisual(display, screen),
DefaultColormap(display, screen));
if (draw) {
baseline = (kMenuHeight - (menu_font->ascent + menu_font->descent)) /
XftDraw* xft_draw = menu_font && menu_text_color_allocated
? XftDrawCreate(display, menu_window,
DefaultVisual(display, screen),
DefaultColormap(display, screen))
: nullptr;
auto draw_label = [&](int item_index, const std::string& label,
const std::string& ascii_label) {
const int item_y =
kMenuVerticalPadding + item_index * kMenuItemHeight;
int baseline = item_y + kMenuItemHeight / 2 + 5;
if (xft_draw) {
baseline = item_y +
(kMenuItemHeight -
(menu_font->ascent + menu_font->descent)) /
2 +
menu_font->ascent;
XftDrawStringUtf8(
draw, &menu_text_color, menu_font, kMenuHorizontalPadding, baseline,
reinterpret_cast<const FcChar8*>(menu_label.c_str()),
static_cast<int>(menu_label.size()));
XftDrawDestroy(draw);
xft_draw, &menu_text_color, menu_font, kMenuHorizontalPadding,
baseline, reinterpret_cast<const FcChar8*>(label.c_str()),
static_cast<int>(label.size()));
} else if (font_set) {
XFontSetExtents* extents = XExtentsOfFontSet(font_set);
if (extents) {
baseline = item_y +
(kMenuItemHeight -
extents->max_logical_extent.height) /
2 -
extents->max_logical_extent.y;
}
Xutf8DrawString(display, menu_window, font_set, gc,
kMenuHorizontalPadding, baseline, label.c_str(),
static_cast<int>(label.size()));
} else {
if (fallback_font) {
XSetFont(display, gc, fallback_font->fid);
baseline = item_y +
(kMenuItemHeight + fallback_font->ascent -
fallback_font->descent) /
2;
}
XDrawString(display, menu_window, gc, kMenuHorizontalPadding, baseline,
ascii_label.c_str(),
static_cast<int>(ascii_label.size()));
}
} else if (font_set) {
XFontSetExtents* extents = XExtentsOfFontSet(font_set);
if (extents) {
baseline = (kMenuHeight - extents->max_logical_extent.height) / 2 -
extents->max_logical_extent.y;
}
Xutf8DrawString(display, menu_window, font_set, gc,
kMenuHorizontalPadding, baseline, menu_label.c_str(),
static_cast<int>(menu_label.size()));
} else {
if (fallback_font) {
XSetFont(display, gc, fallback_font->fid);
baseline = (kMenuHeight + fallback_font->ascent -
fallback_font->descent) /
2;
}
XDrawString(display, menu_window, gc, kMenuHorizontalPadding, baseline,
menu_ascii_label.c_str(),
static_cast<int>(menu_ascii_label.size()));
};
draw_label(0, show_menu_label, show_menu_ascii_label);
draw_label(1, settings_menu_label, settings_menu_ascii_label);
draw_label(2, exit_menu_label, exit_menu_ascii_label);
if (xft_draw) {
XftDrawDestroy(xft_draw);
}
XFreeGC(display, gc);
@@ -962,6 +1427,14 @@ struct LinuxTrayImpl {
}
}
void OpenSettings() {
if (open_settings) {
open_settings();
} else {
ShowWindow();
}
}
void RequestExit() {
if (exit_app) {
exit_app();
@@ -978,12 +1451,21 @@ struct LinuxTrayImpl {
::SDL_Window* app_window = nullptr;
std::function<void()> show_window;
std::function<void()> hide_window;
std::function<void()> open_settings;
std::function<void()> exit_app;
std::string tooltip;
int language_index = 0;
uint32_t exit_event_type = 0;
std::string menu_label;
std::string menu_ascii_label;
std::string show_menu_label;
std::string show_menu_ascii_label;
std::string settings_menu_label;
std::string settings_menu_ascii_label;
std::string exit_menu_label;
std::string exit_menu_ascii_label;
DesktopIndicatorApi desktop_indicator_api;
void* desktop_indicator = nullptr;
void* desktop_menu = nullptr;
bool desktop_indicator_attempted = false;
Display* display = nullptr;
int screen = 0;
::Window root_window = 0;
@@ -996,10 +1478,13 @@ struct LinuxTrayImpl {
Atom xembed_info_atom = None;
Atom utf8_string_atom = None;
Atom net_wm_name_atom = None;
Atom net_wm_window_type_atom = None;
Atom net_wm_window_type_popup_menu_atom = None;
unsigned long black_pixel = 0;
unsigned long white_pixel = 0;
unsigned long brand_pixel = 0;
unsigned long hover_pixel = 0;
unsigned long menu_border_pixel = 0;
Visual* icon_visual = nullptr;
int icon_depth = 0;
Colormap icon_colormap = 0;
@@ -1014,6 +1499,7 @@ struct LinuxTrayImpl {
int icon_width = 0;
int icon_height = 0;
int menu_width = 72;
int hovered_menu_item = -1;
bool docked = false;
bool embedded = false;
bool menu_visible = false;
@@ -1028,11 +1514,13 @@ LinuxTray::LinuxTray(::SDL_Window* app_window, const std::string& tooltip,
LinuxTray::LinuxTray(std::function<void()> show_window,
std::function<void()> hide_window,
std::function<void()> open_settings,
std::function<void()> exit_app,
const std::string& tooltip, int language_index)
: impl_(std::make_unique<LinuxTrayImpl>(
std::move(show_window), std::move(hide_window), std::move(exit_app),
tooltip, language_index)) {}
std::move(show_window), std::move(hide_window),
std::move(open_settings), std::move(exit_app), tooltip,
language_index)) {}
LinuxTray::~LinuxTray() = default;
+3 -1
View File
@@ -25,7 +25,9 @@ class LinuxTray {
LinuxTray(::SDL_Window* app_window, const std::string& tooltip,
int language_index, uint32_t exit_event_type);
LinuxTray(std::function<void()> show_window,
std::function<void()> hide_window, std::function<void()> exit_app,
std::function<void()> hide_window,
std::function<void()> open_settings,
std::function<void()> exit_app,
const std::string& tooltip, int language_index);
~LinuxTray();
+22 -8
View File
@@ -13,6 +13,7 @@
@interface CrossDeskMacTrayTarget : NSObject
- (instancetype)initWithOwner:(crossdesk::MacTrayImpl *)owner;
- (void)statusItemClicked:(id)sender;
- (void)showMainWindow:(id)sender;
- (void)openSettings:(id)sender;
- (void)exitApplication:(id)sender;
@end
@@ -84,6 +85,19 @@ struct MacTrayImpl {
}
NSMenu *menu = [[NSMenu alloc] initWithTitle:@"CrossDesk"];
[menu setAppearance:[NSAppearance appearanceNamed:NSAppearanceNameAqua]];
NSString *show_main_window_title =
NSStringFromUtf8(localization::show_main_window
[localization::detail::ClampLanguageIndex(
language_index)]);
NSMenuItem *show_main_window_item =
[[NSMenuItem alloc] initWithTitle:show_main_window_title
action:@selector(showMainWindow:)
keyEquivalent:@""];
[show_main_window_item setTarget:target];
[menu addItem:show_main_window_item];
NSString *settings_title =
NSStringFromUtf8(localization::settings
[localization::detail::ClampLanguageIndex(
@@ -287,14 +301,7 @@ void MacTray::RemoveTrayIcon() { impl_->RemoveTrayIcon(); }
if (!owner_) {
return;
}
NSEvent *event = [NSApp currentEvent];
if (event && [event type] == NSEventTypeRightMouseUp) {
owner_->ShowMenu();
return;
}
owner_->ShowWindow();
owner_->ShowMenu();
}
- (void)exitApplication:(id)sender {
@@ -304,6 +311,13 @@ void MacTray::RemoveTrayIcon() { impl_->RemoveTrayIcon(); }
}
}
- (void)showMainWindow:(id)sender {
(void)sender;
if (owner_) {
owner_->ShowWindow();
}
}
- (void)openSettings:(id)sender {
(void)sender;
if (owner_) {
+29
View File
@@ -64,11 +64,13 @@ WinTray::WinTray(HWND app_hwnd, HICON icon, const std::wstring& tooltip,
WinTray::WinTray(std::function<void()> show_window,
std::function<void()> hide_window,
std::function<void()> open_settings,
std::function<void()> exit_app, HICON icon,
const std::wstring& tooltip, int language_index)
: WinTray(nullptr, icon, tooltip, language_index) {
show_window_ = std::move(show_window);
hide_window_ = std::move(hide_window);
open_settings_ = std::move(open_settings);
exit_app_ = std::move(exit_app);
}
@@ -97,6 +99,14 @@ void WinTray::ShowApplicationWindow() {
}
}
void WinTray::OpenSettings() {
if (open_settings_) {
open_settings_();
} else {
ShowApplicationWindow();
}
}
bool WinTray::HandleTrayMessage(MSG* msg) {
if (!msg || msg->message != WM_TRAY_CALLBACK) return false;
@@ -111,6 +121,20 @@ bool WinTray::HandleTrayMessage(MSG* msg) {
POINT pt;
GetCursorPos(&pt);
HMENU menu = CreatePopupMenu();
HBRUSH menu_background = CreateSolidBrush(RGB(255, 255, 255));
if (menu_background) {
MENUINFO menu_info{};
menu_info.cbSize = sizeof(menu_info);
menu_info.fMask = MIM_BACKGROUND | MIM_APPLYTOSUBMENUS;
menu_info.hbrBack = menu_background;
SetMenuInfo(menu, &menu_info);
}
AppendMenuW(menu, MF_STRING, 1002,
localization::GetShowMainWindowLabel(language_index_));
AppendMenuW(menu, MF_STRING, 1003,
localization::GetSettingsLabel(language_index_));
AppendMenuW(menu, MF_SEPARATOR, 0, nullptr);
AppendMenuW(menu, MF_STRING, 1001,
localization::GetExitProgramLabel(language_index_));
@@ -119,6 +143,9 @@ bool WinTray::HandleTrayMessage(MSG* msg) {
TrackPopupMenu(menu, TPM_RETURNCMD | TPM_NONOTIFY | TPM_LEFTALIGN,
pt.x, pt.y, 0, hwnd_message_only_, nullptr);
DestroyMenu(menu);
if (menu_background) {
DeleteObject(menu_background);
}
// handle menu command
if (cmd == 1001) {
@@ -131,6 +158,8 @@ bool WinTray::HandleTrayMessage(MSG* msg) {
}
} else if (cmd == 1002) {
ShowApplicationWindow();
} else if (cmd == 1003) {
OpenSettings();
}
break;
}
+5 -1
View File
@@ -22,7 +22,9 @@ class WinTray {
WinTray(HWND app_hwnd, HICON icon, const std::wstring& tooltip,
int language_index);
WinTray(std::function<void()> show_window,
std::function<void()> hide_window, std::function<void()> exit_app,
std::function<void()> hide_window,
std::function<void()> open_settings,
std::function<void()> exit_app,
HICON icon, const std::wstring& tooltip, int language_index);
~WinTray();
@@ -32,6 +34,7 @@ class WinTray {
private:
void ShowApplicationWindow();
void OpenSettings();
HWND app_hwnd_;
HWND hwnd_message_only_;
@@ -41,6 +44,7 @@ class WinTray {
NOTIFYICONDATA nid_;
std::function<void()> show_window_;
std::function<void()> hide_window_;
std::function<void()> open_settings_;
std::function<void()> exit_app_;
};
} // namespace crossdesk
+70
View File
@@ -0,0 +1,70 @@
#ifndef CROSSDESK_GUI_PLATFORM_VIDEO_RENDERER_H_
#define CROSSDESK_GUI_PLATFORM_VIDEO_RENDERER_H_
#include <cstddef>
#include <cstdint>
#include <memory>
#include <string>
#include <string_view>
#include <vector>
namespace crossdesk {
// Platform-independent contract for the native stream video renderer.
//
// Frame submission and snapshot methods may be called from MiniRTC callback
// threads. Surface setup, rendering and teardown remain backend-specific and
// are driven by the Slint presentation module on the UI thread.
class VideoRenderer {
public:
enum class SubmitResult {
submitted,
not_selected,
dropped,
failed,
};
enum class RenderResult {
rendered,
idle,
empty,
failed,
};
struct RenderOutcome {
RenderResult result = RenderResult::failed;
int width = 0;
int height = 0;
uint64_t sequence = 0;
};
virtual ~VideoRenderer() = default;
virtual bool IsReady() const = 0;
// True only while the renderer can accept and present native frames. Metal
// additionally requires attachment to an AppKit view; OpenGL requires an
// active Slint rendering context.
virtual bool IsActive() const = 0;
virtual bool SetSelectedStream(std::string remote_id) = 0;
virtual void DiscardStream(std::string_view remote_id) = 0;
virtual SubmitResult SubmitNv12(std::string_view remote_id,
const uint8_t* data, size_t size, int width,
int height) = 0;
virtual SubmitResult SubmitCachedNv12(std::string_view remote_id,
const uint8_t* data, size_t size,
int width, int height) = 0;
virtual bool CopyLatestNv12(std::string_view remote_id,
std::vector<unsigned char>* output, int* width,
int* height) const = 0;
};
// Exactly one factory implementation is linked for the target platform.
std::unique_ptr<VideoRenderer> CreateVideoRenderer();
} // namespace crossdesk
#endif // CROSSDESK_GUI_PLATFORM_VIDEO_RENDERER_H_
@@ -0,0 +1,13 @@
#include "platform/video_renderer.h"
#include <memory>
#include "platform/metal_video_renderer.h"
namespace crossdesk {
std::unique_ptr<VideoRenderer> CreateVideoRenderer() {
return std::make_unique<MacMetalVideoRenderer>();
}
} // namespace crossdesk
@@ -0,0 +1,12 @@
#include <memory>
#include "platform/opengl_video_renderer.h"
#include "platform/video_renderer.h"
namespace crossdesk {
std::unique_ptr<VideoRenderer> CreateVideoRenderer() {
return std::make_unique<OpenGlVideoRenderer>();
}
} // namespace crossdesk
+12 -1
View File
@@ -14,7 +14,18 @@ bool HideDisabledMainWindowZoomButton();
// Configures live resize and replaces the stream window's native Space
// fullscreen action with an immediate, single-window fullscreen transition.
bool ConfigureStreamWindowLiveResize();
// |slint_view| is the current stream window's AppKit NSView*. Passing the
// identity explicitly prevents a closing window with the same title from
// being selected during a later connection.
bool ConfigureStreamWindowLiveResize(void* slint_view);
// Drops the native stream-window reference if it belongs to |slint_view|.
// This must run before the corresponding Slint component is destroyed.
void UnregisterStreamWindow(void* slint_view);
// Returns whether the configured stream window is currently the active AppKit
// key window.
bool IsStreamWindowActive();
// Enters or leaves the stream window's animation-free fullscreen mode.
bool SetStreamWindowFullscreen(bool fullscreen);
+64 -41
View File
@@ -17,7 +17,8 @@
namespace crossdesk {
namespace {
NSWindow *stream_window = nil;
__weak NSWindow *stream_window = nil;
__weak NSView *stream_slint_view = nil;
CrossDeskStreamFullscreenTarget *stream_fullscreen_target = nil;
bool stream_fullscreen = false;
NSRect stream_restore_frame = NSZeroRect;
@@ -101,48 +102,70 @@ bool HideDisabledMainWindowZoomButton() {
}
}
bool ConfigureStreamWindowLiveResize() {
bool ConfigureStreamWindowLiveResize(void *opaque_slint_view) {
@autoreleasepool {
for (NSWindow *window in [NSApp windows]) {
if (![window.title isEqualToString:@"CrossDesk"]) {
continue;
}
NSButton *zoom_button =
[window standardWindowButton:NSWindowZoomButton];
// The stream window is resizable and therefore keeps an enabled native
// zoom button. The fixed-size main window's zoom button is disabled.
if (zoom_button == nil || !zoom_button.enabled) {
continue;
}
NSView *content_view = window.contentView;
if (content_view == nil) {
return false;
}
// During an ordinary edge resize, scale one cached frame and redraw once
// the new window size has settled.
window.preservesContentDuringLiveResize = YES;
content_view.layerContentsRedrawPolicy =
NSViewLayerContentsRedrawBeforeViewResize;
content_view.layerContentsPlacement =
NSViewLayerContentsPlacementScaleProportionallyToFit;
stream_window = window;
// Native Space fullscreen cross-fades an AppKit source snapshot over a
// separately rendered destination window. Disable that path for the
// stream window and route the green button to the immediate transition.
window.collectionBehavior =
(window.collectionBehavior &
~(NSWindowCollectionBehaviorFullScreenPrimary |
NSWindowCollectionBehaviorFullScreenAuxiliary |
NSWindowCollectionBehaviorFullScreenAllowsTiling)) |
NSWindowCollectionBehaviorFullScreenNone;
InstallStreamFullscreenButton(window);
content_view.needsDisplay = YES;
return true;
NSView *slint_view = (__bridge NSView *)opaque_slint_view;
NSWindow *window = slint_view.window;
if (window == nil) {
return false;
}
return false;
NSButton *zoom_button =
[window standardWindowButton:NSWindowZoomButton];
NSView *content_view = window.contentView;
if (zoom_button == nil || !zoom_button.enabled || content_view == nil) {
return false;
}
// During an ordinary edge resize, scale one cached frame and redraw once
// the new window size has settled.
window.preservesContentDuringLiveResize = YES;
content_view.layerContentsRedrawPolicy =
NSViewLayerContentsRedrawBeforeViewResize;
content_view.layerContentsPlacement =
NSViewLayerContentsPlacementScaleProportionallyToFit;
stream_window = window;
stream_slint_view = slint_view;
// Native Space fullscreen cross-fades an AppKit source snapshot over a
// separately rendered destination window. Disable that path for the
// stream window and route the green button to the immediate transition.
window.collectionBehavior =
(window.collectionBehavior &
~(NSWindowCollectionBehaviorFullScreenPrimary |
NSWindowCollectionBehaviorFullScreenAuxiliary |
NSWindowCollectionBehaviorFullScreenAllowsTiling)) |
NSWindowCollectionBehaviorFullScreenNone;
InstallStreamFullscreenButton(window);
content_view.needsDisplay = YES;
return true;
}
}
void UnregisterStreamWindow(void *opaque_slint_view) {
@autoreleasepool {
NSView *slint_view = (__bridge NSView *)opaque_slint_view;
if (slint_view == nil || slint_view != stream_slint_view) {
return;
}
stream_window = nil;
stream_slint_view = nil;
stream_fullscreen = false;
stream_restore_frame = NSZeroRect;
stream_restore_style_mask = NSWindowStyleMaskTitled;
stream_restore_title_visibility = NSWindowTitleVisible;
stream_restore_titlebar_transparent = NO;
stream_restore_movable = YES;
stream_restore_close_hidden = NO;
stream_restore_miniaturize_hidden = NO;
stream_restore_zoom_hidden = NO;
stream_restore_presentation_options = NSApplicationPresentationDefault;
}
}
bool IsStreamWindowActive() {
@autoreleasepool {
return stream_window != nil && [NSApp isActive] &&
[stream_window isKeyWindow];
}
}
+502
View File
@@ -0,0 +1,502 @@
#include "rendering/slint_video_presenter.h"
#include <libyuv.h>
#include <slint.h>
#include <algorithm>
#include <cmath>
#include <mutex>
#include <unordered_map>
#include <utility>
#include "nv12_scaler.h"
#include "platform/video_renderer.h"
#include "rd_log.h"
#if defined(__APPLE__)
#include "platform/metal_video_renderer.h"
#else
#if defined(_WIN32)
#include <windows.h>
#endif
#include <GL/gl.h>
#include "platform/opengl_video_renderer.h"
#endif
namespace crossdesk {
namespace {
#if !defined(__APPLE__)
constexpr GLint kGlClampToEdge = 0x812F;
constexpr float kStreamWindowCornerRadius = 12.0f;
#else
constexpr int kMetalAttachmentAttemptLimit = 30;
#endif
struct VideoRenderSize {
int width = 0;
int height = 0;
bool operator==(const VideoRenderSize&) const = default;
};
VideoRenderSize FitVideoToRenderArea(int source_width, int source_height,
int area_width, int area_height) {
if (source_width <= 0 || source_height <= 0 || area_width <= 0 ||
area_height <= 0) {
return {};
}
const double scale =
std::min({1.0, static_cast<double>(area_width) / source_width,
static_cast<double>(area_height) / source_height});
int width = std::max(2, static_cast<int>(std::floor(source_width * scale)));
int height = std::max(2, static_cast<int>(std::floor(source_height * scale)));
width &= ~1;
height &= ~1;
return {std::min(width, source_width & ~1),
std::min(height, source_height & ~1)};
}
} // namespace
struct SlintVideoPresenter::Impl {
explicit Impl(VideoRenderer& renderer) : renderer(renderer) {}
VideoRenderer& renderer;
slint::ComponentHandle<ui::StreamWindow>* stream = nullptr;
StateProvider state_provider;
DirtyCallback dirty_callback;
std::unordered_map<std::string, uint64_t> displayed_frame_sequence;
std::unordered_map<std::string, uint64_t> seeded_frame_sequence;
std::vector<uint8_t> scaled_video_frame;
std::vector<uint8_t> scaled_video_frame_scratch;
#if defined(__APPLE__)
int attachment_attempts = 0;
bool skip_attachment_once = false;
#else
std::mutex gl_mutex;
std::vector<uint8_t> gl_conversion_frame;
std::vector<uint8_t> gl_pending_frame;
uint32_t gl_texture = 0;
VideoRenderSize gl_texture_size;
VideoRenderSize gl_image_size;
VideoRenderSize gl_pending_size;
bool gl_pending_dirty = false;
#endif
SurfaceState CurrentState() const {
return state_provider ? state_provider() : SurfaceState{};
}
void MarkDirty() const {
if (dirty_callback) {
dirty_callback();
}
}
bool MarkPresented(const std::string& remote_id, uint64_t sequence) {
if (remote_id.empty() || sequence == 0 ||
displayed_frame_sequence[remote_id] == sequence) {
return false;
}
displayed_frame_sequence[remote_id] = sequence;
return true;
}
#if !defined(__APPLE__)
void ConfigureOpenGlNotifier() {
if (!stream) {
return;
}
const auto error = (*stream)->window().set_rendering_notifier(
[this](slint::RenderingState state, slint::GraphicsAPI graphics_api) {
if (graphics_api != slint::GraphicsAPI::NativeOpenGL) {
return;
}
std::lock_guard lock(gl_mutex);
auto* opengl_renderer =
dynamic_cast<OpenGlVideoRenderer*>(&renderer);
if (state == slint::RenderingState::RenderingSetup) {
GLuint texture = 0;
glGenTextures(1, &texture);
if (texture == 0) {
return;
}
GLint previous_texture = 0;
glGetIntegerv(GL_TEXTURE_BINDING_2D, &previous_texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, kGlClampToEdge);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, kGlClampToEdge);
glBindTexture(GL_TEXTURE_2D, static_cast<GLuint>(previous_texture));
gl_texture = texture;
if (opengl_renderer && opengl_renderer->Setup()) {
const std::string selected_stream =
CurrentState().selected_stream;
opengl_renderer->SetSelectedStream(selected_stream);
seeded_frame_sequence.erase(selected_stream);
MarkDirty();
}
return;
}
if (state == slint::RenderingState::BeforeRendering && stream &&
opengl_renderer && opengl_renderer->IsReady() &&
(*stream)->get_native_video_enabled()) {
const SurfaceState surface = CurrentState();
const auto window_size = (*stream)->window().size();
const float scale_factor =
std::max(1.0f, (*stream)->window().scale_factor());
const int top_inset =
!surface.fullscreen && surface.tab_count > 1
? static_cast<int>(std::lround(30.0f * scale_factor))
: 0;
const bool rounded_window = (*stream)->get_custom_titlebar() &&
!surface.fullscreen &&
!(*stream)->window().is_maximized();
const int corner_radius =
rounded_window ? static_cast<int>(std::lround(
kStreamWindowCornerRadius * scale_factor))
: 0;
opengl_renderer->RenderLatest(
surface.selected_stream, static_cast<int>(window_size.width),
static_cast<int>(window_size.height), top_inset, corner_radius);
return;
}
if (state == slint::RenderingState::BeforeRendering &&
gl_texture != 0 && gl_pending_dirty &&
!gl_pending_frame.empty()) {
GLint previous_texture = 0;
GLint previous_unpack_alignment = 0;
glGetIntegerv(GL_TEXTURE_BINDING_2D, &previous_texture);
glGetIntegerv(GL_UNPACK_ALIGNMENT, &previous_unpack_alignment);
glBindTexture(GL_TEXTURE_2D, gl_texture);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
if (gl_texture_size != gl_pending_size) {
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, gl_pending_size.width,
gl_pending_size.height, 0, GL_RGBA, GL_UNSIGNED_BYTE,
gl_pending_frame.data());
gl_texture_size = gl_pending_size;
} else {
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, gl_pending_size.width,
gl_pending_size.height, GL_RGBA, GL_UNSIGNED_BYTE,
gl_pending_frame.data());
}
glPixelStorei(GL_UNPACK_ALIGNMENT, previous_unpack_alignment);
glBindTexture(GL_TEXTURE_2D, static_cast<GLuint>(previous_texture));
gl_pending_dirty = false;
return;
}
if (state == slint::RenderingState::RenderingTeardown) {
if (opengl_renderer) {
opengl_renderer->Teardown();
}
if (gl_texture != 0) {
const GLuint texture = gl_texture;
glDeleteTextures(1, &texture);
}
gl_texture = 0;
gl_texture_size = {};
gl_image_size = {};
gl_pending_size = {};
gl_pending_dirty = false;
gl_pending_frame.clear();
}
});
if (error.has_value()) {
LOG_WARN("Slint OpenGL video renderer unavailable, using pixel buffers");
} else {
renderer.SetSelectedStream(CurrentState().selected_stream);
}
}
#endif
};
SlintVideoPresenter::SlintVideoPresenter(VideoRenderer& renderer)
: impl_(std::make_unique<Impl>(renderer)) {}
SlintVideoPresenter::~SlintVideoPresenter() = default;
void SlintVideoPresenter::PrepareWindow(
slint::ComponentHandle<ui::StreamWindow>& stream,
StateProvider state_provider, DirtyCallback dirty_callback) {
impl_->stream = &stream;
impl_->state_provider = std::move(state_provider);
impl_->dirty_callback = std::move(dirty_callback);
#if defined(__APPLE__)
stream->set_native_video_enabled(false);
impl_->attachment_attempts = kMetalAttachmentAttemptLimit;
impl_->skip_attachment_once = true;
#else
impl_->ConfigureOpenGlNotifier();
#endif
}
bool SlintVideoPresenter::EnsureAttached() {
#if defined(__APPLE__)
if (!impl_->stream || impl_->attachment_attempts <= 0) {
return false;
}
if (impl_->skip_attachment_once) {
impl_->skip_attachment_once = false;
return false;
}
auto* metal_renderer =
dynamic_cast<MacMetalVideoRenderer*>(&impl_->renderer);
if (!metal_renderer) {
impl_->attachment_attempts = 0;
(*impl_->stream)->set_native_video_enabled(false);
return false;
}
if (!metal_renderer->IsReady()) {
impl_->attachment_attempts = 0;
(*impl_->stream)->set_native_video_enabled(false);
return false;
}
if (metal_renderer->IsAttached()) {
impl_->attachment_attempts = 0;
return false;
}
if (void* view = (*impl_->stream)->window().appkit_view()) {
(*impl_->stream)->set_native_video_enabled(true);
if (metal_renderer->Attach(view)) {
impl_->attachment_attempts = 0;
const SurfaceState surface = impl_->CurrentState();
impl_->displayed_frame_sequence.erase(surface.selected_stream);
impl_->seeded_frame_sequence.erase(surface.selected_stream);
if (metal_renderer->SetSelectedStream(surface.selected_stream)) {
impl_->MarkDirty();
}
return true;
}
(*impl_->stream)->set_native_video_enabled(false);
}
if (--impl_->attachment_attempts == 0) {
(*impl_->stream)->set_native_video_enabled(false);
LOG_WARN("Unable to attach the Metal video surface; using CPU rendering");
}
#endif
return false;
}
bool SlintVideoPresenter::NeedsRedraw() const {
#if defined(__APPLE__)
const auto* metal_renderer =
dynamic_cast<const MacMetalVideoRenderer*>(&impl_->renderer);
return metal_renderer && metal_renderer->IsActive() &&
metal_renderer->NeedsSurfaceRedraw();
#else
return false;
#endif
}
bool SlintVideoPresenter::SelectStream(std::string remote_id) {
const bool changed = impl_->renderer.SetSelectedStream(remote_id);
if (changed && !remote_id.empty()) {
impl_->seeded_frame_sequence.erase(remote_id);
}
return changed;
}
SlintVideoPresenter::PresentResult SlintVideoPresenter::Present(
const Frame& frame) {
PresentResult result;
if (!impl_->stream || frame.remote_id.empty()) {
return result;
}
if (impl_->renderer.IsActive() && frame.nv12 && frame.width > 0 &&
frame.height > 0 && frame.sequence > 0 &&
impl_->seeded_frame_sequence[frame.remote_id] != frame.sequence) {
const auto submit_result = impl_->renderer.SubmitCachedNv12(
frame.remote_id, frame.nv12->data(), frame.nv12->size(), frame.width,
frame.height);
if (submit_result == VideoRenderer::SubmitResult::submitted ||
submit_result == VideoRenderer::SubmitResult::dropped) {
impl_->seeded_frame_sequence[frame.remote_id] = frame.sequence;
}
}
#if defined(__APPLE__)
auto* metal_renderer =
dynamic_cast<MacMetalVideoRenderer*>(&impl_->renderer);
if (metal_renderer && metal_renderer->IsActive()) {
const SurfaceState surface = impl_->CurrentState();
const double top_inset =
!surface.fullscreen && surface.tab_count > 1 ? 30.0 : 0.0;
const auto outcome = metal_renderer->RenderLatest(
frame.remote_id, top_inset, !surface.fullscreen);
if (outcome.result == VideoRenderer::RenderResult::rendered) {
(*impl_->stream)->set_has_frame(true);
(*impl_->stream)->set_receiving_text("");
result.width = outcome.width;
result.height = outcome.height;
result.new_frame =
impl_->MarkPresented(frame.remote_id, outcome.sequence);
} else if (outcome.result == VideoRenderer::RenderResult::empty) {
(*impl_->stream)->set_has_frame(false);
}
return result;
}
#else
auto* opengl_renderer =
dynamic_cast<OpenGlVideoRenderer*>(&impl_->renderer);
if (opengl_renderer && opengl_renderer->IsReady()) {
opengl_renderer->SetSelectedStream(frame.remote_id);
if (!(*impl_->stream)->get_native_video_enabled()) {
(*impl_->stream)->set_native_video_enabled(true);
}
const bool has_frame =
frame.width > 0 && frame.height > 0 && frame.sequence > 0;
(*impl_->stream)->set_has_frame(has_frame);
if (has_frame) {
(*impl_->stream)->set_receiving_text("");
result.width = frame.width;
result.height = frame.height;
result.new_frame = impl_->MarkPresented(frame.remote_id, frame.sequence);
}
(*impl_->stream)->window().request_redraw();
return result;
}
if ((*impl_->stream)->get_native_video_enabled()) {
(*impl_->stream)->set_native_video_enabled(false);
}
#endif
const size_t nv12_size =
static_cast<size_t>(frame.width) * frame.height * 3 / 2;
if (!frame.nv12 || frame.width <= 0 || frame.height <= 0 ||
frame.nv12->size() < nv12_size) {
(*impl_->stream)->set_has_frame(false);
return result;
}
if (impl_->displayed_frame_sequence[frame.remote_id] == frame.sequence) {
return result;
}
const auto window_size = (*impl_->stream)->window().size();
const VideoRenderSize render_size = FitVideoToRenderArea(
frame.width, frame.height, static_cast<int>(window_size.width),
static_cast<int>(window_size.height));
const uint8_t* y_plane = frame.nv12->data();
const uint8_t* uv_plane =
frame.nv12->data() + static_cast<size_t>(frame.width) * frame.height;
int output_width = frame.width;
int output_height = frame.height;
if (render_size.width > 0 && render_size.height > 0 &&
(render_size.width != frame.width ||
render_size.height != frame.height)) {
const size_t scaled_nv12_size =
static_cast<size_t>(render_size.width) * render_size.height * 3 / 2;
impl_->scaled_video_frame.resize(scaled_nv12_size);
uint8_t* scaled_y = impl_->scaled_video_frame.data();
uint8_t* scaled_uv =
scaled_y + static_cast<size_t>(render_size.width) * render_size.height;
if (ScaleNv12ViaI420(y_plane, frame.width, uv_plane, frame.width,
frame.width, frame.height, scaled_y, render_size.width,
scaled_uv, render_size.width, render_size.width,
render_size.height, libyuv::kFilterBox,
&impl_->scaled_video_frame_scratch) == 0) {
y_plane = scaled_y;
uv_plane = scaled_uv;
output_width = render_size.width;
output_height = render_size.height;
}
}
#if !defined(__APPLE__)
uint32_t texture_id = 0;
{
std::lock_guard lock(impl_->gl_mutex);
texture_id = impl_->gl_texture;
}
if (texture_id != 0) {
impl_->gl_conversion_frame.resize(static_cast<size_t>(output_width) *
output_height * 4);
if (libyuv::NV12ToABGR(y_plane, output_width, uv_plane, output_width,
impl_->gl_conversion_frame.data(), output_width * 4,
output_width, output_height) != 0) {
return result;
}
{
std::lock_guard lock(impl_->gl_mutex);
if (impl_->gl_texture == 0) {
return result;
}
texture_id = impl_->gl_texture;
impl_->gl_pending_frame.swap(impl_->gl_conversion_frame);
impl_->gl_pending_size = {output_width, output_height};
impl_->gl_pending_dirty = true;
}
const VideoRenderSize output_size{output_width, output_height};
if (impl_->gl_image_size != output_size) {
(*impl_->stream)
->set_frame(slint::Image::create_from_borrowed_gl_2d_rgba_texture(
texture_id,
slint::Size<uint32_t>{static_cast<uint32_t>(output_width),
static_cast<uint32_t>(output_height)}));
impl_->gl_image_size = output_size;
}
(*impl_->stream)->set_has_frame(true);
(*impl_->stream)->set_receiving_text("");
(*impl_->stream)->window().request_redraw();
result.width = frame.width;
result.height = frame.height;
result.new_frame = impl_->MarkPresented(frame.remote_id, frame.sequence);
return result;
}
#endif
slint::SharedPixelBuffer<slint::Rgb8Pixel> pixels(output_width,
output_height);
if (libyuv::NV12ToRAW(y_plane, output_width, uv_plane, output_width,
reinterpret_cast<uint8_t*>(pixels.begin()),
output_width * 3, output_width, output_height) != 0) {
return result;
}
(*impl_->stream)->set_frame(slint::Image(std::move(pixels)));
(*impl_->stream)->set_has_frame(true);
(*impl_->stream)->set_receiving_text("");
result.width = frame.width;
result.height = frame.height;
result.new_frame = impl_->MarkPresented(frame.remote_id, frame.sequence);
return result;
}
void SlintVideoPresenter::ForgetStream(const std::string& remote_id) {
impl_->displayed_frame_sequence.erase(remote_id);
impl_->seeded_frame_sequence.erase(remote_id);
}
void SlintVideoPresenter::Detach() {
impl_->renderer.SetSelectedStream({});
#if defined(__APPLE__)
if (auto* metal_renderer =
dynamic_cast<MacMetalVideoRenderer*>(&impl_->renderer)) {
metal_renderer->Detach();
}
impl_->attachment_attempts = 0;
impl_->skip_attachment_once = false;
#endif
impl_->stream = nullptr;
impl_->state_provider = {};
impl_->dirty_callback = {};
}
} // namespace crossdesk
+75
View File
@@ -0,0 +1,75 @@
#ifndef CROSSDESK_GUI_RENDERING_SLINT_VIDEO_PRESENTER_H_
#define CROSSDESK_GUI_RENDERING_SLINT_VIDEO_PRESENTER_H_
#include <cstddef>
#include <cstdint>
#include <functional>
#include <memory>
#include <string>
#include <vector>
#include "crossdesk_ui.h"
namespace crossdesk {
class VideoRenderer;
// Owns the bridge between decoded NV12 frames, the selected native graphics
// backend and Slint's StreamWindow. Platform macros and graphics-API lifecycle
// details are intentionally contained in the implementation file.
class SlintVideoPresenter {
public:
struct SurfaceState {
std::string selected_stream;
bool fullscreen = false;
size_t tab_count = 0;
};
struct Frame {
std::string remote_id;
std::shared_ptr<std::vector<unsigned char>> nv12;
int width = 0;
int height = 0;
uint64_t sequence = 0;
};
struct PresentResult {
int width = 0;
int height = 0;
bool new_frame = false;
};
using StateProvider = std::function<SurfaceState()>;
using DirtyCallback = std::function<void()>;
explicit SlintVideoPresenter(VideoRenderer& renderer);
~SlintVideoPresenter();
SlintVideoPresenter(const SlintVideoPresenter&) = delete;
SlintVideoPresenter& operator=(const SlintVideoPresenter&) = delete;
// Called before the window is shown. OpenGL installs Slint's rendering
// notifier here; macOS deliberately defers native view attachment until a
// later UI pass, after AppKit has committed the ordinary window style.
void PrepareWindow(slint::ComponentHandle<ui::StreamWindow>& stream,
StateProvider state_provider,
DirtyCallback dirty_callback);
// Returns true only when a deferred native surface became attached during
// this call, allowing the caller to seed it from a cached frame once.
bool EnsureAttached();
bool NeedsRedraw() const;
bool SelectStream(std::string remote_id);
PresentResult Present(const Frame& frame);
void ForgetStream(const std::string& remote_id);
void Detach();
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
} // namespace crossdesk
#endif // CROSSDESK_GUI_RENDERING_SLINT_VIDEO_PRESENTER_H_
+40 -48
View File
@@ -8,8 +8,10 @@
#include <thread>
#include <vector>
#include "display_stream_id.h"
#include "localization.h"
#include "platform.h"
#include "platform/video_renderer.h"
#include "rd_log.h"
#include "runtime/gui_runtime.h"
@@ -17,11 +19,6 @@ namespace crossdesk {
namespace {
constexpr auto kPresenceProbeTimeout = std::chrono::seconds(5);
constexpr auto kConnectionAttemptTimeout = std::chrono::seconds(20);
bool IsConnectionAttemptPending(ConnectionStatus status) {
return status == ConnectionStatus::Connecting ||
status == ConnectionStatus::Gathering;
}
} // namespace
void GuiRuntime::HandleConnectionStatusChange() {
@@ -95,7 +92,7 @@ void GuiRuntime::HandlePendingPresenceProbe() {
show_offline_warning_window_ = true;
}
void GuiRuntime::HandleConnectionTimeouts() {
void GuiRuntime::HandlePresenceProbeTimeout() {
const auto now = std::chrono::steady_clock::now();
bool presence_probe_timed_out = false;
@@ -121,49 +118,15 @@ void GuiRuntime::HandleConnectionTimeouts() {
show_offline_warning_window_ = true;
LOG_WARN("Presence probe timed out for [{}]", presence_remote_id);
}
bool rejoin_state_changed = false;
for (auto& [_, props] : remote_sessions_) {
if (!props || !props->connection_attempt_active_.load()) {
continue;
}
const ConnectionStatus status = props->connection_status_.load();
if (!IsConnectionAttemptPending(status)) {
props->connection_attempt_active_.store(false);
continue;
}
if (now - props->connection_attempt_started_at_ <
kConnectionAttemptTimeout) {
continue;
}
LOG_WARN("Connection to [{}] timed out, status={}", props->remote_id_,
static_cast<int>(status));
props->connection_attempt_active_.store(false);
props->connection_established_ = false;
props->rejoin_ = false;
props->connection_status_.store(ConnectionStatus::Failed);
focused_remote_id_ = props->remote_id_;
show_connection_status_window_ = true;
rejoin_state_changed = true;
}
if (rejoin_state_changed) {
need_to_rejoin_ = false;
for (const auto& [_, props] : remote_sessions_) {
if (props && props->rejoin_) {
need_to_rejoin_ = true;
break;
}
}
}
}
void GuiRuntime::HandleServerControllerDisconnected(
const std::string& remote_id, const char* reason) {
keyboard_.ReleaseRemotePressedKeys(remote_id, reason);
{
std::lock_guard lock(remote_pointer_input_mutex_);
last_remote_pointer_input_time_.erase(remote_id);
}
bool has_connected_controller = false;
bool has_web_controller = false;
@@ -265,6 +228,21 @@ void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
frame_snapshot = props->front_frame_;
video_width = props->video_width_;
video_height = props->video_height_;
if ((!frame_snapshot || frame_snapshot->empty()) &&
props->thumbnail_frame_ && !props->thumbnail_frame_->empty()) {
frame_snapshot = props->thumbnail_frame_;
video_width = props->thumbnail_width_;
video_height = props->thumbnail_height_;
}
}
auto* native_renderer = video_renderer_.get();
if ((!frame_snapshot || frame_snapshot->empty()) && native_renderer) {
auto native_snapshot = std::make_shared<std::vector<unsigned char>>();
if (native_renderer->CopyLatestNv12(props->remote_id_,
native_snapshot.get(), &video_width,
&video_height)) {
frame_snapshot = std::move(native_snapshot);
}
}
if (frame_snapshot && !frame_snapshot->empty() && video_width > 0 &&
@@ -289,6 +267,11 @@ void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
}
}
if (native_renderer) {
native_renderer->DiscardStream(props->remote_id_);
video_frame_dirty_.store(true, std::memory_order_release);
}
if (props->peer_) {
LOG_INFO("[{}] Leave connection [{}]", props->local_id_, props->remote_id_);
LeaveConnection(props->peer_, props->remote_id_.c_str());
@@ -349,12 +332,21 @@ void GuiRuntime::ResetRemoteSessionResources(
std::lock_guard<std::mutex> lock(props->video_frame_mutex_);
props->front_frame_.reset();
props->back_frame_.reset();
props->thumbnail_frame_.reset();
props->thumbnail_width_ = 0;
props->thumbnail_height_ = 0;
props->background_snapshot_time_ = {};
props->video_width_ = 0;
props->video_height_ = 0;
props->video_size_ = 0;
props->render_rect_dirty_ = true;
props->stream_cleanup_pending_ = false;
}
{
std::lock_guard<std::mutex> lock(props->remote_cursor_state_mutex_);
props->remote_cursor_state_ = {};
props->remote_cursor_state_received_ = false;
}
}
std::shared_ptr<GuiRuntime::RemoteSession> GuiRuntime::FindRemoteSession(
@@ -424,8 +416,10 @@ int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
return -1;
}
for (const auto& display_info : devices_.display_info_list()) {
AddVideoStream(props->peer_, display_info.name.c_str());
const auto& displays = devices_.display_info_list();
for (size_t index = 0; index < displays.size(); ++index) {
const std::string stream_id = MakeDisplayStreamId(index);
AddVideoStream(props->peer_, stream_id.c_str());
}
AddAudioStream(props->peer_, props->audio_label_.c_str());
AddDataStream(props->peer_, props->data_label_.c_str(), false);
@@ -455,8 +449,6 @@ int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
auto props = remote_sessions_[remote_id];
if (!props->connection_established_) {
props->connection_status_.store(ConnectionStatus::Connecting);
props->connection_attempt_active_.store(true);
props->connection_attempt_started_at_ = std::chrono::steady_clock::now();
show_connection_status_window_ = true;
props->remember_password_ = remember_password;

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