Compare commits

..
457 changed files with 8471 additions and 34323 deletions
+10 -12
View File
@@ -8,15 +8,13 @@
# Dependency manifests and local xmake package recipes are the only project
# files needed while pre-warming the build image.
!xmake.lua
!apps/
!apps/desktop/
!apps/desktop/xmake/
!apps/desktop/xmake/**
!deps/thirdparty/
!deps/thirdparty/**
!deps/submodules/
!deps/submodules/xmake.lua
!deps/submodules/minirtc/
!deps/submodules/minirtc/xmake.lua
!deps/submodules/minirtc/thirdparty/
!deps/submodules/minirtc/thirdparty/**
!xmake/
!xmake/**
!thirdparty/
!thirdparty/**
!submodules/
!submodules/xmake.lua
!submodules/minirtc/
!submodules/minirtc/xmake.lua
!submodules/minirtc/thirdparty/
!submodules/minirtc/thirdparty/**
+5 -5
View File
@@ -11,11 +11,11 @@ on:
- docker/linux-build/**
- xmake.lua
- xmake/**
- deps/thirdparty/**
- deps/submodules/xmake.lua
- deps/submodules/minirtc
- deps/submodules/minirtc/xmake.lua
- deps/submodules/minirtc/thirdparty/**
- thirdparty/**
- submodules/xmake.lua
- submodules/minirtc
- submodules/minirtc/xmake.lua
- submodules/minirtc/thirdparty/**
workflow_dispatch:
permissions:
+13 -298
View File
@@ -13,14 +13,8 @@ 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:
@@ -36,12 +30,12 @@ jobs:
- arch: amd64
runner: ubuntu-24.04
image: crossdesk/ubuntu20.04:buildenv-cuda12.6.3-gcc10-cmake3.31.6-pipewire0.3.48-xmake3.0.9-rust1.92.0
package_script: ./apps/desktop/scripts/linux/pkg_amd64.sh
package_script: ./scripts/linux/pkg_amd64.sh
- arch: arm64
runner: ubuntu-24.04-arm
image: crossdesk/ubuntu20.04:buildenv-cuda12.6.3-gcc10-cmake3.31.6-pipewire0.3.48-xmake3.0.9-rust1.92.0
package_script: ./apps/desktop/scripts/linux/pkg_arm64.sh
package_script: ./scripts/linux/pkg_arm64.sh
container:
image: ${{ matrix.image }}
@@ -138,17 +132,6 @@ jobs:
--cc=gcc-10 --cxx=g++-10 --root -y
xmake b -vy --root crossdesk
- name: Verify repository and wire boundaries
env:
XMAKE_GLOBALDIR: /data
run: |
xmake b -vy --root repository_structure_test
xmake b -vy --root crossdesk_wire_test
xmake b -vy --root keyboard_state_wire_test
xmake run --root repository_structure_test
xmake run --root crossdesk_wire_test
xmake run --root keyboard_state_wire_test
- name: Package
run: |
chmod +x ${{ matrix.package_script }}
@@ -189,13 +172,13 @@ jobs:
runner: macos-26-intel
cache-key: intel-macos26-xcode26.6-min14
out-dir: ./build/macosx/x86_64/release/crossdesk
package_script: ./apps/desktop/scripts/macos/pkg_x64.sh
package_script: ./scripts/macosx/pkg_x64.sh
- arch: arm64
binary_arch: arm64
runner: macos-26
cache-key: arm-macos26-xcode26.6-min14
out-dir: ./build/macosx/arm64/release/crossdesk
package_script: ./apps/desktop/scripts/macos/pkg_arm64.sh
package_script: ./scripts/macosx/pkg_arm64.sh
steps:
- name: Extract version number
@@ -271,7 +254,7 @@ jobs:
- name: Build CrossDesk
run: |
xmake f --target_minver=${MACOSX_DEPLOYMENT_TARGET} --CROSSDESK_VERSION=${VERSION_NUM} --USE_CUDA=false -vyD
xmake f --target_minver=${MACOSX_DEPLOYMENT_TARGET} --CROSSDESK_VERSION=${VERSION_NUM} --USE_CUDA=true -y
xmake b -vy crossdesk
- name: Package CrossDesk app
@@ -293,7 +276,7 @@ jobs:
exit 1
fi
./apps/desktop/scripts/macos/verify_app.sh "${APP_PATH}" "${{ matrix.binary_arch }}" "${EXPECTED_MACOS_SDK}"
./scripts/macosx/verify_app.sh "${APP_PATH}" "${{ matrix.binary_arch }}" "${EXPECTED_MACOS_SDK}"
- name: Upload build artifacts
uses: actions/upload-artifact@v6
@@ -306,200 +289,6 @@ jobs:
mkdir -p release
cp crossdesk-macos-${{ matrix.arch }}-${{ env.VERSION_NUM }}.pkg release/
# iOS
build-ios:
name: Build on iOS (arm64)
runs-on: macos-26
timeout-minutes: 120
outputs:
version: ${{ steps.ios_version.outputs.version }}
env:
IOS_DEPLOYMENT_TARGET: "16.0"
EXPECTED_IOS_SDK: "26.5"
XMAKE_VERSION: "3.1.1"
XMAKE_REPO_REVISION: "121109352da272cd1a3d4ed52f2dcda34f839955"
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: Read independent iOS version
id: ios_version
shell: bash
run: |
IOS_VERSION_BASE="$(xcodebuild \
-project apps/ios/CrossDeskMobile.xcodeproj \
-scheme CrossDeskMobile \
-configuration Release \
-destination 'generic/platform=iOS' \
-showBuildSettings CODE_SIGNING_ALLOWED=NO | \
awk '$1 == "MARKETING_VERSION" && $2 == "=" { print $3 }')"
if [[ ! "${IOS_VERSION_BASE}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Invalid iOS marketing version: ${IOS_VERSION_BASE}" >&2
exit 1
fi
VERSION_NUM="v${IOS_VERSION_BASE}-${VERSION_NUM##*-}"
echo "IOS_VERSION_BASE=${IOS_VERSION_BASE}" >> "${GITHUB_ENV}"
echo "VERSION_NUM=${VERSION_NUM}" >> "${GITHUB_ENV}"
echo "version=${VERSION_NUM}" >> "${GITHUB_OUTPUT}"
- name: Initialize submodules
run: git submodule update --init --recursive
- name: Cache xmake dependencies
uses: actions/cache@v5
with:
path: apps/ios/.xmake/packages
key: "${{ runner.os }}-xmake${{ env.XMAKE_VERSION }}-deps-ios-arm64-xcode26.6-xrepo1211093-min16-${{ github.run_id }}"
restore-keys: |
${{ runner.os }}-xmake${{ env.XMAKE_VERSION }}-deps-ios-arm64-xcode26.6-xrepo1211093-min16-
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.13"
- name: Install xmake
uses: xmake-io/github-action-setup-xmake@v1
with:
xmake-version: "${{ env.XMAKE_VERSION }}"
- name: Verify xmake version
shell: bash
run: |
XMAKE_VERSION_OUTPUT="$(xmake --version)"
echo "${XMAKE_VERSION_OUTPUT}"
if [[ "${XMAKE_VERSION_OUTPUT}" != *"xmake v${XMAKE_VERSION}"* ]]; then
echo "Unexpected xmake version: expected ${XMAKE_VERSION}" >&2
exit 1
fi
- name: Pin xmake package repository
shell: bash
run: |
xmake repo -u
XMAKE_REPO_DIR="${HOME}/.xmake/repositories/xmake-repo"
git -C "${XMAKE_REPO_DIR}" fetch --depth=1 origin "${XMAKE_REPO_REVISION}"
git -C "${XMAKE_REPO_DIR}" checkout --detach "${XMAKE_REPO_REVISION}"
test "$(git -C "${XMAKE_REPO_DIR}" rev-parse HEAD)" = "${XMAKE_REPO_REVISION}"
- name: Build CrossDesk Mobile
shell: bash
run: |
DERIVED_DATA="${RUNNER_TEMP}/CrossDeskMobileDerivedData"
echo "DERIVED_DATA=${DERIVED_DATA}" >> "${GITHUB_ENV}"
xcodebuild \
-project apps/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
APP_VERSION=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "${INFO_PLIST}")
if [[ "${APP_VERSION}" != "${IOS_VERSION_BASE}" ]]; then
echo "Unexpected iOS app version: expected ${IOS_VERSION_BASE}, got ${APP_VERSION}" >&2
exit 1
fi
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
# Windows
build-windows-x64:
name: Build on Windows x64
@@ -609,7 +398,7 @@ jobs:
- name: Copy nsProcess plugin to NSIS folder
run: |
$nsisPluginDir = "C:\Program Files (x86)\NSIS\Plugins\x86-unicode"
copy "${{ github.workspace }}\apps\desktop\scripts\windows\nsProcess.dll" $nsisPluginDir
copy "${{ github.workspace }}\scripts\windows\nsProcess.dll" $nsisPluginDir
- name: Build CrossDesk
run: |
@@ -619,53 +408,7 @@ jobs:
- name: Package
shell: pwsh
run: |
& "${{ github.workspace }}\apps\desktop\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 }}/apps/desktop/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 }}\apps\desktop\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
& "${{ github.workspace }}\scripts\windows\pkg_x64.ps1" $env:VERSION_NUM
- name: Build Portable CrossDesk
run: |
@@ -682,9 +425,7 @@ jobs:
$portableFiles = @(
@("crossdesk.exe", "CrossDesk.exe"),
@("crossdesk_service.exe", "crossdesk_service.exe"),
@("crossdesk_session_helper.exe", "crossdesk_session_helper.exe"),
@("crossdesk_privacy_cursor_helper.exe", "crossdesk_privacy_cursor_helper.exe"),
@("crossdesk_privacy_window.dll", "crossdesk_privacy_window.dll")
@("crossdesk_session_helper.exe", "crossdesk_session_helper.exe")
)
foreach ($file in $portableFiles) {
@@ -711,7 +452,7 @@ jobs:
uses: actions/upload-artifact@v6
with:
name: crossdesk-win-x64-${{ env.VERSION_NUM }}
path: ${{ github.workspace }}/apps/desktop/scripts/windows/crossdesk-win-x64-${{ env.VERSION_NUM }}.exe
path: ${{ github.workspace }}/scripts/windows/crossdesk-win-x64-${{ env.VERSION_NUM }}.exe
- name: Upload portable artifact
uses: actions/upload-artifact@v6
@@ -722,7 +463,7 @@ jobs:
release:
name: Publish Release
if: startsWith(github.ref, 'refs/tags/v')
needs: [build-linux, build-macos, build-ios, build-windows-x64]
needs: [build-linux, build-macos, build-windows-x64]
runs-on: ubuntu-latest
steps:
@@ -778,25 +519,6 @@ jobs:
echo "BUILD_DATE_ISO=${BUILD_DATE_ISO}" >> $GITHUB_OUTPUT
echo "PATCH_NUMBER=${PATCH_NUMBER}" >> $GITHUB_OUTPUT
- name: Prepare release notes
env:
RELEASE_TAG: ${{ steps.version.outputs.VERSION_WITH_V }}
VERSION_BASE: ${{ steps.version.outputs.VERSION_BASE }}
run: |
if [[ -f "docs/releases/${RELEASE_TAG}.md" ]]; then
cp "docs/releases/${RELEASE_TAG}.md" release-notes.md
elif [[ -f "docs/releases/v${VERSION_BASE}.md" ]]; then
cp "docs/releases/v${VERSION_BASE}.md" release-notes.md
else
cat > release-notes.md << 'EOF'
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/).
EOF
fi
- name: Rename artifacts
run: |
mkdir -p release
@@ -804,7 +526,6 @@ jobs:
cp artifacts/crossdesk-macos-arm64-${{ steps.version.outputs.VERSION_WITH_V }}/* release/crossdesk-macos-arm64-${{ steps.version.outputs.VERSION_WITH_V }}.pkg
cp artifacts/crossdesk-linux-amd64-${{ steps.version.outputs.VERSION_WITH_V }}/* release/crossdesk-linux-amd64-${{ steps.version.outputs.VERSION_WITH_V }}.deb
cp artifacts/crossdesk-linux-arm64-${{ steps.version.outputs.VERSION_WITH_V }}/* release/crossdesk-linux-arm64-${{ steps.version.outputs.VERSION_WITH_V }}.deb
cp artifacts/crossdesk-ios-arm64-unsigned-${{ needs.build-ios.outputs.version }}/* release/crossdesk-ios-arm64-unsigned-${{ needs.build-ios.outputs.version }}.zip
cp artifacts/crossdesk-win-x64-${{ steps.version.outputs.VERSION_WITH_V }}/* release/crossdesk-win-x64-${{ steps.version.outputs.VERSION_WITH_V }}.exe
cp artifacts/crossdesk-win-x64-portable-${{ steps.version.outputs.VERSION_WITH_V }}/* release/crossdesk-win-x64-portable-${{ steps.version.outputs.VERSION_WITH_V }}.zip
@@ -815,13 +536,13 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.VERSION_WITH_V }}
target_commitish: ${{ github.sha }}
name: Release ${{ steps.version.outputs.VERSION_WITH_V }}
draft: false
prerelease: false
files: release/*
generate_release_notes: false
body_path: release-notes.md
body: |
Binary release only. Source code is not included.
- name: Create or update 'latest' tag
run: |
@@ -839,7 +560,6 @@ jobs:
prerelease: false
files: release/*
generate_release_notes: false
body_path: release-notes.md
- name: Upload artifacts to server
uses: burnett01/rsync-deployments@5.2
@@ -890,11 +610,6 @@ jobs:
}
}
EOF
jq --arg release_name "Release ${{ steps.version.outputs.VERSION_WITH_V }}" \
--rawfile release_notes release-notes.md \
'.releaseName = $release_name | .releaseNotes = $release_notes' \
version.json > version-with-notes.json
mv version-with-notes.json version.json
cat version.json
- name: Upload version.json to server
+1 -6
View File
@@ -1,14 +1,9 @@
# Xmake cache
.xmake/
build/
apps/ios/Vendor/
apps/ios/DerivedData*/
apps/ios/.xmake/
xcuserdata/
*.xcuserstate
# MacOS Cache
.DS_Store
# VSCode cache
.vscode
.vscode
+2 -2
View File
@@ -1,3 +1,3 @@
[submodule "deps/submodules/minirtc"]
path = deps/submodules/minirtc
[submodule "submodules/minirtc"]
path = submodules/minirtc
url = https://github.com/kunkundi/minirtc.git
-99
View File
@@ -1,99 +0,0 @@
# 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).
+319 -180
View File
@@ -1,215 +1,354 @@
<div align="center">
<img src="apps/desktop/resources/linux/crossdesk_128x128.png" width="80" alt="CrossDesk 标志" />
<h1>CrossDesk</h1>
<p>轻量、跨平台的远程桌面,让电脑、浏览器和 iPhone / iPad 连接到同一张桌面。</p>
<p><a href="https://www.crossdesk.cn/">官方网站</a> · <a href="https://github.com/kunkundi/crossdesk/releases">下载客户端</a> · <a href="https://web.crossdesk.cn/">打开 Web 客户端</a> · <a href="README_EN.md">English</a></p>
</div>
# CrossDesk
<div align="center">
<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="Featured|HelloGitHub" /></a>
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-brightgreen.svg)]()
[![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)]()
[![Platforms](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux%20%7C%20iOS-blue)](#download)
[![Release](https://img.shields.io/github/v/release/kunkundi/crossdesk)](https://github.com/kunkundi/crossdesk/releases)
[![Build](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml/badge.svg)](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml)
[![License: GPL v3](https://img.shields.io/badge/license-GPL--3.0-blue)](LICENSE)
[![Stars](https://img.shields.io/github/stars/kunkundi/crossdesk?style=social)](https://github.com/kunkundi/crossdesk/stargazers)
[ [English](README_EN.md) / 中文 ]
</div>
CrossDesk 支持 Windows、macOS、Linux 之间的远程访问,也可从浏览器或原生 iOS 客户端控制电脑。项目基于 [MiniRTC](https://github.com/kunkundi/minirtc),提供实时音视频传输、键鼠控制、文件传输与自托管能力,仍在持续开发中。
> 本文按当前仓库代码整理。截图展示当前 Slint 桌面界面,使用演示 ID、密码、在线状态和连接记录;缩略图来自公开页面;下载包的功能与系统要求请同时核对对应 Release 说明。原生 iOS 客户端的构建、签名方式见下文。
[界面预览](#preview) · [下载与安装](#download) · [快速连接](#quick-start) · [会话操作](#session) · [设置](#settings) · [iOS](#ios) · [自托管](#self-hosting) · [源码构建](docs/BUILD.md) · [常见问题](docs/FAQ.md)
<a id="preview"></a>
## 界面预览
### 桌面客户端
主窗口集中展示本机 ID、连接密码、远程连接入口和近期连接记录。
PC 客户端
![sup_example](https://github.com/user-attachments/assets/eeb64fbe-1f07-4626-be1c-b77396beb905)
Web 客户端
<p align="center">
<img src="docs/images/desktop-main-zh.png" width="800" alt="当前桌面客户端示例:已连接服务器,三台在线设备均显示页面缩略图" />
<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 20.04 及以上 |
## 使用
在菜单栏“对端ID”处输入远端桌面的ID,点击“→”即可发起远程连接。
![usage1](https://github.com/user-attachments/assets/3a4bb59f-c84c-44d2-9a20-11790aac510e)
如果远端桌面设置了连接密码,则本端需填写正确的连接密码才能成功发起远程连接。
![password](https://github.com/user-attachments/assets/1beadcce-640d-4f5c-8e77-51917b5294d5)
发起连接前,可在设置中自定义配置项,如语言、视频编码格式等。
![settings](https://github.com/user-attachments/assets/8bc5468d-7bbb-4e30-95bd-da1f352ac08c)
### Web 客户端
浏览器访问 [CrossDesk Web Client](https://web.crossdesk.cn/)。
输入 **远程设备 ID** 与 **密码**,点击连接即可接入远程设备。如图,**iOS Safari 远程控制 Win11**:
在浏览器中输入远程设备 ID 和密码,无需在控制端安装桌面客户端。
<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" />
<p align="center">
<img src="docs/images/web-client.png" width="800" alt="CrossDesk Web 客户端当前连接首页" />
</p>
### Windows 服务(CrossDesk Service)
CrossDesk 在 Windows 平台提供本地辅助服务 **CrossDesk Service**,服务名为 `CrossDeskService`。该服务用于锁屏、登录界面和安全桌面等受保护场景下的远程控制增强能力,包括:
- 上报远端当前是否处于锁屏、登录、凭据输入或安全桌面状态;
- 支持从控制端发送 `Ctrl+Alt+Del`(SAS);
- 在锁屏、登录和安全桌面阶段转发键盘、鼠标输入。
图片来源与更新方式见 [截图说明](docs/images/README.md)。
Windows 安装包会自动打包 `crossdesk_service.exe` 和 `crossdesk_session_helper.exe`,并在安装时注册为按需启动的 Windows 服务。CrossDesk 客户端启动时会尝试启动已安装的服务;当本机没有 CrossDesk 客户端进程运行时,服务会自动退出。卸载客户端时会同步停止并移除该服务。
## 主要功能
| 能力 | 当前支持 |
| --- | --- |
| 跨平台控制 | Windows / macOS / Linux 桌面互控;浏览器与原生 iOS 作为控制端 |
| 实时画面与声音 | H.264 / AV1、30 / 60 fps 设置、硬件编解码选项、远端声音播放;实际能力取决于设备与构建配置 |
| 多设备与多显示器 | 近期连接、设备别名、会话标签与远端显示器切换 |
| 输入与协作 | 键鼠输入、远端光标同步、组合键、文本剪贴板同步、文件传输 |
| 网络与部署 | P2P 直连、TURN 中继、SRTP 加密选项、自托管信令与中继服务 |
| Windows 锁屏控制 | 通过 CrossDesk Service 支持锁屏、登录界面及安全桌面的输入转发 |
各客户端的入口和能力有所不同;桌面会话操作见下文,iOS 的具体功能见 [开发说明](apps/ios/README.md)。
<a id="download"></a>
## 下载与安装
前往 [GitHub Releases](https://github.com/kunkundi/crossdesk/releases) 或 [官方网站](https://www.crossdesk.cn/),按系统与 CPU 架构选择安装包。
| 平台 | 当前源码 / CI 基线 | 安装方式 |
| --- | --- | --- |
| Windows | Windows 10+,x64 | `.exe` 安装包;便携构建也受支持 |
| macOS | macOS 14.0+,Intel / Apple Silicon | 选择 x64 或 arm64 的 `.pkg` |
| Linux | Ubuntu 20.04+,amd64 / arm64,glibc 2.31 基线 | `.deb` 安装包 |
| iOS / iPadOS | iOS 16.0+,arm64 真机 | 原生客户端;使用 Xcode 构建并签名,CI 产物为未签名应用 |
| Web | 支持 WebRTC 的浏览器 | 访问 [web.crossdesk.cn](https://web.crossdesk.cn/) |
Linux 下载后,在下载目录中将文件名替换为实际安装包名称:
```bash
sudo apt install "./crossdesk-linux-amd64-<version>.deb"
如果是手动编译或手动部署 Windows 版本,请确保 `CrossDesk.exe`、`crossdesk_service.exe` 和 `crossdesk_session_helper.exe` 位于同一目录。安装或卸载服务需要使用管理员权限打开 PowerShell:
```
**macOS 首次运行:** 按应用提示,在“系统设置 → 隐私与安全性”中授予 CrossDesk **屏幕录制**(新系统可能显示为“屏幕与系统音频录制”)及 **辅助功能**权限,再按系统提示重新打开应用。前者用于捕获桌面,后者用于远程键鼠输入。
<a id="quick-start"></a>
## 快速连接
### 从另一台电脑连接
1. **准备被控电脑。** 安装并运行 CrossDesk,等待底部显示“已连接服务器”。在左侧“本桌面”查看 **本机 ID** 和 **密码**,提供给控制端。
2. **输入对端 ID。** 在控制端右侧“远程桌面 → 对端 ID”输入被控电脑的 ID,点击 **→**。
3. **完成密码验证。** 按弹窗输入被控电脑当前的连接密码,确认后等待远程画面出现。需要保存密码时勾选“记住密码”。
4. **再次连接。** 成功连接过的设备会出现在“近期连接”中,可通过卡片重连,也可编辑别名或删除记录。
本机密码旁的眼睛按钮用于显示 / 隐藏密码,铅笔按钮用于修改密码。修改时使用 **6 位数字或英文字母**,并保持连接服务器;等待修改成功、客户端重新连接后,再复制当前密码。已保存旧密码的控制端需重新输入。
### 从浏览器连接
1. 保持被控电脑上的 CrossDesk 运行,并确认已连接服务器;在被控端“设置”中保持 **启用 SRTP** 开启。
2. 打开 [Web 客户端](https://web.crossdesk.cn/),输入 **远程设备 ID** 和 **密码**,点击“连接”。
3. 连接后使用页面提供的显示器、鼠标模式和键盘控件操作远端;手机和平板也可通过浏览器访问。
如果使用自建服务器,需要让控制端与被控端接入同一服务;Web 部署配置见 [CrossDesk Web Client](https://github.com/kunkundi/crossdesk-web-client)。
<a id="session"></a>
## 会话中的常用操作
远程窗口通过会话标签切换设备,控制栏提供以下操作;控制栏收起时先点击展开按钮。将鼠标悬停在图标上可查看提示。
| 入口 | 用途 |
| --- | --- |
| 显示器 | 切换远端显示器 |
| 键盘 | 发送组合键;Windows 的 `Ctrl+Alt+Del` 依赖远端服务 |
| 鼠标 | 控制 / 释放远端鼠标 |
| 扬声器 | 播放声音 / 静音 |
| 文件夹 | 选择文件发送到远端,并查看传输进度 |
| 网络状态 | 查看流量、丢包率、帧率、分辨率及直连 / 中继状态 |
| 全屏 | 进入 / 退出全屏 |
| 断开连接 | 结束当前远程会话 |
桌面端可同步文本剪贴板。接收文件的位置可在“设置 → 文件保存路径”调整。关闭主窗口会隐藏窗口并保持程序运行;需要完全退出时使用系统托盘或菜单栏中的退出入口。
<a id="settings"></a>
## 调整画面与连接设置
点击主窗口右上角 **☰ → 设置**,修改后点击“确认”保存。建议在建立连接前调整;会话进行中部分选项不可修改。设置列表可向下滚动,查看自托管、启动方式与文件保存路径。
<p align="center">
<img src="docs/images/desktop-settings-zh.png" width="720" alt="当前设置界面:画质、帧率、画面偏好、编解码、中继与 SRTP" />
</p>
| 设置 | 说明 |
| --- | --- |
| 画面质量 / 帧率 | 选择低、中、高画质与 30 / 60 fps;实际帧率取决于网络和设备 |
| 画面偏好 | 在帧率优先、画质优先、平衡之间选择 |
| 视频编码格式 | H.264 / AV1;硬件编解码是否可用取决于平台、硬件与构建选项 |
| 启用中继服务 | 允许借助 TURN 中继建立连接;P2P 连接失败时先检查此项 |
| 启用 SRTP | 媒体传输加密选项,两端配置需兼容 |
| 自托管配置 | 设置服务器地址、信令端口与中继端口,并勾选启用 |
| 开机自启 / 启用守护进程 | 调整启动与进程守护行为,按界面提示重启后生效 |
| 文件保存路径 | 选择接收文件的目录 |
<a id="ios"></a>
## 原生 iOS / iPadOS 客户端
仓库的 [`apps/ios`](apps/ios/README.md) 提供原生控制端,与桌面端使用同一套 MiniRTC 协议。它可以控制远端电脑;当前不提供将 iPhone / iPad 作为被控桌面的功能。
1. 按 [iOS 开发说明](apps/ios/README.md) 使用 Xcode 构建并签名,安装到 iOS 16+ 的真机。
2. 在首页输入对端 ID,点击连接并填写密码;成功连接后可从“最近连接”中重连。
3. 在设置中选择鼠标模式:**相对位置**像触控板一样移动光标,**绝对位置**将触摸位置直接映射到远端画面。
4. 点击悬浮图标展开菜单,可切换显示器、显示键盘、控制声音或发送文件。收到的文件位于应用的 `Documents/Received`,可通过传输状态旁的分享按钮导出。
当前支持接收远端文本到 iOS 剪贴板;界面暂未提供主动发送本机剪贴板的按钮。
当前支持单指点击、双指右键、长按拖动与捏合缩放;缩放后的双指平移用于移动视图。原生端与浏览器端分别维护,使用入口和手势可能不同。
<a id="windows-service"></a>
## Windows 锁屏与登录界面
**CrossDesk Service**(服务名 `CrossDeskService`)提供锁屏、登录、凭据输入及安全桌面的状态上报、`Ctrl+Alt+Del` 发送和键鼠输入转发。
安装版会注册按需启动的服务,客户端启动时尝试启动它;本机没有 CrossDesk 客户端进程后服务会自动退出。便携版可在提示窗口或“设置 → 锁屏控制服务”中安装服务,需要管理员权限。该服务依赖客户端运行。
<details>
<summary>手动部署与服务管理命令</summary>
保留完整安装目录;自行部署构建产物时,将 `CrossDesk.exe`、`crossdesk_service.exe`、`crossdesk_session_helper.exe` 及同次构建输出的全部 `.dll` 放在同一目录。在该目录打开管理员 PowerShell,按需执行:
```powershell
# 安装
.\CrossDesk.exe --service-install
# 启动
.\CrossDesk.exe --service-start
# 查看状态
.\CrossDesk.exe --service-status
.\CrossDesk.exe --service-ping
# 停止或卸载服务
.\CrossDesk.exe --service-stop
# 卸载
.\CrossDesk.exe --service-uninstall
```
</details>
如果远端 Windows 服务未安装、未启动或暂时不可用,基础远程桌面连接仍可使用,但锁屏、登录界面和安全桌面阶段的控制能力会受限,客户端会提示“远端Windows服务不可用”。
出现“远端 Windows 服务不可用”时,检查被控电脑的安装和服务状态;普通桌面连接仍可使用,受保护界面的控制能力会受限。
## 如何编译
<a id="self-hosting"></a>
依赖:
- [xmake](https://xmake.io/#/guide/installation)
- [cmake](https://cmake.org/download/) 3.21 及以上
### Linux
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 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
# Ubuntu 20.04 自带的 CMake 3.16 不满足 Slint 1.17 的要求
python3 -m pip install --user cmake==3.31.6
export PATH="$HOME/.local/bin:$PATH"
```
Ubuntu 20.04 默认的 GCC 9 缺少 Slint C++ API 所需的部分 C++20 标准库,
因此上述命令同时安装并在后续配置中指定 GCC 10。
需要启用 Wayland 捕获或 DRM 捕获时,再安装对应依赖:
```bash
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 版本:
```bash
git clone --recurse-submodules https://github.com/kunkundi/crossdesk.git
cd crossdesk
# 已经克隆过仓库时执行这一行
git submodule update --init --recursive
# Ubuntu 20.04 没有 PipeWire 0.3 开发包,安装仅用于编译的头文件 SDK。
# 已安装发行版 PipeWire 0.3 开发包时可跳过此步骤。
sudo ./docker/linux-build/install-pipewire-sdk.sh
xmake f -m release --USE_CUDA=false --cc=gcc-10 --cxx=g++-10 -y
xmake b -vy crossdesk
```
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 \
--cc=gcc-10 --cxx=g++-10 -y
xmake b -vy crossdesk
```
生成 Debian 安装包(必须在对应架构上先完成 Release 编译):
```bash
# amd64
./scripts/linux/pkg_amd64.sh 1.0.0
# arm64
./scripts/linux/pkg_arm64.sh 1.0.0
```
打包脚本会将 Slint 共享运行库安装到软件包私有目录 `/usr/lib/crossdesk`,无需用户另外安装 `libslint_cpp.so`。
PipeWire 不属于强制运行时依赖:程序在运行时检测宿主系统的 PipeWire 0.3,
没有该运行库时仍可使用 X11(以及构建时启用的 DRM)捕获。
### 通用编译选项
```text
--USE_CUDA=true/false: 启用 CUDA 硬件编解码,默认不启用
--USE_WAYLAND=true/false: 在 Linux 上启用 Wayland/PipeWire 捕获,默认不启用
--USE_DRM=true/false: 在 Linux 上启用 DRM 捕获,默认不启用
--CROSSDESK_PORTABLE=true/false: 构建便携版本,默认不启用
--CROSSDESK_VERSION=xxx: 指定 CrossDesk 的版本
# 示例
xmake f --CROSSDESK_VERSION=1.0.0 --USE_CUDA=true
```
运行:
```bash
xmake r crossdesk
```
### 无 CUDA 环境下的开发支持
对于**未安装 CUDA 环境的 Linux 开发者,如果希望编译后的成果物拥有硬件编解码能力**,这里提供了预配置的 [Ubuntu 20.04 兼容构建镜像](https://hub.docker.com/r/crossdesk/ubuntu20.04)。该镜像内置必要的构建依赖,可生成兼容 glibc 2.31 的单一 Linux 安装包。
进入容器,下载工程后执行:
```
export CUDA_PATH=/usr/local/cuda
export XMAKE_GLOBALDIR=/data
xmake f --USE_CUDA=true
xmake b --root -vy crossdesk
```
对于**未安装 CUDA 环境的 Windows 开发者**,执行下面的命令安装 CUDA 编译环境:
```
xmake require -vy "cuda 12.6.3"
```
安装完成后执行:
```
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 加入系统环境变量,或在终端中输入:
```
set CUDA_PATH=path_to_cuda_installdir
```
重新执行:
```
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
使用 curl:
```
curl -fsSL https://xmake.io/shget.text | bash
```
使用 wget:
```
wget https://xmake.io/shget.text -O - | bash
```
使用 powershell:
```
irm https://xmake.io/psget.text | iex
```
#### 编译选项
```
# 切换编译模式
xmake f -m debug/release
# 可选编译参数
-r :重新构建目标
-v :显示详细的构建日志
-y :自动确认提示
# 示例
xmake b -vy crossdesk
```
#### 运行选项
```
# 使用调试模式运行
xmake r -d crossdesk
```
更多使用方法可参考 [Xmake官方文档](https://xmake.io/guide/quick-start.html) 。
## 自托管服务器
推荐使用Docker部署CrossDesk Server。
```bash
sudo docker run -d \
--name crossdesk_server \
--network host \
-e EXTERNAL_IP=xxx.xxx.xxx.xxx \
-e INTERNAL_IP=xxx.xxx.xxx.xxx \
-e CROSSDESK_SERVER_PORT=xxxx \
-e COTURN_PORT=xxxx \
-e MIN_PORT=xxxxx \
-e MAX_PORT=xxxxx \
-v /var/lib/crossdesk:/var/lib/crossdesk \
-v /var/log/crossdesk:/var/log/crossdesk \
crossdesk/crossdesk-server:v1.1.6
```
可自行部署信令服务与 TURN 中继,并在桌面客户端 **☰ → 设置 → 自托管配置** 中填写连接信息。控制端与被控端都需要启用同一套配置。
上述命令中,用户需注意的参数如下:
- [自托管指南](docs/SELF_HOSTING.md):当前 Compose 部署入口、客户端设置、证书信任与排查。
- [CrossDesk Server](https://github.com/kunkundi/crossdesk-server):服务端源码与发布配置。
- [CrossDesk Web Client](https://github.com/kunkundi/crossdesk-web-client):浏览器端源码与自托管说明。
**参数**
- EXTERNAL_IP:服务器公网 IP , 对应 CrossDesk 客户端**自托管服务器配置**中填写的**服务器地址**
- INTERNAL_IP:服务器内网 IP
- CROSSDESK_SERVER_PORT:自托管服务使用的端口,对应 CrossDesk 客户端**自托管服务器配置**中填写的**服务器端口**
- COTURN_PORT: COTURN 服务使用的端口, 对应 CrossDesk 客户端**自托管服务器配置**中填写的**中继服务端口**
- MIN_PORT/MAX_PORT:COTURN 服务使用的端口范围,例如:MIN_PORT=50000, MAX_PORT=60000,范围可根据客户端数量调整。
- `-v /var/lib/crossdesk:/var/lib/crossdesk`:持久化数据库和证书文件到宿主机
- `-v /var/log/crossdesk:/var/log/crossdesk`:持久化日志文件到宿主机
## 开发与反馈
**示例**:
```bash
sudo docker run -d \
--name crossdesk_server \
--network host \
-e EXTERNAL_IP=114.114.114.114 \
-e INTERNAL_IP=10.0.0.1 \
-e CROSSDESK_SERVER_PORT=9099 \
-e COTURN_PORT=3478 \
-e MIN_PORT=50000 \
-e MAX_PORT=60000 \
-v /var/lib/crossdesk:/var/lib/crossdesk \
-v /var/log/crossdesk:/var/log/crossdesk \
crossdesk/crossdesk-server:v1.1.6
```
- [源码构建](docs/BUILD.md):Windows、macOS、Linux、可选编译参数与打包。
- [iOS 开发说明](apps/ios/README.md):原生端构建和真机验证。
- [GUI 架构](docs/gui-architecture.md):桌面 UI、平台实现与工程结构。
- [常见问题](docs/FAQ.md):连接失败、黑屏、文件位置与编译问题。
- [提交问题](https://github.com/kunkundi/crossdesk/issues):请附上两端系统、客户端版本、连接方式和可复现步骤。
**注意**:
- **服务器需开放端口:COTURN_PORT/udp,COTURN_PORT/tcp,MIN_PORT-MAX_PORT/udp,CROSSDESK_SERVER_PORT/tcp。**
- 如果不挂载 volume,容器删除后数据会丢失
- 证书文件会在首次启动时自动生成并持久化到宿主机的 `/var/lib/crossdesk/certs` 路径下。由于默认使用的是自签证书,无法保障安全性,建议在云服务商申请正式证书放到该目录下并重启服务。
- 数据库文件会自动创建并持久化到宿主机的 `/var/lib/crossdesk/db/crossdesk-server.db` 路径下
- 日志文件会自动创建并持久化到宿主机的 `/var/log/crossdesk/` 路径下
## 致谢与许可
**权限注意**:如果 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
```
感谢 [HelloGitHub](https://hellogithub.com/)、[阮一峰的科技爱好者周刊](https://github.com/ruanyf/weekly) 与 [LinuxDo](https://linux.do) 社区的推荐、交流与支持。
CrossDesk 使用 [GPL-3.0](LICENSE) 许可。隐私说明见 [隐私政策](PRIVACY.md#中文)。
### 客户端
1. 点击右上角设置进入设置页面。<br><br>
<img width="600" height="210" alt="image" src="https://github.com/user-attachments/assets/6431131d-b32a-4726-8783-6788f47baa3b" /><br>
### Code signing policy
2. 点击`自托管服务器配置`按钮。<br><br>
<img width="600" height="160" alt="image" src="https://github.com/user-attachments/assets/24c761a3-1985-4d7e-84be-787383c2afb8" /><br>
CrossDesk 使用 SignPath.io 为本仓库构建的官方 Windows 发布包提供代码签名。
3. 输入`服务器地址`(**EXTERNAL_IP**)、`信令服务端口`(**CROSSDESK_SERVER_PORT**)、`中继服务端口`(**COTURN_PORT**),点击确认按钮。
4. 勾选`自托管服务器配置`选项,点击确认按钮保存设置。如果服务端使用的是正式证书,则到此步骤为止,客户端即可显示已连接服务器。
**Free code signing provided by [SignPath.io](https://signpath.io/), certificate by [SignPath Foundation](https://signpath.org/).**
5. 如果使用默认证书(正式证书忽略此步骤),则需要将服务端`/var/lib/crossdesk/certs/`目录下的`api.crossdesk.cn_root.crt`自签根证书下载到运行客户端的机器,并执行下述命令安装证书:
- **Committers and reviewers(代码提交与审核):** [kunkundi](https://github.com/kunkundi)
- **Approvers(签名审批):** [kunkundi](https://github.com/kunkundi)
Windows 平台使用**管理员权限**打开 PowerShell 执行
```
certutil -addstore "Root" "C:\path\to\api.crossdesk.cn_root.crt"
```
Linux
```
sudo cp /path/to/api.crossdesk.cn_root.crt /usr/local/share/ca-certificates/api.crossdesk.cn_root.crt
sudo update-ca-certificates
```
macOS
```
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain path/to/api.crossdesk.cn_root.crt
```
### Web 客户端
详情见项目 [CrossDesk Web Client](https://github.com/kunkundi/crossdesk-web-client)。
# 常见问题
见 [常见问题](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 项目的完善提供了帮助。
+330 -173
View File
@@ -1,215 +1,372 @@
<div align="center">
<img src="apps/desktop/resources/linux/crossdesk_128x128.png" width="80" alt="CrossDesk logo" />
<h1>CrossDesk</h1>
<p>A lightweight, cross-platform remote desktop for computers, browsers, and iPhone / iPad.</p>
<p><a href="https://www.crossdesk.cn/">Website</a> · <a href="https://github.com/kunkundi/crossdesk/releases">Download</a> · <a href="https://web.crossdesk.cn/">Open Web Client</a> · <a href="README.md">中文</a></p>
</div>
# CrossDesk
<div align="center">
<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="Featured|HelloGitHub" /></a>
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-brightgreen.svg)]()
[![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)]()
[![Platforms](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux%20%7C%20iOS-blue)](#download)
[![Release](https://img.shields.io/github/v/release/kunkundi/crossdesk)](https://github.com/kunkundi/crossdesk/releases)
[![Build](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml/badge.svg)](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml)
[![License: GPL v3](https://img.shields.io/badge/license-GPL--3.0-blue)](LICENSE)
[![Stars](https://img.shields.io/github/stars/kunkundi/crossdesk?style=social)](https://github.com/kunkundi/crossdesk/stargazers)
[ [中文](README.md) / English ]
</div>
CrossDesk connects Windows, macOS, and Linux desktops, with browser and native iOS clients for controlling computers. Built on [MiniRTC](https://github.com/kunkundi/minirtc), it supports real-time video and audio, keyboard and mouse control, file transfer, and self-hosting. The project is under active development.
> This guide follows the current repository source. Desktop screenshots render the current Slint UI with sample IDs, passwords, online statuses, and recent devices; thumbnails show public pages. Check each release's notes for the features and requirements of its binaries. Native iOS build and signing instructions are below.
[Preview](#preview) · [Download](#download) · [Quick start](#quick-start) · [Session controls](#session) · [Settings](#settings) · [iOS](#ios) · [Self-hosting](#self-hosting) · [Build](docs/BUILD_EN.md) · [FAQ](docs/FAQ.md#english)
<a id="preview"></a>
## Preview
### Desktop client
The main window brings together your device ID, connection password, remote connection entry, and recent devices.
PC Client
![sup_example](https://github.com/user-attachments/assets/3f17d8f3-7c4a-4b63-bae4-903363628687)
Web Client
<p align="center">
<img src="docs/images/desktop-main-en.png" width="800" alt="Current desktop UI example showing a connected server and three online devices with page thumbnails" />
<img width="850" height="550" alt="6bddcbed47ffd4b9988a4037c7f4f524" src="https://github.com/user-attachments/assets/e44f73f9-24ac-46a3-a189-b7f8b6669881" />
</p>
### Web client
# Intro
Enter a remote device ID and password in your browser without installing the desktop app on the controlling device.
CrossDesk is a lightweight cross-platform remote desktop software.
<p align="center">
<img src="docs/images/web-client.png" width="800" alt="Current CrossDesk Web Client connection page in Chinese" />
</p>
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)).
See the [screenshot notes](docs/images/README.md) for image sources and refresh instructions.
## System Requirements
## Features
| 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 20.04 or later |
| Capability | Current support |
| --- | --- |
| Cross-platform control | Windows / macOS / Linux desktop control; browser and native iOS controllers |
| Live video and audio | H.264 / AV1, 30 / 60 fps settings, hardware codec options, and remote audio; availability depends on hardware and build configuration |
| Devices and displays | Recent connections, device aliases, session tabs, and remote display switching |
| Input and sharing | Keyboard/mouse input, remote cursor synchronization, shortcuts, text clipboard synchronization, and file transfer |
| Networking and deployment | Direct P2P connections, TURN relay, an SRTP encryption option, and self-hosted signaling/relay services |
| Windows protected desktops | CrossDesk Service forwards input on lock screens, sign-in screens, and secure desktops |
Controls and capabilities vary by client. Desktop controls are described below; see the [iOS guide](apps/ios/README.md) for native mobile features.
## Usage
<a id="download"></a>
Enter the remote desktop ID in the menu bar’s “Remote ID” field and click “→” to initiate a remote connection.
## Download and install
![usage1](https://github.com/user-attachments/assets/3a4bb59f-c84c-44d2-9a20-11790aac510e)
Choose a package matching your OS and CPU architecture from [GitHub Releases](https://github.com/kunkundi/crossdesk/releases) or the [website](https://www.crossdesk.cn/).
If the remote desktop requires a connection password, you must enter the correct password on your side to successfully establish the connection.
| Platform | Current source / CI baseline | Installation |
| --- | --- | --- |
| Windows | Windows 10+, x64 | `.exe` installer; portable builds are also supported |
| macOS | macOS 14.0+, Intel / Apple Silicon | x64 or arm64 `.pkg` |
| Linux | Ubuntu 20.04+, amd64 / arm64, glibc 2.31 baseline | `.deb` package |
| iOS / iPadOS | iOS 16.0+, arm64 physical device | Native controller; build and sign with Xcode, or sign the unsigned CI app |
| Web | A WebRTC-capable browser | Open [web.crossdesk.cn](https://web.crossdesk.cn/) |
![password](https://github.com/user-attachments/assets/1beadcce-640d-4f5c-8e77-51917b5294d5)
On Linux, replace the example filename with the downloaded package name:
Before connecting, you can customize configuration options in the settings, such as language and video encoding format.
```bash
sudo apt install "./crossdesk-linux-amd64-<version>.deb"
![settings](https://github.com/user-attachments/assets/8bc5468d-7bbb-4e30-95bd-da1f352ac08c)
### Web Client
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.
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:
```
**First launch on macOS:** follow the app prompts to grant **Screen Recording** (called **Screen & System Audio Recording** on newer systems) and **Accessibility** under **System Settings → Privacy & Security**. Reopen the app if prompted. These permissions allow desktop capture and remote keyboard/mouse input respectively.
<a id="quick-start"></a>
## Quick start
### Connect from another computer
1. **Prepare the host.** Install and run CrossDesk on the computer to control. Wait for the bottom status bar to show that the server is connected. Share the **Local ID** and **Password** shown under **Local Desktop** with the controller.
2. **Enter the remote ID.** On the controlling computer, enter the host's ID in **Remote Desktop → Remote ID**, then click **→**.
3. **Verify the password.** Enter the host's current connection password when prompted and confirm. Select **Remember password** if you want to save it.
4. **Reconnect later.** Previously connected devices appear under **Recent Connections**. Reconnect from a card, edit its alias, or remove its record.
The eye button shows or hides the local password; the pencil button changes it. Use **6 ASCII letters or digits** and stay connected to the server while changing it. Wait for the change to succeed and the client to reconnect, then copy the current password. Controllers that saved the old password must enter it again.
### Connect from a browser
1. Keep CrossDesk running and connected to the server on the host computer, with **Enable SRTP** enabled in its Settings.
2. Open the [Web Client](https://web.crossdesk.cn/), enter the **Remote Device ID** and **Password**, and click **Connect**.
3. Use the page's display, mouse-mode, and keyboard controls during the session. Phone and tablet browsers can also connect.
For self-hosting, connect both sides to the same service. Browser deployment is documented in [CrossDesk Web Client](https://github.com/kunkundi/crossdesk-web-client).
<a id="session"></a>
## Session controls
Switch devices using session tabs. Expand the control bar if it is collapsed, and hover over icons to see their tooltips.
| Control | Action |
| --- | --- |
| Display | Switch the remote monitor |
| Keyboard | Send shortcuts; Windows `Ctrl+Alt+Del` requires the remote service |
| Mouse | Enable / release remote mouse control |
| Speaker | Play / mute remote audio |
| Folder | Select a file to send and view transfer progress |
| Network statistics | Inspect traffic, packet loss, frame rate, resolution, and direct / relay mode |
| Fullscreen | Enter / exit fullscreen |
| Disconnect | End the current remote session |
Desktop clients synchronize text clipboard contents. Configure the receiving directory under **Settings → File Save Path**. Closing the main window hides it and keeps the app running; use the system tray or menu-bar exit command to quit completely.
<a id="settings"></a>
## Video and connection settings
Open **☰ → Settings** in the top-right corner and click **OK** to save. Configure the session before connecting; some settings are disabled during an active session. Scroll down for self-hosting, startup behavior, and the file save path.
<p align="center">
<img src="docs/images/desktop-settings-en.png" width="720" alt="Current settings for quality, frame rate, video preference, codecs, relay, and SRTP" />
</p>
| Setting | Purpose |
| --- | --- |
| Video Quality / Video Capture Frame Rate | Low, medium, or high quality and 30 / 60 fps; actual performance depends on the connection and device |
| Video Preference | Choose frame-rate priority, quality priority, or balanced adaptation |
| Video Encode Format | H.264 / AV1; hardware codec availability depends on the platform, device, and build options |
| Enable TURN Service | Allow relay-assisted connections; check this setting if P2P fails |
| SRTP | Media encryption option; configurations on both ends must be compatible |
| Self-Hosted Config | Set the host, signaling port, and TURN port, then enable the checkbox |
| Auto Start / Enable Daemon | Configure startup and process supervision; restart as indicated by the UI |
| File Save Path | Choose where received files are saved |
<a id="ios"></a>
## Native iOS / iPadOS client
[`apps/ios`](apps/ios/README.md) contains a native controller using the same MiniRTC protocol as the desktop client. It controls remote computers; it does not expose an iPhone or iPad as a remotely controlled desktop.
1. Follow the [iOS guide](apps/ios/README.md) to build and sign the app with Xcode, then install it on an iOS 16+ physical device.
2. Enter a remote ID on the home screen, connect, and enter its password. Successful connections become available in the recent devices list.
3. Choose a mouse mode in Settings: **Relative Position** works like a trackpad; **Absolute Position** maps touches directly to the remote screen.
4. Tap the floating icon to expand the menu, then switch displays, open the keyboard, toggle audio, or send files. Received files are stored in `Documents/Received` and can be exported using the share button beside the transfer status.
The app currently receives remote text into the iOS clipboard. Its UI does not yet expose a button to send the local clipboard.
Gestures include one-finger click, two-finger right-click, long-press drag, and pinch-to-zoom. When zoomed in, two-finger panning moves the viewport. The native and browser clients are maintained separately, so their controls and gestures may differ. The current native UI uses Chinese labels.
<a id="windows-service"></a>
## Windows lock screens and sign-in
**CrossDesk Service** (`CrossDeskService`) reports protected desktop states and forwards `Ctrl+Alt+Del`, keyboard, and mouse input on lock screens, sign-in screens, credential prompts, and secure desktops.
The installer registers an on-demand service. CrossDesk tries to start it on launch, and the service exits when no local CrossDesk client is running. Portable builds offer service installation in the prompt or **Settings → Lock Screen Service**, with administrator privileges. The service depends on a running client.
<details>
<summary>Manual deployment and service commands</summary>
Keep the complete installation directory. For manual deployment, place `CrossDesk.exe`, `crossdesk_service.exe`, `crossdesk_session_helper.exe`, and all `.dll` files from the same build together. Open an administrator PowerShell in that directory and run the commands you need:
```powershell
# install
.\CrossDesk.exe --service-install
# start
.\CrossDesk.exe --service-start
# check status
.\CrossDesk.exe --service-status
.\CrossDesk.exe --service-ping
# Stop or uninstall the service
# stop
.\CrossDesk.exe --service-stop
# uninstall
.\CrossDesk.exe --service-uninstall
```
</details>
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”.
If **Remote Windows service unavailable** appears, check installation and service status on the host. Ordinary desktop connections remain available, while control of protected screens is limited.
## How to build
<a id="self-hosting"></a>
Requirements:
- [xmake](https://xmake.io/#/guide/installation)
- [cmake](https://cmake.org/download/) 3.21 or later
## Self-hosting
### Linux
Host your own signaling and TURN services, then enter their connection details under **☰ → Settings → Self-Hosted Config**. Enable the same server configuration on both the controller and host.
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:
- [Self-hosting guide](docs/SELF_HOSTING_EN.md): current Compose deployment, client settings, certificate trust, and troubleshooting.
- [CrossDesk Server](https://github.com/kunkundi/crossdesk-server): server source and release configuration.
- [CrossDesk Web Client](https://github.com/kunkundi/crossdesk-web-client): browser source and deployment instructions.
```bash
sudo apt-get update
sudo apt-get install -y \
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
## Development and feedback
# Ubuntu 20.04's CMake 3.16 is too old for Slint 1.17.
python3 -m pip install --user cmake==3.31.6
export PATH="$HOME/.local/bin:$PATH"
```
- [Build from source](docs/BUILD_EN.md): Windows, macOS, Linux, optional flags, and packaging.
- [iOS development](apps/ios/README.md): native build and physical-device checks.
- [GUI architecture](docs/gui-architecture.md): desktop UI and platform organization.
- [FAQ](docs/FAQ.md#english): connection failures, blank video, received files, and build issues.
- [Report an issue](https://github.com/kunkundi/crossdesk/issues): include both operating systems, client versions, connection mode, and reproduction steps.
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.
## Acknowledgements and license
Install the optional dependencies when Wayland capture or DRM capture is enabled:
Thanks to [HelloGitHub](https://hellogithub.com/), [Ruanyf Weekly](https://github.com/ruanyf/weekly), and the [LinuxDo](https://linux.do) community for featuring CrossDesk and contributing feedback.
```bash
sudo apt-get install -y libdbus-1-dev libdrm-dev
CrossDesk is licensed under [GPL-3.0](LICENSE). See the [Privacy Policy](PRIVACY.md#english).
# Ubuntu 22.04 and later can alternatively use the distribution packages.
# sudo apt-get install -y libpipewire-0.3-dev libspa-0.2-dev
```
### Code signing policy
Clone the submodules and build a release binary:
CrossDesk uses SignPath.io to sign official Windows releases built from this repository.
```bash
git clone --recurse-submodules https://github.com/kunkundi/crossdesk.git
cd crossdesk
**Free code signing provided by [SignPath.io](https://signpath.io/), certificate by [SignPath Foundation](https://signpath.org/).**
# Run this when the repository has already been cloned
git submodule update --init --recursive
- **Committers and reviewers:** [kunkundi](https://github.com/kunkundi)
- **Approvers:** [kunkundi](https://github.com/kunkundi)
# 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 -m release --USE_CUDA=false --cc=gcc-10 --cxx=g++-10 -y
xmake b -vy crossdesk
```
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 \
--cc=gcc-10 --cxx=g++-10 -y
xmake b -vy crossdesk
```
Build a Debian package after compiling a release binary on the matching architecture:
```bash
# amd64
./scripts/linux/pkg_amd64.sh 1.0.0
# arm64
./scripts/linux/pkg_arm64.sh 1.0.0
```
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
```text
--USE_CUDA=true/false: Enable CUDA hardware codec acceleration, disabled by default
--USE_WAYLAND=true/false: Enable Wayland/PipeWire capture on Linux, disabled by default
--USE_DRM=true/false: Enable DRM capture on Linux, disabled by default
--CROSSDESK_PORTABLE=true/false: Build the portable variant, disabled by default
--CROSSDESK_VERSION=xxx: Set the CrossDesk version
# Example
xmake f --CROSSDESK_VERSION=1.0.0 --USE_CUDA=true
```
Run:
```bash
xmake r crossdesk
```
#### 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 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:
```
export CUDA_PATH=/usr/local/cuda
export XMAKE_GLOBALDIR=/data
xmake f --USE_CUDA=true
xmake b --root -vy crossdesk
```
For **Windows developers without a CUDA environment** installed, run the following command to install the CUDA build environment:
```
xmake require -vy "cuda 12.6.3"
```
After the installation is complete, execute:
```
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:
```
set CUDA_PATH=path_to_cuda_installdir:
```
Then re-run:
```
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
#### Installing Xmake
You can install Xmake using one of the following methods:
Using curl:
```
curl -fsSL https://xmake.io/shget.text | bash
```
Using wget:
```
wget https://xmake.io/shget.text -O - | bash
```
Using powershell:
```
irm https://xmake.io/psget.text | iex
```
#### Build Options
```
# Switch build mode
xmake f -m debug/release
# Optional build parameters
-r : Rebuild the target
-v : Show detailed build logs
-y : Automatically confirm prompts
# Example
xmake b -vy crossdesk
```
#### Run Options
```
# 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) .
## Self-Hosted Server
It is recommended to deploy CrossDesk Server using Docker.
```
sudo docker run -d \
--name crossdesk_server \
--network host \
-e EXTERNAL_IP=xxx.xxx.xxx.xxx \
-e INTERNAL_IP=xxx.xxx.xxx.xxx \
-e CROSSDESK_SERVER_PORT=xxxx \
-e COTURN_PORT=xxxx \
-e MIN_PORT=xxxxx \
-e MAX_PORT=xxxxx \
-v /var/lib/crossdesk:/var/lib/crossdesk \
-v /var/log/crossdesk:/var/log/crossdesk \
crossdesk/crossdesk-server:v1.1.6
```
The parameters you need to pay attention to are as follows:
**Parameters**
- **EXTERNAL_IP**: The server’s public IP. This corresponds to **Server Address** in the CrossDesk client’s **Self-Hosted Server Configuration**.
- **INTERNAL_IP**: The server’s internal IP.
- **CROSSDESK_SERVER_PORT**: The port used by the self-hosted service. This corresponds to **Server Port** in the CrossDesk client’s **Self-Hosted Server Configuration**.
- **COTURN_PORT**: The port used by the COTURN service. This corresponds to **Relay Service Port** in the CrossDesk client’s **Self-Hosted Server Configuration**.
- **MIN_PORT / MAX_PORT**: The port range used by the COTURN service. Example: `MIN_PORT=50000`, `MAX_PORT=60000`. Adjust the range depending on the number of clients.
- `-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**:
```bash
sudo docker run -d \
--name crossdesk_server \
--network host \
-e EXTERNAL_IP=114.114.114.114 \
-e INTERNAL_IP=10.0.0.1 \
-e CROSSDESK_SERVER_PORT=9099 \
-e COTURN_PORT=3478 \
-e MIN_PORT=50000 \
-e MAX_PORT=60000 \
-v /var/lib/crossdesk:/var/lib/crossdesk \
-v /var/log/crossdesk:/var/log/crossdesk \
crossdesk/crossdesk-server:v1.1.6
```
**Notes**
- **The server must open the following ports: COTURN_PORT/udp, COTURN_PORT/tcp, MIN_PORT–MAX_PORT/udp, and CROSSDESK_SERVER_PORT/tcp.**
- If you don’t 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.
- 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).
**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
Place **crossdesk.cn.key** and **crossdesk.cn_bundle.crt** into the **/path/to/your/certs** directory.
### Client Side
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>
2. Click `Self-Hosted Server Configuration` button.<br><br>
<img width="600" height="160" alt="image" src="https://github.com/user-attachments/assets/24c761a3-1985-4d7e-84be-787383c2afb8" /><br>
3. Enter the `Server Address` (**EXTERNAL_IP**), `Signaling Service Port` (**CROSSDESK_SERVER_PORT**), and `Relay Service Port` (**COTURN_PORT**) and click OK button.
4. Check the `Self-hosted server configuration` option and click the OK button to save the settings. If the server is using a valid (official) certificate, the process ends here and the client will show that it is connected to the server.
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:
```
certutil -addstore "Root" "C:\path\to\api.crossdesk.cn_root.crt"
```
Linux
```
sudo cp /path/to/api.crossdesk.cn_root.crt /usr/local/share/ca-certificates/api.crossdesk.cn_root.crt
sudo update-ca-certificates
```
macOS
```
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain path/to/api.crossdesk.cn_root.crt
```
### 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) .
# 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.
@@ -1,13 +0,0 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-11
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
IDI_CROSSDESK_LOGO ICON "crossdesk.ico"
#define CROSSDESK_FILE_DESCRIPTION "CrossDesk Privacy Window"
#define CROSSDESK_ORIGINAL_FILENAME "crossdesk_privacy_window.dll"
#define CROSSDESK_FILE_TYPE VFT_DLL
#include "version_info.rcinc"
@@ -1,4 +0,0 @@
#define CROSSDESK_FILE_DESCRIPTION "CrossDesk Service"
#define CROSSDESK_ORIGINAL_FILENAME "crossdesk_service.exe"
#include "version_info.rcinc"
@@ -1,18 +0,0 @@
// Session helper icon and manifest resources.
IDI_ICON1 ICON "crossdesk.ico"
#define CREATEPROCESS_MANIFEST_RESOURCE_ID 1
#define RT_MANIFEST 24
#ifdef CROSSDESK_PORTABLE
CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "crossdesk_portable.manifest"
#elif defined(CROSSDESK_DEBUG)
CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "crossdesk_debug.manifest"
#else
CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "crossdesk.manifest"
#endif
#define CROSSDESK_FILE_DESCRIPTION "CrossDesk Session Helper"
#define CROSSDESK_ORIGINAL_FILENAME "crossdesk_session_helper.exe"
#include "version_info.rcinc"
@@ -1,54 +0,0 @@
#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
@@ -1,5 +0,0 @@
#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"
-68
View File
@@ -1,68 +0,0 @@
param(
[Parameter(Mandatory = $true)]
[string]$Version
)
$ErrorActionPreference = "Stop"
function Normalize-AppVersion {
param(
[Parameter(Mandatory = $true)]
[string]$InputVersion
)
$prefix = ""
$body = $InputVersion
if ($body.StartsWith("v")) {
$prefix = "v"
$body = $body.Substring(1)
}
if ($body -match '^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})-([0-9]+)$') {
return "${prefix}$($Matches[1])-$($Matches[4])-$($Matches[3])"
}
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" "/DVERSION_NUMERIC=$windowsVersion" "nsis_script.nsi"
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
} finally {
Pop-Location
}
@@ -1,15 +0,0 @@
<?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>
-76
View File
@@ -1,76 +0,0 @@
#include "daemon.h"
#include <chrono>
#include <iostream>
#include <thread>
#include <utility>
#include "platform/daemon_backend.h"
namespace {
constexpr int kRestartDelayMs = 1000;
} // namespace
Daemon::Daemon(const std::string& name) : name_(name), running_(false) {}
void Daemon::stop() {
running_.store(false);
crossdesk::platform::RequestDaemonStop();
}
bool Daemon::isRunning() const {
return running_.load() && !crossdesk::platform::DaemonStopRequested();
}
bool Daemon::start(MainLoopFunc loop) {
crossdesk::platform::ResetDaemonStop();
if (!crossdesk::platform::PrepareDaemon()) return false;
running_.store(true);
return runWithRestart(std::move(loop));
}
bool Daemon::runWithRestart(MainLoopFunc loop) {
int restart_count = 0;
const std::string executable_path =
crossdesk::platform::GetDaemonExecutablePath();
if (executable_path.empty()) {
std::cerr
<< "Failed to get executable path, falling back to direct execution"
<< std::endl;
while (isRunning()) {
try {
loop();
break;
} catch (...) {
++restart_count;
std::cerr << "Exception caught, restarting... (attempt "
<< restart_count << ")" << std::endl;
std::this_thread::sleep_for(
std::chrono::milliseconds(kRestartDelayMs));
}
}
return true;
}
while (isRunning()) {
const crossdesk::platform::DaemonChildResult result =
crossdesk::platform::RunDaemonChild(
executable_path, [this] { return isRunning(); });
if (!isRunning() || result.normal_exit) break;
++restart_count;
if (!result.started) {
std::cerr << "Failed to create child process";
} else if (result.terminated_by_signal) {
std::cerr << "Child process crashed with signal " << result.exit_code;
} else {
std::cerr << "Child process exited with code " << result.exit_code;
}
std::cerr << ", restarting... (attempt " << restart_count << ")"
<< std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(kRestartDelayMs));
}
return true;
}
-21
View File
@@ -1,21 +0,0 @@
#include "autostart.h"
#include "platform/autostart_backend.h"
namespace crossdesk {
bool EnableAutostart(const std::string& app_name) {
const std::string executable_path = platform::GetAutostartExecutablePath();
return !executable_path.empty() &&
platform::EnableAutostart(app_name, executable_path);
}
bool DisableAutostart(const std::string& app_name) {
return platform::DisableAutostart(app_name);
}
bool IsAutostartEnabled(const std::string& app_name) {
return platform::IsAutostartEnabled(app_name);
}
} // namespace crossdesk
-62
View File
@@ -1,62 +0,0 @@
#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
@@ -1,23 +0,0 @@
#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_
@@ -1,44 +0,0 @@
#include "shared_cursor_state.h"
#include <remote_action.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
@@ -1,22 +0,0 @@
#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_
@@ -1,79 +0,0 @@
#include "task_queue_lock_free.h"
#include <exception>
#include <new>
#include <stdexcept>
#include <utility>
namespace crossdesk {
TaskQueueLockFree::TaskQueueLockFree()
: producer_(task_queue_), worker_([this] { WorkerThread(); }) {}
TaskQueueLockFree::~TaskQueueLockFree() { Stop(); }
std::future<void> TaskQueueLockFree::PostTask(std::function<void()> task) {
TaskItem item;
item.run = std::move(task);
auto result = item.completion.get_future();
{
std::lock_guard lock(mutex_);
if (stopping_) {
throw std::runtime_error("Cannot post to a stopped task queue");
}
// Count before publishing: the worker may dequeue as soon as enqueue returns.
++pending_tasks_;
if (!task_queue_.enqueue(producer_, std::move(item))) {
--pending_tasks_;
throw std::bad_alloc();
}
}
wake_.notify_one();
return result;
}
void TaskQueueLockFree::Stop() {
// Concurrent Stop callers all wait for the same worker shutdown to finish.
std::call_once(stop_once_, [this] {
{
std::lock_guard lock(mutex_);
stopping_ = true;
}
wake_.notify_all();
worker_.join();
});
}
void TaskQueueLockFree::WorkerThread() {
while (true) {
TaskItem item;
while (task_queue_.try_dequeue(item)) {
--pending_tasks_;
std::exception_ptr error;
try {
item.run();
} catch (...) {
error = std::current_exception();
}
// Release captured resources before marking the task complete.
item.run = nullptr;
if (error) {
item.completion.set_exception(error);
} else {
item.completion.set_value();
}
}
std::unique_lock lock(mutex_);
wake_.wait(lock, [this] { return stopping_ || pending_tasks_ > 0; });
// A stop request must not discard tasks submitted while the worker was idle.
if (stopping_ && pending_tasks_ == 0) {
return;
}
}
}
} // namespace crossdesk
@@ -1,57 +0,0 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-07
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _TASK_QUEUE_LOCK_FREE_H_
#define _TASK_QUEUE_LOCK_FREE_H_
#include <atomic>
#include <condition_variable>
#include <cstddef>
#include <functional>
#include <future>
#include <mutex>
#include <thread>
#include <concurrentqueue.h>
namespace crossdesk {
// A single worker executes tasks in submission order. The concurrent queue
// stores tasks; a mutex coordinates submissions, worker wakeups and shutdown.
class TaskQueueLockFree {
public:
TaskQueueLockFree();
~TaskQueueLockFree();
// Returns completion or the task's exception. Throws if the queue is stopped.
std::future<void> PostTask(std::function<void()> task);
// Rejects new tasks, finishes accepted tasks and joins the worker.
// May be called repeatedly, but never from the queue's own tasks.
void Stop();
private:
struct TaskItem {
std::function<void()> run;
std::promise<void> completion;
};
void WorkerThread();
moodycamel::ConcurrentQueue<TaskItem> task_queue_;
// All submissions share one producer so different caller threads keep FIFO.
moodycamel::ProducerToken producer_;
std::atomic<std::size_t> pending_tasks_{0};
std::mutex mutex_;
std::condition_variable wake_;
bool stopping_ = false;
std::once_flag stop_once_;
std::thread worker_;
};
} // namespace crossdesk
#endif
@@ -1,55 +0,0 @@
/*
* @Author: DI JUNKUN
* @Date: 2023-12-14
* Copyright (c) 2023 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _DEVICE_CONTROLLER_H_
#define _DEVICE_CONTROLLER_H_
#include <cstdint>
#include <vector>
#include <remote_action.h>
#include "display_info.h"
namespace crossdesk {
// int key_code, bool is_down, uint32_t scan_code, bool extended
using OnKeyAction = void (*)(int, bool, uint32_t, bool, void*);
class DeviceController {
public:
virtual ~DeviceController() = default;
};
class MouseController : public DeviceController {
public:
~MouseController() override = default;
virtual int Init(std::vector<DisplayInfo> display_info_list) = 0;
virtual int Destroy() = 0;
virtual int SendMouseCommand(RemoteAction remote_action,
int display_index) = 0;
virtual void UpdateDisplayInfoList(
const std::vector<DisplayInfo>&) {}
};
class KeyboardCapturer : public DeviceController {
public:
~KeyboardCapturer() override = default;
virtual int Hook(OnKeyAction on_key_action, void* user_ptr) = 0;
virtual int Unhook() = 0;
// Backends with an asynchronous capture loop override this so the UI can
// fall back to window events if that loop stops unexpectedly.
virtual bool IsHookActive() const { return true; }
virtual int SendKeyboardCommand(int key_code, bool is_down,
uint32_t scan_code = 0,
bool extended = false) = 0;
};
} // namespace crossdesk
#endif
@@ -1,36 +0,0 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-04
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _SERVER_WINDOW_STATE_H_
#define _SERVER_WINDOW_STATE_H_
#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
#endif
@@ -1,49 +0,0 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-04
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _WINDOW_GEOMETRY_H_
#define _WINDOW_GEOMETRY_H_
#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
#endif
@@ -1,126 +0,0 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-09
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _SESSION_DEVICE_MANAGER_H_
#define _SESSION_DEVICE_MANAGER_H_
#include <SDL3/SDL.h>
#include <atomic>
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <deque>
#include <mutex>
#include <string>
#include <vector>
#include <remote_action.h>
#include "device_controller.h"
#include "device_controller_factory.h"
#include "display_info.h"
#include "screen_capturer_factory.h"
#include "speaker_capture_controller.h"
namespace crossdesk {
class GuiRuntime;
// Owns media capture/playback and remote input devices for the active GUI
// session. GuiRuntime supplies application intent; this class handles device
// creation, updates and teardown.
class SessionDeviceManager {
public:
explicit SessionDeviceManager(GuiRuntime &owner);
~SessionDeviceManager();
void Initialize();
void UpdateInteractions();
// Call after changing the controller map. UpdateInteractions consumes this
// notification on the UI thread before reading the map under its mutex.
void OnControllerConnectionsChanged() {
cursor_capture_dirty_.store(true, std::memory_order_relaxed);
}
void DestroyDevices();
void DestroyFactories();
int InitializeScreenCapturer();
int StartScreenCapturer();
int StopScreenCapturer();
#ifdef _WIN32
void ReleaseRemoteMouseButtons();
#endif
// Requests are asynchronous; Stop drains/revokes audio delivery before
// returning so the connection peer can be destroyed safely.
void StartSpeakerCapturer();
void StopSpeakerCapturer();
int StartMouseController();
int StopMouseController();
int StartKeyboardCapturer();
int StopKeyboardCapturer();
int InitializeAudioOutput();
int DestroyAudioOutput();
void PushAudio(const char *data, size_t size);
bool SendKeyboardCommand(int key_code, bool is_down, uint32_t scan_code,
bool extended);
void SendMouseCommand(const RemoteAction &action, int selected_display);
int SwitchDisplay(int display_id);
void ResetToInitialDisplay();
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);
bool ShouldCaptureCursor() const;
void RecordCaptureCadence(std::chrono::steady_clock::time_point now, int fps,
bool from_secure_desktop);
GuiRuntime &owner_;
SDL_AudioStream *output_stream_ = nullptr;
ScreenCapturerFactory *screen_capturer_factory_ = nullptr;
ScreenCapturer *screen_capturer_ = nullptr;
std::atomic<bool> cursor_capture_dirty_{true};
SpeakerCaptureController speaker_capture_;
DeviceControllerFactory *device_controller_factory_ = nullptr;
MouseController *mouse_controller_ = nullptr;
KeyboardCapturer *keyboard_capturer_ = nullptr;
std::vector<DisplayInfo> display_info_list_;
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::mutex capture_metrics_mutex_;
std::chrono::steady_clock::time_point capture_metrics_started_{};
std::chrono::steady_clock::time_point last_capture_callback_{};
uint64_t capture_callbacks_ = 0, capture_rate_drops_ = 0;
uint64_t capture_secure_callbacks_ = 0;
uint64_t capture_forwarded_ = 0, capture_send_errors_ = 0;
int64_t capture_max_gap_us_ = 0;
int64_t capture_send_us_ = 0, capture_max_send_us_ = 0;
std::string last_video_frame_stream_id_;
bool invalid_video_stream_id_logged_ = false;
};
} // namespace crossdesk
#endif
@@ -1,626 +0,0 @@
#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"
#include "runtime/gui_runtime.h"
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);
std::memset(destination, 0, Size);
if (source) {
std::strncpy(destination, source, Size - 1);
}
}
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) {}
int SettingsManager::Save() {
std::lock_guard<std::mutex> lock(cache_mutex_);
return SaveLocked();
}
int SettingsManager::SaveLocked() {
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_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.
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);
if (!cache_v2_file) {
return false;
}
cache_v2_file.read(reinterpret_cast<char *>(&cache_v2_), sizeof(cache_v2_));
if (cache_v2_file.gcount() !=
static_cast<std::streamsize>(sizeof(cache_v2_))) {
return false;
}
cache_v2_
.client_id_with_password[sizeof(cache_v2_.client_id_with_password) - 1] =
'\0';
cache_v2_.self_hosted_id[sizeof(cache_v2_.self_hosted_id) - 1] = '\0';
return true;
}
int SettingsManager::Load() {
std::unique_lock<std::mutex> lock(cache_mutex_);
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));
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);
if (!cache_v1_file) {
lock.unlock();
std::memset(owner_.password_saved_, 0, sizeof(owner_.password_saved_));
std::memset(owner_.aes128_key_, 0, sizeof(owner_.aes128_key_));
std::memset(owner_.aes128_iv_, 0, sizeof(owner_.aes128_iv_));
std::memset(owner_.self_hosted_id_, 0, sizeof(owner_.self_hosted_id_));
owner_.thumbnail_ =
std::make_shared<Thumbnail>(owner_.cache_path_ + "/thumbnails/");
owner_.thumbnail_->GetKeyAndIv(owner_.aes128_key_, owner_.aes128_iv_);
owner_.thumbnail_->DeleteAllFilesInDirectory();
Save();
return -1;
}
cache_v1_file.read(reinterpret_cast<char *>(&cache_v1_), sizeof(cache_v1_));
cache_v1_
.client_id_with_password[sizeof(cache_v1_.client_id_with_password) -
1] = '\0';
CopyString(cache_v2_.client_id_with_password,
cache_v1_.client_id_with_password);
std::memcpy(cache_v2_.key, cache_v1_.key, sizeof(cache_v1_.key));
std::memcpy(cache_v2_.iv, cache_v1_.iv, sizeof(cache_v1_.iv));
std::memset(cache_v2_.self_hosted_id, 0, sizeof(cache_v2_.self_hosted_id));
CopyString(owner_.client_id_with_password_,
cache_v1_.client_id_with_password);
std::memset(owner_.self_hosted_id_, 0, sizeof(owner_.self_hosted_id_));
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 v3 cache file");
}
lock.unlock();
ActivateCachedPublicIdentity();
owner_.thumbnail_ =
std::make_shared<Thumbnail>(owner_.cache_path_ + "/thumbnails/",
owner_.aes128_key_, owner_.aes128_iv_);
owner_.language_button_value_ = localization::detail::ClampLanguageIndex(
static_cast<int>(owner_.config_center_->GetLanguage()));
owner_.video_quality_button_value_ =
static_cast<int>(owner_.config_center_->GetVideoQuality());
owner_.video_frame_rate_button_value_ =
static_cast<int>(owner_.config_center_->GetVideoFrameRate());
owner_.video_adaptation_policy_button_value_ =
static_cast<int>(owner_.config_center_->GetVideoAdaptationPolicy());
owner_.video_encode_format_button_value_ =
static_cast<int>(owner_.config_center_->GetVideoEncodeFormat());
owner_.enable_hardware_video_codec_ =
owner_.config_center_->IsHardwareVideoCodec();
owner_.enable_turn_ = owner_.config_center_->IsEnableTurn();
owner_.enable_self_hosted_ = owner_.config_center_->IsSelfHosted();
owner_.enable_autostart_ = owner_.config_center_->IsEnableAutostart();
owner_.enable_daemon_ = owner_.config_center_->IsEnableDaemon();
#if _WIN32 && CROSSDESK_PORTABLE
owner_.portable_service_prompt_suppressed_ =
owner_.config_center_->IsPortableServicePromptSuppressed();
owner_.portable_service_do_not_remind_ =
owner_.portable_service_prompt_suppressed_;
#endif
const std::string saved_path =
owner_.config_center_->GetFileTransferSavePath();
CopyString(owner_.file_transfer_save_path_buf_, saved_path.c_str());
owner_.file_transfer_save_path_last_ = saved_path;
owner_.language_button_value_last_ = owner_.language_button_value_;
owner_.video_quality_button_value_last_ = owner_.video_quality_button_value_;
owner_.video_frame_rate_button_value_last_ =
owner_.video_frame_rate_button_value_;
owner_.video_adaptation_policy_button_value_last_ =
owner_.video_adaptation_policy_button_value_;
owner_.video_encode_format_button_value_last_ =
owner_.video_encode_format_button_value_;
owner_.enable_hardware_video_codec_last_ =
owner_.enable_hardware_video_codec_;
owner_.enable_turn_last_ = owner_.enable_turn_;
owner_.enable_self_hosted_last_ = owner_.enable_self_hosted_;
owner_.enable_autostart_last_ = owner_.enable_autostart_;
LOG_INFO("Load settings from cache file");
return 0;
}
bool SettingsManager::LoadCachedSelfHostedIdentity() {
std::lock_guard<std::mutex> lock(cache_mutex_);
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;
}
CopyString(owner_.self_hosted_id_, cache_v2_.self_hosted_id);
const char *at_pos = std::strchr(owner_.self_hosted_id_, '@');
if (at_pos == nullptr) {
CopyString(owner_.client_id_, owner_.self_hosted_id_);
std::memset(owner_.password_saved_, 0, sizeof(owner_.password_saved_));
} else {
const std::string id(owner_.self_hosted_id_,
at_pos - owner_.self_hosted_id_);
CopyString(owner_.client_id_, id.c_str());
CopyString(owner_.password_saved_, at_pos + 1);
}
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_);
CopyString(owner_.self_hosted_id_, client_id);
if (SaveLocked() != 0) {
LOG_ERROR("Failed to persist self-hosted identity atomically");
}
}
int SettingsManager::LoadRecentConnectionAliases() {
recent_connection_aliases_.clear();
std::ifstream alias_file(owner_.cache_path_ +
"/recent_connection_aliases.json");
if (!alias_file.good()) {
return 0;
}
try {
nlohmann::json alias_json;
alias_file >> alias_json;
const nlohmann::json *aliases = &alias_json;
if (alias_json.contains("aliases") && alias_json["aliases"].is_object()) {
aliases = &alias_json["aliases"];
}
if (!aliases->is_object()) {
LOG_WARN("Invalid recent connection alias file");
return -1;
}
for (auto it = aliases->begin(); it != aliases->end(); ++it) {
if (it.value().is_string()) {
const std::string remote_id = it.key();
const std::string alias = it.value().get<std::string>();
if (!remote_id.empty() && !alias.empty()) {
recent_connection_aliases_[remote_id] = alias;
}
}
}
} catch (const std::exception &e) {
LOG_WARN("Load recent connection aliases failed: {}", e.what());
return -1;
}
return 0;
}
int SettingsManager::SaveRecentConnectionAliases() const {
std::error_code ec;
std::filesystem::create_directories(owner_.cache_path_, ec);
if (ec) {
LOG_WARN("Create cache directory failed while saving aliases: {}",
ec.message());
return -1;
}
nlohmann::json alias_json;
alias_json["aliases"] = nlohmann::json::object();
for (const auto &[remote_id, alias] : recent_connection_aliases_) {
if (!remote_id.empty() && !alias.empty()) {
alias_json["aliases"][remote_id] = alias;
}
}
std::ofstream alias_file(
owner_.cache_path_ + "/recent_connection_aliases.json", std::ios::trunc);
if (!alias_file.good()) {
LOG_WARN("Open recent connection alias file failed");
return -1;
}
alias_file << alias_json.dump(2);
return 0;
}
std::string SettingsManager::RecentConnectionDisplayName(
const Thumbnail::RecentConnection &connection) const {
const auto alias_it = recent_connection_aliases_.find(connection.remote_id);
if (alias_it != recent_connection_aliases_.end() &&
!alias_it->second.empty()) {
return alias_it->second;
}
if (!connection.remote_host_name.empty() &&
connection.remote_host_name != "unknown") {
return connection.remote_host_name;
}
return connection.remote_id;
}
void SettingsManager::BeginEditRecentConnectionAlias(
const Thumbnail::RecentConnection &connection) {
owner_.edit_connection_alias_remote_id_ = connection.remote_id;
std::memset(owner_.edit_connection_alias_, 0,
sizeof(owner_.edit_connection_alias_));
const auto alias_it = recent_connection_aliases_.find(connection.remote_id);
const std::string alias = alias_it != recent_connection_aliases_.end()
? alias_it->second
: RecentConnectionDisplayName(connection);
CopyString(owner_.edit_connection_alias_, alias.c_str());
owner_.focus_on_input_widget_ = true;
owner_.show_edit_connection_alias_window_ = true;
}
void SettingsManager::SetRecentConnectionAlias(const std::string &remote_id,
const std::string &alias) {
if (!remote_id.empty()) {
recent_connection_aliases_[remote_id] = alias;
}
}
void SettingsManager::EraseRecentConnectionAlias(const std::string &remote_id) {
recent_connection_aliases_.erase(remote_id);
}
} // namespace crossdesk
@@ -1,502 +0,0 @@
#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/macos/gui/metal_video_renderer.h"
#else
#if defined(_WIN32)
#include <windows.h>
#endif
#include <GL/gl.h>
#include "platform/common/gui/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
@@ -1,81 +0,0 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-04
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _SLINT_VIDEO_PRESENTER_H_
#define _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
@@ -1,73 +0,0 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-04
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _CURSOR_POSITION_H_
#define _CURSOR_POSITION_H_
#include <algorithm>
#include <vector>
#include <remote_action.h>
#include "display_info.h"
namespace crossdesk {
inline void ResetCursorPosition(CursorState* state) {
if (!state) return;
state->position_update = true;
state->position_valid = false;
state->x = 0.5f;
state->y = 0.5f;
state->visual_offset_x = 0.0f;
state->visual_offset_y = 0.0f;
state->display_id = -1;
}
inline bool NormalizeCursorPosition(
double screen_x, double screen_y,
const std::vector<DisplayInfo>& displays, int preferred_display,
CursorState* state) {
if (!state) return false;
ResetCursorPosition(state);
auto contains = [&](int index) {
if (index < 0 || index >= static_cast<int>(displays.size())) return false;
const auto& display = displays[index];
return display.width > 0 && display.height > 0 &&
screen_x >= display.left && screen_x < display.right &&
screen_y >= display.top && screen_y < display.bottom;
};
int display_id = contains(preferred_display) ? preferred_display : -1;
if (display_id < 0) {
for (int index = 0; index < static_cast<int>(displays.size()); ++index) {
if (contains(index)) {
display_id = index;
break;
}
}
}
if (display_id < 0) return false;
const auto& display = displays[display_id];
// Screen coordinates describe a continuous rectangle with an exclusive
// right/bottom edge. Use its full extent so feedback is the exact inverse of
// normalized input instead of accumulating a one-pixel edge convention.
const double horizontal_extent = std::max(display.width, 1);
const double vertical_extent = std::max(display.height, 1);
state->position_valid = true;
state->x = static_cast<float>(std::clamp(
(screen_x - display.left) / horizontal_extent, 0.0, 1.0));
state->y = static_cast<float>(std::clamp(
(screen_y - display.top) / vertical_extent, 0.0, 1.0));
state->display_id = display_id;
return true;
}
} // namespace crossdesk
#endif
@@ -1,77 +0,0 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-16
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _CURSOR_PRESENTATION_H_
#define _CURSOR_PRESENTATION_H_
#include <remote_action.h>
#include <cstdint>
#include <optional>
namespace crossdesk {
// All times use one monotonic millisecond clock. The owning session's cursor
// mutex protects this policy from the transport and UI threads.
class CursorPresentation {
public:
static constexpr uint64_t kStateTimeoutMs = 1500; // Three 500ms heartbeats.
static constexpr uint64_t kInputActivityMs = 1500;
void Observe(const CursorState& state, uint64_t now) {
if (state.render_mode == CursorRenderMode::unknown) {
// Repeated unavailable samples must not extend the recovery deadline.
if (!sample_unavailable_) stale_at_ = now + kStateTimeoutMs;
sample_unavailable_ = true;
return;
}
sample_unavailable_ = false;
stale_at_ = now + kStateTimeoutMs;
const bool separate =
state.render_mode == CursorRenderMode::separate ||
(state.render_mode == CursorRenderMode::legacy && state.visible);
// Old senders cannot distinguish embedded from hidden. Only explicit
// metadata from a new sender can establish the no-device compatibility
// case.
no_device_ = state.render_mode == CursorRenderMode::hidden &&
state.hidden_reason == CursorHiddenReason::no_pointing_device;
held_shape_ = separate ? state.shape : RemoteCursorShape::none;
if (held_shape_ != RemoteCursorShape::none) {
last_visible_shape_ = held_shape_;
}
if (!no_device_) fallback_engaged_ = false;
}
void NoteInput(uint64_t now) { last_input_ = now; }
RemoteCursorShape Resolve(uint64_t now) {
if (!stale_at_) return RemoteCursorShape::default_cursor;
const bool recent_input =
last_input_ && now - *last_input_ < kInputActivityMs;
const bool can_recover =
now >= *stale_at_ || (!sample_unavailable_ && no_device_);
if (can_recover && recent_input) {
fallback_engaged_ = true;
}
// Once needed, retain the locator while idle until a good non-compatibility
// sample arrives. Otherwise it would blink off whenever input pauses.
return fallback_engaged_ ? last_visible_shape_ : held_shape_;
}
private:
bool sample_unavailable_ = false;
bool no_device_ = false;
bool fallback_engaged_ = false;
std::optional<uint64_t> stale_at_;
std::optional<uint64_t> last_input_;
RemoteCursorShape held_shape_ = RemoteCursorShape::default_cursor;
RemoteCursorShape last_visible_shape_ = RemoteCursorShape::default_cursor;
};
} // namespace crossdesk
#endif
@@ -1,137 +0,0 @@
#include "runtime/cursor_state_provider.h"
#include <remote_action.h>
#if defined(_WIN32)
#include <windows.h>
#include "platform/windows/input/windows_cursor_state.h"
#include "platform/windows/screen_capturer/captured_cursor_state.h"
#include "platform/windows/screen_capturer/dxgi_cursor_state.h"
#include "platform/windows/screen_capturer/secure_desktop_cursor_state.h"
#include "runtime/cursor_position.h"
namespace crossdesk {
struct CursorStateProvider::Impl {
WindowsCursorState cursor;
};
CursorStateProvider::CursorStateProvider() : impl_(std::make_unique<Impl>()) {}
CursorStateProvider::~CursorStateProvider() = default;
bool CursorStateProvider::Sample(const std::vector<DisplayInfo>& displays,
int preferred_display, CursorState* state) {
if (!state) return false;
SecureDesktopCursorSnapshot secure_cursor{};
if (SharedSecureDesktopCursorState().Get(&secure_cursor)) {
if (!secure_cursor.valid) {
state->hidden_reason = CursorHiddenReason::secure_desktop_pending;
return false;
}
state->seq = 0;
NormalizeCursorPosition(secure_cursor.x, secure_cursor.y, displays,
preferred_display, state);
state->visible = secure_cursor.visible != 0;
state->render_mode = static_cast<CursorRenderMode>(secure_cursor.render_mode);
state->hidden_reason =
static_cast<CursorHiddenReason>(secure_cursor.hidden_reason);
state->shape = state->visible
? static_cast<RemoteCursorShape>(secure_cursor.shape)
: RemoteCursorShape::none;
return true;
}
CURSORINFO info{};
info.cbSize = sizeof(info);
if (!GetCursorInfo(&info)) return false;
state->seq = 0;
NormalizeCursorPosition(info.ptScreenPos.x, info.ptScreenPos.y, displays,
preferred_display, state);
void* cursor_monitor = state->position_valid
? displays[state->display_id].handle
: nullptr;
const bool embedded = SharedCapturedCursorState().IsEmbedded(cursor_monitor) ||
((info.flags & CURSOR_SHOWING) != 0 &&
!SharedDxgiCursorState().ShouldDrawCursor(true, cursor_monitor));
impl_->cursor.Sample(info, embedded, state);
return true;
}
} // namespace crossdesk
#elif defined(__linux__) && !defined(__APPLE__)
#include <X11/Xlib.h>
#include <X11/extensions/Xfixes.h>
#include <string>
#include "linux_cursor_shape.h"
#include "platform.h"
#include "shared_cursor_state.h"
#include "runtime/cursor_position.h"
namespace crossdesk {
namespace {
bool CursorHasVisiblePixel(const XFixesCursorImage& image) {
const size_t pixel_count =
static_cast<size_t>(image.width) * static_cast<size_t>(image.height);
for (size_t index = 0; index < pixel_count; ++index) {
if (((image.pixels[index] >> 24U) & 0xffU) != 0) return true;
}
return false;
}
} // namespace
struct CursorStateProvider::Impl {
Display* display = XOpenDisplay(nullptr);
~Impl() {
if (display) XCloseDisplay(display);
}
};
CursorStateProvider::CursorStateProvider() : impl_(std::make_unique<Impl>()) {}
CursorStateProvider::~CursorStateProvider() = default;
bool CursorStateProvider::Sample(const std::vector<DisplayInfo>& displays,
int preferred_display, CursorState* state) {
if (!state || !impl_) return false;
ResetCursorPosition(state);
if (IsWaylandSession()) {
SharedCursorState shared{};
if (GetSharedCursorState(&shared)) {
state->seq = 0;
state->visible = shared.visible;
state->shape = shared.visible ? shared.shape : RemoteCursorShape::none;
return true;
}
}
if (!impl_->display) return false;
XFixesCursorImage* image = XFixesGetCursorImage(impl_->display);
if (!image) return false;
const std::string name = image->name ? image->name : "";
state->seq = 0;
state->visible = CursorHasVisiblePixel(*image);
state->shape = state->visible ? ShapeFromLinuxCursorName(name)
: RemoteCursorShape::none;
NormalizeCursorPosition(image->x, image->y, displays, preferred_display,
state);
XFree(image);
return true;
}
} // namespace crossdesk
#endif
@@ -1,39 +0,0 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-04
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _CURSOR_STATE_PROVIDER_H_
#define _CURSOR_STATE_PROVIDER_H_
#include <memory>
#include <vector>
#include <remote_action.h>
#include "display_info.h"
namespace crossdesk {
// Samples the cursor that is currently displayed by the controlled desktop
// and converts platform-specific cursor handles to protocol cursor shapes.
class CursorStateProvider {
public:
CursorStateProvider();
~CursorStateProvider();
CursorStateProvider(const CursorStateProvider&) = delete;
CursorStateProvider& operator=(const CursorStateProvider&) = delete;
bool Sample(const std::vector<DisplayInfo>& displays,
int preferred_display, CursorState* state);
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
} // namespace crossdesk
#endif
@@ -1,61 +0,0 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-04
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _DEVICE_PRESENCE_CACHE_H_
#define _DEVICE_PRESENCE_CACHE_H_
#include <chrono>
#include <mutex>
#include <string>
#include <unordered_map>
namespace crossdesk {
class DevicePresenceCache {
public:
using Clock = std::chrono::steady_clock;
static constexpr auto kMaxAge = std::chrono::seconds(60);
void SetSignalConnected(bool connected) {
std::lock_guard<std::mutex> lock(mutex_);
signal_connected_ = connected;
cache_.clear();
}
void SetOnline(const std::string &device_id, bool online,
Clock::time_point now = Clock::now()) {
std::lock_guard<std::mutex> lock(mutex_);
if (signal_connected_) {
cache_[device_id] = {online, now};
}
}
bool IsOnline(const std::string &device_id,
Clock::time_point now = Clock::now()) const {
std::lock_guard<std::mutex> lock(mutex_);
const auto it = cache_.find(device_id);
return signal_connected_ && it != cache_.end() && it->second.online &&
now - it->second.updated_at < kMaxAge;
}
void Clear() {
std::lock_guard<std::mutex> lock(mutex_);
cache_.clear();
}
private:
struct Entry {
bool online;
Clock::time_point updated_at;
};
bool signal_connected_ = false;
std::unordered_map<std::string, Entry> cache_;
mutable std::mutex mutex_;
};
} // namespace crossdesk
#endif
@@ -1,43 +0,0 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-07
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _DEVICE_PRESENCE_REQUEST_H_
#define _DEVICE_PRESENCE_REQUEST_H_
#include <algorithm>
#include <nlohmann/json.hpp>
#include <string>
#include <vector>
namespace crossdesk {
inline nlohmann::json BuildDevicePresenceRequest(
const std::string& client_id, const std::vector<std::string>& recent_ids,
bool subscribe, const std::string& probe_id = {}) {
std::vector<std::string> devices;
for (const auto& id : recent_ids) {
// Recent connection keys contain Y/N plus display settings after the ID.
const std::string pure_id = id.substr(0, id.find_first_of("YN"));
if (!pure_id.empty() &&
std::find(devices.begin(), devices.end(), pure_id) == devices.end()) {
devices.push_back(pure_id);
}
}
if (!probe_id.empty() &&
std::find(devices.begin(), devices.end(), probe_id) == devices.end()) {
devices.push_back(probe_id);
}
// Include recent devices even in a one-shot probe: older servers ignore
// subscribe=false and replace their subscriptions with this device list.
return {{"type", "recent_connections_presence"},
{"user_id", client_id},
{"devices", devices},
{"subscribe", subscribe}};
}
} // namespace crossdesk
#endif
-19
View File
@@ -1,19 +0,0 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-04
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _GUI_STATE_H_
#define _GUI_STATE_H_
#include "application/application_state.h"
#include "runtime/runtime_state.h"
namespace crossdesk::gui_detail {
struct GuiState : ApplicationState, RuntimeState {};
} // namespace crossdesk::gui_detail
#endif
@@ -1,188 +0,0 @@
#include <chrono>
#include <cstring>
#include <memory>
#include <mutex>
#include <string>
#include <vector>
#include "platform/video_renderer.h"
#include "runtime/gui_runtime.h"
#include "runtime/peer_event_handler.h"
namespace crossdesk {
namespace {
constexpr auto kBackgroundSnapshotInterval = std::chrono::seconds(1);
const MiniRtcNativeVideoFrame* GetNativeVideoFrame(const MiniRtcVideoFrame* frame) {
if (!frame || !frame->native_frame) {
return nullptr;
}
const auto* native = frame->native_frame;
return native->struct_size >=
static_cast<uint32_t>(sizeof(MiniRtcNativeVideoFrame)) &&
native->owner && native->copy_to_nv12
? native
: nullptr;
}
} // namespace
void PeerEventHandler::OnReceiveVideoBuffer(
const MiniRtcVideoFrame* video_frame, const char* user_id, size_t user_id_size,
const char* src_id, size_t src_id_size, void* user_data) {
auto* handler = static_cast<PeerEventHandler*>(user_data);
if (!handler) return;
auto callback = handler->EnterCallback();
if (!callback) return;
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr;
if (!runtime) {
return;
}
std::string remote_id(user_id, user_id_size);
auto props = runtime->FindRemoteSession(remote_id);
if (!props) return;
if (props->connection_established_) {
bool background_snapshot_only = false;
auto* native_renderer = runtime->video_renderer_.get();
if (native_renderer && native_renderer->IsActive()) {
VideoRenderer::SubmitResult submit_result =
VideoRenderer::SubmitResult::failed;
if (GetNativeVideoFrame(video_frame)) {
submit_result = native_renderer->SubmitNativeFrame(
remote_id, *video_frame->native_frame);
} else
{
submit_result = native_renderer->SubmitNv12(
remote_id, reinterpret_cast<const uint8_t*>(video_frame->data),
video_frame->size, video_frame->width, video_frame->height);
}
if (submit_result == VideoRenderer::SubmitResult::submitted) {
std::lock_guard<std::mutex> lock(props->video_frame_mutex_);
const bool size_changed = (props->video_width_ != video_frame->width) ||
(props->video_height_ != video_frame->height);
if (size_changed) {
props->render_rect_dirty_ = true;
}
props->video_width_ = video_frame->width;
props->video_height_ = video_frame->height;
props->video_size_ = video_frame->size;
// Once the native renderer owns the current stream, do not leave an
// older CPU frame ahead of the renderer's close snapshot.
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_frame_sequence_;
props->streaming_ = true;
runtime->video_frame_dirty_.store(true, std::memory_order_release);
return;
}
if (submit_result == VideoRenderer::SubmitResult::dropped ||
submit_result == VideoRenderer::SubmitResult::failed) {
// Keep presenting the last native frame. A later decoded frame can
// reuse the renderer without changing ownership mid-window.
props->streaming_ = true;
return;
}
if (submit_result == VideoRenderer::SubmitResult::not_selected) {
background_snapshot_only = true;
}
}
{
std::lock_guard<std::mutex> lock(props->video_frame_mutex_);
const auto now = std::chrono::steady_clock::now();
if (background_snapshot_only && props->thumbnail_frame_ &&
!props->thumbnail_frame_->empty() &&
props->background_snapshot_time_ !=
std::chrono::steady_clock::time_point{} &&
now - props->background_snapshot_time_ <
kBackgroundSnapshotInterval) {
props->streaming_ = true;
return;
}
size_t frame_size = video_frame->size;
const MiniRtcNativeVideoFrame* native_frame =
GetNativeVideoFrame(video_frame);
if (native_frame) {
frame_size = static_cast<size_t>(native_frame->width) *
native_frame->height * 3U / 2U;
}
if (frame_size == 0) {
return;
}
// Allocate a third buffer only while the UI still owns the old snapshot.
if (!props->back_frame_ || props->back_frame_.use_count() != 1) {
props->back_frame_ =
std::make_shared<std::vector<unsigned char>>(frame_size);
}
if (props->back_frame_->size() != frame_size) {
props->back_frame_->resize(frame_size);
}
if (native_frame) {
if (native_frame->copy_to_nv12(
native_frame->owner, props->back_frame_->data(),
props->back_frame_->size()) != 0) {
return;
}
} else
{
if (!video_frame->data || video_frame->size < frame_size) {
return;
}
std::memcpy(props->back_frame_->data(), video_frame->data, frame_size);
}
const bool size_changed = (props->video_width_ != video_frame->width) ||
(props->video_height_ != video_frame->height);
if (size_changed) {
props->render_rect_dirty_ = true;
}
props->video_width_ = video_frame->width;
props->video_height_ = video_frame->height;
props->video_size_ = frame_size;
props->front_frame_.swap(props->back_frame_);
props->thumbnail_frame_ = props->front_frame_;
props->thumbnail_width_ = video_frame->width;
props->thumbnail_height_ = video_frame->height;
if (background_snapshot_only) {
props->background_snapshot_time_ = now;
}
++props->video_frame_sequence_;
}
props->streaming_ = true;
if (background_snapshot_only) {
return;
}
runtime->video_frame_dirty_.store(true, std::memory_order_release);
}
}
void PeerEventHandler::OnReceiveAudioBuffer(
const char* data, size_t size, const char* user_id, size_t user_id_size,
const char* src_id, size_t src_id_size, void* user_data) {
auto* handler = static_cast<PeerEventHandler*>(user_data);
if (!handler) return;
auto callback = handler->EnterCallback();
if (!callback) return;
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr;
if (!runtime) {
return;
}
runtime->audio_buffer_fresh_ = true;
runtime->devices_.PushAudio(data, size);
}
} // namespace crossdesk
@@ -1,53 +0,0 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-12
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _PRIVACY_CONTROL_STATE_H_
#define _PRIVACY_CONTROL_STATE_H_
#include <cstdint>
#include "remote_action.h"
namespace crossdesk {
struct PrivacyControlState {
bool active = false;
bool enabled = false;
bool off = false;
bool unsupported = false;
bool can_toggle = false;
};
// Use the same host-confirmed state for presentation and command admission.
// Missing confirmation or a failure makes only this toolbar control unavailable.
inline PrivacyControlState GetPrivacyControlState(
const PrivacyStatus& status, bool received, bool command_pending,
uint64_t status_age_ms, uint64_t command_age_ms) {
PrivacyControlState result;
const bool timeout = command_pending && command_age_ms > 15000;
result.unsupported = timeout ||
(received && (status_age_ms > 5000 || !status.supported ||
status.state == PrivacyState::failed));
if (result.unsupported) return result;
result.active = status.overlay_active || status.remote_paused ||
status.state == PrivacyState::on ||
status.state == PrivacyState::starting ||
status.state == PrivacyState::stopping ||
status.state == PrivacyState::failed;
// A command in flight only gates repeated clicks; it adds no display state.
result.enabled = received &&
status.state == PrivacyState::on && status.overlay_active &&
!status.remote_paused;
result.off = received &&
status.state == PrivacyState::off && !result.active;
result.can_toggle = !command_pending && (result.active || received);
return result;
}
} // namespace crossdesk
#endif
@@ -1,114 +0,0 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-11
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#include <cstring>
#include <shared_mutex>
#include "rd_log.h"
#include "runtime/gui_runtime.h"
namespace crossdesk {
bool GuiRuntime::IsAuthorizedController(const std::string& remote_id) {
// Connections appear here only after the existing password/signalling/RTC
// admission path. Outgoing/viewer sessions live in remote_sessions_ instead.
std::shared_lock lock(connection_status_mutex_);
const auto found = connection_status_.find(remote_id);
return found != connection_status_.end() &&
found->second == ConnectionStatus::Connected;
}
void GuiRuntime::SetControllerPrivacySupport(const std::string& remote_id,
bool supported) {
std::unique_lock lock(connection_status_mutex_);
const auto found = connection_status_.find(remote_id);
if (found == connection_status_.end() ||
found->second != ConnectionStatus::Connected) return;
const bool automatic_enable =
privacy_sessions_.SetSupported(remote_id, supported);
if (!privacy_sessions_.CanEnable()) {
if (privacy_.Engaged()) privacy_.Disable();
} else if (automatic_enable && config_center_->IsEnablePrivacyScreen()) {
privacy_.EnableOnConnection();
}
}
void GuiRuntime::QueuePrivacyCommand(const std::string& remote_id,
const PrivacyCommand& command) {
std::shared_lock session_lock(connection_status_mutex_);
const auto found = connection_status_.find(remote_id);
if (found == connection_status_.end() ||
found->second != ConnectionStatus::Connected ||
!privacy_sessions_.Supports(remote_id)) return;
std::lock_guard lock(privacy_commands_mutex_);
if (privacy_commands_.size() < 32)
privacy_commands_.emplace_back(remote_id, command);
}
void GuiRuntime::HandlePrivacy() {
std::deque<std::pair<std::string, PrivacyCommand>> commands;
{
std::lock_guard lock(privacy_commands_mutex_);
commands.swap(privacy_commands_);
}
for (const auto& [remote_id, command] : commands) {
// Keep admission and activation under the connection lock so an unknown
// controller cannot join between the capability check and enabling.
std::shared_lock lock(connection_status_mutex_);
const auto found = connection_status_.find(remote_id);
if (found == connection_status_.end() ||
found->second != ConnectionStatus::Connected ||
!privacy_sessions_.Supports(remote_id)) continue;
if (command.flag == PrivacyCommandFlag::enable) {
if (privacy_sessions_.CanEnable())
privacy_.Enable(command.block_local_input);
} else if (command.flag == PrivacyCommandFlag::disable) {
privacy_.Disable();
}
last_privacy_status_tick_ = 0;
}
auto status = privacy_.Snapshot();
const uint64_t now = SDL_GetTicks();
if (!peer_ || (last_privacy_status_tick_ != 0 &&
status.revision == last_privacy_revision_ &&
now - last_privacy_status_tick_ < 1000))
return;
std::vector<std::string> controllers;
{
std::shared_lock lock(connection_status_mutex_);
for (const auto& [id, state] : connection_status_)
if (state == ConnectionStatus::Connected && privacy_sessions_.Supports(id))
controllers.push_back(id);
if (!privacy_sessions_.CanEnable()) {
status.state = PrivacyState::unsupported;
status.supported = false;
status.input_block_supported = false;
std::strcpy(status.reason,
"A connected controller does not support privacy screen");
}
}
RemoteAction action{};
action.type = ControlType::privacy_status;
action.ps = status;
const auto message = action.to_json();
bool sent = true;
for (const auto& id : controllers) {
const int result = SendReliableDataFrameToPeer(
peer_, message.data(), message.size(), control_data_label_.c_str(),
id.data(), id.size());
if (result != 0) {
sent = false;
LOG_WARN("Privacy status send failed, peer={}, ret={}", id, result);
}
}
if (sent) {
last_privacy_revision_ = status.revision;
last_privacy_status_tick_ = now;
}
}
} // namespace crossdesk
@@ -1,61 +0,0 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-15
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _PRIVACY_SESSION_POLICY_H_
#define _PRIVACY_SESSION_POLICY_H_
#include <algorithm>
#include <string>
#include <unordered_map>
namespace crossdesk {
// Guarded by GuiRuntime's connection_status_mutex_. Privacy covers the shared
// desktop, so every connected controller must explicitly support it.
class PrivacySessionPolicy {
public:
void Connected(const std::string& remote_id, bool automatic_privacy) {
const auto [entry, inserted] = controllers_.try_emplace(remote_id, false);
if (inserted && controllers_.size() == 1 && automatic_privacy) {
automatic_controller_ = remote_id;
}
}
void Disconnected(const std::string& remote_id) {
controllers_.erase(remote_id);
if (automatic_controller_ == remote_id) automatic_controller_.clear();
}
// Consume automatic activation once, on the first controller's host info.
// Repeated metadata or a later join must not undo an explicit privacy exit.
bool SetSupported(const std::string& remote_id, bool supported) {
const auto entry = controllers_.find(remote_id);
if (entry == controllers_.end()) return false;
entry->second = supported && remote_id.find("web") == std::string::npos;
if (automatic_controller_ != remote_id) return false;
automatic_controller_.clear();
return CanEnable();
}
bool Supports(const std::string& remote_id) const {
const auto entry = controllers_.find(remote_id);
return entry != controllers_.end() && entry->second;
}
bool CanEnable() const {
return !controllers_.empty() &&
std::all_of(controllers_.begin(), controllers_.end(),
[](const auto& entry) { return entry.second; });
}
private:
std::unordered_map<std::string, bool> controllers_;
std::string automatic_controller_;
};
} // namespace crossdesk
#endif
@@ -1,47 +0,0 @@
#include "runtime/session_lifecycle.h"
namespace crossdesk::gui_detail {
CallbackGuard::CallbackGuard(SessionCallbackGate& callbacks)
: callbacks_(callbacks), accepted_(callbacks.BeginCallback()) {}
CallbackGuard::~CallbackGuard() {
if (accepted_) {
callbacks_.EndCallback();
}
}
bool SessionCallbackGate::IsActive() const {
std::lock_guard lock(mutex_);
return accepting_callbacks_;
}
CallbackGuard SessionCallbackGate::Enter() {
return CallbackGuard(*this);
}
bool SessionCallbackGate::BeginCallback() {
std::lock_guard lock(mutex_);
if (!accepting_callbacks_) {
return false;
}
++running_callbacks_;
return true;
}
void SessionCallbackGate::EndCallback() {
std::lock_guard lock(mutex_);
--running_callbacks_;
if (running_callbacks_ == 0) {
callbacks_finished_.notify_all();
}
}
void SessionCallbackGate::Close() {
std::unique_lock lock(mutex_);
accepting_callbacks_ = false;
// Waiting releases the lock so running callbacks can call EndCallback().
callbacks_finished_.wait(lock, [this] { return running_callbacks_ == 0; });
}
} // namespace crossdesk::gui_detail
@@ -1,58 +0,0 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-07
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _SESSION_LIFECYCLE_H_
#define _SESSION_LIFECYCLE_H_
#include <condition_variable>
#include <cstddef>
#include <mutex>
namespace crossdesk::gui_detail {
class SessionCallbackGate;
// Decrements the count on scope exit, including early returns and exceptions.
class CallbackGuard {
public:
explicit CallbackGuard(SessionCallbackGate& callbacks);
~CallbackGuard();
CallbackGuard(const CallbackGuard&) = delete;
CallbackGuard& operator=(const CallbackGuard&) = delete;
explicit operator bool() const { return accepted_; }
private:
SessionCallbackGate& callbacks_;
bool accepted_;
};
// Each peer counts callbacks as they enter and leave.
// Closing rejects new callbacks, then waits for running callbacks to finish.
class SessionCallbackGate {
public:
bool IsActive() const;
CallbackGuard Enter();
// Called by the session owner, never from within this session's callbacks.
void Close();
private:
friend class CallbackGuard;
bool BeginCallback();
void EndCallback();
mutable std::mutex mutex_;
std::condition_variable callbacks_finished_;
bool accepting_callbacks_ = true;
std::size_t running_callbacks_ = 0;
};
} // namespace crossdesk::gui_detail
#endif
-181
View File
@@ -1,181 +0,0 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-04
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _UI_LOCALIZATION_H_
#define _UI_LOCALIZATION_H_
#include <slint.h>
#include <string>
#include "crossdesk_ui.h"
#include "localization.h"
namespace crossdesk::ui_localization {
inline slint::SharedString Text(const std::string& value) {
return slint::SharedString(value);
}
inline int ApplyMainWindowStrings(
const slint::ComponentHandle<ui::MainWindow>& window,
int language_index) {
const int language =
localization::detail::ClampLanguageIndex(language_index);
auto& strings = window->global<ui::UiStrings>();
strings.set_local_desktop(Text(localization::local_desktop[language]));
strings.set_remote_desktop(Text(localization::remote_desktop[language]));
strings.set_recent_connections(
Text(localization::recent_connections[language]));
strings.set_local_id(Text(localization::local_id[language]));
strings.set_device_name(Text(localization::device_name[language]));
strings.set_remote_id(Text(localization::remote_id[language]));
strings.set_online(Text(localization::online[language]));
strings.set_offline(Text(localization::offline[language]));
strings.set_password(Text(localization::password[language]));
strings.set_copied(
Text(localization::local_id_copied_to_clipboard[language]));
strings.set_connect(Text(localization::connect[language]));
strings.set_settings(Text(localization::settings[language]));
strings.set_about(Text(localization::about[language]));
strings.set_ok(Text(localization::ok[language]));
strings.set_cancel(Text(localization::cancel[language]));
strings.set_new_password(Text(localization::new_password[language]));
strings.set_invalid_password(Text(localization::max_password_len[language]));
strings.set_edit_alias(
Text(localization::input_connection_alias[language]));
strings.set_delete_connection(
Text(localization::delete_connection[language]));
strings.set_confirm_delete(
Text(localization::confirm_delete_connection[language]));
strings.set_language(Text(localization::language[language]));
strings.set_video_quality(Text(localization::video_quality[language]));
strings.set_frame_rate(Text(localization::video_frame_rate[language]));
strings.set_capture_method(Text(localization::screen_capture_method[language]));
strings.set_capture_method_auto(
Text(localization::screen_capture_method_auto[language]));
strings.set_adaptation_policy(
Text(localization::video_adaptation_policy[language]));
strings.set_priority_frame_rate(
Text(localization::video_priority_frame_rate[language]));
strings.set_priority_quality(
Text(localization::video_priority_quality[language]));
strings.set_priority_balanced(
Text(localization::video_priority_balanced[language]));
strings.set_codec(Text(localization::video_encode_format[language]));
strings.set_hardware_codec(
Text(localization::enable_hardware_video_codec[language]));
strings.set_turn_relay(Text(localization::enable_turn[language]));
strings.set_self_hosted(
Text(localization::self_hosted_server_config[language]));
strings.set_autostart(Text(localization::enable_autostart[language]));
strings.set_daemon(Text(localization::enable_daemon[language]));
strings.set_privacy_on_connect(Text(localization::privacy_on_connect[language]));
strings.set_file_save_path(
Text(localization::file_transfer_save_path[language]));
strings.set_default_desktop(Text(localization::default_desktop[language]));
strings.set_server_host(
Text(localization::self_hosted_server_address[language]));
strings.set_server_port(
Text(localization::self_hosted_server_port[language]));
strings.set_version(Text(localization::version[language]));
strings.set_signal_connected(Text(localization::signal_connected[language]));
strings.set_signal_disconnected(
Text(localization::signal_disconnected[language]));
strings.set_tls_error(Text(localization::signal_tls_cert_error[language]));
strings.set_update_available(
Text(localization::new_version_available[language]));
strings.set_release_notes(Text(localization::release_notes[language]));
strings.set_download(Text(localization::update[language]));
strings.set_input_password(Text(localization::input_password[language]));
strings.set_reinput_password(Text(localization::reinput_password[language]));
strings.set_remember_password(
Text(localization::remember_password[language]));
strings.set_validate_password(
Text(localization::validate_password[language]));
strings.set_request_permissions(
Text(localization::request_permissions[language]));
strings.set_permission_required(
Text(localization::permission_required_message[language]));
strings.set_screen_recording_permission(
Text(localization::screen_recording_permission[language]));
strings.set_accessibility_permission(
Text(localization::accessibility_permission[language]));
strings.set_service_setup_title(
Text(localization::windows_service_setup_title[language]));
strings.set_service_setup_message(
Text(localization::windows_service_setup_message[language]));
strings.set_service_settings_label(
Text(localization::windows_service_settings_label[language]));
strings.set_install_service(
Text(localization::install_windows_service[language]));
strings.set_service_installed(
Text(localization::windows_service_installed[language]));
strings.set_do_not_remind(
Text(localization::do_not_remind_again[language]));
strings.set_notification(Text(localization::notification[language]));
strings.set_service_suppressed_message(
Text(localization::windows_service_prompt_suppressed_message[language]));
strings.set_quality_low(Text(localization::video_quality_low[language]));
strings.set_quality_medium(
Text(localization::video_quality_medium[language]));
strings.set_quality_high(Text(localization::video_quality_high[language]));
strings.set_codec_h264(Text(localization::h264[language]));
strings.set_codec_av1(Text(localization::av1[language]));
strings.set_self_hosted_settings(
Text(localization::self_hosted_server_settings[language]));
strings.set_access_website(Text(localization::access_website[language]));
strings.set_release_date_label(Text(localization::release_date[language]));
strings.set_later(Text(localization::cancel[language]));
return language;
}
template <typename Window>
inline int ApplyStreamWindowStrings(
const slint::ComponentHandle<Window>& window, int language_index) {
const int language =
localization::detail::ClampLanguageIndex(language_index);
auto& strings = window->template global<ui::StreamStrings>();
strings.set_select_display(Text(localization::select_display[language]));
strings.set_send_shortcut(Text(localization::send_shortcut[language]));
strings.set_privacy_enable(Text(localization::privacy_enable[language]));
strings.set_privacy_disable(Text(localization::privacy_disable[language]));
strings.set_control_mouse(Text(localization::control_mouse[language]));
strings.set_release_mouse(Text(localization::release_mouse[language]));
strings.set_audio(Text(localization::audio_capture[language]));
strings.set_mute(Text(localization::mute[language]));
strings.set_select_file(Text(localization::select_file[language]));
strings.set_show_stats(
Text(localization::show_net_traffic_stats[language]));
strings.set_hide_stats(
Text(localization::hide_net_traffic_stats[language]));
strings.set_fullscreen(Text(localization::fullscreen[language]));
strings.set_exit_fullscreen(Text(localization::exit_fullscreen[language]));
strings.set_disconnect(Text(localization::disconnect[language]));
strings.set_file_transfer(
Text(localization::file_transfer_progress[language]));
strings.set_expand_control(Text(localization::expand_control_bar[language]));
strings.set_collapse_control(
Text(localization::collapse_control_bar[language]));
strings.set_stats_in(Text(localization::in[language]));
strings.set_stats_out(Text(localization::out[language]));
strings.set_stats_loss_rate(Text(localization::loss_rate[language]));
strings.set_stats_resolution(Text(localization::resolution[language]));
strings.set_stats_connection_mode(
Text(localization::connection_mode[language]));
strings.set_stats_encryption(
Text(localization::transport_encryption[language]));
strings.set_stats_encryption_enabled(
Text(localization::transport_encryption_enabled[language]));
strings.set_stats_encryption_disabled(
Text(localization::transport_encryption_disabled[language]));
return language;
}
} // namespace crossdesk::ui_localization
#endif
@@ -1,60 +0,0 @@
#include "path_manager.h"
#include "platform/path_backend.h"
#ifndef CROSSDESK_PORTABLE
#define CROSSDESK_PORTABLE 0
#endif
namespace {
#if CROSSDESK_PORTABLE
std::filesystem::path GetPortableRootPath() {
std::filesystem::path executable_dir =
crossdesk::platform::GetExecutableDirectory();
if (!executable_dir.empty()) return executable_dir;
std::error_code error;
const std::filesystem::path current =
std::filesystem::current_path(error);
return error ? std::filesystem::path(".") : current;
}
#endif
} // namespace
namespace crossdesk {
PathManager::PathManager(const std::string& app_name) : app_name_(app_name) {}
std::filesystem::path PathManager::GetConfigPath() {
#if CROSSDESK_PORTABLE
return GetPortableRootPath() / "data";
#else
return platform::GetConfigPath(app_name_);
#endif
}
std::filesystem::path PathManager::GetCachePath() {
#if CROSSDESK_PORTABLE
return GetPortableRootPath() / "data";
#else
return platform::GetCachePath(app_name_);
#endif
}
std::filesystem::path PathManager::GetLogPath() {
#if CROSSDESK_PORTABLE
return GetPortableRootPath() / "logs";
#else
return platform::GetLogPath(app_name_);
#endif
}
bool PathManager::CreateDirectories(const std::filesystem::path& path) {
std::error_code error;
const bool created = std::filesystem::create_directories(path, error);
return !error && (created || std::filesystem::exists(path));
}
} // namespace crossdesk
@@ -1,16 +0,0 @@
#ifndef CROSSDESK_PLATFORM_AUTOSTART_BACKEND_H_
#define CROSSDESK_PLATFORM_AUTOSTART_BACKEND_H_
#include <string>
namespace crossdesk::platform {
std::string GetAutostartExecutablePath();
bool EnableAutostart(const std::string& app_name,
const std::string& executable_path);
bool DisableAutostart(const std::string& app_name);
bool IsAutostartEnabled(const std::string& app_name);
} // namespace crossdesk::platform
#endif // CROSSDESK_PLATFORM_AUTOSTART_BACKEND_H_
@@ -1,13 +0,0 @@
#ifndef CROSSDESK_PLATFORM_CLIPBOARD_BACKEND_H_
#define CROSSDESK_PLATFORM_CLIPBOARD_BACKEND_H_
namespace crossdesk::platform {
bool ClipboardMonitoring();
void HandleClipboardChange();
void RunClipboardMonitor(int check_interval_ms);
void WakeClipboardMonitor();
} // namespace crossdesk::platform
#endif // CROSSDESK_PLATFORM_CLIPBOARD_BACKEND_H_
@@ -1,82 +0,0 @@
#include "platform/common/gui/cuda_driver.h"
#if CROSSDESK_HAS_CUDA_DRIVER
#if defined(_WIN32)
#include <windows.h>
#else
#include <dlfcn.h>
#endif
#include <type_traits>
#include "rd_log.h"
namespace crossdesk {
const CudaDriver* CudaDriver::Get() {
static const CudaDriver driver;
return driver.loaded_ ? &driver : nullptr;
}
void* CudaDriver::Symbol(const char* name) const {
#if defined(_WIN32)
return reinterpret_cast<void*>(
GetProcAddress(static_cast<HMODULE>(module_), name));
#else
return dlsym(module_, name);
#endif
}
CudaDriver::CudaDriver() {
#if defined(_WIN32)
module_ = LoadLibraryW(L"nvcuda.dll");
#else
module_ = dlopen("libcuda.so.1", RTLD_LAZY | RTLD_LOCAL);
#endif
if (!module_) return;
const auto load = [this](auto& function, const char* name) {
function = reinterpret_cast<std::decay_t<decltype(function)>>(Symbol(name));
return function != nullptr;
};
loaded_ = load(init, "cuInit") &&
load(device_get_count, "cuDeviceGetCount") &&
load(device_get, "cuDeviceGet") &&
load(context_create, "cuCtxCreate_v2") &&
load(context_destroy, "cuCtxDestroy_v2") &&
load(context_push, "cuCtxPushCurrent_v2") &&
load(context_pop, "cuCtxPopCurrent_v2") &&
load(error_name, "cuGetErrorName") &&
load(mem_alloc, "cuMemAlloc_v2") &&
load(mem_free, "cuMemFree_v2") &&
load(memcpy_2d, "cuMemcpy2D_v2") &&
load(memcpy_2d_async, "cuMemcpy2DAsync_v2") &&
load(stream_create, "cuStreamCreate") &&
load(stream_destroy, "cuStreamDestroy_v2") &&
load(stream_synchronize, "cuStreamSynchronize") &&
load(register_gl_buffer, "cuGraphicsGLRegisterBuffer") &&
load(unregister_resource, "cuGraphicsUnregisterResource") &&
load(map_resources, "cuGraphicsMapResources") &&
load(unmap_resources, "cuGraphicsUnmapResources") &&
load(mapped_pointer, "cuGraphicsResourceGetMappedPointer_v2");
}
CudaDriver::~CudaDriver() {
if (!module_) return;
#if defined(_WIN32)
FreeLibrary(static_cast<HMODULE>(module_));
#else
dlclose(module_);
#endif
}
bool CudaDriver::Check(CUresult result, const char* operation) const {
if (result == CUDA_SUCCESS) return true;
const char* name = nullptr;
error_name(result, &name);
LOG_WARN("CUDA/OpenGL {} failed: {} ({})", operation,
name ? name : "unknown CUDA error", static_cast<int>(result));
return false;
}
} // namespace crossdesk
#endif
@@ -1,63 +0,0 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-07
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _CUDA_DRIVER_H_
#define _CUDA_DRIVER_H_
#if defined(USE_CUDA) && USE_CUDA && \
(defined(_WIN32) || \
(defined(__linux__) && (defined(__x86_64__) || defined(__amd64__))))
#define CROSSDESK_HAS_CUDA_DRIVER 1
#include <cuda.h>
namespace crossdesk {
// Application-owned driver entry points. No codec library or MiniRTC internals
// are involved; the module remains loaded for this singleton's lifetime.
class CudaDriver {
public:
static const CudaDriver* Get();
bool Check(CUresult result, const char* operation) const;
decltype(&cuInit) init = nullptr;
decltype(&cuDeviceGetCount) device_get_count = nullptr;
decltype(&cuDeviceGet) device_get = nullptr;
CUresult(CUDAAPI* context_create)(CUcontext*, unsigned int, CUdevice) = nullptr;
decltype(&cuCtxDestroy) context_destroy = nullptr;
decltype(&cuCtxPushCurrent) context_push = nullptr;
decltype(&cuCtxPopCurrent) context_pop = nullptr;
decltype(&cuGetErrorName) error_name = nullptr;
decltype(&cuMemAlloc) mem_alloc = nullptr;
decltype(&cuMemFree) mem_free = nullptr;
decltype(&cuMemcpy2D) memcpy_2d = nullptr;
decltype(&cuMemcpy2DAsync) memcpy_2d_async = nullptr;
decltype(&cuStreamCreate) stream_create = nullptr;
decltype(&cuStreamDestroy) stream_destroy = nullptr;
decltype(&cuStreamSynchronize) stream_synchronize = nullptr;
CUresult(CUDAAPI* register_gl_buffer)(CUgraphicsResource*, unsigned int,
unsigned int) = nullptr;
decltype(&cuGraphicsUnregisterResource) unregister_resource = nullptr;
decltype(&cuGraphicsMapResources) map_resources = nullptr;
decltype(&cuGraphicsUnmapResources) unmap_resources = nullptr;
decltype(&cuGraphicsResourceGetMappedPointer) mapped_pointer = nullptr;
private:
CudaDriver();
~CudaDriver();
CudaDriver(const CudaDriver&) = delete;
CudaDriver& operator=(const CudaDriver&) = delete;
void* Symbol(const char* name) const;
void* module_ = nullptr;
bool loaded_ = false;
};
} // namespace crossdesk
#else
#define CROSSDESK_HAS_CUDA_DRIVER 0
#endif
#endif
@@ -1,198 +0,0 @@
#include "platform/common/gui/cuda_opengl_interop.h"
#include "platform/common/gui/cuda_driver.h"
#include <memory>
#include <new>
#include "platform/common/gui/native_video_frame_ref.h"
namespace crossdesk {
#if CROSSDESK_HAS_CUDA_DRIVER
namespace {
class ScopedCudaContext {
public:
ScopedCudaContext(const CudaDriver& driver, CUcontext context)
: cuda(driver),
active_(cuda.Check(cuda.context_push(context), "cuCtxPushCurrent")) {}
~ScopedCudaContext() { Pop(); }
explicit operator bool() const { return active_; }
bool Pop() {
if (!active_) return true;
active_ = false;
CUcontext context = nullptr;
return cuda.Check(cuda.context_pop(&context), "cuCtxPopCurrent");
}
private:
const CudaDriver& cuda;
bool active_;
};
bool IsCudaFrame(const MiniRtcNativeVideoFrame* frame) {
return frame && GetOpenGlNativeFrame(*frame) &&
frame->type == MiniRtcNativeVideoFrameCudaNv12;
}
} // namespace
struct CudaOpenGlVideoBuffer {
const CudaDriver* driver = nullptr;
CUcontext context = nullptr;
CUstream stream = nullptr;
CUgraphicsResource resource = nullptr;
bool mapped = false;
bool failed = false;
NativeVideoFrameRef source_frame;
};
bool IsCudaDeviceAvailable() {
const auto* cuda = CudaDriver::Get();
int count = 0;
return cuda && cuda->init(0) == CUDA_SUCCESS &&
cuda->device_get_count(&count) == CUDA_SUCCESS && count > 0;
}
CudaOpenGlVideoBuffer* CreateCudaOpenGlVideoBuffer(
const MiniRtcNativeVideoFrame* frame, uint32_t gl_buffer) {
const auto* driver = CudaDriver::Get();
if (!IsCudaFrame(frame) || gl_buffer == 0 || !driver) {
return nullptr;
}
std::unique_ptr<CudaOpenGlVideoBuffer, decltype(&DestroyCudaOpenGlVideoBuffer)>
buffer(new (std::nothrow) CudaOpenGlVideoBuffer,
&DestroyCudaOpenGlVideoBuffer);
if (!buffer) return nullptr;
buffer->driver = driver;
const auto& cuda = *driver;
buffer->context = static_cast<CUcontext>(frame->payload.cuda_nv12.context);
buffer->source_frame = NativeVideoFrameRef(frame);
ScopedCudaContext context(cuda, buffer->context);
if (!context ||
!cuda.Check(cuda.stream_create(&buffer->stream,
CU_STREAM_NON_BLOCKING),
"cuStreamCreate") ||
!cuda.Check(cuda.register_gl_buffer(
&buffer->resource, gl_buffer,
CU_GRAPHICS_REGISTER_FLAGS_WRITE_DISCARD),
"cuGraphicsGLRegisterBuffer") ||
!context.Pop()) {
return nullptr;
}
return buffer.release();
}
int UploadCudaFrameToOpenGlBuffer(
CudaOpenGlVideoBuffer* buffer, const MiniRtcNativeVideoFrame* frame) {
if (!buffer || buffer->failed || !IsCudaFrame(frame) ||
frame->payload.cuda_nv12.context != buffer->context) {
return -1;
}
const auto& cuda = *buffer->driver;
ScopedCudaContext context(cuda, buffer->context);
if (!context) {
buffer->failed = true;
return -1;
}
// The caller has completed this buffer's previous GL use. Hold the new
// surface through asynchronous copies, including partially failed uploads.
buffer->source_frame = NativeVideoFrameRef(frame);
const char* operation = "cuGraphicsMapResources";
CUresult result =
cuda.map_resources(1, &buffer->resource, buffer->stream);
buffer->mapped = result == CUDA_SUCCESS;
CUdeviceptr destination = 0;
size_t mapped_size = 0;
if (result == CUDA_SUCCESS) {
operation = "cuGraphicsResourceGetMappedPointer";
result = cuda.mapped_pointer(
&destination, &mapped_size, buffer->resource);
}
const size_t y_size = static_cast<size_t>(frame->width) * frame->height;
if (result == CUDA_SUCCESS && mapped_size < y_size + y_size / 2) {
operation = "validate mapped buffer size";
result = CUDA_ERROR_INVALID_VALUE;
}
if (result == CUDA_SUCCESS) {
CUDA_MEMCPY2D copy{};
copy.srcMemoryType = CU_MEMORYTYPE_DEVICE;
copy.srcDevice = frame->payload.cuda_nv12.y_device_pointer;
copy.srcPitch = frame->payload.cuda_nv12.y_stride;
copy.dstMemoryType = CU_MEMORYTYPE_DEVICE;
copy.dstDevice = destination;
copy.dstPitch = frame->width;
copy.WidthInBytes = frame->width;
copy.Height = frame->height;
operation = "cuMemcpy2DAsync (Y plane)";
result = cuda.memcpy_2d_async(&copy, buffer->stream);
if (result == CUDA_SUCCESS) {
copy.srcDevice = frame->payload.cuda_nv12.uv_device_pointer;
copy.srcPitch = frame->payload.cuda_nv12.uv_stride;
copy.dstDevice = destination + y_size;
copy.Height = frame->height / 2;
operation = "cuMemcpy2DAsync (UV plane)";
result = cuda.memcpy_2d_async(&copy, buffer->stream);
}
}
if (buffer->mapped) {
const CUresult unmap =
cuda.unmap_resources(1, &buffer->resource, buffer->stream);
buffer->mapped = unmap != CUDA_SUCCESS;
if (result == CUDA_SUCCESS) {
operation = "cuGraphicsUnmapResources";
result = unmap;
} else {
cuda.Check(unmap, "cuGraphicsUnmapResources (cleanup)");
}
}
const bool copied = cuda.Check(result, operation);
const bool popped = context.Pop();
buffer->failed = !copied || !popped;
return buffer->failed ? -1 : 0;
}
void DestroyCudaOpenGlVideoBuffer(CudaOpenGlVideoBuffer* buffer) {
if (!buffer) return;
// Keep the frame's device context alive until after the context is popped.
std::unique_ptr<CudaOpenGlVideoBuffer> owner(buffer);
const auto& cuda = *buffer->driver;
ScopedCudaContext context(cuda, buffer->context);
if (!context) return;
if (buffer->stream) {
cuda.Check(cuda.stream_synchronize(buffer->stream),
"cuStreamSynchronize (destroy)");
}
if (buffer->mapped) {
cuda.Check(cuda.unmap_resources(
1, &buffer->resource, buffer->stream),
"cuGraphicsUnmapResources (destroy)");
cuda.Check(cuda.stream_synchronize(buffer->stream),
"cuStreamSynchronize (unmap)");
}
if (buffer->resource) {
cuda.Check(cuda.unregister_resource(buffer->resource),
"cuGraphicsUnregisterResource");
}
if (buffer->stream) {
cuda.Check(cuda.stream_destroy(buffer->stream), "cuStreamDestroy");
}
}
#else
bool IsCudaDeviceAvailable() { return false; }
CudaOpenGlVideoBuffer* CreateCudaOpenGlVideoBuffer(
const MiniRtcNativeVideoFrame*, uint32_t) {
return nullptr;
}
int UploadCudaFrameToOpenGlBuffer(
CudaOpenGlVideoBuffer*, const MiniRtcNativeVideoFrame*) {
return -1;
}
void DestroyCudaOpenGlVideoBuffer(CudaOpenGlVideoBuffer*) {}
#endif
} // namespace crossdesk
@@ -1,35 +0,0 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-07
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _CUDA_OPENGL_INTEROP_H_
#define _CUDA_OPENGL_INTEROP_H_
#include "minirtc.h"
namespace crossdesk {
bool IsCudaDeviceAvailable();
struct CudaOpenGlVideoBuffer;
// These operations require the matching GL context to be current. The caller
// owns the GL buffer and must allocate its storage before registering it.
CudaOpenGlVideoBuffer* CreateCudaOpenGlVideoBuffer(
const MiniRtcNativeVideoFrame* frame, uint32_t gl_buffer);
// Asynchronous, GPU-only NV12 copy. Retains the source through the next upload
// or destruction, including on failure. Before reusing the buffer, the caller
// must wait for the previous GL fence. Frames must use the same CUDA context.
int UploadCudaFrameToOpenGlBuffer(CudaOpenGlVideoBuffer* buffer,
const MiniRtcNativeVideoFrame* frame);
// Finish prior GL use before calling. Waits for CUDA work and releases retained
// frames; the caller may then resize/delete the GL buffer. Accepts null.
void DestroyCudaOpenGlVideoBuffer(CudaOpenGlVideoBuffer* buffer);
} // namespace crossdesk
#endif
@@ -1,99 +0,0 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-07
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _NATIVE_VIDEO_FRAME_REF_H_
#define _NATIVE_VIDEO_FRAME_REF_H_
#include <limits>
#include <utility>
#include "minirtc.h"
namespace crossdesk {
class NativeVideoFrameRef {
public:
NativeVideoFrameRef() = default;
explicit NativeVideoFrameRef(const MiniRtcNativeVideoFrame *frame) {
if (frame && frame->owner && frame->retain && frame->release) {
frame_ = *frame;
frame_.struct_size = sizeof(frame_);
frame_.retain(frame_.owner);
valid_ = true;
}
}
NativeVideoFrameRef(const NativeVideoFrameRef &other)
: NativeVideoFrameRef(other.Get()) {}
NativeVideoFrameRef(NativeVideoFrameRef &&other) noexcept
: frame_(other.frame_), valid_(std::exchange(other.valid_, false)) {
other.frame_ = {};
}
NativeVideoFrameRef &operator=(NativeVideoFrameRef other) noexcept {
Swap(other);
return *this;
}
~NativeVideoFrameRef() { Reset(); }
void Reset() {
if (valid_ && frame_.owner && frame_.release) {
frame_.release(frame_.owner);
}
frame_ = {};
valid_ = false;
}
void Swap(NativeVideoFrameRef &other) noexcept {
std::swap(frame_, other.frame_);
std::swap(valid_, other.valid_);
}
const MiniRtcNativeVideoFrame *Get() const { return valid_ ? &frame_ : nullptr; }
explicit operator bool() const { return valid_; }
private:
MiniRtcNativeVideoFrame frame_{};
bool valid_ = false;
};
inline const MiniRtcNativeVideoFrame *GetOpenGlNativeFrame(
const MiniRtcNativeVideoFrame &frame) {
const MiniRtcNativeVideoFrame *native = &frame;
if (native->struct_size < static_cast<uint32_t>(sizeof(MiniRtcNativeVideoFrame)) ||
!native->owner || !native->retain || !native->release ||
!native->copy_to_nv12 || native->width == 0 || native->height == 0 ||
(native->width & 1U) != 0 || (native->height & 1U) != 0 ||
native->width > static_cast<uint32_t>(std::numeric_limits<int>::max()) ||
native->height > static_cast<uint32_t>(std::numeric_limits<int>::max())) {
return nullptr;
}
if (native->type == MiniRtcNativeVideoFrameCpuNv12) {
return native->payload.cpu_nv12.y_plane &&
native->payload.cpu_nv12.uv_plane &&
native->payload.cpu_nv12.y_stride >= native->width &&
native->payload.cpu_nv12.uv_stride >= native->width
? native
: nullptr;
}
if (native->type == MiniRtcNativeVideoFrameCudaNv12) {
return native->payload.cuda_nv12.y_device_pointer != 0 &&
native->payload.cuda_nv12.uv_device_pointer != 0 &&
native->payload.cuda_nv12.y_stride >= native->width &&
native->payload.cuda_nv12.uv_stride >= native->width &&
native->payload.cuda_nv12.context
? native
: nullptr;
}
return nullptr;
}
} // namespace crossdesk
#endif
File diff suppressed because it is too large Load Diff
@@ -1,72 +0,0 @@
#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 cross-platform native NV12 descriptors or tightly packed CPU
// fallback frames from its decode callback thread. CUDA frames rotate through
// fenced OpenGL upload resources; CPU descriptors expose their planes without
// an intermediate queue copy. The UI thread always consumes the newest frame.
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;
SubmitResult SubmitNativeFrame(std::string_view remote_id,
const MiniRtcNativeVideoFrame &frame) 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_
@@ -1,49 +0,0 @@
#include "runtime/gui_runtime.h"
namespace crossdesk {
void GuiRuntime::ResetRemoteServiceStatus(RemoteSession& props) {
{
std::lock_guard lock(props.privacy_status_mutex_);
props.privacy_status_ = {};
props.privacy_status_received_ = false;
props.privacy_command_pending_ = false;
props.privacy_status_tick_ = 0;
}
props.remote_service_status_received_ = false;
props.remote_service_available_ = false;
props.remote_interactive_stage_.clear();
}
void GuiRuntime::ApplyRemoteServiceStatus(RemoteSession& props,
const ServiceStatus& status) {
props.remote_service_status_received_ = true;
props.remote_service_available_ = status.available;
props.remote_interactive_stage_ = status.interactive_stage;
}
GuiRuntime::RemoteUnlockState GuiRuntime::GetRemoteUnlockState(
const RemoteSession& props) const {
if (!props.remote_service_status_received_) {
return RemoteUnlockState::none;
}
if (!props.remote_service_available_) {
return RemoteUnlockState::service_unavailable;
}
if (props.remote_interactive_stage_ == "credential-ui") {
return RemoteUnlockState::credential_ui;
}
if (props.remote_interactive_stage_ == "lock-screen") {
return RemoteUnlockState::lock_screen;
}
if (props.remote_interactive_stage_ == "secure-desktop") {
return RemoteUnlockState::secure_desktop;
}
return RemoteUnlockState::none;
}
#if !defined(_WIN32)
void GuiRuntime::HandleWindowsServiceIntegration() {}
#endif
} // namespace crossdesk
@@ -1,12 +0,0 @@
#include <memory>
#include "platform/common/gui/opengl_video_renderer.h"
#include "platform/video_renderer.h"
namespace crossdesk {
std::unique_ptr<VideoRenderer> CreateVideoRenderer() {
return std::make_unique<OpenGlVideoRenderer>();
}
} // namespace crossdesk
@@ -1,56 +0,0 @@
#include "platform/daemon_backend.h"
#include <errno.h>
#include <signal.h>
#include <sys/wait.h>
#include <unistd.h>
#include <chrono>
#include <thread>
namespace crossdesk::platform {
volatile std::sig_atomic_t g_daemon_stop_requested = 0;
void ResetDaemonStop() { g_daemon_stop_requested = 0; }
void RequestDaemonStop() { g_daemon_stop_requested = 1; }
bool DaemonStopRequested() { return g_daemon_stop_requested != 0; }
DaemonChildResult RunDaemonChild(const std::string& executable_path,
const std::function<bool()>& keep_running) {
DaemonChildResult result;
const pid_t child = fork();
if (child == 0) {
execl(executable_path.c_str(), executable_path.c_str(), "--child",
nullptr);
_exit(1);
}
if (child < 0) return result;
result.started = true;
int status = 0;
pid_t waited = -1;
while (keep_running()) {
waited = waitpid(child, &status, WNOHANG);
if (waited == child || (waited < 0 && errno != EINTR)) break;
std::this_thread::sleep_for(std::chrono::milliseconds(200));
}
if (!keep_running() && waited != child) {
kill(child, SIGTERM);
waited = waitpid(child, &status, 0);
}
if (waited < 0) return result;
if (WIFEXITED(status)) {
result.exit_code = WEXITSTATUS(status);
result.normal_exit = result.exit_code == 0;
} else if (WIFSIGNALED(status)) {
result.terminated_by_signal = true;
result.exit_code = WTERMSIG(status);
}
return result;
}
} // namespace crossdesk::platform
@@ -1,29 +0,0 @@
#ifndef CROSSDESK_PLATFORM_DAEMON_BACKEND_H_
#define CROSSDESK_PLATFORM_DAEMON_BACKEND_H_
#include <csignal>
#include <functional>
#include <string>
namespace crossdesk::platform {
extern volatile std::sig_atomic_t g_daemon_stop_requested;
struct DaemonChildResult {
bool started = false;
bool normal_exit = false;
bool terminated_by_signal = false;
int exit_code = 0;
};
void ResetDaemonStop();
void RequestDaemonStop();
bool DaemonStopRequested();
bool PrepareDaemon();
std::string GetDaemonExecutablePath();
DaemonChildResult RunDaemonChild(const std::string& executable_path,
const std::function<bool()>& keep_running);
} // namespace crossdesk::platform
#endif // CROSSDESK_PLATFORM_DAEMON_BACKEND_H_
@@ -1,65 +0,0 @@
#include "platform/autostart_backend.h"
#include <limits.h>
#include <unistd.h>
#include <cstdlib>
#include <filesystem>
#include <fstream>
namespace crossdesk::platform {
namespace {
std::filesystem::path AutostartPath(const std::string& app_name) {
const char* home = std::getenv("HOME");
if (!home || !home[0]) return {};
return std::filesystem::path(home) / ".config" / "autostart" /
(app_name + ".desktop");
}
} // namespace
std::string GetAutostartExecutablePath() {
char path[PATH_MAX] = {};
const ssize_t count = readlink("/proc/self/exe", path, sizeof(path) - 1);
return count > 0 ? std::string(path, static_cast<size_t>(count))
: std::string();
}
bool EnableAutostart(const std::string& app_name,
const std::string& executable_path) {
const std::filesystem::path path = AutostartPath(app_name);
if (path.empty()) return false;
std::error_code error;
std::filesystem::create_directories(path.parent_path(), error);
if (error) return false;
std::ofstream file(path);
if (!file.is_open()) return false;
file << "[Desktop Entry]\n"
<< "Type=Application\n"
<< "Exec=" << executable_path << "\n"
<< "Hidden=false\n"
<< "NoDisplay=false\n"
<< "X-GNOME-Autostart-enabled=true\n"
<< "Terminal=false\n"
<< "StartupNotify=false\n"
<< "Name=" << app_name << "\n";
file.close();
return file.good();
}
bool DisableAutostart(const std::string& app_name) {
const std::filesystem::path path = AutostartPath(app_name);
if (path.empty()) return false;
std::error_code error;
return std::filesystem::remove(path, error) && !error;
}
bool IsAutostartEnabled(const std::string& app_name) {
const std::filesystem::path path = AutostartPath(app_name);
return !path.empty() && std::filesystem::is_regular_file(path);
}
} // namespace crossdesk::platform
@@ -1,202 +0,0 @@
#include "clipboard.h"
#include <X11/Xatom.h>
#include <X11/Xlib.h>
#include <X11/extensions/Xfixes.h>
#include <chrono>
#include <climits>
#include <string>
#include <thread>
#include "platform/clipboard_backend.h"
#include "rd_log.h"
namespace crossdesk {
std::string Clipboard::GetText() {
Display* display = XOpenDisplay(nullptr);
if (!display) {
LOG_ERROR("Clipboard::GetText: failed to open X display");
return {};
}
Window owner = XGetSelectionOwner(display, XA_PRIMARY);
if (owner == None) {
owner =
XGetSelectionOwner(display, XInternAtom(display, "CLIPBOARD", False));
if (owner == None) {
XCloseDisplay(display);
return {};
}
}
Atom selection = XA_PRIMARY;
Atom target = XInternAtom(display, "UTF8_STRING", False);
if (target == None) target = XA_STRING;
const Window window = XCreateSimpleWindow(
display, DefaultRootWindow(display), 0, 0, 1, 1, 0, 0, 0);
XSelectInput(display, window, PropertyChangeMask);
XConvertSelection(display, selection, target, XA_PRIMARY, window,
CurrentTime);
std::string result;
bool done = false;
while (!done) {
XEvent event = {};
XNextEvent(display, &event);
if (event.type != SelectionNotify) continue;
if (event.xselection.property == None) {
if (selection == XA_PRIMARY) {
selection = XInternAtom(display, "CLIPBOARD", False);
XConvertSelection(display, selection, target, XA_PRIMARY, window,
CurrentTime);
continue;
}
break;
}
Atom actual_type = None;
int actual_format = 0;
unsigned long item_count = 0;
unsigned long bytes_after = 0;
unsigned char* data = nullptr;
if (XGetWindowProperty(display, window, XA_PRIMARY, 0, LONG_MAX / 4,
False, AnyPropertyType, &actual_type,
&actual_format, &item_count, &bytes_after,
&data) == Success) {
if (data) {
result.assign(reinterpret_cast<char*>(data), item_count);
XFree(data);
}
done = true;
}
}
XDestroyWindow(display, window);
XCloseDisplay(display);
return result;
}
bool Clipboard::SetText(const std::string& text) {
Display* display = XOpenDisplay(nullptr);
if (!display) {
LOG_ERROR("Clipboard::SetText: failed to open X display");
return false;
}
const Window window = XCreateSimpleWindow(
display, DefaultRootWindow(display), 0, 0, 1, 1, 0, 0, 0);
const Atom clipboard = XInternAtom(display, "CLIPBOARD", False);
const Atom utf8 = XInternAtom(display, "UTF8_STRING", False);
const Atom targets = XInternAtom(display, "TARGETS", False);
XSetSelectionOwner(display, clipboard, window, CurrentTime);
if (XGetSelectionOwner(display, clipboard) != window) {
XDestroyWindow(display, window);
XCloseDisplay(display);
return false;
}
XChangeProperty(display, window, XA_PRIMARY, utf8, 8, PropModeReplace,
reinterpret_cast<const unsigned char*>(text.data()),
static_cast<int>(text.size()));
while (true) {
XEvent event = {};
XNextEvent(display, &event);
if (event.type == SelectionClear) break;
if (event.type != SelectionRequest) continue;
const XSelectionRequestEvent& request = event.xselectionrequest;
XSelectionEvent response = {};
response.type = SelectionNotify;
response.display = request.display;
response.requestor = request.requestor;
response.selection = request.selection;
response.time = request.time;
response.target = request.target;
response.property = request.property;
if (request.target == targets) {
Atom supported[] = {utf8, XA_STRING, targets};
XChangeProperty(display, request.requestor, request.property, XA_ATOM,
32, PropModeReplace,
reinterpret_cast<unsigned char*>(supported), 3);
} else if (request.target == utf8 || request.target == XA_STRING) {
XChangeProperty(display, request.requestor, request.property,
request.target, 8, PropModeReplace,
reinterpret_cast<const unsigned char*>(text.data()),
static_cast<int>(text.size()));
} else {
response.property = None;
}
XSendEvent(display, request.requestor, False, 0,
reinterpret_cast<XEvent*>(&response));
XSync(display, False);
}
XDestroyWindow(display, window);
XCloseDisplay(display);
return true;
}
bool Clipboard::HasText() {
Display* display = XOpenDisplay(nullptr);
if (!display) return false;
const Atom clipboard = XInternAtom(display, "CLIPBOARD", False);
Window owner = XGetSelectionOwner(display, clipboard);
if (owner == None) owner = XGetSelectionOwner(display, XA_PRIMARY);
XCloseDisplay(display);
return owner != None;
}
namespace platform {
void RunClipboardMonitor(int check_interval_ms) {
Display* display = XOpenDisplay(nullptr);
if (!display) {
LOG_ERROR("Failed to open X display for clipboard monitoring");
return;
}
int event_base = 0;
int error_base = 0;
if (!XFixesQueryExtension(display, &event_base, &error_base)) {
LOG_WARN("XFixes extension not available, falling back to polling");
XCloseDisplay(display);
while (ClipboardMonitoring()) {
std::this_thread::sleep_for(
std::chrono::milliseconds(check_interval_ms > 0 ? check_interval_ms
: 100));
if (ClipboardMonitoring()) HandleClipboardChange();
}
return;
}
const Atom clipboard = XInternAtom(display, "CLIPBOARD", False);
const Window event_window = XCreateSimpleWindow(
display, DefaultRootWindow(display), 0, 0, 1, 1, 0, 0, 0);
XFixesSelectSelectionInput(display, event_window, clipboard,
XFixesSetSelectionOwnerNotifyMask |
XFixesSelectionWindowDestroyNotifyMask |
XFixesSelectionClientCloseNotifyMask);
LOG_INFO("Clipboard event monitoring started (Linux XFixes)");
while (ClipboardMonitoring()) {
while (ClipboardMonitoring() && XPending(display) > 0) {
XEvent event = {};
XNextEvent(display, &event);
if (event.type == event_base + XFixesSelectionNotify) {
HandleClipboardChange();
}
}
std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
XFixesSelectSelectionInput(display, event_window, clipboard, 0);
XDestroyWindow(display, event_window);
XCloseDisplay(display);
}
void WakeClipboardMonitor() {}
} // namespace platform
} // namespace crossdesk
@@ -1,404 +0,0 @@
#include "linux_cursor_shape.h"
#include <remote_action.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
@@ -1,49 +0,0 @@
#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_
@@ -1,58 +0,0 @@
#include "platform/daemon_backend.h"
#include <errno.h>
#include <fcntl.h>
#include <limits.h>
#include <signal.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#include <cstring>
#include <iostream>
namespace crossdesk::platform {
bool PrepareDaemon() {
const bool from_terminal =
isatty(STDIN_FILENO) != 0 || isatty(STDOUT_FILENO) != 0;
pid_t process = fork();
if (process < 0) return false;
if (process > 0) _exit(0);
if (setsid() < 0) return false;
process = fork();
if (process < 0) return false;
if (process > 0) _exit(0);
umask(0);
if (chdir("/") != 0) {
std::cerr << "Failed to change daemon working directory to /: "
<< std::strerror(errno) << std::endl;
return false;
}
const int null_fd = open("/dev/null", O_RDWR);
if (null_fd >= 0) {
dup2(null_fd, STDIN_FILENO);
if (!from_terminal) {
dup2(null_fd, STDOUT_FILENO);
dup2(null_fd, STDERR_FILENO);
}
if (null_fd > STDERR_FILENO) close(null_fd);
}
signal(SIGTERM, [](int) { g_daemon_stop_requested = 1; });
signal(SIGINT, [](int) { g_daemon_stop_requested = 1; });
signal(SIGPIPE, SIG_IGN);
return true;
}
std::string GetDaemonExecutablePath() {
char path[PATH_MAX] = {};
const ssize_t count = readlink("/proc/self/exe", path, sizeof(path) - 1);
return count > 0 ? std::string(path, static_cast<size_t>(count))
: std::string();
}
} // namespace crossdesk::platform
@@ -1,53 +0,0 @@
#include "platform/linux/gui/cuda_graphics.h"
#include <cstdlib>
#include <initializer_list>
#include "rd_log.h"
#include "platform/common/gui/cuda_opengl_interop.h"
namespace crossdesk {
void ConfigureLinuxCudaGraphics(bool hardware_acceleration) {
#if USE_CUDA && (defined(__x86_64__) || defined(__amd64__))
if (!hardware_acceleration) {
return;
}
// Respect explicit GPU/driver selection, including a request to use Mesa
// or software rendering. Native frames can still use the CPU fallback.
for (const char* name : {"__NV_PRIME_RENDER_OFFLOAD",
"__NV_PRIME_RENDER_OFFLOAD_PROVIDER",
"__GLX_VENDOR_LIBRARY_NAME",
"__EGL_VENDOR_LIBRARY_FILENAMES",
"__EGL_VENDOR_LIBRARY_DIRS", "DRI_PRIME",
"LIBGL_ALWAYS_SOFTWARE",
"MESA_LOADER_DRIVER_OVERRIDE"}) {
const char* value = std::getenv(name);
if (value && value[0] != '\0') {
LOG_INFO("Keeping explicit Linux graphics selection: {}={}", name,
value);
return;
}
}
if (!IsCudaDeviceAvailable()) {
return;
}
// Both EGL and GLX need to render on NVIDIA to share CUDA frame buffers.
// These are process-local, not desktop settings.
// https://download.nvidia.com/XFree86/Linux-x86_64/595.45.04/README/primerenderoffload.html
if (setenv("__NV_PRIME_RENDER_OFFLOAD", "1", 1) != 0 ||
setenv("__GLX_VENDOR_LIBRARY_NAME", "nvidia", 1) != 0) {
LOG_WARN("Unable to select NVIDIA PRIME rendering for CUDA video");
return;
}
LOG_INFO("Selected NVIDIA PRIME rendering for CUDA/OpenGL video interop");
#else
(void)hardware_acceleration;
#endif
}
} // namespace crossdesk
@@ -1,18 +0,0 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-07
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _CUDA_GRAPHICS_H_
#define _CUDA_GRAPHICS_H_
namespace crossdesk {
// Call before SDL or Slint initializes EGL/GLX. Hybrid laptops otherwise
// create the GUI context on the integrated GPU while NVDEC uses NVIDIA.
void ConfigureLinuxCudaGraphics(bool hardware_acceleration);
} // namespace crossdesk
#endif
@@ -1,19 +0,0 @@
#include "device_controller_factory.h"
#include "keyboard_capturer.h"
#include "mouse_controller.h"
namespace crossdesk {
DeviceController* DeviceControllerFactory::Create(Device device) {
switch (device) {
case Mouse:
return new PlatformMouseController();
case Keyboard:
return new PlatformKeyboardCapturer();
default:
return nullptr;
}
}
} // namespace crossdesk
@@ -1,403 +0,0 @@
#include "keyboard_capturer.h"
#include <X11/XKBlib.h>
#include <X11/Xatom.h>
#include <X11/Xlib.h>
#include <X11/extensions/XInput2.h>
#include <X11/extensions/XTest.h>
#include <X11/keysym.h>
#include <array>
#include <exception>
#include <errno.h>
#include <poll.h>
#include "keyboard_converter.h"
#include "linux_evdev_keycode.h"
#include "platform.h"
#include "rd_log.h"
#include "windows_key_metadata.h"
namespace crossdesk {
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;
}
return key_sym;
}
PlatformKeyboardCapturer::PlatformKeyboardCapturer()
: display_(nullptr),
running_(false),
use_wayland_portal_(false),
wayland_init_attempted_(false),
dbus_connection_(nullptr) {
XInitThreads();
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;
}
PlatformKeyboardCapturer::~PlatformKeyboardCapturer() {
Unhook();
CleanupWaylandPortal();
if (display_) {
std::lock_guard<std::mutex> lock(x11_injection_mutex_);
XCloseDisplay(display_);
display_ = nullptr;
x11_xtest_available_ = false;
}
}
bool PlatformKeyboardCapturer::RefreshXTestDevices() {
// XTEST identifies injected input. Do not filter by names such as "virtual":
// an xrdp keyboard is a legitimate controller input device.
const Atom property = XInternAtom(capture_display_, "XTEST Device", True);
if (property == None) return false;
int count = 0;
XIDeviceInfo* devices = XIQueryDevice(capture_display_, XIAllDevices, &count);
if (!devices) return false;
xtest_devices_.clear();
for (int i = 0; i < count; ++i) {
if (devices[i].use != XISlaveKeyboard) continue;
Atom type = None;
int format = 0;
unsigned long items = 0, remaining = 0;
unsigned char* value = nullptr;
const int result = XIGetProperty(
capture_display_, devices[i].deviceid, property, 0, 1, False,
XA_INTEGER, &type, &format, &items, &remaining, &value);
if (result == Success && type == XA_INTEGER && format == 8 &&
items == 1 && value && value[0] == 1) {
xtest_devices_.insert(devices[i].deviceid);
}
if (value) XFree(value);
}
XIFreeDeviceInfo(devices);
return true;
}
void PlatformKeyboardCapturer::CaptureKey(int source_id, int x11_keycode,
bool is_down) {
if (!capture_has_focus_ || xtest_devices_.count(source_id) ||
x11_keycode < 8 || x11_keycode > 255)
return;
// The GUI stops capture on focus loss, but its event loop may lag behind
// the native input thread. Never forward keys typed into another window.
Window focus = None;
int revert = 0;
XGetInputFocus(capture_display_, &focus, &revert);
if (focus != capture_focus_) return;
const uint64_t identity = (static_cast<uint64_t>(source_id) << 32) |
static_cast<uint32_t>(x11_keycode);
auto found = captured_keys_.find(identity);
if (found == captured_keys_.end()) {
// A press can have reached the Slint fallback just before native capture
// started. Forward its release too, so that handoff cannot leave a key down.
CapturedKey key;
// Use unshifted key identity, independently of the local input method.
// Preserve it until release, even if the keyboard mapping changes.
for (int level = 0; level < 2 && key.key_code == 0; ++level) {
const KeySym symbol = NormalizeKeySym(XkbKeycodeToKeysym(
capture_display_, static_cast<KeyCode>(x11_keycode), 0, level));
if (symbol == XK_KP_Enter) {
key = {0x0D, 0x1C, true};
} else if (symbol == XK_ISO_Level3_Shift) {
key.key_code = 0xA5;
} else if (symbol == XK_grave) {
key.key_code = 0xC0;
} else if (const auto vk = x11KeySymToVkCode.find(symbol);
vk != x11KeySymToVkCode.end()) {
key.key_code = vk->second;
}
}
if (!key.key_code) return;
if (!key.scan_code) {
LookupWindowsKeyMetadataFromVk(key.key_code, &key.scan_code, &key.extended);
if (!key.scan_code) {
const int evdev = LinuxEvdevKeycodeFromWindowsVk(key.key_code);
for (uint32_t scan = 1; scan <= 0x58; ++scan) {
if (evdev >= 0 &&
LinuxEvdevKeycodeFromWindowsScanCode(scan, false) == evdev) {
key.scan_code = scan;
break;
}
}
}
}
found = captured_keys_.emplace(identity, key).first;
}
const CapturedKey key = found->second;
if (!is_down) captured_keys_.erase(found);
if (on_key_action_) {
on_key_action_(key.key_code, is_down, key.scan_code, key.extended, user_ptr_);
}
}
void PlatformKeyboardCapturer::RunCapture() {
const int fd = ConnectionNumber(capture_display_);
while (running_) {
while (running_ && XPending(capture_display_) > 0) {
XEvent event{};
XNextEvent(capture_display_, &event);
if (event.type == xkb_event_base_) {
auto* xkb = reinterpret_cast<XkbEvent*>(&event);
if (xkb->any.xkb_type == XkbMapNotify)
XkbRefreshKeyboardMapping(&xkb->map);
} else if (event.type == MappingNotify) {
XRefreshKeyboardMapping(&event.xmapping);
} else if (event.type == FocusOut) {
// Keep focus changes in event order too: a fast switch away and back
// may finish before either the GUI or XGetInputFocus observes it.
capture_has_focus_ = false;
for (const auto& [identity, key] : captured_keys_) {
if (on_key_action_)
on_key_action_(key.key_code, false, key.scan_code, key.extended,
user_ptr_);
}
captured_keys_.clear();
raw_presses_.fill({});
} else if (event.type == FocusIn) {
capture_has_focus_ = true;
} else if (event.type == KeyPress && !event.xkey.send_event &&
event.xkey.keycode < raw_presses_.size() &&
event.xkey.time != raw_presses_[event.xkey.keycode].time) {
// Server-generated repeats have no matching raw press. Subscribe to
// core events on the focused window so Slint's core/XIM delivery is
// preserved (selecting XI_KeyPress here would take precedence over it).
const int source = raw_presses_[event.xkey.keycode].source_id;
const uint64_t identity = (static_cast<uint64_t>(source) << 32) |
event.xkey.keycode;
if (captured_keys_.count(identity))
CaptureKey(source, event.xkey.keycode, true);
} else if (event.type == GenericEvent &&
event.xcookie.extension == xi_opcode_ &&
XGetEventData(capture_display_, &event.xcookie)) {
if (event.xcookie.evtype == XI_RawKeyPress ||
event.xcookie.evtype == XI_RawKeyRelease) {
const auto* key = static_cast<XIRawEvent*>(event.xcookie.data);
const bool is_down = event.xcookie.evtype == XI_RawKeyPress;
if (is_down && key->detail >= 0 &&
static_cast<size_t>(key->detail) < raw_presses_.size()) {
// Include filtered XTEST presses, so their core events cannot be
// mistaken for repeats of a physically held key.
raw_presses_[key->detail] = {key->time, key->sourceid};
}
CaptureKey(key->sourceid, key->detail, is_down);
} else if (event.xcookie.evtype == XI_HierarchyChanged &&
!RefreshXTestDevices()) {
LOG_ERROR("Failed to refresh XInput2 keyboard devices");
running_ = false;
}
XFreeEventData(capture_display_, &event.xcookie);
}
}
if (!running_) break;
struct pollfd pfd = {fd, POLLIN, 0};
const int result = poll(&pfd, 1, 50);
if ((result < 0 && errno != EINTR) ||
(result > 0 && (pfd.revents & (POLLERR | POLLHUP | POLLNVAL)))) {
LOG_ERROR("XInput2 keyboard capture event connection failed");
break;
}
}
running_ = false;
}
int PlatformKeyboardCapturer::Hook(OnKeyAction on_key_action, void* user_ptr) {
if (running_) return 0;
Unhook();
capture_display_ = XOpenDisplay(nullptr);
if (!capture_display_ || !on_key_action) {
LOG_ERROR("Cannot open X11 keyboard capture display or callback is empty");
Unhook();
return -1;
}
int event = 0, error = 0;
// XI 2.1 supplies raw-event source IDs, needed to exclude XTEST devices.
int major = 2, minor = 1;
if (!XQueryExtension(capture_display_, "XInputExtension", &xi_opcode_,
&event, &error) ||
XIQueryVersion(capture_display_, &major, &minor) != Success || major < 2 ||
(major == 2 && minor < 1)) {
LOG_WARN("XInput2 keyboard capture is unavailable");
Unhook();
return -1;
}
major = XkbMajorVersion;
minor = XkbMinorVersion;
int xkb_opcode = 0;
if (!XkbQueryExtension(capture_display_, &xkb_opcode, &xkb_event_base_,
&error, &major, &minor) || !RefreshXTestDevices()) {
LOG_WARN("Cannot initialize XKB or enumerate XInput2 keyboard devices");
Unhook();
return -1;
}
int revert = 0;
XGetInputFocus(capture_display_, &capture_focus_, &revert);
if (capture_focus_ == None || capture_focus_ == PointerRoot) {
LOG_WARN("XInput2 keyboard capture requires a focused application window");
Unhook();
return -1;
}
capture_has_focus_ = true;
std::array<unsigned char, XIMaskLen(XI_LASTEVENT)> raw{};
std::array<unsigned char, XIMaskLen(XI_LASTEVENT)> hierarchy{};
XISetMask(raw.data(), XI_RawKeyPress);
XISetMask(raw.data(), XI_RawKeyRelease);
XISetMask(hierarchy.data(), XI_HierarchyChanged);
XIEventMask masks[] = {
{XIAllMasterDevices, static_cast<int>(raw.size()), raw.data()},
{XIAllDevices, static_cast<int>(hierarchy.size()), hierarchy.data()}};
for (int screen = 0; screen < ScreenCount(capture_display_); ++screen) {
if (XISelectEvents(capture_display_, RootWindow(capture_display_, screen),
masks, 2) != Success) {
LOG_WARN("Cannot subscribe to XInput2 keyboard events");
Unhook();
return -1;
}
}
XSelectInput(capture_display_, capture_focus_, KeyPressMask | FocusChangeMask);
XkbSelectEvents(capture_display_, XkbUseCoreKbd, XkbMapNotifyMask,
XkbMapNotifyMask);
XSync(capture_display_, False);
on_key_action_ = on_key_action;
user_ptr_ = user_ptr;
running_ = true;
try {
event_thread_ = std::thread([this] { RunCapture(); });
} catch (const std::exception& error) {
LOG_ERROR("Cannot start XInput2 keyboard capture thread: {}", error.what());
Unhook();
return -1;
}
LOG_INFO("XInput2 keyboard capture started");
return 0;
}
int PlatformKeyboardCapturer::Unhook() {
running_ = false;
if (event_thread_.joinable()) event_thread_.join();
// Closing the private connection removes every subscription and queued
// event, so restarting capture cannot forward keys from the previous focus.
if (capture_display_) {
XCloseDisplay(capture_display_);
capture_display_ = nullptr;
}
on_key_action_ = nullptr;
user_ptr_ = nullptr;
captured_keys_.clear();
raw_presses_.fill({});
xtest_devices_.clear();
capture_focus_ = None;
capture_has_focus_ = false;
return 0;
}
int PlatformKeyboardCapturer::SendKeyboardCommand(int key_code, bool is_down,
uint32_t scan_code, bool extended) {
if (IsWaylandSession()) {
if (!use_wayland_portal_ && !wayland_init_attempted_) {
wayland_init_attempted_ = true;
if (InitWaylandPortal()) {
use_wayland_portal_ = true;
LOG_INFO("Keyboard controller initialized with Wayland portal backend");
} else {
LOG_WARN(
"Wayland keyboard control init failed, falling back to X11/XTest "
"backend");
}
}
if (use_wayland_portal_) {
return SendWaylandKeyboardCommand(key_code, is_down, scan_code, extended);
}
}
if (!display_) {
LOG_ERROR("Display not initialized.");
return -1;
}
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
@@ -1,51 +0,0 @@
#include "platform/path_backend.h"
#include <limits.h>
#include <unistd.h>
#include <cstdlib>
#include <vector>
namespace crossdesk::platform {
namespace {
std::filesystem::path Home() {
const char* value = std::getenv("HOME");
return value ? std::filesystem::path(value) : std::filesystem::path();
}
std::filesystem::path EnvOrDefault(const char* name,
const std::filesystem::path& fallback) {
const char* value = std::getenv(name);
return value ? std::filesystem::path(value) : fallback;
}
} // namespace
std::filesystem::path GetExecutableDirectory() {
std::vector<char> buffer(PATH_MAX);
while (true) {
const ssize_t length =
readlink("/proc/self/exe", buffer.data(), buffer.size() - 1);
if (length <= 0) return {};
if (static_cast<size_t>(length) < buffer.size() - 1) {
buffer[static_cast<size_t>(length)] = '\0';
return std::filesystem::path(buffer.data()).parent_path();
}
buffer.resize(buffer.size() * 2);
}
}
std::filesystem::path GetConfigPath(const std::string& app_name) {
return EnvOrDefault("XDG_CONFIG_HOME", Home() / ".config") / app_name;
}
std::filesystem::path GetCachePath(const std::string& app_name) {
return EnvOrDefault("XDG_CACHE_HOME", Home() / ".cache") / app_name;
}
std::filesystem::path GetLogPath(const std::string& app_name) {
return GetCachePath(app_name) / "logs";
}
} // namespace crossdesk::platform
@@ -1,244 +0,0 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-13
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _PRIVACY_BACKEND_LINUX_H_
#define _PRIVACY_BACKEND_LINUX_H_
#include <poll.h>
#include <spawn.h>
#include <sys/eventfd.h>
#include <sys/socket.h>
#include <sys/wait.h>
#include <unistd.h>
#include <algorithm>
#include <cerrno>
#include <chrono>
#include <cstring>
#include <thread>
#include "privacy_backend.h"
#include "privacy_guard.h"
extern char** environ;
namespace crossdesk {
namespace {
using Clock = std::chrono::steady_clock;
class LinuxPrivacyBackend final : public PrivacyBackend {
public:
LinuxPrivacyBackend() : wake_(eventfd(0, EFD_CLOEXEC | EFD_NONBLOCK)) {}
~LinuxPrivacyBackend() override {
// EOF also recovers after a crash, SIGKILL, or a normal shutdown. Never
// terminate the guard: it must finish restoring the saved display ramps.
if (socket_ >= 0) close(socket_);
if (wake_ >= 0) close(wake_);
if (child_ > 0) {
const pid_t child = child_;
std::thread([child] {
while (waitpid(child, nullptr, 0) < 0 && errno == EINTR) {
}
}).detach();
}
}
PrivacyCapabilities Query() override {
if (wake_ < 0)
return {false, false, "Cannot create Linux privacy wake event"};
if (!StartGuard()) return {false, false, failure_};
if (!Send(LinuxPrivacyCommand::query)) return {false, false, failure_};
// The first query also serves automatic privacy on the first connection.
// Bound process startup so an unavailable helper cannot stall the GUI.
if (!discovered_) {
pollfd fd{socket_, POLLIN, 0};
poll(&fd, 1, 1000);
}
ReadReplies();
return {reply_.supported, reply_.input_supported,
reply_.capability_reason[0] ? reply_.capability_reason
: "Checking Linux privacy support"};
}
bool Enable(bool block_input, const PrivacyScreenText&,
std::string& error) override {
failure_.clear();
emergency_ = false;
if (!Send(block_input ? LinuxPrivacyCommand::enable_blocking
: LinuxPrivacyCommand::enable)) {
error = failure_;
return false;
}
engaged_ = true;
++generation_;
last_reply_ = Clock::now();
recovered_ = false;
reply_.active = false;
return true;
}
void Recover() override {
ReadReplies();
if (recovered_) {
failure_.clear();
return;
}
if (recovering_) return;
if (Send(LinuxPrivacyCommand::recover)) recovering_ = true;
}
bool IsRecovered() const override { return recovered_; }
bool RecoveryPending() const override { return recovering_; }
PrivacyHealth Poll() override {
ReadReplies();
if (engaged_ && Clock::now() - last_reply_ > std::chrono::seconds(1))
failure_ = "Linux privacy guard stopped responding";
if (engaged_ &&
Clock::now() - last_heartbeat_ >= std::chrono::milliseconds(100)) {
Send(LinuxPrivacyCommand::heartbeat);
last_heartbeat_ = Clock::now();
}
const bool emergency = emergency_;
emergency_ = false;
return {failure_, emergency, reply_.active};
}
void WaitForEvents(unsigned timeout_ms) override {
pollfd fds[] = {{wake_, POLLIN, 0}, {socket_, POLLIN, 0}};
poll(fds, 2, static_cast<int>(timeout_ms));
uint64_t value;
if (fds[0].revents & POLLIN) {
while (read(wake_, &value, sizeof(value)) == sizeof(value)) {
}
}
}
void Wake() override {
const uint64_t value = 1;
if (wake_ >= 0) {
const auto ignored = write(wake_, &value, sizeof(value));
(void)ignored;
}
}
private:
bool StartGuard() {
if (socket_ >= 0) return true;
if (child_ > 0) {
const auto result = waitpid(child_, nullptr, WNOHANG);
if (result == 0) return false;
child_ = -1;
}
int sockets[2];
if (socketpair(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC | SOCK_NONBLOCK, 0,
sockets) != 0) {
failure_ = "Cannot create Linux privacy guard socket";
return false;
}
posix_spawn_file_actions_t actions;
int result = posix_spawn_file_actions_init(&actions);
if (result == 0) {
// Close the parent end first: it may occupy the guard's destination fd.
result = posix_spawn_file_actions_addclose(&actions, sockets[0]);
if (result == 0)
result = posix_spawn_file_actions_adddup2(&actions, sockets[1],
kLinuxPrivacyGuardSocket);
if (result == 0 && sockets[1] != kLinuxPrivacyGuardSocket)
result = posix_spawn_file_actions_addclose(&actions, sockets[1]);
if (result == 0) {
char executable[] = "/proc/self/exe";
char* args[] = {executable,
const_cast<char*>(kLinuxPrivacyGuardArgument), nullptr};
result =
posix_spawn(&child_, executable, &actions, nullptr, args, environ);
}
posix_spawn_file_actions_destroy(&actions);
}
close(sockets[1]);
if (result != 0) {
close(sockets[0]);
child_ = -1;
failure_ = std::string("Cannot start Linux privacy guard: ") +
std::strerror(result);
return false;
}
socket_ = sockets[0];
reply_ = {};
discovered_ = false;
generation_ = 0;
failure_.clear();
return true;
}
void LostGuard() {
if (socket_ >= 0) close(socket_);
socket_ = -1;
reply_ = {};
recovering_ = false;
failure_ =
"Linux privacy guard disconnected; local protection is unavailable";
// Do not claim successful recovery after an unexpected guard failure.
// A normal guard reports restoration before closing its socket.
}
bool Send(LinuxPrivacyCommand command) {
if (socket_ < 0) return false;
if (send(socket_, &command, sizeof(command), MSG_NOSIGNAL) ==
sizeof(command))
return true;
if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) LostGuard();
return false;
}
void ReadReplies() {
if (socket_ < 0) return;
for (;;) {
LinuxPrivacyReply next;
const auto bytes = recv(socket_, &next, sizeof(next), 0);
if (bytes < 0 &&
(errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR))
return;
if (bytes != sizeof(next)) {
LostGuard();
return;
}
next.capability_reason[sizeof(next.capability_reason) - 1] = '\0';
next.failure[sizeof(next.failure) - 1] = '\0';
discovered_ = true;
// Ignore queued capability/heartbeat replies from before this enable.
// A matching generation can also acknowledge watchdog-driven recovery.
if (next.generation != generation_) continue;
reply_ = next;
last_reply_ = Clock::now();
emergency_ = emergency_ || next.emergency;
if (next.failure[0]) failure_ = next.failure;
recovered_ = next.recovered;
if (next.recovered) {
recovering_ = false;
engaged_ = false;
}
}
}
int wake_ = -1;
int socket_ = -1;
pid_t child_ = -1;
LinuxPrivacyReply reply_;
uint64_t generation_ = 0;
bool engaged_ = false;
bool discovered_ = false;
bool recovering_ = false;
bool recovered_ = true;
bool emergency_ = false;
std::string failure_;
Clock::time_point last_heartbeat_{};
Clock::time_point last_reply_{};
};
} // namespace
std::unique_ptr<PrivacyBackend> CreateLinuxPrivacyBackend() {
return std::make_unique<LinuxPrivacyBackend>();
}
} // namespace crossdesk
#endif
@@ -1,721 +0,0 @@
/* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. */
#include "privacy_guard.h"
#include <X11/XKBlib.h>
#include <X11/Xatom.h>
#include <X11/extensions/XInput.h>
#include <X11/extensions/XInput2.h>
#include <X11/extensions/XTest.h>
#include <X11/extensions/Xfixes.h>
#include <X11/extensions/Xrandr.h>
#include <X11/keysym.h>
#include <fcntl.h>
#include <poll.h>
#include <sys/socket.h>
#include <unistd.h>
#include <algorithm>
#include <array>
#include <cerrno>
#include <chrono>
#include <csignal>
#include <cstdlib>
#include <cstring>
#include <map>
#include <memory>
#include <string>
#include <tuple>
#include <vector>
namespace crossdesk {
namespace {
using Clock = std::chrono::steady_clock;
using Gamma = std::unique_ptr<XRRCrtcGamma, decltype(&XRRFreeGamma)>;
using Resources =
std::unique_ptr<XRRScreenResources, decltype(&XRRFreeScreenResources)>;
using CrtcInfo = std::unique_ptr<XRRCrtcInfo, decltype(&XRRFreeCrtcInfo)>;
volatile std::sig_atomic_t stopping = 0;
int x_error = 0;
void StopGuard(int) { stopping = 1; }
int OnXError(Display*, XErrorEvent* event) {
x_error = event->error_code;
return 0;
}
template <size_t N>
void SetText(char (&dest)[N], const std::string& text) {
std::strncpy(dest, text.c_str(), N - 1);
dest[N - 1] = '\0';
}
struct Output {
Window root = 0;
RRCrtc crtc = 0;
RRMode mode = 0;
int x = 0, y = 0;
unsigned width = 0, height = 0;
Rotation rotation = 0;
std::vector<RROutput> connectors;
Gamma saved{nullptr, XRRFreeGamma};
bool SameLayout(const Output& other) const {
return std::tie(root, crtc, mode, x, y, width, height, rotation,
connectors) == std::tie(other.root, other.crtc, other.mode,
other.x, other.y, other.width,
other.height, other.rotation,
other.connectors);
}
};
struct Device {
int id = 0, use = 0, attachment = 0;
bool keyboard = false, touch = false;
std::string name;
bool operator==(const Device& other) const {
return std::tie(id, use, attachment, keyboard, touch, name) ==
std::tie(other.id, other.use, other.attachment, other.keyboard,
other.touch, other.name);
}
};
bool IsBlack(const XRRCrtcGamma* gamma) {
if (!gamma || gamma->size <= 0) return false;
for (int i = 0; i < gamma->size; ++i)
if (gamma->red[i] || gamma->green[i] || gamma->blue[i]) return false;
return true;
}
// This class lives only in the exec'd guard process. Its Xlib error handler is
// isolated from the GUI and capture threads' process-global error handlers.
class NativePrivacy {
public:
~NativePrivacy() {
Recover();
if (display_) XCloseDisplay(display_);
}
void Query() {
reply_.supported = reply_.input_supported = false;
std::string error;
if (!Initialize(error)) {
SetText(reply_.capability_reason, error);
return;
}
std::vector<Output> outputs;
std::vector<Device> devices;
if (!ReadOutputs(outputs, true, error) || !ReadDevices(devices, error)) {
SetText(reply_.capability_reason, error);
return;
}
const bool keyboard = std::any_of(devices.begin(), devices.end(),
[](const auto& d) { return d.keyboard; });
if (!keyboard) {
SetText(reply_.capability_reason,
"No local keyboard available for privacy recovery");
return;
}
reply_.supported = true;
// XIGrabDevice does not suppress XI 2.2 touch delivery. Do not advertise
// input protection for touchscreens without a separate touch grab.
reply_.input_supported = std::none_of(
devices.begin(), devices.end(), [](const auto& d) { return d.touch; });
SetText(reply_.capability_reason,
reply_.input_supported
? "X11 privacy black screen available; local recovery: "
"Ctrl+Alt+Shift+F12"
: "X11 privacy black screen available; touchscreen input "
"blocking is unavailable");
}
void Enable(bool block_input) {
++reply_.generation;
reply_.failure[0] = '\0';
reply_.emergency = false;
if (!reply_.recovered) {
Fail("Linux privacy resources are still in use");
return;
}
Query();
if (!reply_.supported || (block_input && !reply_.input_supported)) {
Fail(reply_.capability_reason);
return;
}
std::string error;
keys_.clear();
if (!Acquire(error) || !ReadOutputs(outputs_, true, error) ||
!ReadDevices(devices_, error) || !ProtectInput(block_input, error)) {
Fail(error);
return;
}
// No display ramp is changed until every original ramp has been saved.
// Gamma is applied at scanout, after XGetImage/DRM framebuffer capture.
// There is deliberately no cover window to contaminate remote frames.
x_error = 0;
for (const auto& output : outputs_) {
Gamma black(XRRAllocGamma(output.saved->size), XRRFreeGamma);
if (!black) {
Fail("Cannot allocate Linux privacy display ramp");
return;
}
std::fill_n(black->red, black->size, 0);
std::fill_n(black->green, black->size, 0);
std::fill_n(black->blue, black->size, 0);
XRRSetCrtcGamma(display_, output.crtc, black.get());
}
for (int i = 0; i < ScreenCount(display_); ++i) {
XFixesHideCursor(display_, RootWindow(display_, i));
hidden_roots_.push_back(RootWindow(display_, i));
}
XSync(display_, False);
if (x_error) {
Fail("Cannot blank Linux displays or hide the local cursor");
return;
}
// Active slave grabs temporarily detach devices. Snapshot after grabbing,
// and monitor subsequent hardware/hierarchy changes during protection.
if (!ReadDevices(devices_, error)) {
Fail(error);
return;
}
reply_.active = true;
Check();
}
void Recover() {
reply_.active = false;
if (!display_) {
reply_.recovered = true;
return;
}
for (int id : grabbed_) XIUngrabDevice(display_, id, CurrentTime);
grabbed_.clear();
for (Window root : hidden_roots_) XFixesShowCursor(display_, root);
hidden_roots_.clear();
// Keep failed restorations for retry. Removed CRTCs no longer own a local
// output; all still-present CRTCs must acknowledge the original ramp.
for (auto it = outputs_.begin(); it != outputs_.end();) {
x_error = 0;
Resources resources(XRRGetScreenResourcesCurrent(display_, it->root),
XRRFreeScreenResources);
if (!resources) {
++it;
continue;
}
const bool exists =
std::find(resources->crtcs, resources->crtcs + resources->ncrtc,
it->crtc) != resources->crtcs + resources->ncrtc;
if (exists && it->saved) {
XRRSetCrtcGamma(display_, it->crtc, it->saved.get());
XSync(display_, False);
if (x_error) {
++it;
continue;
}
}
it = outputs_.erase(it);
}
keys_.clear();
if (outputs_.empty() && owner_) {
XDestroyWindow(display_, owner_);
owner_ = 0;
}
XSync(display_, False);
reply_.recovered = outputs_.empty() && !owner_;
}
void Fail(const std::string& reason) {
SetText(reply_.failure, reason);
Recover();
}
void Poll() {
if (!display_) return;
while (XPending(display_)) {
XEvent event{};
XNextEvent(display_, &event);
if (event.type != GenericEvent || event.xcookie.extension != xi_opcode_ ||
!XGetEventData(display_, &event.xcookie))
continue;
if (reply_.active) {
const int type = event.xcookie.evtype;
if (type == XI_KeyPress || type == XI_KeyRelease) {
const auto* key = static_cast<XIDeviceEvent*>(event.xcookie.data);
Key(key->sourceid, key->detail, type == XI_KeyPress);
} else if (!blocking_ &&
(type == XI_RawKeyPress || type == XI_RawKeyRelease)) {
const auto* key = static_cast<XIRawEvent*>(event.xcookie.data);
Key(key->sourceid, key->detail, type == XI_RawKeyPress);
}
}
XFreeEventData(display_, &event.xcookie);
}
if (reply_.active &&
Clock::now() - checked_ >= std::chrono::milliseconds(100))
Check();
if (!reply_.active && !reply_.recovered) Recover();
}
const LinuxPrivacyReply& Reply() const { return reply_; }
int Connection() const { return display_ ? ConnectionNumber(display_) : -1; }
void AcknowledgeEmergency() { reply_.emergency = false; }
private:
bool Initialize(std::string& error) {
if (initialized_) return true;
const char* session = std::getenv("XDG_SESSION_TYPE");
const char* wayland = std::getenv("WAYLAND_DISPLAY");
if ((session && std::strcmp(session, "wayland") == 0) ||
(wayland && *wayland)) {
error =
"Linux privacy screen requires an X11 session; Wayland is "
"unsupported";
return false;
}
const char* address = std::getenv("DISPLAY");
if (!address ||
(*address != ':' && std::strncmp(address, "unix:", 5) != 0)) {
error = "Linux privacy screen requires a local X11 display";
return false;
}
if (!display_) display_ = XOpenDisplay(nullptr);
if (!display_) {
error = "Cannot open the local X11 display";
return false;
}
int event = 0, code = 0, opcode = 0;
int major = 1, minor = 3;
if (XQueryExtension(display_, "XWAYLAND", &opcode, &event, &code)) {
error = "XWayland cannot protect native Wayland displays";
return false;
}
if (!XRRQueryExtension(display_, &event, &code) ||
!XRRQueryVersion(display_, &major, &minor) || major < 1 ||
(major == 1 && minor < 3)) {
error = "Linux privacy screen requires RandR 1.3 display gamma control";
return false;
}
major = 4;
minor = 0;
if (!XFixesQueryExtension(display_, &event, &code) ||
!XFixesQueryVersion(display_, &major, &minor) || major < 4) {
error = "Linux privacy screen requires XFixes 4 cursor hiding";
return false;
}
major = 2;
minor = 2;
if (!XQueryExtension(display_, "XInputExtension", &xi_opcode_, &event,
&code) ||
XIQueryVersion(display_, &major, &minor) != Success || major < 2) {
error = "Linux privacy screen requires XInput 2 local input monitoring";
return false;
}
xtest_property_ = XInternAtom(display_, "XTEST Device", True);
if (!xtest_property_ ||
!XTestQueryExtension(display_, &event, &code, &major, &minor)) {
error = "Cannot identify X11 remote input devices";
return false;
}
selection_ = XInternAtom(display_, "_CROSSDESK_PRIVACY_GUARD", False);
std::array<unsigned char, XIMaskLen(XI_LASTEVENT)> bits{};
XISetMask(bits.data(), XI_RawKeyPress);
XISetMask(bits.data(), XI_RawKeyRelease);
XIEventMask mask{XIAllDevices, static_cast<int>(bits.size()), bits.data()};
x_error = 0;
for (int i = 0; i < ScreenCount(display_); ++i)
XISelectEvents(display_, RootWindow(display_, i), &mask, 1);
XSync(display_, False);
if (x_error) {
error = "Cannot monitor the local privacy recovery shortcut";
return false;
}
initialized_ = true;
return true;
}
bool ReadOutputs(std::vector<Output>& outputs, bool save,
std::string& error) {
outputs.clear();
x_error = 0;
for (int screen = 0; screen < ScreenCount(display_); ++screen) {
const Window root = RootWindow(display_, screen);
Resources resources(XRRGetScreenResourcesCurrent(display_, root),
XRRFreeScreenResources);
if (!resources) {
error = "Cannot enumerate Linux display outputs";
return false;
}
for (int i = 0; i < resources->ncrtc; ++i) {
CrtcInfo info(
XRRGetCrtcInfo(display_, resources.get(), resources->crtcs[i]),
XRRFreeCrtcInfo);
if (!info) {
error = "Cannot inspect Linux display controller";
return false;
}
if (!info->mode || !info->noutput) continue;
Output output;
output.root = root;
output.crtc = resources->crtcs[i];
output.mode = info->mode;
output.x = info->x;
output.y = info->y;
output.width = info->width;
output.height = info->height;
output.rotation = info->rotation;
output.connectors.assign(info->outputs, info->outputs + info->noutput);
std::sort(output.connectors.begin(), output.connectors.end());
if (save) {
output.saved.reset(XRRGetCrtcGamma(display_, output.crtc));
if (!output.saved || output.saved->size <= 0 ||
IsBlack(output.saved.get())) {
error =
"Display gamma control is unavailable or the display is "
"already blank";
return false;
}
}
outputs.push_back(std::move(output));
}
}
XSync(display_, False);
if (x_error || outputs.empty()) {
error = "No controllable Linux display outputs";
return false;
}
return true;
}
bool ReadDevices(std::vector<Device>& devices, std::string& error) {
devices.clear();
x_error = 0;
int count = 0;
XIDeviceInfo* info = XIQueryDevice(display_, XIAllDevices, &count);
if (!info) {
error = "Cannot enumerate local X11 input devices";
return false;
}
for (int i = 0; i < count; ++i) {
const auto& d = info[i];
if (!d.enabled || d.use == XIMasterPointer || d.use == XIMasterKeyboard)
continue;
Atom type = None;
int format = 0;
unsigned long items = 0, remaining = 0;
unsigned char* value = nullptr;
const int result =
XIGetProperty(display_, d.deviceid, xtest_property_, 0, 1, False,
XA_INTEGER, &type, &format, &items, &remaining, &value);
const bool injected = result == Success && type == XA_INTEGER &&
format == 8 && items == 1 && value && value[0] == 1;
if (value) XFree(value);
if (injected) continue;
Device device;
device.id = d.deviceid;
device.use = d.use;
device.attachment = d.attachment;
device.name = d.name ? d.name : "";
for (int c = 0; c < d.num_classes; ++c) {
device.keyboard |= d.classes[c]->type == XIKeyClass;
device.touch |= d.classes[c]->type == XITouchClass;
}
devices.push_back(std::move(device));
}
XIFreeDeviceInfo(info);
XSync(display_, False);
if (x_error) {
error = "Cannot verify local X11 input devices";
return false;
}
return true;
}
bool Acquire(std::string& error) {
// A second instance must not save the first instance's black ramps as its
// originals. Selection ownership and creation are atomic on this server.
XGrabServer(display_);
if (XGetSelectionOwner(display_, selection_) == None) {
owner_ = XCreateSimpleWindow(display_, DefaultRootWindow(display_), 0, 0,
1, 1, 0, 0, 0);
XSetSelectionOwner(display_, selection_, owner_, CurrentTime);
}
XUngrabServer(display_);
XSync(display_, False);
if (!owner_ || XGetSelectionOwner(display_, selection_) != owner_) {
error = "Another CrossDesk instance owns Linux privacy protection";
return false;
}
reply_.recovered = false;
return true;
}
bool ProtectInput(bool block_input, std::string& error) {
blocking_ = block_input;
if (!block_input) return true;
for (const auto& device : devices_) {
if (device.touch) {
error = "Touchscreen privacy input blocking is unavailable";
return false;
}
if (device.keyboard) {
// Preserve held local modifiers for the emergency chord independently
// of the releases sent to the master keyboard below.
XDevice* keyboard = XOpenDevice(display_, device.id);
XDeviceState* state =
keyboard ? XQueryDeviceState(display_, keyboard) : nullptr;
if (!state) {
if (keyboard) XCloseDevice(display_, keyboard);
error = "Cannot inspect held local keyboard input";
return false;
}
XInputClass* input = state->data;
for (int i = 0; i < state->num_classes; ++i) {
if (input->c_class == KeyClass) {
const auto* keys = reinterpret_cast<XKeyState*>(input);
for (int key = 0; key < 256; ++key)
keys_[device.id][key] = keys->keys[key / 8] & (1 << (key % 8));
}
input = reinterpret_cast<XInputClass*>(
reinterpret_cast<char*>(input) + input->length);
}
XFreeDeviceState(state);
XCloseDevice(display_, keyboard);
}
std::array<unsigned char, XIMaskLen(XI_LASTEVENT)> bits{};
for (int type : {XI_KeyPress, XI_KeyRelease, XI_ButtonPress,
XI_ButtonRelease, XI_Motion})
XISetMask(bits.data(), type);
XIEventMask mask{device.id, static_cast<int>(bits.size()), bits.data()};
const int result = XIGrabDevice(
display_, device.id, DefaultRootWindow(display_), CurrentTime, None,
GrabModeAsync, GrabModeAsync, False, &mask);
if (result != GrabSuccess) {
error = "Cannot grab every local X11 input device";
return false;
}
grabbed_.push_back(device.id);
}
return ReleaseHeldInput(error);
}
bool ReleaseHeldInput(std::string& error) {
// Slave grabs float physical devices, but their previous presses can
// remain on the master. Release them after all grabs are installed so a
// held local Ctrl/button cannot alter the remote controller's next input.
int original_pointer = 0, count = 0;
XIGetClientPointer(display_, None, &original_pointer);
XIDeviceInfo* masters = XIQueryDevice(display_, XIAllMasterDevices, &count);
if (!masters) {
error = "Cannot inspect held X11 input";
return false;
}
x_error = 0;
bool ok = true;
for (int i = 0; i < count; ++i) {
if (masters[i].use != XIMasterPointer || !masters[i].enabled) continue;
XISetClientPointer(display_, None, masters[i].deviceid);
char keys[32]{};
XQueryKeymap(display_, keys);
for (int key = 8; key < 256; ++key)
if (keys[key / 8] & (1 << (key % 8)))
ok &= XTestFakeKeyEvent(display_, key, False, CurrentTime) != 0;
Window root = None, child = None;
double root_x = 0, root_y = 0, win_x = 0, win_y = 0;
XIButtonState buttons{};
XIModifierState modifiers{};
XIGroupState group{};
XIQueryPointer(display_, masters[i].deviceid, DefaultRootWindow(display_),
&root, &child, &root_x, &root_y, &win_x, &win_y, &buttons,
&modifiers, &group);
for (int button = 1; button < buttons.mask_len * 8; ++button)
if (XIMaskIsSet(buttons.mask, button))
ok &= XTestFakeButtonEvent(display_, button, False, CurrentTime) != 0;
if (buttons.mask) XFree(buttons.mask);
XSync(display_, False);
XQueryKeymap(display_, keys);
for (char pressed : keys) ok &= pressed == 0;
buttons = {};
XIQueryPointer(display_, masters[i].deviceid, DefaultRootWindow(display_),
&root, &child, &root_x, &root_y, &win_x, &win_y, &buttons,
&modifiers, &group);
for (int byte = 0; byte < buttons.mask_len; ++byte)
ok &= buttons.mask[byte] == 0;
if (buttons.mask) XFree(buttons.mask);
}
XIFreeDeviceInfo(masters);
if (original_pointer) XISetClientPointer(display_, None, original_pointer);
XSync(display_, False);
if (!ok || x_error) {
error = "Cannot release held X11 input for privacy";
return false;
}
return true;
}
void Key(int source, int keycode, bool down) {
if (keycode < 0 || keycode >= 256 ||
std::none_of(devices_.begin(), devices_.end(), [source](const auto& d) {
return d.id == source && d.keyboard;
}))
return;
keys_[source][keycode] = down;
bool ctrl = false, alt = false, shift = false;
for (const auto& [id, keys] : keys_) {
for (int code = 0; code < 256; ++code) {
if (!keys[code]) continue;
const KeySym symbol = XkbKeycodeToKeysym(display_, code, 0, 0);
ctrl |= symbol == XK_Control_L || symbol == XK_Control_R;
alt |= symbol == XK_Alt_L || symbol == XK_Alt_R;
shift |= symbol == XK_Shift_L || symbol == XK_Shift_R;
}
}
if (down && ctrl && alt && shift &&
XkbKeycodeToKeysym(display_, keycode, 0, 0) == XK_F12) {
reply_.emergency = true;
Recover();
}
}
void Check() {
checked_ = Clock::now();
std::string error;
std::vector<Output> current;
std::vector<Device> devices;
if (!ReadOutputs(current, false, error) || !ReadDevices(devices, error)) {
Fail(error);
return;
}
if (current.size() != outputs_.size() ||
!std::equal(
current.begin(), current.end(), outputs_.begin(),
[](const auto& a, const auto& b) { return a.SameLayout(b); })) {
Fail("Display topology changed; Linux privacy has been turned off");
return;
}
if (devices != devices_) {
Fail("Local input devices changed; Linux privacy has been turned off");
return;
}
x_error = 0;
for (const auto& output : outputs_) {
Gamma ramp(XRRGetCrtcGamma(display_, output.crtc), XRRFreeGamma);
if (!IsBlack(ramp.get())) {
Fail(
"Display gamma protection was changed; Linux privacy is "
"unavailable");
return;
}
}
XSync(display_, False);
if (x_error || XGetSelectionOwner(display_, selection_) != owner_)
Fail("Cannot verify Linux privacy protection");
}
Display* display_ = nullptr;
bool initialized_ = false;
bool blocking_ = false;
int xi_opcode_ = 0;
Atom xtest_property_ = None, selection_ = None;
Window owner_ = 0;
std::vector<Output> outputs_;
std::vector<Device> devices_;
std::vector<int> grabbed_;
std::vector<Window> hidden_roots_;
std::map<int, std::array<bool, 256>> keys_;
LinuxPrivacyReply reply_;
Clock::time_point checked_{};
};
} // namespace
int RunLinuxPrivacyGuard() {
const int socket = kLinuxPrivacyGuardSocket;
int type = 0;
socklen_t length = sizeof(type);
if (getsockopt(socket, SOL_SOCKET, SO_TYPE, &type, &length) != 0 ||
type != SOCK_SEQPACKET)
return 1;
fcntl(socket, F_SETFD, FD_CLOEXEC);
fcntl(socket, F_SETFL, O_NONBLOCK);
struct sigaction action {};
action.sa_handler = StopGuard;
sigemptyset(&action.sa_mask);
sigaction(SIGTERM, &action, nullptr);
sigaction(SIGINT, &action, nullptr);
sigaction(SIGHUP, &action, nullptr);
XSetErrorHandler(OnXError);
NativePrivacy privacy;
auto heartbeat = Clock::now();
bool connected = true;
bool dirty = false;
while (connected && !stopping) {
pollfd fds[] = {{socket, POLLIN, 0}, {privacy.Connection(), POLLIN, 0}};
poll(fds, 2, privacy.Reply().active ? 25 : 250);
if (fds[0].revents & (POLLERR | POLLHUP | POLLNVAL)) break;
for (int i = 0; i < 64; ++i) {
LinuxPrivacyCommand command;
const auto bytes = recv(socket, &command, sizeof(command), 0);
if (bytes < 0 &&
(errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR))
break;
if (bytes != sizeof(command)) {
connected = false;
break;
}
heartbeat = Clock::now();
switch (command) {
case LinuxPrivacyCommand::query:
if (!privacy.Reply().active) privacy.Query();
break;
case LinuxPrivacyCommand::enable:
privacy.Enable(false);
break;
case LinuxPrivacyCommand::enable_blocking:
privacy.Enable(true);
break;
case LinuxPrivacyCommand::recover:
privacy.Recover();
break;
case LinuxPrivacyCommand::heartbeat:
break;
default:
connected = false;
break;
}
dirty = true;
}
if (!connected || stopping) break;
const auto before = privacy.Reply();
privacy.Poll();
if (privacy.Reply().active &&
Clock::now() - heartbeat > std::chrono::seconds(3))
privacy.Fail(
"Privacy controller stopped responding; local desktop and input "
"restored");
const auto& after = privacy.Reply();
dirty |= before.active != after.active ||
before.recovered != after.recovered ||
before.emergency != after.emergency ||
std::strcmp(before.failure, after.failure) != 0;
if (dirty) {
const auto& reply = privacy.Reply();
if (send(socket, &reply, sizeof(reply), MSG_NOSIGNAL) == sizeof(reply)) {
dirty = false;
privacy.AcknowledgeEmergency();
} else if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR)
break;
}
}
// Keep trying if the X server temporarily rejected restoration, even after
// the parent exited. Disconnecting releases XI grabs and cursor hiding too.
do {
privacy.Recover();
if (!privacy.Reply().recovered) poll(nullptr, 0, 250);
} while (!privacy.Reply().recovered);
const auto& reply = privacy.Reply();
const auto ignored = send(socket, &reply, sizeof(reply), MSG_NOSIGNAL);
(void)ignored;
close(socket);
return 0;
}
} // namespace crossdesk
@@ -1,42 +0,0 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-13
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _PRIVACY_GUARD_H_
#define _PRIVACY_GUARD_H_
#include <cstdint>
namespace crossdesk {
inline constexpr char kLinuxPrivacyGuardArgument[] = "--linux-privacy-guard";
inline constexpr int kLinuxPrivacyGuardSocket = 3;
// Private protocol over an inherited SOCK_SEQPACKET socket. No listening
// socket, shell, elevated privileges or separately installed helper is needed.
enum class LinuxPrivacyCommand : uint8_t {
query,
enable,
enable_blocking,
recover,
heartbeat,
};
struct LinuxPrivacyReply {
uint64_t generation = 0;
bool supported = false;
bool input_supported = false;
bool active = false;
bool recovered = true;
bool emergency = false;
char capability_reason[256]{};
char failure[256]{};
};
int RunLinuxPrivacyGuard();
} // namespace crossdesk
#endif
@@ -1,11 +0,0 @@
#include "screen_capturer_factory.h"
#include "screen_capturer_linux.h"
namespace crossdesk {
ScreenCapturer* ScreenCapturerFactory::Create() {
return new ScreenCapturerLinux();
}
} // namespace crossdesk
@@ -1,11 +0,0 @@
#include "speaker_capturer_factory.h"
#include "speaker_capturer_linux.h"
namespace crossdesk {
SpeakerCapturer* SpeakerCapturerFactory::Create() {
return new SpeakerCapturerLinux();
}
} // namespace crossdesk
@@ -1,242 +0,0 @@
#include "speaker_capturer_linux.h"
#include <pulse/error.h>
#include <pulse/introspect.h>
#include <string>
#include <utility>
#include "rd_log.h"
namespace crossdesk {
namespace {
constexpr size_t kFrameSizeBytes = 480 * sizeof(int16_t);
constexpr auto kStartupTimeout = std::chrono::seconds(2);
constexpr auto kPollInterval = std::chrono::milliseconds(5);
void ReleaseOperation(pa_operation* operation) {
if (!operation) return;
if (pa_operation_get_state(operation) == PA_OPERATION_RUNNING)
pa_operation_cancel(operation);
pa_operation_unref(operation);
}
} // namespace
SpeakerCapturerLinux::~SpeakerCapturerLinux() { Destroy(); }
int SpeakerCapturerLinux::Init(speaker_data_cb cb) {
if (inited_) return 0;
cb_ = std::move(cb);
inited_ = true;
return 0;
}
// Use nonblocking iterations instead of pa_threaded_mainloop_wait(): a server
// that never replies, disappears, or never reaches STREAM_READY must not leave
// startup/Stop waiting for a signal that will never be delivered.
bool SpeakerCapturerLinux::PumpUntil(
const std::function<bool()>& ready,
std::chrono::steady_clock::time_point deadline) {
while (!stop_flag_) {
if (ready()) return true;
if (std::chrono::steady_clock::now() >= deadline ||
!PA_CONTEXT_IS_GOOD(pa_context_get_state(context_)))
return false;
if (pa_mainloop_iterate(mainloop_, 0, nullptr) < 0) return false;
std::this_thread::sleep_for(kPollInterval);
}
return false;
}
bool SpeakerCapturerLinux::Prepare() {
const auto deadline = std::chrono::steady_clock::now() + kStartupTimeout;
mainloop_ = pa_mainloop_new();
if (!mainloop_) return false;
context_ =
pa_context_new(pa_mainloop_get_api(mainloop_), "CrossDesk system audio");
if (!context_ ||
pa_context_connect(context_, nullptr, PA_CONTEXT_NOFLAGS, nullptr) < 0)
return false;
if (!PumpUntil(
[&] { return pa_context_get_state(context_) == PA_CONTEXT_READY; },
deadline))
return false;
struct ServerResult {
bool done = false;
std::string sink;
} server;
pa_operation* operation = pa_context_get_server_info(
context_,
[](pa_context*, const pa_server_info* info, void* data) {
auto& result = *static_cast<ServerResult*>(data);
if (info && info->default_sink_name)
result.sink = info->default_sink_name;
result.done = true;
},
&server);
const bool got_server =
operation && PumpUntil([&] { return server.done; }, deadline);
ReleaseOperation(operation);
if (!got_server || server.sink.empty()) return false;
// Query the actual monitor name; a changed/default device need not follow
// the "<sink>.monitor" naming convention.
struct SinkResult {
bool done = false;
std::string monitor;
} sink;
operation = pa_context_get_sink_info_by_name(
context_, server.sink.c_str(),
[](pa_context*, const pa_sink_info* info, int eol, void* data) {
auto& result = *static_cast<SinkResult*>(data);
if (info && info->monitor_source_name)
result.monitor = info->monitor_source_name;
if (eol != 0) result.done = true;
},
&sink);
const bool got_sink =
operation && PumpUntil([&] { return sink.done; }, deadline);
ReleaseOperation(operation);
if (!got_sink || sink.monitor.empty()) return false;
const pa_sample_spec spec{PA_SAMPLE_S16LE, 48000, 1};
stream_ = pa_stream_new(context_, "System audio", &spec, nullptr);
if (!stream_) return false;
pa_stream_set_read_callback(
stream_,
[](pa_stream* stream, size_t, void* data) {
static_cast<SpeakerCapturerLinux*>(data)->ReadAudio(stream);
},
this);
pa_buffer_attr attr{};
attr.maxlength = static_cast<uint32_t>(-1);
attr.fragsize = static_cast<uint32_t>(kFrameSizeBytes);
if (pa_stream_connect_record(stream_, sink.monitor.c_str(), &attr,
PA_STREAM_ADJUST_LATENCY) < 0)
return false;
if (!PumpUntil(
[&] {
const auto state = pa_stream_get_state(stream_);
return state == PA_STREAM_READY || !PA_STREAM_IS_GOOD(state);
},
deadline) ||
pa_stream_get_state(stream_) != PA_STREAM_READY)
return false;
LOG_INFO("System audio monitor ready: {}", sink.monitor);
return true;
}
void SpeakerCapturerLinux::Run() {
const bool ready = Prepare();
running_ = ready && !stop_flag_;
{
std::lock_guard<std::mutex> lock(state_mtx_);
startup_success_ = running_;
startup_done_ = true;
}
startup_wake_.notify_one();
if (!ready && !stop_flag_) {
LOG_ERROR(
"PulseAudio capture startup failed or timed out: {}",
context_ ? pa_strerror(pa_context_errno(context_)) : "no context");
}
while (running_ && !stop_flag_) {
if (pa_mainloop_iterate(mainloop_, 0, nullptr) < 0 ||
pa_context_get_state(context_) != PA_CONTEXT_READY ||
pa_stream_get_state(stream_) != PA_STREAM_READY)
break;
std::this_thread::sleep_for(kPollInterval);
}
running_ = false;
Cleanup();
}
int SpeakerCapturerLinux::Start() {
if (!inited_) return -1;
if (IsRunning()) return 0;
Stop();
stop_flag_ = false;
paused_ = false;
{
std::lock_guard<std::mutex> lock(state_mtx_);
startup_done_ = false;
startup_success_ = false;
}
mainloop_thread_ = std::thread([this] { Run(); });
std::unique_lock<std::mutex> lock(state_mtx_);
const bool started =
startup_wake_.wait_for(lock, kStartupTimeout + std::chrono::seconds(1),
[&] { return startup_done_; }) &&
startup_success_;
lock.unlock();
if (!started) {
Stop();
return -1;
}
return 0;
}
int SpeakerCapturerLinux::Stop() {
stop_flag_ = true;
if (mainloop_thread_.joinable()) mainloop_thread_.join();
running_ = false;
return 0;
}
int SpeakerCapturerLinux::Destroy() {
Stop();
cb_ = nullptr;
inited_ = false;
return 0;
}
void SpeakerCapturerLinux::ReadAudio(pa_stream* stream) {
const void* data = nullptr;
size_t size = 0;
if (pa_stream_peek(stream, &data, &size) < 0 || size == 0) return;
if (data && !paused_ && !stop_flag_ && cb_) {
const auto* bytes = static_cast<const uint8_t*>(data);
frame_cache_.insert(frame_cache_.end(), bytes, bytes + size);
size_t consumed = 0;
while (!stop_flag_ && frame_cache_.size() - consumed >= kFrameSizeBytes) {
cb_(frame_cache_.data() + consumed, kFrameSizeBytes, "audio");
consumed += kFrameSizeBytes;
}
frame_cache_.erase(frame_cache_.begin(), frame_cache_.begin() + consumed);
} else {
frame_cache_.clear();
}
// Drop holes and paused data too, otherwise the read queue cannot advance.
pa_stream_drop(stream);
}
void SpeakerCapturerLinux::Cleanup() {
if (stream_) {
pa_stream_set_read_callback(stream_, nullptr, nullptr);
pa_stream_disconnect(stream_);
pa_stream_unref(stream_);
stream_ = nullptr;
}
if (context_) {
pa_context_disconnect(context_);
pa_context_unref(context_);
context_ = nullptr;
}
if (mainloop_) {
pa_mainloop_free(mainloop_);
mainloop_ = nullptr;
}
frame_cache_.clear();
}
bool SpeakerCapturerLinux::IsRunning() const { return running_; }
int SpeakerCapturerLinux::Pause() {
paused_ = true;
return 0;
}
int SpeakerCapturerLinux::Resume() {
paused_ = false;
return 0;
}
} // namespace crossdesk
@@ -1,62 +0,0 @@
/*
* @Author: DI JUNKUN
* @Date: 2025-07-15
* Copyright (c) 2025 by DI JUNKUN, All Rights Reserved.
*/
#ifndef CROSSDESK_SPEAKER_CAPTURER_LINUX_H_
#define CROSSDESK_SPEAKER_CAPTURER_LINUX_H_
#include <pulse/pulseaudio.h>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <functional>
#include <mutex>
#include <thread>
#include <vector>
#include "speaker_capturer.h"
namespace crossdesk {
class SpeakerCapturerLinux : public SpeakerCapturer {
public:
SpeakerCapturerLinux() = default;
~SpeakerCapturerLinux() override;
int Init(speaker_data_cb cb) override;
int Destroy() override;
int Start() override;
int Stop() override;
bool IsRunning() const override;
int Pause();
int Resume();
private:
void Run();
bool Prepare();
bool PumpUntil(const std::function<bool()>& ready,
std::chrono::steady_clock::time_point deadline);
void Cleanup();
void ReadAudio(pa_stream* stream);
speaker_data_cb cb_;
bool inited_ = false;
std::atomic<bool> paused_{false};
std::atomic<bool> stop_flag_{false};
std::atomic<bool> running_{false};
std::thread mainloop_thread_;
std::mutex state_mtx_;
std::condition_variable startup_wake_;
bool startup_done_ = false;
bool startup_success_ = false;
// These objects are created, driven and freed exclusively by Run(). Stop
// only changes an atomic flag and joins, never races PulseAudio callbacks.
pa_mainloop* mainloop_ = nullptr;
pa_context* context_ = nullptr;
pa_stream* stream_ = nullptr;
std::vector<uint8_t> frame_cache_;
};
} // namespace crossdesk
#endif
@@ -1,81 +0,0 @@
#include "platform.h"
#include <fcntl.h>
#include <net/if.h>
#include <sys/ioctl.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include "rd_log.h"
namespace crossdesk {
std::string GetMac() {
const int socket_fd = socket(AF_INET, SOCK_DGRAM, 0);
if (socket_fd < 0) return {};
char buffer[1024] = {};
struct ifconf config = {};
config.ifc_len = sizeof(buffer);
config.ifc_buf = buffer;
if (ioctl(socket_fd, SIOCGIFCONF, &config) < 0) {
close(socket_fd);
return {};
}
std::string result;
struct ifreq request = {};
struct ifreq* cursor = config.ifc_req;
const struct ifreq* end = cursor + config.ifc_len / sizeof(struct ifreq);
for (; cursor != end; ++cursor) {
std::strncpy(request.ifr_name, cursor->ifr_name,
sizeof(request.ifr_name) - 1);
if (ioctl(socket_fd, SIOCGIFFLAGS, &request) < 0 ||
(request.ifr_flags & IFF_LOOPBACK) ||
ioctl(socket_fd, SIOCGIFHWADDR, &request) < 0) {
continue;
}
char address[16] = {};
int length = 0;
for (int index = 0; index < 6; ++index) {
length += std::snprintf(
address + length, sizeof(address) - length, "%.2X",
static_cast<unsigned char>(request.ifr_hwaddr.sa_data[index]));
}
result = address;
break;
}
close(socket_fd);
return result;
}
std::string GetHostName() {
char hostname[256] = {};
if (gethostname(hostname, sizeof(hostname)) == -1) {
LOG_ERROR("gethostname failed");
return {};
}
return hostname;
}
bool IsWaylandSession() {
const char* session_type = std::getenv("XDG_SESSION_TYPE");
if (session_type) {
if (std::strcmp(session_type, "wayland") == 0 ||
std::strcmp(session_type, "Wayland") == 0) {
return true;
}
if (std::strcmp(session_type, "x11") == 0 ||
std::strcmp(session_type, "X11") == 0) {
return false;
}
}
const char* display = std::getenv("WAYLAND_DISPLAY");
return display && display[0] != '\0';
}
} // namespace crossdesk
@@ -1,74 +0,0 @@
#include "platform/autostart_backend.h"
#include <limits.h>
#include <mach-o/dyld.h>
#include <cstdlib>
#include <filesystem>
#include <fstream>
namespace crossdesk::platform {
namespace {
std::filesystem::path AutostartPath(const std::string& app_name) {
const char* home = std::getenv("HOME");
if (!home || !home[0]) return {};
return std::filesystem::path(home) / "Library" / "LaunchAgents" /
(app_name + ".plist");
}
} // namespace
std::string GetAutostartExecutablePath() {
char path[PATH_MAX] = {};
uint32_t size = sizeof(path);
if (_NSGetExecutablePath(path, &size) != 0) return {};
char resolved[PATH_MAX] = {};
return realpath(path, resolved) ? std::string(resolved) : std::string(path);
}
bool EnableAutostart(const std::string& app_name,
const std::string& executable_path) {
const std::filesystem::path path = AutostartPath(app_name);
if (path.empty()) return false;
std::error_code error;
std::filesystem::create_directories(path.parent_path(), error);
if (error) return false;
std::ofstream file(path);
if (!file.is_open()) return false;
file << R"(<?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>Label</key>
<string>)"
<< app_name << R"(</string>
<key>ProgramArguments</key>
<array>
<string>)"
<< executable_path << R"(</string>
</array>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>)";
file.close();
return file.good();
}
bool DisableAutostart(const std::string& app_name) {
const std::filesystem::path path = AutostartPath(app_name);
if (path.empty()) return false;
std::error_code error;
return std::filesystem::remove(path, error) && !error;
}
bool IsAutostartEnabled(const std::string& app_name) {
const std::filesystem::path path = AutostartPath(app_name);
return !path.empty() && std::filesystem::is_regular_file(path);
}
} // namespace crossdesk::platform
@@ -1,20 +0,0 @@
#include "platform/daemon_backend.h"
#include <limits.h>
#include <mach-o/dyld.h>
#include <cstdlib>
namespace crossdesk::platform {
bool PrepareDaemon() { return true; }
std::string GetDaemonExecutablePath() {
char path[PATH_MAX] = {};
uint32_t size = sizeof(path);
if (_NSGetExecutablePath(path, &size) != 0) return {};
char resolved[PATH_MAX] = {};
return realpath(path, resolved) ? std::string(resolved) : std::string(path);
}
} // namespace crossdesk::platform
@@ -1,87 +0,0 @@
#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. VideoToolbox CVPixelBuffers
// are sampled directly through CVMetalTextureCache; packed CPU NV12 remains a
// fallback for software decoding and cached snapshots. AppKit attachment and
// rendering are main-thread operations, while frame submission is safe 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;
// Retains an IOSurface-backed VideoToolbox CVPixelBuffer until the Metal
// command buffer using it has completed.
SubmitResult SubmitNativeFrame(std::string_view remote_id,
const MiniRtcNativeVideoFrame& frame) 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
@@ -1,245 +0,0 @@
#include "runtime/cursor_state_provider.h"
#include <remote_action.h>
#if defined(__APPLE__)
#import <AppKit/AppKit.h>
#import <CoreGraphics/CGRemoteOperation.h>
#include <cstdint>
#include <vector>
#include "runtime/cursor_position.h"
#include "../../privacy/privacy_cursor_mac.h"
namespace crossdesk {
namespace {
// Quartz reports the arrow cursor's event hotspot. The visible apex in the
// current macOS system artwork is about 1.6 logical points above that hotspot
// (NSCursor.arrowCursor has a (5, 5) hotspot). Send this as presentation-only
// metadata so controllers can align their glyph without changing input.
constexpr double kDefaultArrowVisualTipYOffset = -1.6;
struct CursorFingerprint {
uint64_t pixel_hash = 0;
size_t width = 0;
size_t height = 0;
size_t bytes_per_row = 0;
CFIndex pixel_bytes = 0;
NSPoint hot_spot = NSZeroPoint;
};
struct KnownCursor {
CursorFingerprint fingerprint;
RemoteCursorShape shape = RemoteCursorShape::default_cursor;
};
bool FingerprintCursor(NSCursor* cursor, CursorFingerprint* fingerprint) {
if (!cursor || !fingerprint || !cursor.image) return false;
NSRect proposed_rect = {NSZeroPoint, cursor.image.size};
CGImageRef image =
[cursor.image CGImageForProposedRect:&proposed_rect
context:nil
hints:nil];
if (!image) return false;
CGDataProviderRef provider = CGImageGetDataProvider(image);
if (!provider) return false;
CFDataRef data = CGDataProviderCopyData(provider);
if (!data) return false;
// NSCursor.currentSystemCursor returns a fresh NSCursor/NSImage wrapper on
// each query. Hash the decoded CGImage pixels so it can still be matched to
// AppKit's semantic cursor instances without encoding a large TIFF every
// frame.
constexpr uint64_t kFnvOffsetBasis = 1469598103934665603ULL;
constexpr uint64_t kFnvPrime = 1099511628211ULL;
uint64_t hash = kFnvOffsetBasis;
const UInt8* bytes = CFDataGetBytePtr(data);
const CFIndex length = CFDataGetLength(data);
for (CFIndex index = 0; index < length; ++index) {
hash ^= bytes[index];
hash *= kFnvPrime;
}
fingerprint->pixel_hash = hash;
fingerprint->width = CGImageGetWidth(image);
fingerprint->height = CGImageGetHeight(image);
fingerprint->bytes_per_row = CGImageGetBytesPerRow(image);
fingerprint->pixel_bytes = length;
fingerprint->hot_spot = cursor.hotSpot;
CFRelease(data);
return true;
}
bool SameCursor(const CursorFingerprint& left,
const CursorFingerprint& right) {
return left.pixel_hash == right.pixel_hash && left.width == right.width &&
left.height == right.height &&
left.bytes_per_row == right.bytes_per_row &&
left.pixel_bytes == right.pixel_bytes &&
NSEqualPoints(left.hot_spot, right.hot_spot);
}
void AddKnownCursor(std::vector<KnownCursor>* cursors, NSCursor* cursor,
RemoteCursorShape shape) {
CursorFingerprint fingerprint;
if (FingerprintCursor(cursor, &fingerprint)) {
cursors->push_back({fingerprint, shape});
}
}
std::vector<KnownCursor> BuildKnownCursors() {
std::vector<KnownCursor> cursors;
cursors.reserve(40);
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
AddKnownCursor(&cursors, NSCursor.arrowCursor,
RemoteCursorShape::default_cursor);
AddKnownCursor(&cursors, NSCursor.pointingHandCursor,
RemoteCursorShape::pointer);
AddKnownCursor(&cursors, NSCursor.crosshairCursor,
RemoteCursorShape::crosshair);
AddKnownCursor(&cursors, NSCursor.IBeamCursor, RemoteCursorShape::text);
AddKnownCursor(&cursors, NSCursor.IBeamCursorForVerticalLayout,
RemoteCursorShape::text);
AddKnownCursor(&cursors, NSCursor.operationNotAllowedCursor,
RemoteCursorShape::not_allowed);
AddKnownCursor(&cursors, NSCursor.dragLinkCursor, RemoteCursorShape::alias);
AddKnownCursor(&cursors, NSCursor.dragCopyCursor, RemoteCursorShape::copy);
AddKnownCursor(&cursors, NSCursor.openHandCursor, RemoteCursorShape::grab);
AddKnownCursor(&cursors, NSCursor.closedHandCursor,
RemoteCursorShape::grabbing);
AddKnownCursor(&cursors, NSCursor.resizeLeftRightCursor,
RemoteCursorShape::ew_resize);
AddKnownCursor(&cursors, NSCursor.resizeUpDownCursor,
RemoteCursorShape::ns_resize);
AddKnownCursor(&cursors, NSCursor.resizeUpCursor,
RemoteCursorShape::n_resize);
AddKnownCursor(&cursors, NSCursor.resizeRightCursor,
RemoteCursorShape::e_resize);
AddKnownCursor(&cursors, NSCursor.resizeDownCursor,
RemoteCursorShape::s_resize);
AddKnownCursor(&cursors, NSCursor.resizeLeftCursor,
RemoteCursorShape::w_resize);
#pragma clang diagnostic pop
#if __MAC_OS_X_VERSION_MAX_ALLOWED >= 150000
if (@available(macOS 15.0, *)) {
// Window-frame resize artwork differs from the legacy divider cursors
// above. Include every edge/corner and both one-way variants. Inward
// arrows point toward the frame's center; outward arrows point away.
struct FrameResizeCursorMapping {
NSCursorFrameResizePosition position;
RemoteCursorShape bidirectional;
RemoteCursorShape inward;
RemoteCursorShape outward;
};
constexpr FrameResizeCursorMapping mappings[] = {
{NSCursorFrameResizePositionTop, RemoteCursorShape::ns_resize,
RemoteCursorShape::s_resize, RemoteCursorShape::n_resize},
{NSCursorFrameResizePositionLeft, RemoteCursorShape::ew_resize,
RemoteCursorShape::e_resize, RemoteCursorShape::w_resize},
{NSCursorFrameResizePositionBottom, RemoteCursorShape::ns_resize,
RemoteCursorShape::n_resize, RemoteCursorShape::s_resize},
{NSCursorFrameResizePositionRight, RemoteCursorShape::ew_resize,
RemoteCursorShape::w_resize, RemoteCursorShape::e_resize},
{NSCursorFrameResizePositionTopLeft, RemoteCursorShape::nwse_resize,
RemoteCursorShape::se_resize, RemoteCursorShape::nw_resize},
{NSCursorFrameResizePositionTopRight, RemoteCursorShape::nesw_resize,
RemoteCursorShape::sw_resize, RemoteCursorShape::ne_resize},
{NSCursorFrameResizePositionBottomLeft, RemoteCursorShape::nesw_resize,
RemoteCursorShape::ne_resize, RemoteCursorShape::sw_resize},
{NSCursorFrameResizePositionBottomRight, RemoteCursorShape::nwse_resize,
RemoteCursorShape::nw_resize, RemoteCursorShape::se_resize},
};
for (const FrameResizeCursorMapping& mapping : mappings) {
AddKnownCursor(
&cursors,
[NSCursor frameResizeCursorFromPosition:mapping.position
inDirections:NSCursorFrameResizeDirectionsAll],
mapping.bidirectional);
AddKnownCursor(
&cursors,
[NSCursor frameResizeCursorFromPosition:mapping.position
inDirections:NSCursorFrameResizeDirectionsInward],
mapping.inward);
AddKnownCursor(
&cursors,
[NSCursor frameResizeCursorFromPosition:mapping.position
inDirections:NSCursorFrameResizeDirectionsOutward],
mapping.outward);
}
}
#endif
return cursors;
}
RemoteCursorShape ShapeFromMacCursor(NSCursor* cursor) {
CursorFingerprint fingerprint;
if (!FingerprintCursor(cursor, &fingerprint)) {
return RemoteCursorShape::default_cursor;
}
// Sampling begins from the UI tick, after AppKit has initialized its cursor
// catalog. Cache those fingerprints because only the current cursor needs
// to be decoded on subsequent frames.
static const std::vector<KnownCursor> known_cursors = BuildKnownCursors();
for (const KnownCursor& known : known_cursors) {
if (SameCursor(fingerprint, known.fingerprint)) return known.shape;
}
return RemoteCursorShape::default_cursor;
}
} // namespace
struct CursorStateProvider::Impl {};
CursorStateProvider::CursorStateProvider() : impl_(std::make_unique<Impl>()) {}
CursorStateProvider::~CursorStateProvider() = default;
bool CursorStateProvider::Sample(const std::vector<DisplayInfo>& displays,
int preferred_display, CursorState* state) {
if (!state) return false;
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
NSCursor* cursor = NSCursor.currentSystemCursor;
const bool privacy_hidden = IsMacPrivacyCursorHidden();
const bool visible = CGCursorIsVisible() || privacy_hidden;
if (!cursor && privacy_hidden) cursor = NSCursor.arrowCursor;
#pragma clang diagnostic pop
state->seq = 0;
state->visible = visible && cursor != nil;
state->shape = state->visible ? ShapeFromMacCursor(cursor)
: RemoteCursorShape::none;
ResetCursorPosition(state);
CGEventRef event = CGEventCreate(nullptr);
if (event) {
const CGPoint location = CGEventGetLocation(event);
NormalizeCursorPosition(location.x, location.y, displays,
preferred_display, state);
if (state->position_valid && state->visible &&
state->shape == RemoteCursorShape::default_cursor &&
state->display_id >= 0 &&
state->display_id < static_cast<int>(displays.size())) {
const double display_height =
std::max(displays[state->display_id].height, 1);
state->visual_offset_y = static_cast<float>(
kDefaultArrowVisualTipYOffset / display_height);
}
CFRelease(event);
}
return true;
}
} // namespace crossdesk
#endif
@@ -1,13 +0,0 @@
#include "platform/video_renderer.h"
#include <memory>
#include "platform/macos/gui/metal_video_renderer.h"
namespace crossdesk {
std::unique_ptr<VideoRenderer> CreateVideoRenderer() {
return std::make_unique<MacMetalVideoRenderer>();
}
} // namespace crossdesk
@@ -1,19 +0,0 @@
#include "device_controller_factory.h"
#include "keyboard_capturer.h"
#include "mouse_controller.h"
namespace crossdesk {
DeviceController* DeviceControllerFactory::Create(Device device) {
switch (device) {
case Mouse:
return new PlatformMouseController();
case Keyboard:
return new PlatformKeyboardCapturer();
default:
return nullptr;
}
}
} // namespace crossdesk
@@ -1,17 +0,0 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-13
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _MAC_INJECTED_EVENT_H_
#define _MAC_INJECTED_EVENT_H_
#include <cstdint>
namespace crossdesk {
// Shared by the keyboard/mouse injectors and the privacy input tap.
inline constexpr int64_t kCrossDeskInjectedEvent = 0x43524f5353444553;
}
#endif
@@ -1,48 +0,0 @@
#include "platform/path_backend.h"
#include <mach-o/dyld.h>
#include <cstdlib>
#include <vector>
namespace crossdesk::platform {
namespace {
std::filesystem::path Home() {
const char* value = std::getenv("HOME");
return value ? std::filesystem::path(value) : std::filesystem::path();
}
std::filesystem::path EnvOrDefault(const char* name,
const std::filesystem::path& fallback) {
const char* value = std::getenv(name);
return value ? std::filesystem::path(value) : fallback;
}
} // namespace
std::filesystem::path GetExecutableDirectory() {
uint32_t size = 0;
_NSGetExecutablePath(nullptr, &size);
std::vector<char> buffer(size + 1);
if (_NSGetExecutablePath(buffer.data(), &size) != 0) return {};
std::error_code error;
std::filesystem::path executable =
std::filesystem::weakly_canonical(buffer.data(), error);
if (error) executable = buffer.data();
return executable.parent_path();
}
std::filesystem::path GetConfigPath(const std::string& app_name) {
return EnvOrDefault("XDG_CONFIG_HOME", Home() / ".config") / app_name;
}
std::filesystem::path GetCachePath(const std::string& app_name) {
return EnvOrDefault("XDG_CACHE_HOME", Home() / ".cache") / app_name;
}
std::filesystem::path GetLogPath(const std::string& app_name) {
return Home() / "Library" / "Logs" / app_name;
}
} // namespace crossdesk::platform
@@ -1,400 +0,0 @@
/* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. */
#import <AppKit/AppKit.h>
#import <ApplicationServices/ApplicationServices.h>
#include <algorithm>
#include <chrono>
#include <deque>
#include <functional>
#include <cstdlib>
#include <libproc.h>
#include <mutex>
#include <utility>
#include <unistd.h>
#include "privacy_backend.h"
#include "privacy_capture_state.h"
#include "privacy_cover_view.h"
#include "privacy_cursor_mac.h"
#include "rd_log.h"
#include "../input/mac_injected_event.h"
// These windows never take keyboard focus. Remote clicks pass through them;
// physical input is intercepted independently on the controller's run loop.
@interface CrossDeskPrivacyPanel : NSPanel
@end
@implementation CrossDeskPrivacyPanel
- (BOOL)canBecomeKeyWindow { return NO; }
- (BOOL)canBecomeMainWindow { return NO; }
@end
namespace crossdesk {
namespace {
using Clock = std::chrono::steady_clock;
std::mutex tasks_mutex;
std::deque<std::function<void()>> tasks;
std::mutex capture_mutex;
MacPrivacyCaptureState capture_state;
void OnMain(std::function<void()> task) {
{
std::lock_guard lock(tasks_mutex);
tasks.push_back(std::move(task));
}
dispatch_async(dispatch_get_main_queue(), ^{ FlushMacPrivacyTasks(); });
}
void PublishCovers(std::vector<uint32_t> windows, uint32_t cursor_window = 0) {
{
std::lock_guard lock(capture_mutex);
capture_state.excluded_windows = std::move(windows);
capture_state.cursor_window = cursor_window;
++capture_state.revision;
}
dispatch_async(dispatch_get_main_queue(), ^{
[NSNotificationCenter.defaultCenter postNotificationName:@"CrossDeskPrivacyCoversChanged"
object:nil];
});
}
struct Monitor {
CGDirectDisplayID id = 0;
CGRect bounds{}; // CoreGraphics desktop coordinates (points).
NSRect frame{}; // AppKit desktop coordinates (bottom left).
CrossDeskPrivacyPanel* __strong cover = nil;
};
struct WindowState {
std::mutex mutex;
std::vector<Monitor> monitors;
bool recovered = true;
bool recovering = false;
bool covers_shown = false;
bool check_queued = false;
MacPrivacyCursor cursor;
std::string failure;
Clock::time_point checked = Clock::now();
};
CrossDeskPrivacyPanel* MakePanel(NSRect frame, NSInteger level) {
auto* panel = [[CrossDeskPrivacyPanel alloc]
initWithContentRect:frame
styleMask:NSWindowStyleMaskBorderless | NSWindowStyleMaskNonactivatingPanel
backing:NSBackingStoreBuffered defer:NO];
panel.releasedWhenClosed = NO;
panel.hidesOnDeactivate = NO;
panel.opaque = YES;
panel.backgroundColor = NSColor.blackColor;
panel.hasShadow = NO;
panel.ignoresMouseEvents = YES;
panel.level = level;
panel.animationBehavior = NSWindowAnimationBehaviorNone;
panel.collectionBehavior = NSWindowCollectionBehaviorCanJoinAllSpaces |
NSWindowCollectionBehaviorFullScreenAuxiliary |
NSWindowCollectionBehaviorStationary |
NSWindowCollectionBehaviorIgnoresCycle;
// Exclusion is explicit in ScreenCaptureKit. SharingNone alone does not
// exclude windows on recent macOS releases, and can hide them from discovery.
panel.sharingType = NSWindowSharingReadOnly;
return panel;
}
bool IsSystemCursorPlane(NSDictionary* info, CGRect bounds) {
const auto layer = [info[(__bridge NSString*)kCGWindowLayer] intValue];
// WindowServer's cursor plane also carries small recording indicators.
// Validate both its geometry and executable identity before ignoring it.
if (layer != CGWindowLevelForKey(kCGCursorWindowLevelKey) ||
bounds.size.width <= 0 || bounds.size.height <= 0 ||
bounds.size.width > 512 || bounds.size.height > 512)
return false;
char executable[PROC_PIDPATHINFO_MAXSIZE]{};
const auto pid = [info[(__bridge NSString*)kCGWindowOwnerPID] intValue];
static const std::string system_executable = [] {
char resolved[PROC_PIDPATHINFO_MAXSIZE]{};
return realpath("/System/Library/PrivateFrameworks/SkyLight.framework/Resources/WindowServer",
resolved) ? std::string(resolved) : std::string();
}();
return proc_pidpath(pid, executable, sizeof(executable)) > 0 &&
!system_executable.empty() && system_executable == executable;
}
std::string CoverageFailure(const std::vector<Monitor>& monitors) {
uint32_t count = 0;
if (CGGetActiveDisplayList(0, nullptr, &count) != kCGErrorSuccess || !count)
return "No active macOS desktop; privacy screen unavailable";
std::vector<CGDirectDisplayID> displays(count);
if (CGGetActiveDisplayList(count, displays.data(), &count) != kCGErrorSuccess ||
count != monitors.size())
return "Display topology changed; turn privacy off to recover";
for (const auto& m : monitors) {
if (std::find(displays.begin(), displays.end(), m.id) == displays.end() ||
!CGRectEqualToRect(CGDisplayBounds(m.id), m.bounds) ||
!m.cover.visible || !m.cover.isOnActiveSpace ||
!NSEqualRects(m.cover.frame, m.frame) || m.cover.alphaValue != 1.0)
return "Privacy cover or display geometry changed; privacy screen unavailable";
}
NSDictionary* session = CFBridgingRelease(CGSessionCopyCurrentDictionary());
if (!session || ![session[(__bridge NSString*)kCGSessionOnConsoleKey] boolValue])
return "macOS session is not on console; privacy screen unavailable";
NSArray* windows = CFBridgingRelease(CGWindowListCopyWindowInfo(
kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements,
kCGNullWindowID));
if (!windows) return "Unable to verify macOS privacy window order";
for (const auto& m : monitors) {
bool found = false;
for (NSDictionary* info in windows) {
const auto window = [info[(__bridge NSString*)kCGWindowNumber] unsignedIntValue];
if (window == m.cover.windowNumber) { found = true; break; }
if ([info[(__bridge NSString*)kCGWindowAlpha] doubleValue] <= 0) continue;
CGRect bounds{};
if (!CGRectMakeWithDictionaryRepresentation(
(__bridge CFDictionaryRef)info[(__bridge NSString*)kCGWindowBounds], &bounds))
return "Unable to verify a window above the privacy cover";
if (CGRectIntersectsRect(bounds, m.bounds)) {
if (IsSystemCursorPlane(info, bounds)) continue;
LOG_WARN("macOS privacy overlap: window={}, pid={}, layer={}, alpha={}, "
"bounds=({}, {}, {}, {}), cover={}, cover_level={}",
window,
[info[(__bridge NSString*)kCGWindowOwnerPID] intValue],
[info[(__bridge NSString*)kCGWindowLayer] intValue],
[info[(__bridge NSString*)kCGWindowAlpha] doubleValue],
bounds.origin.x, bounds.origin.y, bounds.size.width, bounds.size.height,
m.cover.windowNumber, m.cover.level);
return "A system or application window overlaps the privacy cover; privacy screen unavailable";
}
}
if (!found) return "Privacy cover disappeared; privacy screen unavailable";
}
return {};
}
class MacPrivacyBackend final : public PrivacyBackend {
public:
MacPrivacyBackend() : state_(std::make_shared<WindowState>()) {
loop_ = CFRunLoopGetCurrent();
CFRetain(loop_);
CFRunLoopSourceContext context{};
wake_source_ = CFRunLoopSourceCreate(nullptr, 0, &context);
CFRunLoopAddSource(loop_, wake_source_, kCFRunLoopDefaultMode);
}
~MacPrivacyBackend() override {
Recover();
CFRunLoopRemoveSource(loop_, wake_source_, kCFRunLoopDefaultMode);
CFRelease(wake_source_);
CFRelease(loop_);
}
PrivacyCapabilities Query() override {
if (@available(macOS 14.0, *)) {
if (!CGPreflightScreenCaptureAccess())
return {false, false, "Grant Screen Recording permission to use privacy screen"};
if (!AXIsProcessTrusted())
return {false, false, "Grant Accessibility permission to protect local input and enable local recovery"};
if (!MacPrivacyCursor::Supported())
return {false, false, "Background cursor control unavailable on this macOS version"};
return {true, true, "macOS privacy screen available (ScreenCaptureKit)"};
}
return {false, false, "Privacy screen requires macOS 14 or newer"};
}
bool Enable(bool block_input, const PrivacyScreenText& text, std::string& error) override {
const auto caps = Query();
if (!caps.overlay) { error = caps.reason; return false; }
block_input_ = block_input;
emergency_ = false;
tap_failed_ = false;
CGEventMask mask = CGEventMaskBit(kCGEventKeyDown) | CGEventMaskBit(kCGEventKeyUp) |
CGEventMaskBit(kCGEventFlagsChanged) | CGEventMaskBit(kCGEventLeftMouseDown) |
CGEventMaskBit(kCGEventLeftMouseUp) | CGEventMaskBit(kCGEventRightMouseDown) |
CGEventMaskBit(kCGEventRightMouseUp) | CGEventMaskBit(kCGEventMouseMoved) |
CGEventMaskBit(kCGEventLeftMouseDragged) | CGEventMaskBit(kCGEventRightMouseDragged) |
CGEventMaskBit(kCGEventOtherMouseDown) | CGEventMaskBit(kCGEventOtherMouseUp) |
CGEventMaskBit(kCGEventOtherMouseDragged) | CGEventMaskBit(kCGEventScrollWheel) |
CGEventMaskBit(14); // NX_SYSDEFINED: media/brightness keys.
tap_ = CGEventTapCreate(kCGHIDEventTap, kCGHeadInsertEventTap,
kCGEventTapOptionDefault, mask, Input, this);
if (!tap_) { error = "Cannot install macOS privacy input tap"; return false; }
tap_source_ = CFMachPortCreateRunLoopSource(nullptr, tap_, 0);
if (!tap_source_) { error = "Cannot create macOS privacy input run loop"; return false; }
CFRunLoopAddSource(loop_, tap_source_, kCFRunLoopDefaultMode);
CGEventTapEnable(tap_, true);
{
std::lock_guard lock(state_->mutex);
state_->recovered = false;
state_->failure.clear();
state_->checked = Clock::now();
}
auto state = state_;
OnMain([state, text] {
@autoreleasepool {
std::lock_guard lock(state->mutex);
if (!state->cursor.Prepare()) {
state->failure = "Cannot prepare the privacy cursor";
return;
}
std::vector<uint32_t> covers;
for (NSScreen* screen in NSScreen.screens) {
Monitor m;
m.id = [screen.deviceDescription[@"NSScreenNumber"] unsignedIntValue];
m.bounds = CGDisplayBounds(m.id);
m.frame = screen.frame;
NSView* content = CreateMacPrivacyCoverView(
NSMakeRect(0, 0, m.frame.size.width, m.frame.size.height),
[NSString stringWithUTF8String:text.unlock_hint.c_str()]);
if (!content) {
state->failure = "Could not load the CrossDesk privacy screen logo";
break;
}
m.cover = MakePanel(m.frame, CGWindowLevelForKey(kCGScreenSaverWindowLevelKey) + 1);
m.cover.contentView = content;
// Register transparent windows first. Make them opaque only after
// SCK has acknowledged exclusion, avoiding a black remote frame.
m.cover.alphaValue = 0;
[m.cover orderFrontRegardless];
covers.push_back(static_cast<uint32_t>(m.cover.windowNumber));
state->monitors.push_back(std::move(m));
}
PublishCovers(std::move(covers), state->cursor.WindowId());
if (state->monitors.empty()) state->failure = "No active macOS desktop";
state->checked = Clock::now();
}
});
return true;
}
void Recover() override {
if (tap_) { CGEventTapEnable(tap_, false); CFMachPortInvalidate(tap_); }
if (tap_source_) {
CFRunLoopRemoveSource(loop_, tap_source_, kCFRunLoopDefaultMode);
CFRelease(tap_source_); tap_source_ = nullptr;
}
if (tap_) { CFRelease(tap_); tap_ = nullptr; }
{
std::lock_guard lock(state_->mutex);
if (state_->recovered || state_->recovering) return;
state_->recovering = true;
}
auto state = state_;
OnMain([state] {
std::lock_guard lock(state->mutex);
for (auto& m : state->monitors) {
[m.cover orderOut:nil]; [m.cover close]; m.cover = nil;
}
state->monitors.clear();
state->covers_shown = false;
state->cursor.Restore();
PublishCovers({});
state->failure.clear();
state->recovering = false;
state->recovered = true;
});
}
bool IsRecovered() const override {
std::lock_guard lock(state_->mutex);
return state_->recovered && !tap_;
}
bool RecoveryPending() const override {
std::lock_guard lock(state_->mutex);
return state_->recovering;
}
PrivacyHealth Poll() override {
if (emergency_) { emergency_ = false; return {{}, true}; }
if (tap_ && (tap_failed_ || !CGEventTapIsEnabled(tap_) || !AXIsProcessTrusted()))
return {"Local input protection interrupted; privacy screen unavailable", false};
auto state = state_;
std::lock_guard lock(state->mutex);
if (state->recovered || state->recovering) return {};
if (!state->failure.empty()) return {state->failure, false};
const auto age = Clock::now() - state->checked;
if (age > std::chrono::seconds(1))
return {"macOS privacy window checks stopped responding; privacy screen unavailable", false};
if (!state->check_queued && age > std::chrono::milliseconds(100)) {
state->check_queued = true;
OnMain([state] {
std::lock_guard lock(state->mutex);
if (!state->recovered && !state->recovering && state->failure.empty()) {
const auto capture = GetMacPrivacyCaptureState();
if (!state->covers_shown && !state->monitors.empty() &&
capture.revision == capture.applied_revision) {
if (!state->cursor.Hide()) {
state->failure = "Cannot hide the local privacy cursor";
state->checked = Clock::now();
state->check_queued = false;
return;
}
for (auto& m : state->monitors) {
m.cover.alphaValue = 1;
[m.cover displayIfNeeded];
[m.cover orderFrontRegardless];
}
state->covers_shown = true;
}
if (state->covers_shown) {
state->failure = state->cursor.Healthy() ? CoverageFailure(state->monitors)
: "Cannot keep the local privacy cursor hidden";
}
}
state->checked = Clock::now();
state->check_queued = false;
});
}
const auto capture = GetMacPrivacyCaptureState();
return {{}, false, state->covers_shown &&
capture.revision == capture.applied_revision};
}
void WaitForEvents(unsigned timeout_ms) override {
@autoreleasepool {
CFRunLoopRunInMode(kCFRunLoopDefaultMode, timeout_ms / 1000.0, true);
}
}
void Wake() override { CFRunLoopSourceSignal(wake_source_); CFRunLoopWakeUp(loop_); }
private:
static CGEventRef Input(CGEventTapProxy, CGEventType type, CGEventRef event, void* context) {
auto* self = static_cast<MacPrivacyBackend*>(context);
if (type == kCGEventTapDisabledByTimeout || type == kCGEventTapDisabledByUserInput) {
self->tap_failed_ = true;
CGEventTapEnable(self->tap_, true); // Keep the local recovery chord usable.
return event;
}
if (!event) return event;
if (CGEventGetIntegerValueField(event, kCGEventSourceUserData) == kCrossDeskInjectedEvent &&
CGEventGetIntegerValueField(event, kCGEventSourceUnixProcessID) == getpid()) return event;
const auto flags = CGEventGetFlags(event);
const auto chord = kCGEventFlagMaskControl | kCGEventFlagMaskAlternate | kCGEventFlagMaskShift;
if (type == kCGEventKeyDown && (flags & chord) == chord &&
CGEventGetIntegerValueField(event, kCGKeyboardEventKeycode) == 53) {
self->emergency_ = true;
return nullptr;
}
return self->block_input_ ? nullptr : event;
}
std::shared_ptr<WindowState> state_;
CFRunLoopRef loop_ = nullptr;
CFRunLoopSourceRef wake_source_ = nullptr;
CFMachPortRef tap_ = nullptr;
CFRunLoopSourceRef tap_source_ = nullptr;
bool block_input_ = false;
bool emergency_ = false;
bool tap_failed_ = false;
};
} // namespace
void FlushMacPrivacyTasks() {
if (![NSThread isMainThread]) return;
std::deque<std::function<void()>> pending;
{
std::lock_guard lock(tasks_mutex);
pending.swap(tasks);
}
@autoreleasepool { for (auto& task : pending) task(); }
}
MacPrivacyCaptureState GetMacPrivacyCaptureState() {
std::lock_guard lock(capture_mutex);
return capture_state;
}
void AcknowledgeMacPrivacyCapture(uint64_t revision) {
std::lock_guard lock(capture_mutex);
if (revision == capture_state.revision) capture_state.applied_revision = revision;
}
std::unique_ptr<PrivacyBackend> CreateMacPrivacyBackend() {
return std::make_unique<MacPrivacyBackend>();
}
} // namespace crossdesk
@@ -1,23 +0,0 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-13
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _PRIVACY_CAPTURE_STATE_H_
#define _PRIVACY_CAPTURE_STATE_H_
#include <cstdint>
#include <vector>
namespace crossdesk {
struct MacPrivacyCaptureState {
uint64_t revision = 0;
uint64_t applied_revision = 0;
std::vector<uint32_t> excluded_windows;
uint32_t cursor_window = 0;
};
MacPrivacyCaptureState GetMacPrivacyCaptureState();
void AcknowledgeMacPrivacyCapture(uint64_t revision);
}
#endif
@@ -1,17 +0,0 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-13
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _PRIVACY_COVER_VIEW_H_
#define _PRIVACY_COVER_VIEW_H_
#import <AppKit/AppKit.h>
namespace crossdesk {
// Returns nil if the CrossDesk icon resource cannot be loaded.
NSView* CreateMacPrivacyCoverView(NSRect frame, NSString* unlock_hint);
}
#endif
@@ -1,106 +0,0 @@
/* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. */
#include "privacy_cover_view.h"
#include <algorithm>
#include <cmath>
namespace {
NSImage* LoadPrivacyLogo() {
// The macOS installer currently packages the icon as "crossedesk.icns".
for (NSString* name in @[@"crossdesk", @"crossedesk"]) {
NSString* path = [NSBundle.mainBundle pathForResource:name ofType:@"icns"];
if (path) {
NSImage* image = [[NSImage alloc] initWithContentsOfFile:path];
if (image.valid) return image;
}
}
// Support unbundled development builds, including launches from outside
// the repository. No source-tree path is embedded in the executable.
NSString* resource = @"apps/desktop/resources/macos/crossdesk.icns";
NSString* directory = NSBundle.mainBundle.executablePath.stringByDeletingLastPathComponent;
NSMutableArray<NSString*>* paths = [NSMutableArray arrayWithObject:
[NSFileManager.defaultManager.currentDirectoryPath stringByAppendingPathComponent:resource]];
for (int depth = 0; directory.length && depth <= 4; ++depth) {
[paths addObject:[directory stringByAppendingPathComponent:resource]];
directory = directory.stringByDeletingLastPathComponent;
}
for (NSString* path in paths) {
NSImage* image = [[NSImage alloc] initWithContentsOfFile:path.stringByStandardizingPath];
if (image.valid) return image;
}
return nil;
}
NSAttributedString* Text(NSString* value, CGFloat size, NSFontWeight weight, NSColor* color) {
NSMutableParagraphStyle* paragraph = [[NSMutableParagraphStyle alloc] init];
paragraph.lineBreakMode = NSLineBreakByWordWrapping;
return [[NSAttributedString alloc] initWithString:value attributes:@{
NSFontAttributeName: [NSFont systemFontOfSize:size weight:weight],
NSForegroundColorAttributeName: color,
NSParagraphStyleAttributeName: paragraph}];
}
constexpr NSStringDrawingOptions kTextOptions =
NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading;
CGFloat TextHeight(NSAttributedString* text, CGFloat width) {
return std::ceil([text boundingRectWithSize:NSMakeSize(width, CGFLOAT_MAX)
options:kTextOptions].size.height);
}
}
@interface CrossDeskPrivacyCoverView : NSView
@property(nonatomic, strong) NSImage* logo;
@property(nonatomic, copy) NSString* unlockHint;
@end
@implementation CrossDeskPrivacyCoverView
- (BOOL)isOpaque { return YES; }
- (BOOL)isFlipped { return YES; }
- (void)drawRect:(NSRect)dirtyRect {
[NSColor.blackColor setFill];
NSRectFill(self.bounds);
const CGFloat width = NSWidth(self.bounds), height = NSHeight(self.bounds);
if (width <= 0 || height <= 0) return;
// Match the Windows cover's sizes, colors and bottom-left alignment.
// AppKit coordinates are points; Retina backing scale is applied by AppKit.
const CGFloat scale = std::min({CGFloat(1), width / 640, height / 200});
const CGFloat margin = 32 * scale, text_width = 398 * scale;
NSColor* foreground = [NSColor colorWithSRGBRed:245.0 / 255 green:247.0 / 255
blue:250.0 / 255 alpha:1];
NSColor* muted = [NSColor colorWithSRGBRed:181.0 / 255 green:190.0 / 255
blue:204.0 / 255 alpha:1];
NSAttributedString* brand = Text(@"CrossDesk", 14 * scale, NSFontWeightSemibold, foreground);
NSAttributedString* hint = Text(self.unlockHint, 16 * scale, NSFontWeightRegular, muted);
NSAttributedString* shortcut = Text(@"Control + Option + Shift + Esc", 24 * scale,
NSFontWeightSemibold, foreground);
const CGFloat logo_size = std::ceil(brand.size.width);
const CGFloat brand_height = TextHeight(brand, logo_size);
const CGFloat hint_height = TextHeight(hint, text_width);
const CGFloat shortcut_height = TextHeight(shortcut, text_width);
const CGFloat bottom = height - margin;
const CGFloat logo_top = bottom - brand_height - 6 * scale - logo_size;
[self.logo drawInRect:NSMakeRect(margin, logo_top, logo_size, logo_size)
fromRect:NSZeroRect operation:NSCompositingOperationSourceOver
fraction:1 respectFlipped:YES hints:@{NSImageHintInterpolation: @(NSImageInterpolationHigh)}];
[brand drawWithRect:NSMakeRect(margin, bottom - brand_height, logo_size, brand_height)
options:kTextOptions];
const CGFloat text_left = margin + logo_size + 24 * scale;
[hint drawWithRect:NSMakeRect(text_left, bottom - shortcut_height - 12 * scale - hint_height,
text_width, hint_height) options:kTextOptions];
[shortcut drawWithRect:NSMakeRect(text_left, bottom - shortcut_height,
text_width, shortcut_height) options:kTextOptions];
}
@end
namespace crossdesk {
NSView* CreateMacPrivacyCoverView(NSRect frame, NSString* unlock_hint) {
NSImage* logo = LoadPrivacyLogo();
if (!logo) return nil;
auto* view = [[CrossDeskPrivacyCoverView alloc] initWithFrame:frame];
view.logo = logo;
view.unlockHint = unlock_hint ?: @"To turn off the privacy screen, press";
view.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
return view;
}
}
@@ -1,35 +0,0 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-13
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _PRIVACY_CURSOR_MAC_H_
#define _PRIVACY_CURSOR_MAC_H_
#include <memory>
#include <cstdint>
namespace crossdesk {
// Main-thread resource. Hides only for the lifetime of a local privacy cover;
// does not take focus, move the pointer, or suppress remote input.
class MacPrivacyCursor {
public:
MacPrivacyCursor();
~MacPrivacyCursor();
static bool Supported();
bool Prepare();
bool Hide();
bool Healthy() const;
uint32_t WindowId() const;
void Restore();
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
// Local hiding must not be reported as an invisible cursor to controllers.
bool IsMacPrivacyCursorHidden();
}
#endif
@@ -1,157 +0,0 @@
/* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. */
#include "privacy_cursor_mac.h"
#import <AppKit/AppKit.h>
#import <ApplicationServices/ApplicationServices.h>
#include <atomic>
#include <dlfcn.h>
@interface CrossDeskPrivacyCursorPanel : NSPanel
@end
@implementation CrossDeskPrivacyCursorPanel
- (BOOL)canBecomeKeyWindow { return NO; }
- (BOOL)canBecomeMainWindow { return NO; }
@end
namespace crossdesk {
namespace {
std::atomic<bool> hidden_by_privacy{false};
struct CursorApi {
using Connection = int (*)();
using Copy = CGError (*)(int, int, CFStringRef, CFTypeRef*);
using Set = CGError (*)(int, int, CFStringRef, CFTypeRef);
Connection connection = reinterpret_cast<Connection>(dlsym(RTLD_DEFAULT, "CGSMainConnectionID"));
Copy copy = reinterpret_cast<Copy>(dlsym(RTLD_DEFAULT, "CGSCopyConnectionProperty"));
Set set = reinterpret_cast<Set>(dlsym(RTLD_DEFAULT, "CGSSetConnectionProperty"));
};
const CursorApi& Api() { static const CursorApi api; return api; }
CFStringRef const kBackgroundCursor = CFSTR("SetsCursorInBackground");
}
struct MacPrivacyCursor::Impl {
int connection = 0;
bool previous_background = false;
bool background_changed = false;
unsigned hide_count = 0;
unsigned visible_ticks = 0;
bool healthy = true;
NSTimer* __strong timer = nil;
CrossDeskPrivacyCursorPanel* __strong panel = nil;
NSImageView* __strong view = nil;
void UpdateImage() {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
NSCursor* cursor = NSCursor.currentSystemCursor ?: NSCursor.arrowCursor;
#pragma clang diagnostic pop
NSImage* image = cursor.image;
if (!image || image.size.width <= 0 || image.size.height <= 0) return;
const NSPoint point = NSEvent.mouseLocation;
const NSSize size = image.size;
[panel setFrame:NSMakeRect(point.x - cursor.hotSpot.x,
point.y - size.height + cursor.hotSpot.y, size.width, size.height) display:NO];
view.image = image;
[panel displayIfNeeded];
}
bool KeepHidden() {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
if (hide_count && !CGCursorIsVisible()) { visible_ticks = 0; return true; }
#pragma clang diagnostic pop
if (++visible_ticks > 6 ||
CGDisplayHideCursor(kCGDirectMainDisplay) != kCGErrorSuccess) return false;
++hide_count;
return true;
}
};
MacPrivacyCursor::MacPrivacyCursor() : impl_(std::make_unique<Impl>()) {}
MacPrivacyCursor::~MacPrivacyCursor() { Restore(); }
bool MacPrivacyCursor::Supported() {
return Api().connection && Api().copy && Api().set;
}
bool MacPrivacyCursor::Prepare() {
if (impl_->panel) return true;
if (!Supported()) return false;
// A cursor sprite below the local cover is captured only for clients that
// request an embedded cursor. Desktop clients exclude it and use metadata.
impl_->panel = [[CrossDeskPrivacyCursorPanel alloc] initWithContentRect:NSMakeRect(0, 0, 32, 32)
styleMask:NSWindowStyleMaskBorderless | NSWindowStyleMaskNonactivatingPanel
backing:NSBackingStoreBuffered defer:NO];
auto* panel = impl_->panel;
panel.releasedWhenClosed = NO;
panel.hidesOnDeactivate = NO;
panel.opaque = NO;
panel.backgroundColor = NSColor.clearColor;
panel.hasShadow = NO;
panel.ignoresMouseEvents = YES;
panel.level = CGWindowLevelForKey(kCGScreenSaverWindowLevelKey);
panel.animationBehavior = NSWindowAnimationBehaviorNone;
panel.collectionBehavior = NSWindowCollectionBehaviorCanJoinAllSpaces |
NSWindowCollectionBehaviorFullScreenAuxiliary | NSWindowCollectionBehaviorStationary |
NSWindowCollectionBehaviorIgnoresCycle;
panel.sharingType = NSWindowSharingReadOnly;
impl_->view = [[NSImageView alloc] initWithFrame:panel.contentView.bounds];
impl_->view.imageScaling = NSImageScaleNone;
impl_->view.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
panel.contentView = impl_->view;
panel.alphaValue = 0;
impl_->UpdateImage();
[panel orderFrontRegardless];
return panel != nil && panel.windowNumber > 0;
}
bool MacPrivacyCursor::Hide() {
if (impl_->timer) return true;
if (!Prepare()) return false;
impl_->connection = Api().connection();
impl_->previous_background = false;
CFTypeRef previous = nullptr;
if (Api().copy(impl_->connection, impl_->connection, kBackgroundCursor, &previous) == kCGErrorSuccess && previous)
impl_->previous_background = CFEqual(previous, kCFBooleanTrue);
if (previous) CFRelease(previous);
// This connection-scoped WindowServer property is also used by Deskflow.
// Resolve it dynamically: an unavailable API disables optional privacy only.
if (Api().set(impl_->connection, impl_->connection, kBackgroundCursor, kCFBooleanTrue) != kCGErrorSuccess)
return false;
impl_->background_changed = true;
impl_->healthy = true;
impl_->visible_ticks = 0;
hidden_by_privacy = true;
if (!impl_->KeepHidden()) { Restore(); return false; }
impl_->UpdateImage();
impl_->panel.alphaValue = 1;
auto* state = impl_.get();
impl_->timer = [NSTimer timerWithTimeInterval:1.0 / 60 repeats:YES block:^(NSTimer*) {
if (state->healthy) state->healthy = state->KeepHidden();
state->UpdateImage();
}];
[[NSRunLoop mainRunLoop] addTimer:impl_->timer forMode:NSRunLoopCommonModes];
return true;
}
bool MacPrivacyCursor::Healthy() const { return impl_->timer && impl_->healthy; }
uint32_t MacPrivacyCursor::WindowId() const {
return impl_->panel ? static_cast<uint32_t>(impl_->panel.windowNumber) : 0;
}
void MacPrivacyCursor::Restore() {
[impl_->timer invalidate];
impl_->timer = nil;
[impl_->panel orderOut:nil];
[impl_->panel close];
impl_->panel = nil;
impl_->view = nil;
// Balance only our own successful hide calls, including re-hides after
// WindowServer changes cursor ownership when a remote click activates an app.
while (impl_->hide_count) {
CGDisplayShowCursor(kCGDirectMainDisplay);
--impl_->hide_count;
}
hidden_by_privacy = false;
if (impl_->background_changed) {
Api().set(impl_->connection, impl_->connection, kBackgroundCursor,
impl_->previous_background ? kCFBooleanTrue : kCFBooleanFalse);
impl_->background_changed = false;
}
}
bool IsMacPrivacyCursorHidden() { return hidden_by_privacy.load(); }
}
@@ -1,11 +0,0 @@
#include "screen_capturer_factory.h"
#include "screen_capturer_sck.h"
namespace crossdesk {
ScreenCapturer* ScreenCapturerFactory::Create() {
return new ScreenCapturerSck();
}
} // namespace crossdesk
@@ -1,982 +0,0 @@
/*
* Copyright (c) 2024 The WebRTC project authors. All Rights Reserved.
*
* Use of this source code is governed by a BSD-style license
* that can be found in the LICENSE file in the root of the source
* tree. An additional intellectual property rights grant can be found
* in the file PATENTS. All contributing project authors may
* be found in the AUTHORS file in the root of the source tree.
*/
#include "screen_capturer_sck.h"
#include <AppKit/AppKit.h>
#include <ApplicationServices/ApplicationServices.h>
#include <CoreGraphics/CoreGraphics.h>
#include <IOKit/IOKitLib.h>
#include <IOKit/graphics/IOGraphicsLib.h>
#include <IOSurface/IOSurface.h>
#include <ScreenCaptureKit/ScreenCaptureKit.h>
#include <atomic>
#include <cstring>
#include <limits>
#include <map>
#include <mutex>
#include <vector>
#include "display_info.h"
#include <display_stream_id.h>
#include "rd_log.h"
#include "privacy_controller.h"
#include "../privacy/privacy_capture_state.h"
#include <algorithm>
using namespace crossdesk;
class ScreenCapturerSckImpl;
static const int kFullDesktopScreenId = -1;
static std::string NSErrorToString(NSError *error) {
if (!error) {
return "";
}
const char *description = [error.localizedDescription UTF8String];
return description ? description : "";
}
static void RetainCapturedPixelBuffer(void *owner) {
if (owner) {
CVPixelBufferRetain(static_cast<CVPixelBufferRef>(owner));
}
}
static void ReleaseCapturedPixelBuffer(void *owner) {
if (owner) {
CVPixelBufferRelease(static_cast<CVPixelBufferRef>(owner));
}
}
static int CopyCapturedPixelBufferToNv12(void *owner, uint8_t *destination,
size_t destination_size) {
auto pixel_buffer = static_cast<CVPixelBufferRef>(owner);
if (!pixel_buffer || !destination ||
!CVPixelBufferIsPlanar(pixel_buffer) ||
CVPixelBufferGetPlaneCount(pixel_buffer) < 2) {
return -1;
}
const OSType pixel_format = CVPixelBufferGetPixelFormatType(pixel_buffer);
if (pixel_format != kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange &&
pixel_format != kCVPixelFormatType_420YpCbCr8BiPlanarFullRange) {
return -1;
}
const size_t width = CVPixelBufferGetWidth(pixel_buffer);
const size_t height = CVPixelBufferGetHeight(pixel_buffer);
const size_t required_size = width * height * 3U / 2U;
if (destination_size < required_size ||
CVPixelBufferLockBaseAddress(pixel_buffer,
kCVPixelBufferLock_ReadOnly) !=
kCVReturnSuccess) {
return -1;
}
const auto *y_plane = static_cast<const uint8_t *>(
CVPixelBufferGetBaseAddressOfPlane(pixel_buffer, 0));
const auto *uv_plane = static_cast<const uint8_t *>(
CVPixelBufferGetBaseAddressOfPlane(pixel_buffer, 1));
const size_t y_stride = CVPixelBufferGetBytesPerRowOfPlane(pixel_buffer, 0);
const size_t uv_stride = CVPixelBufferGetBytesPerRowOfPlane(pixel_buffer, 1);
int result = 0;
if (!y_plane || !uv_plane || y_stride < width || uv_stride < width) {
result = -1;
} else {
for (size_t row = 0; row < height; ++row) {
std::memcpy(destination + row * width, y_plane + row * y_stride, width);
}
uint8_t *uv_destination = destination + width * height;
for (size_t row = 0; row < height / 2U; ++row) {
std::memcpy(uv_destination + row * width,
uv_plane + row * uv_stride, width);
}
}
CVPixelBufferUnlockBaseAddress(pixel_buffer, kCVPixelBufferLock_ReadOnly);
return result;
}
// The ScreenCaptureKit API was available in macOS 12.3, but full-screen capture
// was reported to be broken before macOS 13 - see http://crbug.com/40234870.
// Also, the `SCContentFilter` fields `contentRect` and `pointPixelScale` were
// introduced in macOS 14.
API_AVAILABLE(macos(14.0))
@interface SckHelper : NSObject <SCStreamDelegate, SCStreamOutput>
- (instancetype)initWithCapturer:(ScreenCapturerSckImpl *)capturer;
- (void)onShareableContentCreated:(SCShareableContent *)content generation:(uint64_t)generation
revision:(uint64_t)revision;
// Called just before the capturer is destroyed. This avoids a dangling pointer,
// and prevents any new calls into a deleted capturer. If any method-call on the
// capturer is currently running on a different thread, this blocks until it
// completes.
- (void)releaseCapturer;
- (void)privacyCoversChanged:(NSNotification*)notification;
- (void)configurationFinished:(SCStream*)stream generation:(uint64_t)generation
revision:(uint64_t)revision complete:(BOOL)complete
configuration:(SCStreamConfiguration*)configuration error:(NSError*)error;
@end
class API_AVAILABLE(macos(14.0)) ScreenCapturerSckImpl : public ScreenCapturer {
public:
explicit ScreenCapturerSckImpl();
ScreenCapturerSckImpl(const ScreenCapturerSckImpl &) = delete;
ScreenCapturerSckImpl &operator=(const ScreenCapturerSckImpl &) = delete;
~ScreenCapturerSckImpl();
public:
int Init(const int fps, cb_desktop_data cb) override;
void SetPrivacyController(PrivacyController* privacy) override { privacy_ = privacy; }
int Start(bool show_cursor) override;
int SwitchTo(int monitor_index) override;
int Destroy() override;
int Stop() override;
int Pause(int monitor_index) override { return 0; }
int Resume(int monitor_index) override { return 0; }
std::vector<DisplayInfo> GetDisplayInfoList() override {
std::lock_guard<std::mutex> lock(lock_);
return display_info_list_;
}
int ResetToInitialMonitor() override;
private:
std::vector<DisplayInfo> display_info_list_;
std::map<int, CGDirectDisplayID> display_id_map_;
int fps_ = 60;
bool show_cursor_ = false;
bool capture_requested_ = false;
bool invalid_stream_id_logged_ = false;
bool native_output_logged_ = false;
PrivacyController* privacy_ = nullptr;
uint64_t configuration_generation_ = 0;
bool configuration_in_flight_ = false;
bool reconfigure_pending_ = false;
SCStreamConfiguration* __strong applied_configuration_ = nil;
public:
// Called by SckHelper when shareable content is returned by ScreenCaptureKit. `content` will be
// nil if an error occurred. May run on an arbitrary thread.
void OnShareableContentCreated(SCShareableContent *content, uint64_t generation,
uint64_t revision);
void ConfigurationFinished(SCStream* source, uint64_t generation, uint64_t revision,
bool complete, SCStreamConfiguration* configuration,
NSError* error);
void RetryPrivacyFilter();
void PrivacyCoversChanged() { StartOrReconfigureCapturer(); }
void CaptureStopped(SCStream* source, NSError* error) {
{
std::lock_guard lock(lock_);
if (!capture_requested_ || source != stream_) return;
}
LOG_ERROR("ScreenCaptureKit stream stopped: {}", NSErrorToString(error));
if (privacy_) privacy_->Fail("ScreenCaptureKit stream stopped");
}
// Called by SckHelper to notify of a newly captured frame. May run on an arbitrary thread.
// void OnNewIOSurface(IOSurfaceRef io_surface, CFDictionaryRef attachment);
void OnNewCVPixelBuffer(CVPixelBufferRef pixelBuffer, CFDictionaryRef attachment);
private:
// Called when starting the capturer or the configuration has changed (either from a
// SwitchTo() call, or the screen-resolution has changed). This tells SCK to fetch new
// shareable content, and the completion-handler will either start a new stream, or reconfigure
// the existing stream. Runs on the caller's thread.
void StartOrReconfigureCapturer();
// Helper object to receive Objective-C callbacks from ScreenCaptureKit and call into this C++
// object. The helper may outlive this C++ instance, if a completion-handler is passed to
// ScreenCaptureKit APIs and the C++ object is deleted before the handler executes.
SckHelper *__strong helper_ = nil;
// Callback for returning captured frames to the caller. ScreenCaptureKit may
// invoke it on its sample-handler queue.
cb_desktop_data _on_data = nullptr;
// Signals that a permanent error occurred. This may be set on any thread, and is read by
// CaptureFrame() which runs on the caller's thread.
std::atomic<bool> permanent_error_ = false;
// Guards some variables that may be accessed on different threads.
std::mutex lock_;
// Provides captured desktop frames.
SCStream *__strong stream_ = nil;
// Currently selected display, or 0 if the full desktop is selected. This capturer does not
// support full-desktop capture, and will fall back to the first display.
CGDirectDisplayID current_display_ = 0;
int initial_monitor_index_ = 0;
int current_monitor_index_ = 0;
std::string current_stream_id_;
};
static std::string NSStringToUtf8(NSString *value) {
if (!value || value.length == 0) return "";
const char *utf8 = value.UTF8String;
return utf8 ? utf8 : "";
}
static std::string GetAppKitDisplayName(CGDirectDisplayID display_id) {
if (@available(macOS 10.15, *)) {
@autoreleasepool {
for (NSScreen *screen in NSScreen.screens) {
NSNumber *screen_number = screen.deviceDescription[@"NSScreenNumber"];
if (screen_number && screen_number.unsignedIntValue == display_id) {
return NSStringToUtf8(screen.localizedName);
}
}
}
}
return "";
}
std::string GetDisplayName(CGDirectDisplayID display_id) {
// IODisplayConnect is no longer exposed for some external displays on
// modern Apple Silicon Macs. NSScreen is the system-owned source for the
// user-visible name and maps directly to CGDirectDisplayID.
std::string appkit_name = GetAppKitDisplayName(display_id);
if (!appkit_name.empty()) return appkit_name;
// Keep the IOKit path for older macOS versions and display drivers.
io_iterator_t iter;
io_service_t serv = 0, matched_serv = 0;
CFMutableDictionaryRef matching = IOServiceMatching("IODisplayConnect");
if (IOServiceGetMatchingServices(kIOMainPortDefault, matching, &iter) != KERN_SUCCESS) {
return "";
}
while ((serv = IOIteratorNext(iter)) != 0) {
CFDictionaryRef info = IODisplayCreateInfoDictionary(serv, kIODisplayOnlyPreferredName);
if (info) {
CFNumberRef vendorID = (CFNumberRef)CFDictionaryGetValue(info, CFSTR(kDisplayVendorID));
CFNumberRef productID = (CFNumberRef)CFDictionaryGetValue(info, CFSTR(kDisplayProductID));
uint32_t vID = 0, pID = 0;
if (vendorID && productID && CFNumberGetValue(vendorID, kCFNumberIntType, &vID) &&
CFNumberGetValue(productID, kCFNumberIntType, &pID) &&
vID == CGDisplayVendorNumber(display_id) && pID == CGDisplayModelNumber(display_id)) {
matched_serv = serv;
CFRelease(info);
break;
}
CFRelease(info);
}
IOObjectRelease(serv);
}
IOObjectRelease(iter);
if (!matched_serv) return "";
CFDictionaryRef display_info =
IODisplayCreateInfoDictionary(matched_serv, kIODisplayOnlyPreferredName);
IOObjectRelease(matched_serv);
if (!display_info) return "";
CFDictionaryRef product_name_dict =
(CFDictionaryRef)CFDictionaryGetValue(display_info, CFSTR(kDisplayProductName));
std::string result;
if (product_name_dict) {
CFIndex count = CFDictionaryGetCount(product_name_dict);
if (count > 0) {
std::vector<const void *> keys(count);
std::vector<const void *> values(count);
CFDictionaryGetKeysAndValues(product_name_dict, keys.data(), values.data());
CFStringRef name_ref = (CFStringRef)values[0];
if (name_ref) {
CFIndex maxSize =
CFStringGetMaximumSizeForEncoding(CFStringGetLength(name_ref), kCFStringEncodingUTF8) +
1;
std::vector<char> buffer(maxSize);
if (CFStringGetCString(name_ref, buffer.data(), buffer.size(), kCFStringEncodingUTF8)) {
result = buffer.data();
}
}
}
}
CFRelease(display_info);
return result;
}
ScreenCapturerSckImpl::ScreenCapturerSckImpl() {
helper_ = [[SckHelper alloc] initWithCapturer:this];
}
ScreenCapturerSckImpl::~ScreenCapturerSckImpl() {
SckHelper *helper_to_release = nil;
{
std::lock_guard<std::mutex> lock(lock_);
if (stream_) {
[stream_ stopCaptureWithCompletionHandler:nil];
stream_ = nil;
}
_on_data = nullptr;
helper_to_release = helper_;
helper_ = nil;
}
[helper_to_release releaseCapturer];
display_info_list_.clear();
display_id_map_.clear();
}
int ScreenCapturerSckImpl::Init(const int fps, cb_desktop_data cb) {
if (!cb) {
LOG_ERROR("Screen capturer callback is null");
return -1;
}
_on_data = cb;
fps_ = fps > 0 ? fps : 60;
display_info_list_.clear();
display_id_map_.clear();
native_output_logged_ = false;
if (@available(macOS 10.15, *)) {
bool has_permission = CGPreflightScreenCaptureAccess();
if (!has_permission) {
LOG_ERROR("Screen recording permission not granted");
return -1;
}
}
dispatch_semaphore_t sema = dispatch_semaphore_create(0);
__block SCShareableContent *content = nil;
__block NSError *capture_error = nil;
[SCShareableContent
getShareableContentWithCompletionHandler:^(SCShareableContent *result, NSError *error) {
if (error) {
capture_error = error;
LOG_ERROR("Failed to get shareable content: {}", NSErrorToString(error));
} else {
content = result;
}
dispatch_semaphore_signal(sema);
}];
dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);
if (capture_error || !content || content.displays.count == 0) {
LOG_ERROR("Failed to get display info, error: {}",
NSErrorToString(capture_error));
return -1;
}
for (SCDisplay *display in content.displays) {
CGDirectDisplayID display_id = display.displayID;
CGRect bounds = CGDisplayBounds(display_id);
bool is_primary = CGDisplayIsMain(display_id);
std::string name = GetDisplayName(display_id);
if (name.empty()) {
name = MakeDisplayStreamId(display_info_list_.size());
}
LOG_INFO("macOS display discovered: index={}, display_id={}, name='{}'",
display_info_list_.size(), display_id, name);
DisplayInfo info((void *)(uintptr_t)display_id, name, is_primary,
static_cast<int>(bounds.origin.x), static_cast<int>(bounds.origin.y),
static_cast<int>(bounds.origin.x + bounds.size.width),
static_cast<int>(bounds.origin.y + bounds.size.height));
display_info_list_.push_back(info);
display_id_map_[display_info_list_.size() - 1] = display_id;
}
initial_monitor_index_ = 0;
current_monitor_index_ = initial_monitor_index_;
current_display_ = display_id_map_[current_monitor_index_];
current_stream_id_ = MakeDisplayStreamId(current_monitor_index_);
return 0;
}
int ScreenCapturerSckImpl::Start(bool show_cursor) {
if (permanent_error_) {
LOG_ERROR("Cannot start capturer: permanent error occurred");
return -1;
}
{
std::lock_guard<std::mutex> lock(lock_);
if (display_info_list_.empty()) {
LOG_ERROR("Cannot start capturer: display info not initialized");
return -1;
}
show_cursor_ = show_cursor;
capture_requested_ = true;
invalid_stream_id_logged_ = false;
}
if (privacy_) privacy_->CaptureChanged(true);
StartOrReconfigureCapturer();
return 0;
}
int ScreenCapturerSckImpl::SwitchTo(int monitor_index) {
bool should_reconfigure = false;
{
std::lock_guard<std::mutex> lock(lock_);
auto display_it = display_id_map_.find(monitor_index);
if (display_it == display_id_map_.end() || monitor_index < 0 ||
monitor_index >= static_cast<int>(display_info_list_.size())) {
LOG_WARN("SwitchTo skipped, invalid monitor_index={}, displays={}",
monitor_index, display_id_map_.size());
return -1;
}
current_monitor_index_ = monitor_index;
current_display_ = display_it->second;
current_stream_id_ = MakeDisplayStreamId(monitor_index);
should_reconfigure = capture_requested_;
}
if (should_reconfigure) {
StartOrReconfigureCapturer();
}
return 0;
}
int ScreenCapturerSckImpl::ResetToInitialMonitor() {
const int target = initial_monitor_index_;
bool should_reconfigure = false;
{
std::lock_guard<std::mutex> lock(lock_);
if (display_info_list_.empty()) return -1;
auto display_it = display_id_map_.find(target);
if (display_it == display_id_map_.end()) {
LOG_WARN("ResetToInitialMonitor skipped, invalid monitor_index={}", target);
return -1;
}
const CGDirectDisplayID target_display = display_it->second;
if (current_display_ == target_display &&
current_monitor_index_ == target) {
return 0;
}
current_monitor_index_ = target;
current_display_ = target_display;
current_stream_id_ = MakeDisplayStreamId(target);
should_reconfigure = capture_requested_ && stream_ != nil;
}
// Resetting session state must not create a capture stream. Preserve the
// selected monitor for the next Start(), and only reconfigure an active one.
if (should_reconfigure) {
StartOrReconfigureCapturer();
}
return 0;
}
int ScreenCapturerSckImpl::Destroy() {
if (privacy_) privacy_->CaptureChanged(false);
SckHelper *helper_to_release = nil;
{
std::lock_guard<std::mutex> lock(lock_);
if (stream_) {
LOG_INFO("Destroying stream");
[stream_ stopCaptureWithCompletionHandler:nil];
stream_ = nil;
}
capture_requested_ = false;
configuration_in_flight_ = reconfigure_pending_ = false;
applied_configuration_ = nil;
++configuration_generation_;
current_display_ = 0;
current_stream_id_.clear();
permanent_error_ = false;
_on_data = nullptr;
helper_to_release = helper_;
helper_ = nil;
}
[helper_to_release releaseCapturer];
return 0;
}
int ScreenCapturerSckImpl::Stop() {
if (privacy_) privacy_->CaptureChanged(false);
std::lock_guard<std::mutex> lock(lock_);
capture_requested_ = false;
configuration_in_flight_ = reconfigure_pending_ = false;
applied_configuration_ = nil;
++configuration_generation_;
if (stream_) {
LOG_INFO("Stopping stream");
[stream_ stopCaptureWithCompletionHandler:nil];
stream_ = nil;
}
current_display_ = 0;
return 0;
}
void ScreenCapturerSckImpl::OnShareableContentCreated(SCShareableContent *content,
uint64_t generation, uint64_t revision) {
{
std::lock_guard<std::mutex> lock(lock_);
if (!capture_requested_ || generation != configuration_generation_) {
LOG_INFO("Ignoring stale ScreenCaptureKit display refresh after stop");
return;
}
}
// The window list belongs to the privacy revision present when the request
// was issued. A newer cover must be enumerated by a new request.
if (GetMacPrivacyCaptureState().revision != revision) {
StartOrReconfigureCapturer();
return;
}
if (!content) {
LOG_ERROR("getShareableContent failed");
if (privacy_) privacy_->Fail("Could not refresh ScreenCaptureKit privacy content");
std::lock_guard lock(lock_);
if (!stream_) permanent_error_ = true;
return;
}
if (!content.displays || content.displays.count == 0) {
LOG_ERROR("getShareableContent returned no displays");
if (privacy_) privacy_->Fail("No shareable displays available for privacy");
std::lock_guard lock(lock_);
if (!stream_) permanent_error_ = true;
return;
}
SCDisplay *captured_display = nil;
bool show_cursor = false;
{
std::lock_guard<std::mutex> lock(lock_);
if (!capture_requested_ || generation != configuration_generation_) return;
int logical_index = current_monitor_index_;
if (logical_index < 0 ||
logical_index >= static_cast<int>(display_info_list_.size())) {
logical_index = 0;
}
SCDisplay *logical_fallback = nil;
int display_index = 0;
for (SCDisplay *display in content.displays) {
if (display_index == logical_index) logical_fallback = display;
if (current_display_ != 0 && current_display_ == display.displayID) {
captured_display = display;
break;
}
++display_index;
}
if (!captured_display) {
captured_display = logical_fallback ? logical_fallback
: content.displays.firstObject;
if (!logical_fallback) logical_index = 0;
}
if (captured_display) {
const CGDirectDisplayID old_display = current_display_;
const CGDirectDisplayID new_display = captured_display.displayID;
const std::string stable_stream_id = ResolveDisplayStreamId(
current_stream_id_.c_str(), display_info_list_.size(), logical_index);
if (stable_stream_id.empty()) {
LOG_ERROR("Cannot map macOS display {} to a registered video stream",
new_display);
return;
}
current_monitor_index_ = logical_index;
current_display_ = new_display;
current_stream_id_ = stable_stream_id;
display_id_map_[logical_index] = new_display;
CGRect bounds = CGDisplayBounds(new_display);
auto& info = display_info_list_[logical_index];
info.handle = (void *)(uintptr_t)new_display;
info.is_primary = CGDisplayIsMain(new_display);
info.left = static_cast<int>(bounds.origin.x);
info.top = static_cast<int>(bounds.origin.y);
info.right = static_cast<int>(bounds.origin.x + bounds.size.width);
info.bottom = static_cast<int>(bounds.origin.y + bounds.size.height);
info.width = info.right - info.left;
info.height = info.bottom - info.top;
const std::string refreshed_name = GetDisplayName(new_display);
if (!refreshed_name.empty()) {
info.name = refreshed_name;
}
if (old_display != new_display) {
LOG_INFO("macOS display mapping refreshed: slot={}, old_id={}, "
"new_id={}, stream='{}'",
logical_index, old_display, new_display, stable_stream_id);
}
}
show_cursor = show_cursor_;
}
if (!captured_display) {
LOG_ERROR("Failed to find valid display");
permanent_error_ = true;
return;
}
const auto privacy_capture = GetMacPrivacyCaptureState();
// The cursor sprite is included only when this stream embeds the cursor.
// Other controllers receive the unchanged cursor metadata separately.
auto excluded_ids = privacy_capture.excluded_windows;
if (!show_cursor && privacy_capture.cursor_window)
excluded_ids.push_back(privacy_capture.cursor_window);
NSMutableArray<SCWindow*>* excluded = [NSMutableArray array];
for (SCWindow* window in content.windows) {
if (std::find(excluded_ids.begin(), excluded_ids.end(), window.windowID) != excluded_ids.end())
[excluded addObject:window];
}
// Newly registered transparent covers may need another enumeration. Never
// acknowledge a partial exclusion or remove an active cover's filter.
const bool privacy_filter_complete = excluded.count == excluded_ids.size();
LOG_INFO("macOS privacy filter: generation={}, revision={}, requested={}, excluded={}",
generation, privacy_capture.revision, excluded_ids.size(),
excluded.count);
if (!privacy_filter_complete) {
bool have_stream;
{ std::lock_guard lock(lock_); have_stream = stream_ != nil; }
if (privacy_capture.applied_revision == privacy_capture.revision) {
if (privacy_) privacy_->Fail("ScreenCaptureKit could not enumerate every privacy cover");
return; // Preserve the existing exclusion until recovery removes covers.
}
if (have_stream) {
RetryPrivacyFilter();
return; // Transparent covers are still being registered by WindowServer.
}
// Start ordinary capture even if a newly created, still-transparent cover
// is not enumerable yet. Retry exclusion after start; never acknowledge it.
[excluded removeAllObjects];
}
SCContentFilter *filter = [[SCContentFilter alloc] initWithDisplay:captured_display
excludingWindows:excluded];
if (!filter) {
LOG_ERROR("Failed to create SCContentFilter");
permanent_error_ = true;
return;
}
SCStreamConfiguration *config = [[SCStreamConfiguration alloc] init];
if (!config) {
LOG_ERROR("Failed to create SCStreamConfiguration");
permanent_error_ = true;
return;
}
config.pixelFormat = kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange;
config.showsCursor = show_cursor;
config.width = filter.contentRect.size.width * filter.pointPixelScale;
config.height = filter.contentRect.size.height * filter.pointPixelScale;
config.captureResolution = SCCaptureResolutionAutomatic;
config.minimumFrameInterval = CMTimeMake(1, fps_);
std::unique_lock<std::mutex> lock(lock_);
if (!capture_requested_ || generation != configuration_generation_) return;
if (configuration_in_flight_) { reconfigure_pending_ = true; return; }
if (GetMacPrivacyCaptureState().revision != revision) {
lock.unlock();
RetryPrivacyFilter();
return;
}
configuration_in_flight_ = true;
if (stream_) {
SckHelper* helper = helper_;
SCStream* source = stream_;
auto finished = ^(NSError* error) {
[helper configurationFinished:source generation:generation revision:revision
complete:privacy_filter_complete configuration:config error:error];
};
// Apply configuration before its filter and acknowledge the final update.
// Privacy-only toggles do not need a configuration change.
const bool same_config = applied_configuration_ &&
applied_configuration_.width == config.width &&
applied_configuration_.height == config.height &&
applied_configuration_.showsCursor == config.showsCursor &&
applied_configuration_.pixelFormat == config.pixelFormat &&
CMTimeCompare(applied_configuration_.minimumFrameInterval, config.minimumFrameInterval) == 0;
if (same_config) {
[source updateContentFilter:filter completionHandler:finished];
} else {
[source updateConfiguration:config completionHandler:^(NSError* error) {
if (error) finished(error);
else [source updateContentFilter:filter completionHandler:finished];
}];
}
} else {
stream_ = [[SCStream alloc] initWithFilter:filter configuration:config delegate:helper_];
// TODO: crbug.com/327458809 - Choose an appropriate sampleHandlerQueue for
// best performance.
NSError *add_stream_output_error = nil;
dispatch_queue_t queue = dispatch_queue_create("ScreenCaptureKit.Queue", DISPATCH_QUEUE_SERIAL);
bool add_stream_output_result = [stream_ addStreamOutput:helper_
type:SCStreamOutputTypeScreen
sampleHandlerQueue:queue
error:&add_stream_output_error];
if (!add_stream_output_result) {
configuration_in_flight_ = false;
stream_ = nil;
LOG_ERROR("addStreamOutput failed: {}", NSErrorToString(add_stream_output_error));
permanent_error_ = true;
return;
}
SckHelper* helper = helper_;
SCStream* source = stream_;
auto handler = ^(NSError *error) {
[helper configurationFinished:source generation:generation revision:revision
complete:privacy_filter_complete configuration:config error:error];
};
[stream_ startCaptureWithCompletionHandler:handler];
}
}
void ScreenCapturerSckImpl::RetryPrivacyFilter() {
SckHelper* helper;
{
std::lock_guard lock(lock_);
if (!capture_requested_) return;
helper = helper_;
}
// The retained helper safely ignores this callback after Destroy().
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 50 * NSEC_PER_MSEC),
dispatch_get_main_queue(), ^{ [helper privacyCoversChanged:nil]; });
}
void ScreenCapturerSckImpl::ConfigurationFinished(SCStream* source, uint64_t generation,
uint64_t revision, bool complete,
SCStreamConfiguration* configuration,
NSError* error) {
bool refresh = false;
{
std::lock_guard lock(lock_);
if (!capture_requested_ || source != stream_) return;
configuration_in_flight_ = false;
const auto current = GetMacPrivacyCaptureState();
refresh = reconfigure_pending_ || current.revision != revision;
reconfigure_pending_ = false;
if (!error) {
applied_configuration_ = configuration;
if (complete && !refresh && generation == configuration_generation_)
AcknowledgeMacPrivacyCapture(revision);
refresh = refresh || !complete;
}
LOG_INFO("macOS privacy filter completed: generation={}, revision={}, complete={}, refresh={}, error={}",
generation, revision, complete, refresh, NSErrorToString(error));
}
if (error && privacy_)
privacy_->Fail("ScreenCaptureKit privacy filter: " + NSErrorToString(error));
if (refresh) RetryPrivacyFilter();
}
void ScreenCapturerSckImpl::OnNewCVPixelBuffer(CVPixelBufferRef pixelBuffer,
CFDictionaryRef attachment) {
(void)attachment;
if (!pixelBuffer) {
return;
}
const size_t width = CVPixelBufferGetWidth(pixelBuffer);
const size_t height = CVPixelBufferGetHeight(pixelBuffer);
const OSType pixel_format = CVPixelBufferGetPixelFormatType(pixelBuffer);
if (width == 0 || height == 0 || (width & 1U) != 0 ||
(height & 1U) != 0 || !CVPixelBufferIsPlanar(pixelBuffer) ||
CVPixelBufferGetPlaneCount(pixelBuffer) < 2 ||
(pixel_format != kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange &&
pixel_format != kCVPixelFormatType_420YpCbCr8BiPlanarFullRange)) {
LOG_ERROR("Invalid CVPixelBuffer: width={}, height={}, format={}, planes={}",
width, height, pixel_format,
CVPixelBufferGetPlaneCount(pixelBuffer));
return;
}
const size_t required_size = width * height * 3U / 2U;
if (required_size > static_cast<size_t>((std::numeric_limits<int>::max)())) {
LOG_ERROR("Captured frame is too large: {} bytes", required_size);
return;
}
cb_desktop_data callback;
std::string stream_id;
bool log_native_output = false;
{
std::lock_guard<std::mutex> lock(lock_);
if (!_on_data || !capture_requested_) {
return;
}
stream_id = ResolveDisplayStreamId(current_stream_id_.c_str(),
display_info_list_.size(),
current_monitor_index_);
if (stream_id.empty()) {
if (!invalid_stream_id_logged_) {
LOG_ERROR("Dropping macOS frames without a registered stream id, "
"display_id={}",
current_display_);
invalid_stream_id_logged_ = true;
}
return;
}
invalid_stream_id_logged_ = false;
callback = _on_data;
if (!native_output_logged_) {
native_output_logged_ = true;
log_native_output = true;
}
}
MiniRtcNativeVideoFrame native_frame{};
native_frame.struct_size = sizeof(native_frame);
native_frame.type = MiniRtcNativeVideoFrameCVPixelBuffer;
native_frame.width = static_cast<uint32_t>(width);
native_frame.height = static_cast<uint32_t>(height);
native_frame.payload.cv_pixel_buffer = pixelBuffer;
native_frame.owner = pixelBuffer;
native_frame.retain = &RetainCapturedPixelBuffer;
native_frame.release = &ReleaseCapturedPixelBuffer;
native_frame.copy_to_nv12 = &CopyCapturedPixelBufferToNv12;
if (log_native_output) {
LOG_INFO("macOS ScreenCaptureKit native CVPixelBuffer output enabled");
}
callback(nullptr, static_cast<int>(required_size), static_cast<int>(width),
static_cast<int>(height), stream_id.c_str(), &native_frame);
}
void ScreenCapturerSckImpl::StartOrReconfigureCapturer() {
if (permanent_error_) {
LOG_ERROR("Cannot reconfigure capturer: permanent error occurred");
return;
}
if (@available(macOS 10.15, *)) {
bool has_permission = CGPreflightScreenCaptureAccess();
if (!has_permission) {
LOG_ERROR("Screen recording permission not granted");
permanent_error_ = true;
return;
}
}
SckHelper *local_helper;
uint64_t generation;
uint64_t revision;
{
std::lock_guard lock(lock_);
if (!capture_requested_) return;
if (configuration_in_flight_) { reconfigure_pending_ = true; return; }
reconfigure_pending_ = false;
local_helper = helper_;
generation = ++configuration_generation_;
revision = GetMacPrivacyCaptureState().revision;
}
if (!local_helper) {
LOG_ERROR("Cannot reconfigure capturer: helper is null");
return;
}
auto handler = ^(SCShareableContent *content, NSError *error) {
if (error) {
LOG_ERROR("getShareableContent failed: {}", NSErrorToString(error));
[local_helper onShareableContentCreated:nil generation:generation revision:revision];
return;
}
[local_helper onShareableContentCreated:content generation:generation revision:revision];
};
[SCShareableContent getShareableContentExcludingDesktopWindows:NO
onScreenWindowsOnly:NO
completionHandler:handler];
}
@implementation SckHelper {
// This lock is to prevent the capturer being destroyed while an instance
// method is still running on another thread.
std::mutex _capturer_lock;
ScreenCapturerSckImpl *_capturer;
}
- (instancetype)initWithCapturer:(ScreenCapturerSckImpl *)capturer {
self = [super init];
if (self) {
_capturer = capturer;
[NSNotificationCenter.defaultCenter addObserver:self selector:@selector(privacyCoversChanged:)
name:@"CrossDeskPrivacyCoversChanged" object:nil];
}
return self;
}
- (void)privacyCoversChanged:(NSNotification*)notification {
std::lock_guard<std::mutex> lock(_capturer_lock);
if (_capturer) _capturer->PrivacyCoversChanged();
}
- (void)configurationFinished:(SCStream*)stream generation:(uint64_t)generation
revision:(uint64_t)revision complete:(BOOL)complete
configuration:(SCStreamConfiguration*)configuration error:(NSError*)error {
std::lock_guard<std::mutex> lock(_capturer_lock);
if (_capturer)
_capturer->ConfigurationFinished(stream, generation, revision, complete, configuration, error);
}
- (void)onShareableContentCreated:(SCShareableContent *)content generation:(uint64_t)generation
revision:(uint64_t)revision {
std::lock_guard<std::mutex> lock(_capturer_lock);
if (_capturer) {
_capturer->OnShareableContentCreated(content, generation, revision);
} else {
LOG_ERROR("Invalid capturer");
}
}
- (void)stream:(SCStream *)stream
didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
ofType:(SCStreamOutputType)type {
CVPixelBufferRef pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer);
if (!pixelBuffer) {
return;
}
CFRetain(pixelBuffer);
CFArrayRef attachmentsArray = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, false);
if (!attachmentsArray || CFArrayGetCount(attachmentsArray) == 0) {
LOG_ERROR("Discarding frame with no attachments");
CFRelease(pixelBuffer);
return;
}
CFDictionaryRef attachment =
static_cast<CFDictionaryRef>(CFArrayGetValueAtIndex(attachmentsArray, 0));
std::lock_guard<std::mutex> lock(_capturer_lock);
if (_capturer) {
_capturer->OnNewCVPixelBuffer(pixelBuffer, attachment);
}
CFRelease(pixelBuffer);
}
- (void)releaseCapturer {
[NSNotificationCenter.defaultCenter removeObserver:self];
std::lock_guard<std::mutex> lock(_capturer_lock);
_capturer = nullptr;
}
- (void)stream:(SCStream*)stream didStopWithError:(NSError*)error {
std::lock_guard<std::mutex> lock(_capturer_lock);
if (_capturer) _capturer->CaptureStopped(stream, error);
}
@end
std::unique_ptr<ScreenCapturer> ScreenCapturerSck::CreateScreenCapturerSck() {
return std::make_unique<ScreenCapturerSckImpl>();
}
@@ -1,11 +0,0 @@
#include "speaker_capturer_factory.h"
#include "speaker_capturer_macosx.h"
namespace crossdesk {
SpeakerCapturer* SpeakerCapturerFactory::Create() {
return new SpeakerCapturerMacosx();
}
} // namespace crossdesk
@@ -1,323 +0,0 @@
#import <AVFoundation/AVFoundation.h>
#import <Foundation/Foundation.h>
#import <ScreenCaptureKit/ScreenCaptureKit.h>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <memory>
#include <mutex>
#include <utility>
#include "rd_log.h"
#include "speaker_capturer_macosx.h"
namespace {
constexpr auto kCaptureTimeout = std::chrono::seconds(2);
std::string NSErrorToString(NSError* error) {
if (!error) return "";
NSString* message = [NSString
stringWithFormat:@"%@ (%@:%ld), reason=%@", error.localizedDescription, error.domain,
(long)error.code, error.localizedFailureReason ?: @""];
return message.UTF8String ?: "";
}
// Late system replies own only their result, never the capturer or stack data.
// Complete() also tells a late successful start to stop its abandoned stream.
template <typename T>
class CaptureReply {
public:
bool Complete(T value) {
std::lock_guard<std::mutex> lock(mutex_);
if (expired_) return false;
value_ = std::move(value);
ready_ = true;
wake_.notify_one();
return true;
}
bool Wait(T& value) {
std::unique_lock<std::mutex> lock(mutex_);
if (!wake_.wait_for(lock, kCaptureTimeout, [&] { return ready_; })) {
expired_ = true;
return false;
}
value = value_;
return true;
}
private:
std::mutex mutex_;
std::condition_variable wake_;
bool ready_ = false;
bool expired_ = false;
T value_{};
};
struct ContentReply {
SCShareableContent* content = nil;
NSError* error = nil;
};
struct StreamReply {
NSError* error = nil;
};
struct AudioCallbackState {
std::mutex mutex;
crossdesk::SpeakerCapturer::speaker_data_cb callback;
std::atomic<bool> running{false};
};
} // namespace
@interface SpeakerCaptureDelegate : NSObject <SCStreamDelegate, SCStreamOutput> {
std::shared_ptr<AudioCallbackState> state_;
}
- (instancetype)initWithState:(std::shared_ptr<AudioCallbackState>)state;
@end
@implementation SpeakerCaptureDelegate
- (instancetype)initWithState:(std::shared_ptr<AudioCallbackState>)state {
self = [super init];
if (self) state_ = std::move(state);
return self;
}
- (void)stream:(SCStream*)stream didStopWithError:(NSError*)error {
state_->running = false;
SPDLOG_LOGGER_WARN(crossdesk::get_logger(), "System audio stream stopped: {}",
NSErrorToString(error));
}
- (void)stream:(SCStream*)stream
didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
ofType:(SCStreamOutputType)type {
if (type != SCStreamOutputTypeAudio) return;
const auto state = state_;
std::lock_guard<std::mutex> lock(state->mutex);
if (!state->callback || !state->running) return;
CMBlockBufferRef blockBuffer = CMSampleBufferGetDataBuffer(sampleBuffer);
if (!blockBuffer) {
return;
}
size_t length = CMBlockBufferGetDataLength(blockBuffer);
char* dataPtr = NULL;
OSStatus dataStatus = CMBlockBufferGetDataPointer(blockBuffer, 0, NULL, NULL, &dataPtr);
if (dataStatus != noErr || dataPtr == nullptr || length == 0) {
return;
}
CMAudioFormatDescriptionRef formatDesc = CMSampleBufferGetFormatDescription(sampleBuffer);
if (!formatDesc) {
return;
}
const AudioStreamBasicDescription* asbd =
CMAudioFormatDescriptionGetStreamBasicDescription(formatDesc);
if (!asbd || asbd->mChannelsPerFrame == 0) {
return;
}
if (state->callback) {
std::vector<short> out_pcm16;
if (asbd->mFormatFlags & kAudioFormatFlagIsFloat) {
int channels = asbd->mChannelsPerFrame;
int samples = (int)(length / sizeof(float));
float* floatData = (float*)dataPtr;
std::vector<short> pcm16(samples);
for (int i = 0; i < samples; ++i) {
float v = floatData[i];
if (v > 1.0f) v = 1.0f;
if (v < -1.0f) v = -1.0f;
pcm16[i] = (short)(v * 32767.0f);
}
if (channels > 1) {
int mono_samples = samples / channels;
out_pcm16.resize(mono_samples);
for (int i = 0; i < mono_samples; ++i) {
int sum = 0;
for (int c = 0; c < channels; ++c) {
sum += pcm16[i * channels + c];
}
out_pcm16[i] = sum / channels;
}
} else {
out_pcm16 = std::move(pcm16);
}
} else if (asbd->mBitsPerChannel == 16) {
int channels = asbd->mChannelsPerFrame;
int samples = (int)(length / 2);
short* src = (short*)dataPtr;
if (channels > 1) {
int mono_samples = samples / channels;
out_pcm16.resize(mono_samples);
for (int i = 0; i < mono_samples; ++i) {
int sum = 0;
for (int c = 0; c < channels; ++c) {
sum += src[i * channels + c];
}
out_pcm16[i] = sum / channels;
}
} else {
out_pcm16.assign(src, src + samples);
}
}
size_t frame_bytes = 960; // 480 * 2
size_t total_bytes = out_pcm16.size() * sizeof(short);
unsigned char* p = (unsigned char*)out_pcm16.data();
for (size_t offset = 0; offset + frame_bytes <= total_bytes; offset += frame_bytes) {
if (!state->callback) {
return;
}
state->callback(p + offset, frame_bytes, "audio");
}
}
}
@end
namespace crossdesk {
class SpeakerCapturerMacosx::Impl {
public:
speaker_data_cb callback;
SCStream* stream = nil;
SpeakerCaptureDelegate* delegate = nil;
dispatch_queue_t queue = nil;
std::shared_ptr<AudioCallbackState> state;
};
SpeakerCapturerMacosx::SpeakerCapturerMacosx() : impl_(new Impl) {}
SpeakerCapturerMacosx::~SpeakerCapturerMacosx() {
Destroy();
delete impl_;
}
int SpeakerCapturerMacosx::Init(speaker_data_cb cb) {
impl_->callback = std::move(cb);
if (!impl_->queue)
impl_->queue = dispatch_queue_create("SpeakerAudio.Queue", DISPATCH_QUEUE_SERIAL);
return 0;
}
int SpeakerCapturerMacosx::Start() {
@autoreleasepool {
if (!impl_->callback) return -1;
if (IsRunning()) return 0;
if (Stop() != 0) return -1;
// A cached SCDisplay becomes invalid after hotplug, sleep or a display
// reconfiguration. Obtain a fresh snapshot on every start/reconnection.
auto content_reply = std::make_shared<CaptureReply<ContentReply>>();
[SCShareableContent
getShareableContentWithCompletionHandler:^(SCShareableContent* content, NSError* error) {
content_reply->Complete({content, error});
}];
ContentReply content;
if (!content_reply->Wait(content)) {
LOG_ERROR("Timed out refreshing system audio capture displays");
return -1;
}
if (content.error || !content.content) {
LOG_ERROR("Failed to refresh system audio capture displays: {}",
NSErrorToString(content.error));
return -1;
}
SCDisplay* display = nil;
const auto main_id = CGMainDisplayID();
for (SCDisplay* candidate in content.content.displays) {
if (candidate.displayID == main_id) {
display = candidate;
break;
}
}
if (!display) display = content.content.displays.firstObject;
if (!display) {
LOG_ERROR("No display available for system audio capture");
return -1;
}
LOG_INFO("Starting system audio capture with current display {}", display.displayID);
SCStreamConfiguration* config = [[SCStreamConfiguration alloc] init];
config.capturesAudio = YES;
config.sampleRate = 48000;
config.channelCount = 1;
SCContentFilter* filter = [[SCContentFilter alloc] initWithDisplay:display
excludingWindows:@[]];
impl_->state = std::make_shared<AudioCallbackState>();
impl_->state->callback = impl_->callback;
impl_->delegate = [[SpeakerCaptureDelegate alloc] initWithState:impl_->state];
impl_->stream = [[SCStream alloc] initWithFilter:filter
configuration:config
delegate:impl_->delegate];
NSError* output_error = nil;
if (!impl_->stream || ![impl_->stream addStreamOutput:impl_->delegate
type:SCStreamOutputTypeAudio
sampleHandlerQueue:impl_->queue
error:&output_error]) {
LOG_ERROR("Failed to attach system audio output: {}", NSErrorToString(output_error));
Stop();
return -1;
}
auto start_reply = std::make_shared<CaptureReply<StreamReply>>();
SCStream* stream = impl_->stream;
impl_->state->running = true;
[stream startCaptureWithCompletionHandler:^(NSError* error) {
if (!start_reply->Complete({error}) && !error) {
[stream stopCaptureWithCompletionHandler:nil];
}
}];
StreamReply started;
if (!start_reply->Wait(started)) {
LOG_ERROR("Timed out starting system audio capture");
Stop();
return -1;
}
if (started.error || !impl_->state->running) {
LOG_ERROR("Failed to start system audio capture: {}", NSErrorToString(started.error));
Stop();
return -1;
}
return 0;
}
}
int SpeakerCapturerMacosx::Stop() {
@autoreleasepool {
if (auto state = std::exchange(impl_->state, {})) {
std::lock_guard<std::mutex> lock(state->mutex);
state->running = false;
state->callback = nullptr;
}
SCStream* stream = impl_->stream;
impl_->stream = nil;
impl_->delegate = nil;
if (!stream) return 0;
auto stop_reply = std::make_shared<CaptureReply<StreamReply>>();
[stream stopCaptureWithCompletionHandler:^(NSError* error) {
stop_reply->Complete({error});
}];
StreamReply stopped;
if (!stop_reply->Wait(stopped)) {
LOG_ERROR("Timed out stopping system audio capture; callbacks revoked");
return -1;
}
// A start failure or daemon restart can leave an already stopped stream.
// Its references have been discarded, so future starts can recover.
if (stopped.error) LOG_WARN("System audio stop reply: {}", NSErrorToString(stopped.error));
return 0;
}
}
bool SpeakerCapturerMacosx::IsRunning() const { return impl_->state && impl_->state->running; }
int SpeakerCapturerMacosx::Destroy() {
const int result = Stop();
impl_->callback = nullptr;
impl_->queue = nil;
return result;
}
int SpeakerCapturerMacosx::Pause() { return Stop(); }
int SpeakerCapturerMacosx::Resume() { return Start(); }
} // namespace crossdesk
@@ -1,67 +0,0 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-04
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _SYSTEM_INFO_H_
#define _SYSTEM_INFO_H_
#include "platform.h"
#include <ifaddrs.h>
#include <net/if_dl.h>
#include <net/if_types.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cstdio>
#include <cstring>
#include "rd_log.h"
namespace crossdesk {
std::string GetMac() {
struct ifaddrs* addresses = nullptr;
if (getifaddrs(&addresses) != 0) return {};
std::string result;
for (struct ifaddrs* cursor = addresses; cursor; cursor = cursor->ifa_next) {
if (!cursor->ifa_addr || cursor->ifa_addr->sa_family != AF_LINK ||
std::strcmp(cursor->ifa_name, "en0") != 0) {
continue;
}
const auto* link =
reinterpret_cast<const struct sockaddr_dl*>(cursor->ifa_addr);
if (link->sdl_type != IFT_ETHER) continue;
const auto* bytes = reinterpret_cast<const unsigned char*>(
&link->sdl_data[link->sdl_nlen]);
char address[16] = {};
int length = 0;
for (int index = 0; index < link->sdl_alen; ++index) {
length += std::snprintf(address + length, sizeof(address) - length,
"%.2X", bytes[index]);
}
result = address;
break;
}
freeifaddrs(addresses);
return result;
}
std::string GetHostName() {
char hostname[256] = {};
if (gethostname(hostname, sizeof(hostname)) == -1) {
LOG_ERROR("gethostname failed");
return {};
}
return hostname;
}
bool IsWaylandSession() { return false; }
} // namespace crossdesk
#endif
-16
View File
@@ -1,16 +0,0 @@
#ifndef CROSSDESK_PLATFORM_PATH_BACKEND_H_
#define CROSSDESK_PLATFORM_PATH_BACKEND_H_
#include <filesystem>
#include <string>
namespace crossdesk::platform {
std::filesystem::path GetExecutableDirectory();
std::filesystem::path GetConfigPath(const std::string& app_name);
std::filesystem::path GetCachePath(const std::string& app_name);
std::filesystem::path GetLogPath(const std::string& app_name);
} // namespace crossdesk::platform
#endif // CROSSDESK_PLATFORM_PATH_BACKEND_H_
@@ -1,78 +0,0 @@
#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>
#include "minirtc.h"
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;
// Retains a platform-native decoded frame when the backend supports its
// storage and handle types. Unsupported backends keep the default fallback.
virtual SubmitResult SubmitNativeFrame(std::string_view,
const MiniRtcNativeVideoFrame&) {
return SubmitResult::failed;
}
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_
@@ -1,69 +0,0 @@
#include "platform/autostart_backend.h"
#include <windows.h>
#include <filesystem>
#include <string>
namespace crossdesk::platform {
namespace {
constexpr const char* kRunKey =
"Software\\Microsoft\\Windows\\CurrentVersion\\Run";
} // namespace
std::string GetAutostartExecutablePath() {
char path[32768] = {};
const DWORD length = GetModuleFileNameA(nullptr, path, sizeof(path));
return length > 0 && length < sizeof(path) ? std::string(path, length)
: std::string();
}
bool EnableAutostart(const std::string& app_name,
const std::string& executable_path) {
if (!std::filesystem::exists(executable_path)) return false;
HKEY key = nullptr;
if (RegOpenKeyExA(HKEY_CURRENT_USER, kRunKey, 0, KEY_WRITE, &key) !=
ERROR_SUCCESS) {
return false;
}
std::string value = executable_path;
if (value.find(' ') != std::string::npos &&
(value.front() != '"' || value.back() != '"')) {
value = "\"" + value + "\"";
}
const LONG result = RegSetValueExA(
key, app_name.c_str(), 0, REG_SZ,
reinterpret_cast<const BYTE*>(value.c_str()),
static_cast<DWORD>(value.size() + 1));
RegCloseKey(key);
return result == ERROR_SUCCESS;
}
bool DisableAutostart(const std::string& app_name) {
HKEY key = nullptr;
if (RegOpenKeyExA(HKEY_CURRENT_USER, kRunKey, 0, KEY_WRITE, &key) !=
ERROR_SUCCESS) {
return false;
}
const LONG result = RegDeleteValueA(key, app_name.c_str());
RegCloseKey(key);
return result == ERROR_SUCCESS || result == ERROR_FILE_NOT_FOUND;
}
bool IsAutostartEnabled(const std::string& app_name) {
HKEY key = nullptr;
if (RegOpenKeyExA(HKEY_CURRENT_USER, kRunKey, 0, KEY_READ, &key) !=
ERROR_SUCCESS) {
return false;
}
const LONG result =
RegQueryValueExA(key, app_name.c_str(), nullptr, nullptr, nullptr, nullptr);
RegCloseKey(key);
return result == ERROR_SUCCESS;
}
} // namespace crossdesk::platform
@@ -1,139 +0,0 @@
#include "clipboard.h"
#include <windows.h>
#include <string>
#include "platform/clipboard_backend.h"
#include "rd_log.h"
namespace crossdesk {
namespace {
HWND g_clipboard_window = nullptr;
constexpr const char* kClipboardWindowClass = "CrossDeskClipboardMonitor";
LRESULT CALLBACK ClipboardWindowProc(HWND window, UINT message, WPARAM wparam,
LPARAM lparam) {
if (message == WM_CLIPBOARDUPDATE) {
platform::HandleClipboardChange();
return 0;
}
return DefWindowProc(window, message, wparam, lparam);
}
} // namespace
std::string Clipboard::GetText() {
if (!OpenClipboard(nullptr)) {
LOG_ERROR("Clipboard::GetText: failed to open clipboard");
return {};
}
std::string result;
HANDLE data = GetClipboardData(CF_UNICODETEXT);
if (data) {
const wchar_t* text = static_cast<const wchar_t*>(GlobalLock(data));
if (text) {
const int size =
WideCharToMultiByte(CP_UTF8, 0, text, -1, nullptr, 0, nullptr, nullptr);
if (size > 1) {
result.resize(static_cast<size_t>(size));
WideCharToMultiByte(CP_UTF8, 0, text, -1, result.data(), size,
nullptr, nullptr);
result.resize(static_cast<size_t>(size - 1));
}
GlobalUnlock(data);
}
}
CloseClipboard();
return result;
}
bool Clipboard::SetText(const std::string& text) {
if (!OpenClipboard(nullptr)) {
LOG_ERROR("Clipboard::SetText: failed to open clipboard");
return false;
}
if (!EmptyClipboard()) {
LOG_ERROR("Clipboard::SetText: failed to empty clipboard");
CloseClipboard();
return false;
}
const int size =
MultiByteToWideChar(CP_UTF8, 0, text.c_str(), -1, nullptr, 0);
HGLOBAL memory =
size > 0 ? GlobalAlloc(GMEM_MOVEABLE, size * sizeof(wchar_t)) : nullptr;
if (!memory) {
LOG_ERROR("Clipboard::SetText: failed to allocate memory");
CloseClipboard();
return false;
}
wchar_t* output = static_cast<wchar_t*>(GlobalLock(memory));
if (!output) {
GlobalFree(memory);
CloseClipboard();
return false;
}
MultiByteToWideChar(CP_UTF8, 0, text.c_str(), -1, output, size);
GlobalUnlock(memory);
if (!SetClipboardData(CF_UNICODETEXT, memory)) {
GlobalFree(memory);
CloseClipboard();
return false;
}
CloseClipboard();
return true;
}
bool Clipboard::HasText() {
if (!OpenClipboard(nullptr)) return false;
const bool result = IsClipboardFormatAvailable(CF_UNICODETEXT) ||
IsClipboardFormatAvailable(CF_TEXT);
CloseClipboard();
return result;
}
namespace platform {
void RunClipboardMonitor(int) {
WNDCLASSA window_class = {};
window_class.lpfnWndProc = ClipboardWindowProc;
window_class.hInstance = GetModuleHandle(nullptr);
window_class.lpszClassName = kClipboardWindowClass;
RegisterClassA(&window_class);
g_clipboard_window = CreateWindowA(
kClipboardWindowClass, nullptr, 0, 0, 0, 0, 0, HWND_MESSAGE, nullptr,
nullptr, nullptr);
if (!g_clipboard_window ||
!AddClipboardFormatListener(g_clipboard_window)) {
LOG_ERROR("Failed to initialize clipboard monitor window");
if (g_clipboard_window) DestroyWindow(g_clipboard_window);
g_clipboard_window = nullptr;
return;
}
LOG_INFO("Clipboard event monitoring started (Windows)");
MSG message = {};
while (ClipboardMonitoring()) {
const BOOL result = GetMessage(&message, nullptr, 0, 0);
if (result == 0 || result == -1) break;
TranslateMessage(&message);
DispatchMessage(&message);
}
RemoveClipboardFormatListener(g_clipboard_window);
DestroyWindow(g_clipboard_window);
g_clipboard_window = nullptr;
UnregisterClassA(kClipboardWindowClass, GetModuleHandle(nullptr));
}
void WakeClipboardMonitor() {
if (g_clipboard_window) PostMessage(g_clipboard_window, WM_QUIT, 0, 0);
}
} // namespace platform
} // namespace crossdesk

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