mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-16 00:33:22 +08:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7897a003d | ||
|
|
d2478006ba | ||
|
|
7ff6c8a4ad |
+10
-12
@@ -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/**
|
||||
|
||||
@@ -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
-333
@@ -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,22 +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: Install Linux headless runtime dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends xvfb xfce4 dbus
|
||||
|
||||
- name: Package
|
||||
run: |
|
||||
chmod +x ${{ matrix.package_script }}
|
||||
@@ -194,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
|
||||
@@ -276,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
|
||||
@@ -298,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
|
||||
@@ -311,226 +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
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Select corresponding source tag
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
|
||||
echo "CROSSDESK_SOURCE_TAG=${GITHUB_REF_NAME}" >> "${GITHUB_ENV}"
|
||||
echo "CROSSDESK_RELEASE_BUILD=YES" >> "${GITHUB_ENV}"
|
||||
fi
|
||||
|
||||
- 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-source-v1-xcode26.6-xrepo1211093-min16-${{ github.run_id }}"
|
||||
restore-keys: |
|
||||
${{ runner.os }}-xmake${{ env.XMAKE_VERSION }}-deps-ios-arm64-source-v1-xcode26.6-xrepo1211093-min16-
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Verify iOS license catalog
|
||||
run: |
|
||||
python3 -B -m unittest discover -s apps/ios/tests -p 'third_party_licenses_test.py' -v
|
||||
python3 -B apps/ios/scripts/generate_third_party_licenses.py
|
||||
|
||||
- name: Verify iOS privacy controls
|
||||
run: python3 -B -m unittest discover -s apps/ios/tests -p 'privacy_controls_test.py' -v
|
||||
|
||||
- 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
|
||||
|
||||
VERIFY_FLAGS=()
|
||||
if [[ "${CROSSDESK_RELEASE_BUILD:-NO}" == "YES" ]]; then
|
||||
VERIFY_FLAGS+=(--require-release)
|
||||
fi
|
||||
python3 apps/ios/scripts/verify_app_bundle.py "${APP_PATH}" "${VERIFY_FLAGS[@]}"
|
||||
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}/"
|
||||
cp apps/ios/licenses/sources.json "${PACKAGE_DIR}/ThirdPartySources.json"
|
||||
cp apps/ios/licenses/OPEN_SOURCE_NOTICE.txt "${PACKAGE_DIR}/OPEN_SOURCE_NOTICE.txt"
|
||||
cp "${APP_PATH}/SourceMetadata.json" "${PACKAGE_DIR}/SourceMetadata.json"
|
||||
|
||||
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
|
||||
@@ -640,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: |
|
||||
@@ -650,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: |
|
||||
@@ -713,10 +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"),
|
||||
@("slint_cpp.dll", "slint_cpp.dll")
|
||||
@("crossdesk_session_helper.exe", "crossdesk_session_helper.exe")
|
||||
)
|
||||
|
||||
foreach ($file in $portableFiles) {
|
||||
@@ -730,13 +439,6 @@ jobs:
|
||||
|
||||
Copy-Item (Join-Path $buildDir "*.dll") $portableDir -Force
|
||||
|
||||
# Bundled Amyuni usbmmidd_v2 virtual display driver (headless hosts)
|
||||
$driverDir = Join-Path $buildDir "usbmmidd_v2"
|
||||
if (!(Test-Path (Join-Path $driverDir "deviceinstaller64.exe"))) {
|
||||
throw "Missing bundled usbmmidd_v2 driver package: $driverDir"
|
||||
}
|
||||
Copy-Item $driverDir (Join-Path $portableDir "usbmmidd_v2") -Recurse -Force
|
||||
|
||||
foreach ($file in $portableFiles) {
|
||||
$packagedFile = Join-Path $portableDir $file[1]
|
||||
if (!(Test-Path $packagedFile)) {
|
||||
@@ -750,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
|
||||
@@ -761,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:
|
||||
@@ -817,21 +519,6 @@ jobs:
|
||||
echo "BUILD_DATE_ISO=${BUILD_DATE_ISO}" >> $GITHUB_OUTPUT
|
||||
echo "PATCH_NUMBER=${PATCH_NUMBER}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Prepare release notes
|
||||
run: |
|
||||
cat > "$RUNNER_TEMP/release-notes.md" << EOF
|
||||
## Corresponding source
|
||||
|
||||
Source for these binaries: [${GITHUB_REF_NAME}](https://github.com/${GITHUB_REPOSITORY}/tree/${GITHUB_SHA}).
|
||||
Clone that version with recursive submodules. The repository includes the build scripts and dependency patches.
|
||||
[iOS source, modification and rebuild instructions](https://github.com/${GITHUB_REPOSITORY}/blob/${GITHUB_SHA}/apps/ios/README.md).
|
||||
The iOS archive also contains the exact source revision and third-party source catalog with archive checksums.
|
||||
|
||||
## Code signing policy
|
||||
|
||||
Free code signing provided by [SignPath.io](https://signpath.io/), certificate by [SignPath Foundation](https://signpath.org/).
|
||||
EOF
|
||||
|
||||
- name: Rename artifacts
|
||||
run: |
|
||||
mkdir -p release
|
||||
@@ -839,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
|
||||
|
||||
@@ -850,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: ${{ runner.temp }}/release-notes.md
|
||||
body: |
|
||||
Binary release only. Source code is not included.
|
||||
|
||||
- name: Create or update 'latest' tag
|
||||
run: |
|
||||
@@ -874,7 +560,6 @@ jobs:
|
||||
prerelease: false
|
||||
files: release/*
|
||||
generate_release_notes: false
|
||||
body_path: ${{ runner.temp }}/release-notes.md
|
||||
|
||||
- name: Upload artifacts to server
|
||||
uses: burnett01/rsync-deployments@5.2
|
||||
@@ -925,11 +610,6 @@ jobs:
|
||||
}
|
||||
}
|
||||
EOF
|
||||
jq --arg release_name "Release ${{ steps.version.outputs.VERSION_WITH_V }}" \
|
||||
--rawfile release_notes "$RUNNER_TEMP/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
@@ -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
@@ -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
|
||||
|
||||
-147
@@ -1,147 +0,0 @@
|
||||
# CrossDesk Privacy Policy
|
||||
|
||||
Last updated: September 28, 2026
|
||||
|
||||
[中文](#中文) | [English](#english)
|
||||
|
||||
## 中文
|
||||
|
||||
更新日期:2026 年 9 月 28 日
|
||||
|
||||
CrossDesk 重视您的个人信息与隐私保护。本政策说明我们在提供远程桌面服务时如何处理相关信息,以及您可以如何管理授权、保存的内容和数据请求。请在使用前阅读并了解本政策。
|
||||
|
||||
### 一、适用范围
|
||||
|
||||
本政策适用于 CrossDesk 桌面客户端、iOS 客户端,以及由 CrossDesk 项目维护者(以下简称“我们”)提供的默认网络服务。
|
||||
|
||||
如果您选择自行部署或使用其他运营者提供的服务器,该服务器所处理的信息、保存期限及安全管理由相应运营者负责。请在使用前了解其隐私政策。本政策中有关客户端本地数据处理的说明仍适用。
|
||||
|
||||
### 二、我们处理的信息及用途
|
||||
|
||||
**设备连接与身份验证。** 为登记设备、验证连接权限、查询在线状态及建立远程连接,客户端与服务端会处理设备 ID、认证信息及在线状态。服务端还会保存设备身份及认证校验信息、在线与离线时间、客户端版本与平台、设备关联记录,并生成必要的运行和安全日志,用于维持服务、排查故障及防止滥用。
|
||||
|
||||
**必要的网络信息。** 建立连接、网络地址转换穿透及中继转发需要处理 IP 地址、端口等网络信息。这些信息用于通信和网络诊断,不等同于读取设备的定位权限。连接服务、中继服务及网络请求可能产生相应日志。
|
||||
|
||||
**远程会话内容。** 您发起或接受远程连接后,软件根据使用的功能在参与会话的设备之间传输画面、音频、键盘和鼠标操作、剪贴板文本及文件。会话内容可能包含个人或工作信息,请仅连接您拥有或已获授权使用的设备,并按需管理共享内容。
|
||||
|
||||
**版本检查。** 桌面客户端启动后会获取版本信息;iOS 客户端在连接成功并收到远端版本信息后,会获取最新桌面版本信息,用于提示升级。该请求不携带设备 ID 或远端版本号,但网络服务会处理完成请求所必需的网络信息。
|
||||
|
||||
CrossDesk 未集成广告投放、广告追踪或用户行为分析功能。客户端不会主动上传诊断日志、崩溃报告或用户行为分析数据。您主动向我们提交问题或日志时,请先移除与问题无关的个人信息及连接凭据。
|
||||
|
||||
### 三、数据传输与相关服务
|
||||
|
||||
我们提供设备登记、连接协商、中继转发及版本检查服务。即使您使用自托管连接服务,客户端仍会按前述条件访问我们提供的版本服务。
|
||||
|
||||
远程会话内容优先在参与会话的设备间直接传输,必要时通过配置的中继服务器转发。中继服务用于实时转发,不提供会话录像或文件归档。客户端本地保存的画面预览和接收文件,按本政策相关说明处理。
|
||||
|
||||
自托管服务器由您选择的运营者管理。更换连接服务器后,设备登记、连接协商及中继数据将由相应运营者处理。
|
||||
|
||||
### 四、信息保存与安全措施
|
||||
|
||||
**客户端本地数据。** 软件会在本机保存连接设置、用户偏好、最近连接记录及必要的运行日志,以支持后续使用和故障排查。iOS 中的本机登录凭据及您选择记住的远程连接密码保存在系统钥匙串中,不通过 iCloud 钥匙串同步;旧版偏好中的本机登录凭据在成功迁入钥匙串后删除。
|
||||
|
||||
**远程画面预览。** iOS 默认不保存远程画面预览。只有您在“设置 → 隐私”单独开启并确认后,才会从下一次连接起,每次连接保存一张远程画面,用于最近连接预览。预览仅保存在本机,不上传,并设置为不纳入设备备份。您可以单独清除预览;关闭保存或撤回隐私授权也会清除已有预览。旧版本未经此选择保存的预览会在升级后清理。
|
||||
|
||||
**接收的文件。** iOS 接收文件保存在本应用的文件目录中,您可通过系统“文件”应用管理或删除。清除画面预览不会删除接收文件或最近连接记录。
|
||||
|
||||
**服务端记录。** 为支持再次连接、服务运维和安全排查,设备身份、状态、关联记录及运行日志可能在连接结束后继续保存。保存范围和期限受服务运行需要、部署规则及适用的保留要求约束。您可以通过下述联系方式查询与您设备相关的信息及保存期限,或提出删除请求。撤回隐私授权、删除本地记录或卸载客户端,不会自动删除已产生的服务端记录;日志、备份和快照需分别核查和处理。
|
||||
|
||||
iOS 通过系统钥匙串和文件保护机制保护相应本地数据,默认信令连接进行 TLS 证书校验。请妥善保管设备和连接凭据,避免向无关人员共享访问密码。
|
||||
|
||||
### 五、iOS 权限与授权选择
|
||||
|
||||
**隐私授权。** 首次使用,或从未提供此选择的旧版升级时,您需阅读本政策、主动勾选并点击同意后,客户端才会登记本机身份、连接信令服务和查询设备在线状态。拒绝后仍可查看设置、离线隐私政策和本地连接记录,远程连接功能暂不可用。
|
||||
|
||||
**撤回同意。** 您可以在“设置 → 隐私 → 隐私与授权”中查看隐私政策、授权状态或撤回授权。客户端将停止远程会话、信令连接和版本检查,关闭并清除本地画面预览。再次使用连接功能前需重新同意;重新同意不会自动开启画面预览保存。撤回不影响撤回前已经发生的信息处理,也不等同于删除服务端数据。
|
||||
|
||||
**系统权限与会话功能。** iOS 客户端作为控制端接收远程电脑的画面与音频,不采集本机屏幕、摄像头或麦克风。局域网访问权限用于连接本地网络中的设备,您可在系统设置中管理。发送文件通过系统文件选择器选择,不要求开放整个文件库。当前 iOS 界面不读取或发送本机剪贴板文本;远端发送的剪贴板文本可在会话中写入本机剪贴板。
|
||||
|
||||
应用及通信组件使用本地文件元数据、应用偏好和计时接口实现文件传输、设置保存及网络计时,不使用这些信息制作设备指纹或进行广告追踪。
|
||||
|
||||
### 六、查询与删除请求
|
||||
|
||||
您可以管理本地最近连接记录、保存的画面预览及接收文件。删除某条最近连接记录时,其预览及已保存的远程连接密码也会删除。
|
||||
|
||||
如需查询、获取数据摘要或删除默认服务中与您设备相关的记录,请通过下述邮箱联系我们,说明设备 ID 和请求范围。为避免未经授权的披露或删除,我们会核实请求权限;请勿通过邮件发送访问密码、私钥或其他敏感凭据。
|
||||
|
||||
删除服务端设备身份后,原身份及对应凭据将失效,再次连接可能登记为新设备。数据库记录的删除不表示日志、备份或其他设备上已接收的内容同时删除。使用自托管服务时,请向相应服务器运营者提出服务端数据请求。
|
||||
|
||||
### 七、政策更新与联系方式
|
||||
|
||||
本政策可能随功能及服务方式调整而更新,更新后的内容和日期将在隐私政策中公布。您可随时在应用设置中查阅当前随应用提供的政策文本。
|
||||
|
||||
如对本政策或个人信息处理有疑问,请联系:
|
||||
|
||||
邮箱:[junkun.di@hotmail.com](mailto:junkun.di@hotmail.com)
|
||||
|
||||
---
|
||||
|
||||
## English
|
||||
|
||||
Updated: September 28, 2026
|
||||
|
||||
CrossDesk respects your privacy. This policy explains how information is processed when we provide remote desktop services and how you can manage consent, locally saved content and data requests. Please read this policy before using the software.
|
||||
|
||||
### 1. Scope
|
||||
|
||||
This policy applies to the CrossDesk desktop and iOS clients and the default network services provided by the CrossDesk project maintainers ("we" or "us").
|
||||
|
||||
If you deploy your own server or choose a server provided by another operator, that operator is responsible for its data processing, retention and security practices. Please review its privacy policy before use. The descriptions of local client data processing in this policy still apply.
|
||||
|
||||
### 2. Information processed and its purposes
|
||||
|
||||
**Device connections and authentication.** To register devices, verify connection permissions, query availability and establish remote connections, the clients and server process device IDs, authentication information and presence. The server also retains device identity and authentication verification records, online and offline times, client versions and platforms, and device associations. Necessary operational and security logs support service operation, troubleshooting and abuse prevention.
|
||||
|
||||
**Necessary network information.** Connections, network address translation traversal and relay forwarding require IP addresses, ports and other network information. This supports communication and network diagnostics and does not involve reading the device's location permission. Connection services, relay services and network requests may generate related logs.
|
||||
|
||||
**Remote-session content.** After you initiate or accept a remote connection, the software transfers content between participating devices according to the features used. This includes screens, audio, keyboard and mouse input, clipboard text and files. Such content may contain personal or work information. Connect only to devices you own or are authorized to access, and manage shared content accordingly.
|
||||
|
||||
**Version checks.** The desktop client retrieves release information after startup. After connecting and receiving the remote host's version, the iOS client retrieves the latest desktop release information to suggest updates. These requests do not contain device IDs or the remote host's version, but the service processes network information necessary to complete the request.
|
||||
|
||||
CrossDesk does not integrate advertising, advertising tracking or behavioral analytics. The clients do not proactively upload diagnostic logs, crash reports or behavioral analytics data. If you choose to submit an issue or logs, remove unrelated personal information and connection credentials first.
|
||||
|
||||
### 3. Data transmission and related services
|
||||
|
||||
We provide device registration, connection negotiation, relay forwarding and version checks. Even when you use a self-hosted connection service, the client still accesses our version service under the conditions described above.
|
||||
|
||||
Remote-session content is transmitted directly between participating devices where possible, or through the configured relay server when needed. The relay service forwards content in real time and does not provide session recording or file archiving. Locally saved previews and received files are handled as described in this policy.
|
||||
|
||||
Self-hosted servers are managed by the operator you choose. If you change the connection server, the corresponding operator will process device registration, connection negotiation and relay data.
|
||||
|
||||
### 4. Storage and security measures
|
||||
|
||||
**Local client data.** The software stores connection settings, preferences, recent connections and necessary operational logs on the device for subsequent use and troubleshooting. On iOS, local login credentials and remote connection passwords you choose to remember are stored in the system Keychain without iCloud Keychain synchronization. Legacy local login credentials in preferences are removed after successful migration to the Keychain.
|
||||
|
||||
**Remote desktop previews.** Saving previews is off by default on iOS. Only after you separately enable and confirm it in Settings → Privacy will the app save one remote frame per connection, starting with the next connection, for recent-connection previews. Previews remain local, are not uploaded and are marked as excluded from device backups. You can clear them separately. Disabling saving or withdrawing network consent also clears them. Previews saved by older versions without this choice are cleared on upgrade.
|
||||
|
||||
**Received files.** Received files on iOS are stored in the app's file directory and can be managed or deleted through Files. Clearing previews does not delete received files or recent-connection records.
|
||||
|
||||
**Server records.** Device identities, presence, associations and operational logs may remain after a connection ends to support reconnection, service operation and security troubleshooting. Their scope and retention depend on operational needs, deployment rules and applicable retention requirements. You can contact us below to ask about records and retention periods related to your device or request deletion. Withdrawing network consent, deleting local records or uninstalling the client does not automatically delete existing server records. Logs, backups and snapshots require separate review and handling.
|
||||
|
||||
On iOS, the system Keychain and file protection mechanisms protect the relevant local data, and default signaling connections validate TLS certificates. Keep your devices and connection credentials secure and avoid sharing access passwords with unrelated people.
|
||||
|
||||
### 5. iOS permissions and consent choices
|
||||
|
||||
**Privacy authorization.** On first use, or when upgrading from a version without this choice, you must read this policy, actively select the checkbox and agree before the client registers a local identity, connects to signaling or queries device presence. If you decline, settings, the offline policy and local connection history remain available, while remote connections are unavailable.
|
||||
|
||||
**Withdrawal.** You can review the privacy policy and your authorization status or withdraw consent in Settings → Privacy → Privacy and Authorization. The client stops remote sessions, signaling and version checks, disables preview saving and clears local previews. Consent is required again before reconnecting. Agreeing again does not automatically enable preview saving. Withdrawal does not undo processing that already occurred or delete server data.
|
||||
|
||||
**System permissions and session features.** The iOS client receives remote computer screens and audio as a controller; it does not capture the local screen, camera or microphone. Local network access supports connections to devices on your network and can be managed in system settings. File sending uses the system document picker without requiring access to the entire file library. The current iOS interface does not read or send local clipboard text. Text sent by the remote device may be written to the local clipboard during a session.
|
||||
|
||||
The app and its communication components use local file metadata, preferences and timing APIs for file transfer, settings and network timing, not for device fingerprinting or advertising tracking.
|
||||
|
||||
### 6. Access and deletion requests
|
||||
|
||||
You can manage local recent connections, saved previews and received files. Removing a recent connection also removes its preview and remembered remote connection password.
|
||||
|
||||
To request access to information, a data summary or deletion of records associated with your device in the default service, contact the email address below with the device ID and scope of your request. We verify the requester's authority to avoid unauthorized disclosure or deletion. Do not send access passwords, private keys or other sensitive credentials by email.
|
||||
|
||||
Deleting a server-side device identity invalidates that identity and its credentials; reconnecting may register a new device. Deleting database records does not mean that logs, backups or content already received by other devices are also deleted. For a self-hosted service, direct server-side data requests to its operator.
|
||||
|
||||
### 7. Policy changes and contact
|
||||
|
||||
This policy may be updated as features and services change. Updated text and its date will be published in the privacy policy. You can review the policy bundled with your app in its settings at any time.
|
||||
|
||||
For questions about this policy or the processing of personal information, contact:
|
||||
|
||||
Email: [junkun.di@hotmail.com](mailto:junkun.di@hotmail.com)
|
||||
@@ -1,237 +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>
|
||||
[]()
|
||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||
[](https://github.com/kunkundi/crossdesk/commits/web-client)
|
||||
[](https://github.com/kunkundi/crossdesk/actions)
|
||||
[](https://hub.docker.com/r/crossdesk/crossdesk-server/tags)
|
||||
[]()
|
||||
[]()
|
||||
[]()
|
||||
|
||||
[](#download)
|
||||
[](https://github.com/kunkundi/crossdesk/releases)
|
||||
[](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml)
|
||||
[](LICENSE)
|
||||
[](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 客户端
|
||||

|
||||
|
||||
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,点击“→”即可发起远程连接。
|
||||
|
||||

|
||||
|
||||
如果远端桌面设置了连接密码,则本端需填写正确的连接密码才能成功发起远程连接。
|
||||
|
||||

|
||||
|
||||
发起连接前,可在设置中自定义配置项,如语言、视频编码格式等。
|
||||

|
||||
|
||||
### 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 支持锁屏、登录界面及安全桌面的输入转发 |
|
||||
| Linux 无头运行 | 复用已有桌面和应用,或创建独立 Xvfb 虚拟桌面;终端控制台可查看状态、修改密码和设置,见 [运行说明](docs/HEADLESS.md) |
|
||||
|
||||
各客户端的入口和能力有所不同;桌面会话操作见下文,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 运行,并确认已连接服务器。
|
||||
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` 依赖远端服务 |
|
||||
| 鼠标 | 控制 / 释放远端鼠标 |
|
||||
| 扬声器 | 播放声音 / 静音 |
|
||||
| 文件夹 | 选择文件发送到远端,并查看传输进度 |
|
||||
| 画面设置 | 在当前会话中调整画面质量、采集帧率与画面偏好 |
|
||||
| 网络状态 | 查看流量、丢包率、帧率、分辨率、直连 / 中继及加密状态 |
|
||||
| 隐私屏 | 开启或关闭被控电脑的隐私屏;悬停查看当前状态与可用操作 |
|
||||
| 全屏 | 进入 / 退出全屏 |
|
||||
| 断开连接 | 结束当前远程会话 |
|
||||
|
||||
桌面端可同步文本剪贴板。接收文件的位置可在“设置 → 文件保存路径”调整。关闭主窗口会隐藏窗口并保持程序运行;需要完全退出时使用系统托盘或菜单栏中的退出入口。
|
||||
|
||||
### 在会话中调整画面
|
||||
|
||||
连接成功后,点击工具栏中的 **画面设置**(滑杆图标),直接选择低 / 中 / 高画质、**30 / 60 fps**,以及帧率优先 / 画质优先 / 平衡。选项会发送给当前被控端,无需断开重连;实际画面效果取决于网络、设备与编码器能力。旧版被控端不支持时,菜单会提示并禁用选项。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-stream-video-settings-zh.png" width="800" alt="当前远程会话的画面设置菜单:画面质量、采集帧率与画面偏好;背景为公开页面演示图" />
|
||||
</p>
|
||||
|
||||
<a id="settings"></a>
|
||||
|
||||
## 客户端设置与更新
|
||||
|
||||
点击主窗口右上角 **☰ → 设置**,修改后点击“确认”保存。编码与连接选项建议在建立连接前调整;会话进行中部分选项不可修改。设置列表可向下滚动,查看自托管、启动方式与文件保存路径。画质、帧率和画面偏好在远程会话工具栏中调整。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-settings-zh.png" width="720" alt="当前客户端设置:编码格式、硬件编解码、中继、强制中继与自动隐私屏" />
|
||||
</p>
|
||||
|
||||
| 设置 | 说明 |
|
||||
| --- | --- |
|
||||
| 语言 | 中文、English、Русский |
|
||||
| 采集方式(Windows) | 自动 / DXGI / WGC / GDI;无显示器主机使用“自动”启用自动适配 |
|
||||
| 视频编码格式 | H.264 / AV1;硬件编解码是否可用取决于平台、硬件与构建选项 |
|
||||
| 启用中继服务 | 允许借助 TURN 中继建立连接;P2P 连接失败时先检查此项 |
|
||||
| 强制中继连接 | 启用中继服务后可勾选,让连接通过中继建立;关闭中继服务会同时关闭此项 |
|
||||
| 保存远程画面预览 | 默认开启,保存远程画面预览;关闭后缩略图区域显示被控端平台,并清除已有预览,保留连接记录和密码 |
|
||||
| 被连接时开启隐私屏 | 设置本机被远程连接时是否自动启用隐私屏;会话中可从控制端工具栏切换 |
|
||||
| 自托管配置 | 填写服务器地址和信令服务端口,并勾选启用 |
|
||||
| 开机自启 / 启用守护进程 | 调整启动与进程守护行为,按界面提示重启后生效 |
|
||||
| 文件保存路径 | 选择接收文件的目录 |
|
||||
|
||||
### 检查更新
|
||||
|
||||
客户端启动后会在后台检查版本。发现新版本时,主窗口菜单入口会显示更新提示;打开 **☰ → 关于** 可查看版本,也可点击 **检查更新** 手动检查。发现更新后可查看更新说明并前往下载页面。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-about-zh.png" width="720" alt="关于窗口与检查更新按钮,版本号和检查结果为演示数据" />
|
||||
</p>
|
||||
|
||||
<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
-195
@@ -1,237 +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>
|
||||
[]()
|
||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||
[](https://github.com/kunkundi/crossdesk/commits/web-client)
|
||||
[](https://github.com/kunkundi/crossdesk/actions)
|
||||
[](https://hub.docker.com/r/crossdesk/crossdesk-server/tags)
|
||||
[]()
|
||||
[]()
|
||||
[]()
|
||||
|
||||
[](#download)
|
||||
[](https://github.com/kunkundi/crossdesk/releases)
|
||||
[](https://github.com/kunkundi/crossdesk/actions/workflows/build.yml)
|
||||
[](LICENSE)
|
||||
[](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
|
||||

|
||||
|
||||
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, hardware codecs, and remote audio; adjust quality, 30 / 60 fps, and video preference during a session |
|
||||
| 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 and forced relay, SRTP media encryption, and self-hosted signaling/relay services |
|
||||
| Privacy screen | Enable automatically when connected to, or toggle from the session toolbar; availability depends on the host OS and capture method |
|
||||
| Windows protected desktops | CrossDesk Service forwards input on lock screens, sign-in screens, and secure desktops |
|
||||
| Linux headless operation | Reuse existing desktops and applications or create separate Xvfb desktops; use the terminal console to check status and change passwords or settings; see [setup](docs/HEADLESS_EN.md) |
|
||||
|
||||
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
|
||||

|
||||
|
||||
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/) |
|
||||

|
||||
|
||||
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"
|
||||

|
||||
|
||||
### 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 refresh button generates a new random password, and the pencil button lets you edit it. Passwords use **6 ASCII letters or digits**. Stay connected to the server when editing or refreshing a password. 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.
|
||||
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 |
|
||||
| Video settings | Adjust video quality, capture frame rate, and video preference during the current session |
|
||||
| Network statistics | Inspect traffic, packet loss, frame rate, resolution, direct / relay mode, and encryption status |
|
||||
| Privacy screen | Enable or disable the host's privacy screen; hover to see its status and available actions |
|
||||
| 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.
|
||||
|
||||
### Adjust video during a session
|
||||
|
||||
After connecting, open **Video settings** (the sliders icon) in the toolbar. Choose low / medium / high quality, **30 / 60 fps**, and frame-rate priority / quality priority / balanced adaptation. Changes are sent to the current host without reconnecting; actual results depend on the network, device, and encoder. For older hosts that do not support live video settings, the menu explains the limitation and disables the options.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-stream-video-settings-en.png" width="800" alt="Current session video menu with quality, capture frame rate, and video preference; the background is a public page used for demonstration" />
|
||||
</p>
|
||||
|
||||
<a id="settings"></a>
|
||||
|
||||
## Client settings and updates
|
||||
|
||||
Open **☰ → Settings** in the top-right corner and click **OK** to save. Configure codecs and connections before connecting; some settings are disabled during an active session. Scroll down for self-hosting, startup behavior, and the file save path. Adjust quality, frame rate, and video preference from the remote session toolbar.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-settings-en.png" width="720" alt="Current client settings with codec, hardware codecs, TURN, forced relay, and automatic privacy screen options" />
|
||||
</p>
|
||||
|
||||
| Setting | Purpose |
|
||||
| --- | --- |
|
||||
| Language | 中文, English, Русский |
|
||||
| Capture Method (Windows) | Auto / DXGI / WGC / GDI; choose Auto for automatic headless adaptation |
|
||||
| Video Encode Format | H.264 / AV1; hardware codec availability depends on the platform, device, and build options |
|
||||
| Enable TURN Service | Allow relay-assisted connections; check this setting if P2P fails |
|
||||
| Force Relay | Require relay connections when TURN is enabled; turning TURN off also disables this option |
|
||||
| Save remote screen previews | On by default. When disabled, the thumbnail area shows the remote device’s platform and existing previews are cleared; connection records and passwords are preserved |
|
||||
| Privacy screen on connect | Automatically enable privacy when this computer is controlled remotely; the controller can toggle it from the session toolbar |
|
||||
| Self-Hosted Config | Set the server address and signaling 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 |
|
||||
|
||||
### Check for updates
|
||||
|
||||
The client checks for updates in the background after launch. A new version adds an update indicator to the main window menu. Open **☰ → About** to view the version or click **Check for updates** to check manually. When an update is available, you can view its release notes and open the download page.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/desktop-about-en.png" width="720" alt="About dialog with the Check for updates button; version and check result are demonstration data" />
|
||||
</p>
|
||||
|
||||
<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,12 +0,0 @@
|
||||
Copyright 2014-2021 Amyuni Technologies Inc.
|
||||
https://www.amyuni.com
|
||||
|
||||
This software is provided 'as-is', without any express or implied warranty. In no event will the authors be held liable for any damages arising from the use of this software.
|
||||
By using this software, you accept to be prompted with an advertisement page which is not always under the control of the author. A fully commercial version is available for users who do not wisth to see those advertisements.
|
||||
|
||||
Permission is granted to anyone to use this software for any purpose subject to the following restrictions:
|
||||
|
||||
1. The origin of this software must not be misrepresented; you must not claim that you wrote the original software.
|
||||
If you use this software in a product, an acknowledgment in the product documentation would be required.
|
||||
2. Altered versions must be plainly marked as such, and must not be misrepresented as being the original software.
|
||||
3. This notice may not be removed or altered from any distribution.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,50 +0,0 @@
|
||||
Procedure to add a virtual monitor to your system (Windows 10 Only!)
|
||||
|
||||
- Download our virtual display driver from https://www.amyuni.com/downloads/usbmmidd_v2.zip
|
||||
- Unpack the zip file to an empty folder, e.g. c:\temp\usbmmidd
|
||||
- Make sure you read the License.txt file as with any other software product
|
||||
- Open a command prompt window as Administrator (you cannot add a device to your system unless you "Run As Administrator")
|
||||
- Run the following commands:
|
||||
- cd c:\temp\usbmmid (or whatever destination folder you chose)
|
||||
- deviceinstaller64 install usbmmidd.inf usbmmidd (If you are on a 32-bit system, replace "deviceinstaller64" by "deviceinstaller")
|
||||
- Make sure you see the message that the drivers are signed by Amyuni Technologies Inc. This is a confirmation that the drivers went through Microsoft driver signing procedure and are virus free
|
||||
|
||||
- Starting with version 2, the virtual monitor supports the following display resolutions by default:
|
||||
|
||||
1024 x 768 pixels
|
||||
1360 x 768 pixels
|
||||
1440 x 900 pixels
|
||||
1600 x 900 pixels
|
||||
1600 x 1200 pixels
|
||||
1920 x 1080 pixels [Default Resolution]
|
||||
1920 x 1200 pixels
|
||||
2560 x 1440 pixels
|
||||
3840 x 2160 pixels
|
||||
|
||||
After the first step above is executed, you can modify the list of supported resolutions by changing the following registry key:
|
||||
HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\WUDF\Services\usbmmIdd\Parameters\Monitors
|
||||
|
||||
Up to 10 resolutions can be defined through this registry key. The keys should be named from "0" to "9". The (Default) registry value defines the default monitor resolution.
|
||||
|
||||
Note that the list of resolutions can also be found in the usbmmidd.inf file that accompanies the printer driver. The inf file should not be modified in any way, otherwise the virtual display driver digital signature is invalidated.
|
||||
|
||||
Once you are satisfied with the list of resolutions, you can activate the virtual monitor by running:
|
||||
- deviceinstaller64 enableidd 1
|
||||
|
||||
With version 2, rou can run this command multiple times to add up to 4 virtual monitors to your system.
|
||||
|
||||
The included batch file usbmmidd.bat runs these commands automatically and selects between the 32- or 64-bit version of deviceinstaller.
|
||||
If you are not comfortable opening a command prompt and entering these commands, right-click on usbmmidd.bat and "Run As Administrator."
|
||||
|
||||
That's it. You now have a high definition monitor in your Display Settings. You can change the display resolution, position the monitor, extend the desktop or do whatever you do with a real monitor.
|
||||
|
||||
To deactivate the virtual monitor, run the command:
|
||||
deviceinstaller64 enableidd 0
|
||||
If ou have added more than one monitor, you should run this command multiple times to remove all monitors.
|
||||
|
||||
to reactivate a virtual monitor:
|
||||
deviceinstaller64 enableidd 1
|
||||
|
||||
To completely remove the drivers from your system, you can either go through the Device Manager and uninstall "USB Mobile Monitor Virtual Display", or run the commands:
|
||||
deviceinstaller64 stop usbmmidd
|
||||
deviceinstaller64 remove usbmmid
|
||||
Binary file not shown.
@@ -1,16 +0,0 @@
|
||||
@cd /d "%~dp0"
|
||||
|
||||
@goto %PROCESSOR_ARCHITECTURE%
|
||||
@exit
|
||||
|
||||
:AMD64
|
||||
@cmd /c deviceinstaller64.exe install usbmmidd.inf usbmmidd
|
||||
@cmd /c deviceinstaller64.exe enableidd 1
|
||||
@goto end
|
||||
|
||||
:x86
|
||||
@cmd /c deviceinstaller.exe install usbmmidd.inf usbmmidd
|
||||
@cmd /c deviceinstaller.exe enableidd 1
|
||||
|
||||
:end
|
||||
@pause
|
||||
Binary file not shown.
Binary file not shown.
@@ -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"
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,88 +0,0 @@
|
||||
#include "task_queue_lock_free.h"
|
||||
|
||||
#include <concurrentqueue.h>
|
||||
|
||||
#include <exception>
|
||||
#include <new>
|
||||
#include <stdexcept>
|
||||
#include <utility>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
struct TaskQueueLockFree::QueueStorage {
|
||||
moodycamel::ConcurrentQueue<TaskItem> tasks;
|
||||
// All submissions share one producer so different caller threads keep FIFO.
|
||||
moodycamel::ProducerToken producer{tasks};
|
||||
};
|
||||
|
||||
TaskQueueLockFree::TaskQueueLockFree()
|
||||
: queue_(std::make_unique<QueueStorage>()),
|
||||
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 (!queue_->tasks.enqueue(queue_->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 (queue_->tasks.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,55 +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 <memory>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
|
||||
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();
|
||||
|
||||
struct QueueStorage;
|
||||
std::unique_ptr<QueueStorage> queue_;
|
||||
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,455 +0,0 @@
|
||||
#include "config_center.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
|
||||
#include "SimpleIni.h"
|
||||
#include "autostart.h"
|
||||
#include "rd_log.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
struct ConfigCenter::IniStorage : CSimpleIniA {};
|
||||
|
||||
namespace {
|
||||
|
||||
bool IsValidTurnModeValue(long value) {
|
||||
return value >= static_cast<long>(ConfigCenter::TURN_MODE::DISABLED) &&
|
||||
value <= static_cast<long>(ConfigCenter::TURN_MODE::FORCE_TCP);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ConfigCenter::ConfigCenter(const std::string& config_path)
|
||||
: config_path_(config_path), ini_(std::make_unique<IniStorage>()) {
|
||||
ini_->SetUnicode(true);
|
||||
Load();
|
||||
}
|
||||
|
||||
ConfigCenter::~ConfigCenter() {}
|
||||
|
||||
int ConfigCenter::Load() {
|
||||
SI_Error rc = ini_->LoadFile(config_path_.c_str());
|
||||
if (rc < 0) {
|
||||
Save();
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool persist_config_migration = false;
|
||||
// These options now belong only to the active remote connection. Discard
|
||||
// legacy local defaults so they cannot affect this host or new connections.
|
||||
persist_config_migration |= ini_->Delete(section_, "video_quality");
|
||||
persist_config_migration |= ini_->Delete(section_, "video_frame_rate");
|
||||
persist_config_migration |= ini_->Delete(section_, "video_adaptation_policy");
|
||||
persist_config_migration |= ini_->Delete(section_, "video_content_type");
|
||||
persist_config_migration |= ini_->Delete(section_, "screen_content");
|
||||
persist_config_migration |=
|
||||
ini_->Delete(section_, "enable_desktop_quality_optimization");
|
||||
persist_config_migration |=
|
||||
ini_->Delete(section_, "enable_minimize_to_tray");
|
||||
|
||||
const long language_value =
|
||||
ini_->GetLongValue(section_, "language", static_cast<long>(language_));
|
||||
if (language_value < static_cast<long>(LANGUAGE::CHINESE) ||
|
||||
language_value > static_cast<long>(LANGUAGE::RUSSIAN)) {
|
||||
language_ = LANGUAGE::ENGLISH;
|
||||
} else {
|
||||
language_ = static_cast<LANGUAGE>(language_value);
|
||||
}
|
||||
|
||||
const long screen_capture_method_value = ini_->GetLongValue(
|
||||
section_, "screen_capture_method",
|
||||
static_cast<long>(ScreenCaptureMethod::Auto));
|
||||
screen_capture_method_ = IsValidScreenCaptureMethod(screen_capture_method_value)
|
||||
? static_cast<ScreenCaptureMethod>(
|
||||
screen_capture_method_value)
|
||||
: ScreenCaptureMethod::Auto;
|
||||
|
||||
video_encode_format_ = static_cast<VIDEO_ENCODE_FORMAT>(
|
||||
ini_->GetLongValue(section_, "video_encode_format",
|
||||
static_cast<long>(video_encode_format_)));
|
||||
|
||||
hardware_video_codec_ = ini_->GetBoolValue(section_, "hardware_video_codec",
|
||||
hardware_video_codec_);
|
||||
const char* turn_mode_value = ini_->GetValue(section_, "turn_mode", nullptr);
|
||||
if (turn_mode_value != nullptr && strlen(turn_mode_value) > 0) {
|
||||
const long parsed_turn_mode = ini_->GetLongValue(
|
||||
section_, "turn_mode", static_cast<long>(turn_mode_));
|
||||
if (IsValidTurnModeValue(parsed_turn_mode)) {
|
||||
turn_mode_ = static_cast<TURN_MODE>(parsed_turn_mode);
|
||||
} else {
|
||||
LOG_WARN("Invalid TURN mode [{}], using auto UDP/TCP",
|
||||
parsed_turn_mode);
|
||||
turn_mode_ = TURN_MODE::AUTO_UDP_TCP;
|
||||
}
|
||||
} else {
|
||||
const bool legacy_enable_turn = ini_->GetBoolValue(
|
||||
section_, "enable_turn", turn_mode_ != TURN_MODE::DISABLED);
|
||||
turn_mode_ = legacy_enable_turn ? TURN_MODE::AUTO_UDP_TCP
|
||||
: TURN_MODE::DISABLED;
|
||||
ini_->SetLongValue(section_, "turn_mode", static_cast<long>(turn_mode_));
|
||||
persist_config_migration = true;
|
||||
}
|
||||
// Migrate the former user switch. Keep true in the file for older builds,
|
||||
// while the current application always requests encrypted native media.
|
||||
if (!ini_->GetBoolValue(section_, "enable_srtp", false)) {
|
||||
ini_->SetBoolValue(section_, "enable_srtp", true);
|
||||
persist_config_migration = true;
|
||||
}
|
||||
enable_self_hosted_ =
|
||||
ini_->GetBoolValue(section_, "enable_self_hosted", enable_self_hosted_);
|
||||
|
||||
const char* signal_server_host_value =
|
||||
ini_->GetValue(section_, "signal_server_host", nullptr);
|
||||
if (signal_server_host_value != nullptr &&
|
||||
strlen(signal_server_host_value) > 0) {
|
||||
signal_server_host_ = signal_server_host_value;
|
||||
} else {
|
||||
signal_server_host_ = "";
|
||||
}
|
||||
const char* signal_server_port_value =
|
||||
ini_->GetValue(section_, "signal_server_port", nullptr);
|
||||
if (signal_server_port_value != nullptr &&
|
||||
strlen(signal_server_port_value) > 0) {
|
||||
signal_server_port_ =
|
||||
static_cast<int>(ini_->GetLongValue(section_, "signal_server_port", 0));
|
||||
} else {
|
||||
signal_server_port_ = 0;
|
||||
}
|
||||
enable_autostart_ =
|
||||
ini_->GetBoolValue(section_, "enable_autostart", enable_autostart_);
|
||||
enable_daemon_ = ini_->GetBoolValue(section_, "enable_daemon", enable_daemon_);
|
||||
enable_privacy_screen_.store(
|
||||
ini_->GetBoolValue(section_, "enable_privacy_screen", true));
|
||||
save_remote_previews_.store(
|
||||
ini_->GetBoolValue(section_, "save_remote_previews", true));
|
||||
portable_service_prompt_suppressed_ =
|
||||
ini_->GetBoolValue(section_, "portable_service_prompt_suppressed",
|
||||
portable_service_prompt_suppressed_);
|
||||
|
||||
const char* file_transfer_save_path_value =
|
||||
ini_->GetValue(section_, "file_transfer_save_path", nullptr);
|
||||
if (file_transfer_save_path_value != nullptr &&
|
||||
strlen(file_transfer_save_path_value) > 0) {
|
||||
file_transfer_save_path_ = file_transfer_save_path_value;
|
||||
} else {
|
||||
file_transfer_save_path_ = "";
|
||||
}
|
||||
|
||||
if (persist_config_migration && CommitIni() < 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::CommitIni() {
|
||||
// Write beside the destination and rename only after a complete save. A
|
||||
// failed write must not truncate the last usable configuration.
|
||||
static std::atomic<unsigned long> sequence{0};
|
||||
const std::string temporary = config_path_ + ".tmp-" +
|
||||
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()) +
|
||||
"-" + std::to_string(sequence++);
|
||||
std::error_code error;
|
||||
if (ini_->SaveFile(temporary.c_str()) < 0) {
|
||||
LOG_ERROR("Failed to write configuration: {}", config_path_);
|
||||
std::filesystem::remove(temporary, error);
|
||||
return -1;
|
||||
}
|
||||
const auto previous = std::filesystem::status(config_path_, error);
|
||||
if (!error && std::filesystem::is_regular_file(previous)) {
|
||||
std::filesystem::permissions(temporary, previous.permissions(), error);
|
||||
if (error) {
|
||||
LOG_ERROR("Failed to preserve configuration permissions: {}", error.message());
|
||||
std::filesystem::remove(temporary, error);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
std::filesystem::rename(temporary, config_path_, error);
|
||||
if (!error) return 0;
|
||||
LOG_ERROR("Failed to replace configuration {}: {}", config_path_, error.message());
|
||||
std::filesystem::remove(temporary, error);
|
||||
return -1;
|
||||
}
|
||||
|
||||
int ConfigCenter::StoreValues(
|
||||
std::initializer_list<std::pair<const char*, std::string>> values) {
|
||||
std::vector<std::pair<const char*, std::optional<std::string>>> previous;
|
||||
for (const auto& value : values) {
|
||||
const char* old = ini_->GetValue(section_, value.first, nullptr);
|
||||
previous.emplace_back(value.first, old ? std::optional<std::string>(old) : std::nullopt);
|
||||
ini_->SetValue(section_, value.first, value.second.c_str());
|
||||
}
|
||||
if (CommitIni() == 0) return 0;
|
||||
for (const auto& value : previous) {
|
||||
if (value.second) ini_->SetValue(section_, value.first, value.second->c_str());
|
||||
else ini_->Delete(section_, value.first);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
int ConfigCenter::Save() {
|
||||
ini_->SetLongValue(section_, "language", static_cast<long>(language_));
|
||||
ini_->SetLongValue(section_, "screen_capture_method",
|
||||
static_cast<long>(screen_capture_method_));
|
||||
ini_->SetLongValue(section_, "video_encode_format",
|
||||
static_cast<long>(video_encode_format_));
|
||||
ini_->SetBoolValue(section_, "hardware_video_codec", hardware_video_codec_);
|
||||
ini_->SetLongValue(section_, "turn_mode", static_cast<long>(turn_mode_));
|
||||
ini_->SetBoolValue(section_, "enable_turn",
|
||||
turn_mode_ != TURN_MODE::DISABLED);
|
||||
ini_->SetBoolValue(section_, "enable_srtp", true);
|
||||
ini_->SetBoolValue(section_, "enable_self_hosted", enable_self_hosted_);
|
||||
|
||||
// only save when self hosted
|
||||
if (enable_self_hosted_) {
|
||||
ini_->SetValue(section_, "signal_server_host", signal_server_host_.c_str());
|
||||
ini_->SetLongValue(section_, "signal_server_port",
|
||||
static_cast<long>(signal_server_port_));
|
||||
}
|
||||
|
||||
ini_->SetBoolValue(section_, "enable_autostart", enable_autostart_);
|
||||
ini_->SetBoolValue(section_, "enable_daemon", enable_daemon_);
|
||||
ini_->SetBoolValue(section_, "enable_privacy_screen",
|
||||
enable_privacy_screen_.load());
|
||||
ini_->SetBoolValue(section_, "save_remote_previews",
|
||||
save_remote_previews_.load());
|
||||
ini_->SetBoolValue(section_, "portable_service_prompt_suppressed",
|
||||
portable_service_prompt_suppressed_);
|
||||
|
||||
ini_->SetValue(section_, "file_transfer_save_path",
|
||||
file_transfer_save_path_.c_str());
|
||||
|
||||
SI_Error rc = CommitIni();
|
||||
if (rc < 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// setters
|
||||
|
||||
int ConfigCenter::SetLanguage(LANGUAGE language) {
|
||||
const int value = static_cast<int>(language);
|
||||
if (value < 0 || value > 2 || StoreValues({{"language", std::to_string(value)}}) != 0) return -1;
|
||||
language_ = language;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetScreenCaptureMethod(ScreenCaptureMethod method) {
|
||||
if (!IsValidScreenCaptureMethod(static_cast<long>(method))) {
|
||||
return -1;
|
||||
}
|
||||
ini_->SetLongValue(section_, "screen_capture_method", static_cast<long>(method));
|
||||
if (CommitIni() < 0) {
|
||||
ini_->SetLongValue(section_, "screen_capture_method",
|
||||
static_cast<long>(screen_capture_method_));
|
||||
return -1;
|
||||
}
|
||||
screen_capture_method_ = method;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetVideoEncodeFormat(VIDEO_ENCODE_FORMAT format) {
|
||||
if (format != VIDEO_ENCODE_FORMAT::H264 && format != VIDEO_ENCODE_FORMAT::AV1) return -1;
|
||||
if (StoreValues({{"video_encode_format", std::to_string(static_cast<int>(format))}}) != 0) return -1;
|
||||
video_encode_format_ = format;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetHardwareVideoCodec(bool enabled) {
|
||||
if (enabled && !IsHardwareVideoCodecAvailable()) return -1;
|
||||
if (StoreValues({{"hardware_video_codec", enabled ? "true" : "false"}}) != 0) return -1;
|
||||
hardware_video_codec_ = enabled;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetTurnMode(TURN_MODE mode) {
|
||||
if (!IsValidTurnModeValue(static_cast<long>(mode))) return -1;
|
||||
if (StoreValues({{"turn_mode", std::to_string(static_cast<int>(mode))},
|
||||
{"enable_turn", mode == TURN_MODE::DISABLED ? "false" : "true"}}) != 0) return -1;
|
||||
turn_mode_ = mode;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetTurn(bool enable_turn, bool force_relay) {
|
||||
if (!enable_turn) {
|
||||
return SetTurnMode(TURN_MODE::DISABLED);
|
||||
}
|
||||
if (!force_relay) {
|
||||
return SetTurnMode(TURN_MODE::AUTO_UDP_TCP);
|
||||
}
|
||||
// Use TURN/UDP by default, preserving an explicitly configured TCP relay.
|
||||
return SetTurnMode(turn_mode_ == TURN_MODE::FORCE_TCP ? TURN_MODE::FORCE_TCP
|
||||
: TURN_MODE::FORCE_UDP);
|
||||
}
|
||||
|
||||
int ConfigCenter::SetServerHost(const std::string& host) {
|
||||
if (host.empty() || host.size() > 253 || host.find_first_of("/\\@?#") != std::string::npos ||
|
||||
std::any_of(host.begin(), host.end(), [](unsigned char c) { return c <= 32 || c == 127; })) return -1;
|
||||
if (StoreValues({{"signal_server_host", host}}) != 0) return -1;
|
||||
signal_server_host_ = host;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetServerPort(int port) {
|
||||
if (port < 1 || port > 65535 || StoreValues({{"signal_server_port", std::to_string(port)}}) != 0) return -1;
|
||||
signal_server_port_ = port;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetSelfHosted(bool enabled) {
|
||||
if (enabled && (signal_server_host_.empty() || signal_server_port_ < 1 || signal_server_port_ > 65535)) return -1;
|
||||
if (StoreValues({{"enable_self_hosted", enabled ? "true" : "false"}}) != 0) return -1;
|
||||
enable_self_hosted_ = enabled;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetAutostart(bool enabled) {
|
||||
const bool previous = IsAutostartEnabled("CrossDesk");
|
||||
if (enabled != previous && !(enabled ? EnableAutostart("CrossDesk") : DisableAutostart("CrossDesk"))) {
|
||||
LOG_ERROR("SetAutostart failed");
|
||||
return -1;
|
||||
}
|
||||
if (StoreValues({{"enable_autostart", enabled ? "true" : "false"}}) != 0) {
|
||||
if (enabled != previous) {
|
||||
if (previous) EnableAutostart("CrossDesk");
|
||||
else DisableAutostart("CrossDesk");
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
enable_autostart_ = enabled;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetDaemon(bool enabled) {
|
||||
if (StoreValues({{"enable_daemon", enabled ? "true" : "false"}}) != 0) return -1;
|
||||
enable_daemon_ = enabled;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetPrivacyScreen(bool enable_privacy_screen) {
|
||||
const bool previous = enable_privacy_screen_.load();
|
||||
if (previous == enable_privacy_screen) return 0;
|
||||
ini_->SetBoolValue(section_, "enable_privacy_screen", enable_privacy_screen);
|
||||
if (CommitIni() < 0) {
|
||||
ini_->SetBoolValue(section_, "enable_privacy_screen", previous);
|
||||
LOG_ERROR("Failed to save automatic privacy screen preference");
|
||||
return -1;
|
||||
}
|
||||
// RTC callbacks read only the value committed by the settings thread.
|
||||
enable_privacy_screen_.store(enable_privacy_screen);
|
||||
LOG_INFO("Privacy screen on incoming connection: {}", enable_privacy_screen);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetSaveRemotePreviews(bool enabled) {
|
||||
if (StoreValues({{"save_remote_previews", enabled ? "true" : "false"}}) != 0) {
|
||||
return -1;
|
||||
}
|
||||
save_remote_previews_.store(enabled);
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool ConfigCenter::IsSaveRemotePreviews() const {
|
||||
return save_remote_previews_.load();
|
||||
}
|
||||
|
||||
int ConfigCenter::SetPortableServicePromptSuppressed(bool suppressed) {
|
||||
portable_service_prompt_suppressed_ = suppressed;
|
||||
ini_->SetBoolValue(section_, "portable_service_prompt_suppressed",
|
||||
portable_service_prompt_suppressed_);
|
||||
SI_Error rc = CommitIni();
|
||||
if (rc < 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// getters
|
||||
|
||||
ConfigCenter::LANGUAGE ConfigCenter::GetLanguage() const { return language_; }
|
||||
|
||||
ScreenCaptureMethod ConfigCenter::GetScreenCaptureMethod() const {
|
||||
return screen_capture_method_;
|
||||
}
|
||||
|
||||
ConfigCenter::VIDEO_ENCODE_FORMAT ConfigCenter::GetVideoEncodeFormat() const {
|
||||
return video_encode_format_;
|
||||
}
|
||||
|
||||
bool ConfigCenter::IsHardwareVideoCodecAvailable() {
|
||||
#if (((defined(_WIN32) || defined(__linux__)) && !defined(__aarch64__) && \
|
||||
!defined(__arm__) && USE_CUDA) || \
|
||||
defined(__APPLE__))
|
||||
return true;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool ConfigCenter::IsHardwareVideoCodec() const {
|
||||
return IsHardwareVideoCodecAvailable() && hardware_video_codec_;
|
||||
}
|
||||
|
||||
ConfigCenter::TURN_MODE ConfigCenter::GetTurnMode() const {
|
||||
return turn_mode_;
|
||||
}
|
||||
|
||||
bool ConfigCenter::IsEnableTurn() const {
|
||||
return turn_mode_ != TURN_MODE::DISABLED;
|
||||
}
|
||||
|
||||
bool ConfigCenter::IsForceRelay() const {
|
||||
return turn_mode_ == TURN_MODE::FORCE_UDP || turn_mode_ == TURN_MODE::FORCE_TCP;
|
||||
}
|
||||
|
||||
bool ConfigCenter::IsEnableSrtp() const { return true; }
|
||||
|
||||
std::string ConfigCenter::GetSignalServerHost() const {
|
||||
return signal_server_host_;
|
||||
}
|
||||
|
||||
int ConfigCenter::GetSignalServerPort() const { return signal_server_port_; }
|
||||
|
||||
std::string ConfigCenter::GetDefaultServerHost() const {
|
||||
return signal_server_host_default_;
|
||||
}
|
||||
|
||||
int ConfigCenter::GetDefaultSignalServerPort() const {
|
||||
return server_port_default_;
|
||||
}
|
||||
|
||||
bool ConfigCenter::IsSelfHosted() const { return enable_self_hosted_; }
|
||||
|
||||
bool ConfigCenter::IsEnableAutostart() const { return enable_autostart_; }
|
||||
|
||||
bool ConfigCenter::IsEnableDaemon() const { return enable_daemon_; }
|
||||
|
||||
bool ConfigCenter::IsEnablePrivacyScreen() const {
|
||||
return enable_privacy_screen_.load();
|
||||
}
|
||||
|
||||
bool ConfigCenter::IsPortableServicePromptSuppressed() const {
|
||||
return portable_service_prompt_suppressed_;
|
||||
}
|
||||
|
||||
int ConfigCenter::SetFileTransferSavePath(const std::string& path) {
|
||||
if (path.size() >= 512 || std::any_of(path.begin(), path.end(), [](unsigned char c) { return c < 32 || c == 127; })) return -1;
|
||||
std::lock_guard<std::mutex> lock(file_path_mutex_);
|
||||
if (StoreValues({{"file_transfer_save_path", path}}) != 0) return -1;
|
||||
file_transfer_save_path_ = path;
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::string ConfigCenter::GetFileTransferSavePath() const {
|
||||
std::lock_guard<std::mutex> lock(file_path_mutex_);
|
||||
return file_transfer_save_path_;
|
||||
}
|
||||
} // namespace crossdesk
|
||||
@@ -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,98 +0,0 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-04
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _APPLICATION_STATE_H_
|
||||
#define _APPLICATION_STATE_H_
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace crossdesk::gui_detail {
|
||||
|
||||
struct MainWindowState {
|
||||
bool exit_ = false;
|
||||
};
|
||||
|
||||
// Application-global interaction flags that coordinate UI events with media
|
||||
// and remote-control devices.
|
||||
struct InteractionState {
|
||||
bool start_mouse_controller_ = false;
|
||||
bool mouse_controller_is_started_ = false;
|
||||
bool start_screen_capturer_ = false;
|
||||
bool screen_capturer_is_started_ = false;
|
||||
bool start_keyboard_capturer_ = false;
|
||||
bool keyboard_capturer_is_started_ = false;
|
||||
bool keyboard_capturer_uses_window_events_ = false;
|
||||
bool focus_on_stream_window_ = false;
|
||||
bool audio_capture_ = false;
|
||||
int screen_width_ = 1280;
|
||||
int screen_height_ = 720;
|
||||
int selected_display_ = 0;
|
||||
char remote_id_display_[12] = "";
|
||||
unsigned char audio_buffer_[720]{};
|
||||
int audio_len_ = 0;
|
||||
bool audio_buffer_fresh_ = false;
|
||||
bool need_to_rejoin_ = false;
|
||||
std::chrono::steady_clock::time_point last_rejoin_check_time_ =
|
||||
std::chrono::steady_clock::now();
|
||||
std::string controlled_remote_id_;
|
||||
std::string focused_remote_id_;
|
||||
std::string remote_client_id_;
|
||||
};
|
||||
|
||||
struct UpdateState {
|
||||
bool update_available_ = false;
|
||||
std::string latest_version_;
|
||||
std::string release_name_;
|
||||
std::string release_notes_;
|
||||
std::string release_date_;
|
||||
};
|
||||
|
||||
struct StreamWindowState {
|
||||
bool need_to_create_stream_window_ = false;
|
||||
bool stream_window_created_ = false;
|
||||
bool stream_window_inited_ = false;
|
||||
bool control_mouse_ = false;
|
||||
};
|
||||
|
||||
struct ServerWindowState {
|
||||
// Transport callbacks run outside the UI thread. Keep the two lifecycle
|
||||
// requests atomic so a disconnect cannot be lost while the Slint timer is
|
||||
// deciding whether to create or destroy the controlled-side window.
|
||||
std::atomic<bool> need_to_create_server_window_{false};
|
||||
std::atomic<bool> need_to_destroy_server_window_{false};
|
||||
bool server_window_created_ = false;
|
||||
bool server_window_inited_ = false;
|
||||
bool server_window_collapsed_ = false;
|
||||
};
|
||||
|
||||
struct UiState {
|
||||
bool password_validating_ = false;
|
||||
uint32_t password_validating_time_ = 0;
|
||||
bool rejoin_ = false;
|
||||
bool show_password_ = true;
|
||||
bool show_connection_status_window_ = false;
|
||||
bool fullscreen_button_pressed_ = false;
|
||||
bool is_client_mode_ = false;
|
||||
bool is_server_mode_ = false;
|
||||
bool reload_recent_connections_ = true;
|
||||
bool show_offline_warning_window_ = false;
|
||||
std::string offline_warning_text_;
|
||||
bool re_enter_remote_id_ = false;
|
||||
};
|
||||
|
||||
struct ApplicationState : MainWindowState,
|
||||
InteractionState,
|
||||
UpdateState,
|
||||
StreamWindowState,
|
||||
ServerWindowState,
|
||||
UiState {};
|
||||
|
||||
} // namespace crossdesk::gui_detail
|
||||
|
||||
#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,392 +0,0 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2024-05-29
|
||||
* Copyright (c) 2024 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
#ifndef _LOCALIZATION_DATA_H_
|
||||
#define _LOCALIZATION_DATA_H_
|
||||
|
||||
namespace crossdesk {
|
||||
namespace localization {
|
||||
|
||||
namespace detail {
|
||||
|
||||
struct TranslationRow {
|
||||
const char* key;
|
||||
const void* zh;
|
||||
const void* en;
|
||||
const void* ru;
|
||||
};
|
||||
|
||||
// Single source of truth for all UI strings.
|
||||
#define CROSSDESK_LOCALIZATION_ALL(X) \
|
||||
X(messages, u8"消息", "Messages", u8"Сообщения") \
|
||||
X(message_unread, u8"未读", "Unread", u8"Не прочитано") \
|
||||
X(message_read, u8"已读", "Read", u8"Прочитано") \
|
||||
X(message_empty, u8"暂无公告", "No announcements", u8"Нет объявлений") \
|
||||
X(message_select, u8"选择公告查看详情", "Select an announcement to read", u8"Выберите объявление") \
|
||||
X(message_loading, u8"正在加载…", "Loading…", u8"Загрузка…") \
|
||||
X(message_read_save_error, u8"无法保存本地阅读状态,请重新打开公告重试。", "Unable to save reading status locally. Open the announcement again to retry.", u8"Не удалось сохранить статус чтения. Откройте объявление снова.") \
|
||||
X(message_close, u8"关闭", "Close", u8"Закрыть") \
|
||||
X(message_delete, u8"删除", "Delete", u8"Удалить") \
|
||||
X(message_delete_title, u8"删除公告", "Delete announcement", u8"Удалить объявление") \
|
||||
X(message_delete_confirmation, u8"删除这条公告?", "Delete this announcement?", u8"Удалить это объявление?") \
|
||||
X(message_delete_error, u8"无法删除这条公告,请重试。", "Unable to delete this announcement. Please try again.", u8"Не удалось удалить объявление. Повторите попытку.") \
|
||||
X(console_cli_help, u8"Linux 无头运行选项:\n --headless 自动选择已有桌面并启用控制台\n --headless-display DISPLAY 高级:指定已有显示会话\n --headless-size WIDTHxHEIGHT 新建虚拟屏幕;默认1920x1080,偶数尺寸320..8192\n --headless-session EXECUTABLE 启动桌面环境(startxfce4;none 仅启动裸屏幕)\n --no-headless 禁用自动回退\n正常启动会自动选择已有桌面,无需填写显示编号。控制台输入 settings 管理设置。\n", "Linux headless options:\n --headless Select an existing desktop and enable the console\n --headless-display DISPLAY Advanced: override desktop selection\n --headless-size WIDTHxHEIGHT New virtual display; default 1920x1080, even 320..8192\n --headless-session EXECUTABLE Start a desktop (startxfce4; none for a bare display)\n --no-headless Disable automatic fallback\nNormal startup selects your existing desktop automatically. Enter settings in the console to configure it.\n", u8"Параметры запуска без монитора:\n --headless Выбрать рабочий стол и включить консоль\n --headless-display DISPLAY Дополнительно: указать сеанс дисплея\n --headless-size WIDTHxHEIGHT Новый виртуальный экран; 1920x1080 по умолчанию, чётные размеры 320..8192\n --headless-session EXECUTABLE Запустить рабочий стол (startxfce4; none — только экран)\n --no-headless Отключить автоматический резервный запуск\nРабочий стол выбирается автоматически. Команда settings открывает настройки консоли.\n") \
|
||||
X(console_title, u8"CrossDesk 无头控制台", "CrossDesk headless console", u8"Консоль CrossDesk") \
|
||||
X(console_log_file, u8"日志文件", "Log file", u8"Файл журнала") \
|
||||
X(console_help, u8"命令: status 查看ID/密码/状态;settings 设置;password 修改密码;random 随机密码;help 帮助;quit 退出", "Commands: status, settings, password, random, help, quit", u8"Команды: status, settings, password, random, help, quit") \
|
||||
X(console_status, u8"状态", "Status", u8"Состояние") \
|
||||
X(console_online, u8"在线", "Online", u8"В сети") \
|
||||
X(console_offline, u8"未连接服务器", "Disconnected from server", u8"Нет подключения к серверу") \
|
||||
X(console_id, u8"设备 ID", "Device ID", u8"ID устройства") \
|
||||
X(console_password, u8"连接密码", "Connection password", u8"Пароль подключения") \
|
||||
X(console_waiting, u8"等待分配", "Waiting for assignment", u8"Ожидание назначения") \
|
||||
X(console_password_hidden, u8"(仅在交互终端显示)", "(shown only in an interactive terminal)", u8"(виден только в интерактивном терминале)") \
|
||||
X(console_cancelled, u8"已取消修改。", "Change cancelled.", u8"Изменение отменено.") \
|
||||
X(console_password_invalid, u8"密码必须是6位英文字母或数字,未提交修改。", "Password must be 6 ASCII letters or digits; no change submitted.", u8"Пароль должен содержать 6 латинских букв или цифр. Изменение не отправлено.") \
|
||||
X(console_password_offline, u8"尚未连接服务器,暂时无法修改密码。", "Connect to the server before changing the password.", u8"Для смены пароля подключитесь к серверу.") \
|
||||
X(console_password_prompt, u8"输入新密码(6位英文字母或数字;输入 cancel 取消):", "New password (6 ASCII letters or digits; cancel to abort):", u8"Новый пароль (6 латинских букв или цифр; cancel — отмена):") \
|
||||
X(console_quitting, u8"正在退出 CrossDesk……", "Exiting CrossDesk...", u8"Выход из CrossDesk...") \
|
||||
X(console_unknown_command, u8"未知命令,输入 help 查看帮助。", "Unknown command. Enter help for commands.", u8"Неизвестная команда. Введите help для справки.") \
|
||||
X(console_line_too_long, u8"命令过长,已忽略。", "Command too long; ignored.", u8"Команда слишком длинная и проигнорирована.") \
|
||||
X(console_random_failed, u8"无法生成随机密码,请重试。", "Unable to generate a random password; try again.", u8"Не удалось создать случайный пароль. Повторите попытку.") \
|
||||
X(console_password_pending, u8"密码修改正在处理中,请等待服务器确认。", "Password change pending; wait for server confirmation.", u8"Смена пароля выполняется. Дождитесь подтверждения сервера.") \
|
||||
X(console_password_stage_failed, u8"无法保存密码修改记录,未提交修改。请检查日志。", "Unable to save password recovery record; no change submitted. Check the log.", u8"Не удалось сохранить запись восстановления пароля. Изменение не отправлено. Проверьте журнал.") \
|
||||
X(console_password_send_failed, u8"发送密码修改请求失败,请等待连接恢复后重试。", "Failed to submit password change; retry after reconnection.", u8"Не удалось отправить запрос смены пароля. Повторите после подключения.") \
|
||||
X(console_password_submitted, u8"已提交密码修改,正在等待服务器确认……", "Password change submitted; waiting for server confirmation...", u8"Запрос смены пароля отправлен. Ожидание подтверждения...") \
|
||||
X(console_password_uncertain, u8"密码修改结果尚未确认,正在重新连接服务器核实。", "Password change not yet confirmed; reconnecting to verify.", u8"Смена пароля не подтверждена. Повторное подключение для проверки.") \
|
||||
X(console_password_rejected, u8"服务器未接受密码修改,原密码保持不变。详情见日志。", "Server rejected the password change; the original password remains. See the log.", u8"Сервер отклонил смену пароля. Старый пароль сохранён. См. журнал.") \
|
||||
X(console_password_confirmed, u8"服务器已确认密码修改,正在重新连接。", "Server confirmed the password change; reconnecting.", u8"Сервер подтвердил смену пароля. Повторное подключение.") \
|
||||
X(console_password_recovered, u8"重新连接确认:新密码已生效。", "Reconnection confirmed: the new password is active.", u8"Повторное подключение подтвердило новый пароль.") \
|
||||
X(console_password_retained, u8"重新连接确认:继续使用原密码。", "Reconnection confirmed: the original password remains active.", u8"Повторное подключение подтвердило прежний пароль.") \
|
||||
X(console_exited, u8"CrossDesk 已退出。", "CrossDesk exited.", u8"CrossDesk завершён.") \
|
||||
X(console_stopped, u8"CrossDesk 已停止,退出码", "CrossDesk stopped; exit code", u8"CrossDesk остановлен; код выхода") \
|
||||
X(console_settings_title, u8"设置(修改后立即保存)", "Settings (each change is saved immediately)", u8"Настройки (изменения сохраняются сразу)") \
|
||||
X(console_settings_prompt, u8"输入编号或选项名称修改;back 返回。也可使用 settings set 名称 值。", "Enter an item number or key; back to return. Shortcut: settings set KEY VALUE.", u8"Введите номер или имя параметра; back — назад. Команда: settings set KEY VALUE.") \
|
||||
X(console_settings_value, u8"输入新值(cancel 取消)", "Enter a new value (cancel to abort)", u8"Введите новое значение (cancel — отмена)") \
|
||||
X(console_settings_unavailable, u8"设置尚未初始化,请稍后重试。", "Settings are not ready; try again shortly.", u8"Настройки ещё не готовы. Повторите позже.") \
|
||||
X(console_settings_invalid, u8"无效的选项或值,未保存。请按菜单列出的格式输入。", "Invalid setting or value; not saved. Use the format shown in the menu.", u8"Неверный параметр или значение. Ничего не сохранено. Используйте указанный формат.") \
|
||||
X(console_settings_save_failed, u8"保存设置失败,原设置保持不变。请检查日志。", "Failed to save settings; the previous value is retained. Check the log.", u8"Не удалось сохранить настройки. Прежнее значение сохранено. Проверьте журнал.") \
|
||||
X(console_settings_busy, u8"有远程会话或密码修改正在进行,暂时不能更改连接设置。", "Connection settings cannot change during a remote session or password change.", u8"Нельзя менять параметры подключения во время удалённого сеанса или смены пароля.") \
|
||||
X(console_settings_saved, u8"已保存并生效。", "Saved and applied.", u8"Сохранено и применено.") \
|
||||
X(console_settings_unchanged, u8"设置未改变。", "Setting unchanged.", u8"Настройка не изменилась.") \
|
||||
X(console_settings_reconnect, u8"已保存,正在重新连接服务器。", "Saved; reconnecting to the server.", u8"Сохранено. Повторное подключение к серверу.") \
|
||||
X(console_settings_next_connection, u8"已保存,将用于后续连接。", "Saved; applies to subsequent connections.", u8"Сохранено. Будет применено к следующим подключениям.") \
|
||||
X(console_settings_next_file, u8"已保存,将用于后续接收的文件。", "Saved; applies to subsequently received files.", u8"Сохранено. Будет применено к следующим получаемым файлам.") \
|
||||
X(console_settings_autostart, u8"已保存,在下次图形桌面登录时生效。", "Saved; applies at the next graphical desktop login.", u8"Сохранено. Будет применено при следующем входе в графический сеанс.") \
|
||||
X(console_settings_daemon, u8"已保存,普通模式重启后生效;无头控制台仍保持前台运行。", "Saved for the next normal-mode start; the headless console stays in the foreground.", u8"Сохранено для следующего запуска в обычном режиме. Консоль остаётся на переднем плане.") \
|
||||
X(console_settings_hardware_unavailable, u8"当前构建不支持硬件编解码,未保存。", "Hardware codecs are unavailable in this build; not saved.", u8"Аппаратные кодеки недоступны в этой сборке. Не сохранено.") \
|
||||
X(console_settings_server_required, u8"请先设置有效的服务器地址和端口,再启用自托管。", "Set a valid server host and port before enabling self-hosting.", u8"Укажите адрес и порт сервера перед включением собственного сервера.") \
|
||||
X(console_settings_path_invalid, u8"请选择已存在且可写的绝对目录路径;default 使用默认目录。", "Use an existing writable absolute directory, or default for the default location.", u8"Укажите существующий доступный для записи абсолютный путь к каталогу или default.") \
|
||||
X(console_settings_readonly, u8"该选项由系统或控制端管理,不可在此修改。", "This option is managed by the system or controller and is read-only here.", u8"Этот параметр управляется системой или клиентом и здесь недоступен для изменения.") \
|
||||
X(console_setting_language, u8"语言", "Language", u8"Язык") \
|
||||
X(console_setting_codec, u8"视频编码", "Video codec", u8"Видеокодек") \
|
||||
X(console_setting_hardware, u8"硬件编解码", "Hardware codecs", u8"Аппаратные кодеки") \
|
||||
X(console_setting_turn, u8"中继模式", "TURN relay mode", u8"Режим ретрансляции TURN") \
|
||||
X(console_setting_self_hosted, u8"自托管", "Self-hosted server", u8"Собственный сервер") \
|
||||
X(console_setting_host, u8"服务器地址", "Server host", u8"Адрес сервера") \
|
||||
X(console_setting_port, u8"信令端口", "Signaling port", u8"Порт сигнализации") \
|
||||
X(console_setting_privacy, u8"连接时自动开启隐私屏", "Privacy screen on connection", u8"Приватный экран при подключении") \
|
||||
X(console_setting_files, u8"文件保存目录", "Received file directory", u8"Каталог получаемых файлов") \
|
||||
X(console_setting_autostart, u8"登录桌面时自启", "Start at desktop login", u8"Запуск при входе в систему") \
|
||||
X(console_setting_daemon, u8"守护进程(无头控制台中不生效)", "Daemon (inactive in headless console)", u8"Фоновый режим (не действует в консоли)") \
|
||||
X(save_remote_previews, u8"保存远程画面预览:", "Save remote screen previews:", u8"Сохранять превью экрана:") \
|
||||
X(unknown_platform, u8"未知平台", "Unknown platform", u8"Неизвестная платформа") \
|
||||
X(privacy_screen, u8"隐私屏", "Privacy screen", u8"Приватный экран") \
|
||||
X(privacy_enable, u8"开启隐私屏", "Enable privacy screen", u8"Включить приватный экран") \
|
||||
X(privacy_disable, u8"关闭隐私屏", "Disable privacy screen", u8"Выключить приватный экран") \
|
||||
X(privacy_on, u8"隐私屏:已开启", "Privacy screen: on", u8"Приватный экран: включён") \
|
||||
X(privacy_off, u8"隐私屏:已关闭", "Privacy screen: off", u8"Приватный экран: выключен") \
|
||||
X(privacy_unsupported, u8"隐私屏:不可用", "Privacy screen: unavailable", u8"Приватный экран: недоступен") \
|
||||
X(local_desktop, u8"本桌面", "Local Desktop", u8"Локальный рабочий стол") \
|
||||
X(local_id, u8"本机ID", "Local ID", u8"Локальный ID") \
|
||||
X(local_id_copied_to_clipboard, u8"已复制到剪贴板", "Copied to clipboard", \
|
||||
u8"Скопировано в буфер обмена") \
|
||||
X(password, u8"密码", "Password", u8"Пароль") \
|
||||
X(refresh_password, u8"刷新随机密码", "Refresh random password", \
|
||||
u8"Обновить случайный пароль") \
|
||||
X(max_password_len, u8"最大6个字符", "Max 6 chars", u8"Макс. 6 символов") \
|
||||
X(remote_desktop, u8"远程桌面", "Remote Desktop", \
|
||||
u8"Удаленный рабочий стол") \
|
||||
X(device_name, u8"设备名称", "Device Name", u8"Имя устройства") \
|
||||
X(remote_id, u8"对端ID", "Remote ID", u8"Удаленный ID") \
|
||||
X(connect, u8"连接", "Connect", u8"Подключиться") \
|
||||
X(recent_connections, u8"近期连接", "Recent Connections", \
|
||||
u8"Недавние подключения") \
|
||||
X(disconnect, u8"断开连接", "Disconnect", u8"Отключить") \
|
||||
X(select_display, u8"选择显示器", "Select Display", u8"Выбрать дисплей") \
|
||||
X(display_screen, u8"显示屏", "Display", u8"Экран") \
|
||||
X(expand_control_bar, u8"展开控制栏", "Expand Control Bar", \
|
||||
u8"Развернуть панель управления") \
|
||||
X(collapse_control_bar, u8"收起控制栏", "Collapse Control Bar", \
|
||||
u8"Свернуть панель управления") \
|
||||
X(fullscreen, u8"全屏", " Fullscreen", u8"Полный экран") \
|
||||
X(show_net_traffic_stats, u8"显示网络状态", "Show Net Traffic Stats", \
|
||||
u8"Показать статистику трафика") \
|
||||
X(hide_net_traffic_stats, u8"隐藏网络状态", "Hide Net Traffic Stats", \
|
||||
u8"Скрыть статистику трафика") \
|
||||
X(video, u8"视频", "Video", u8"Видео") \
|
||||
X(audio, u8"音频", "Audio", u8"Аудио") \
|
||||
X(data, u8"数据", "Data", u8"Данные") \
|
||||
X(total, u8"总计", "Total", u8"Итого") \
|
||||
X(in, u8"输入", "In", u8"Вход") \
|
||||
X(out, u8"输出", "Out", u8"Выход") \
|
||||
X(loss_rate, u8"丢包率", "Loss Rate", u8"Потери пакетов") \
|
||||
X(exit_fullscreen, u8"退出全屏", "Exit fullscreen", \
|
||||
u8"Выйти из полноэкранного режима") \
|
||||
X(control_mouse, u8"控制鼠标", "Control Mouse", u8"Управление мышью") \
|
||||
X(release_mouse, u8"释放鼠标", "Release Mouse", u8"Освободить мышь") \
|
||||
X(audio_capture, u8"播放声音", "Audio Capture", u8"Воспроизведение звука") \
|
||||
X(mute, u8" 静音", " Mute", u8"Без звука") \
|
||||
X(send_shortcut, u8"发送组合键", "Send Shortcut", u8"Сочетания клавиш") \
|
||||
X(send_sas, u8"发送SAS", "Send SAS", u8"Отправить SAS") \
|
||||
X(lock_remote, u8"锁定远端", "Lock Remote", u8"Заблокировать") \
|
||||
X(remote_password_box_visible, u8"远端密码框已出现", \
|
||||
"Remote password box visible", u8"Окно ввода пароля видно") \
|
||||
X(remote_lock_screen_hint, u8"远端处于锁屏封面,可发送SAS", \
|
||||
"Remote lock screen visible, send SAS", \
|
||||
u8"Видна блокировка, отправьте SAS") \
|
||||
X(remote_secure_desktop_active, u8"远端已进入安全桌面", \
|
||||
"Remote secure desktop active", u8"Активен защищенный рабочий стол") \
|
||||
X(remote_service_unavailable, u8"远端Windows服务不可用", \
|
||||
"Remote Windows service unavailable", \
|
||||
u8"Служба Windows на удаленной стороне недоступна") \
|
||||
X(windows_service_setup_title, u8"安装 CrossDesk Service", \
|
||||
"Install CrossDesk Service", u8"Установить CrossDesk Service") \
|
||||
X(windows_service_setup_message, \
|
||||
u8"为支持该设备在锁屏状态下被远程控制,需要以管理员权限安装 CrossDesk " \
|
||||
u8"Service。\n未安装该服务不影响 CrossDesk " \
|
||||
u8"正常使用,仅无法在锁屏状态下控制本机。", \
|
||||
"To support remote control of this device while it is locked, CrossDesk " \
|
||||
"Service must be installed with administrator permission.\nWithout this " \
|
||||
"service, CrossDesk still works normally; only lock-screen control of " \
|
||||
"this computer is unavailable.", \
|
||||
u8"Чтобы поддерживать удаленное управление этим устройством на экране " \
|
||||
u8"блокировки, необходимо установить CrossDesk Service с правами " \
|
||||
u8"администратора.\nБез этой службы CrossDesk продолжит работать " \
|
||||
u8"нормально; будет недоступно только управление этим компьютером на " \
|
||||
u8"экране блокировки.") \
|
||||
X(install_windows_service, u8"安装", "Install", u8"Установить") \
|
||||
X(windows_service_settings_label, u8"锁屏控制服务:", \
|
||||
"Lock Screen Service:", u8"Служба блокировки экрана:") \
|
||||
X(windows_service_installed, u8"已安装", "Installed", u8"Установлена") \
|
||||
X(do_not_remind_again, u8"不再提醒", "Do not remind again", \
|
||||
u8"Больше не напоминать") \
|
||||
X(windows_service_prompt_suppressed_message, \
|
||||
u8"已不再提醒。后续如需启用锁屏状态下被远程控制,可在设置中点击“安装”。", \
|
||||
"You will not be reminded again. To enable remote control while locked " \
|
||||
"later, click Install in Settings.", \
|
||||
u8"Напоминание отключено. Чтобы позже включить удаленное управление на " \
|
||||
u8"экране блокировки, нажмите «Установить» в настройках.") \
|
||||
X(installing_windows_service, u8"正在安装服务...", "Installing service...", \
|
||||
u8"Установка службы...") \
|
||||
X(windows_service_install_success, u8"服务已安装并启动", \
|
||||
"Service installed and started", u8"Служба установлена и запущена") \
|
||||
X(windows_service_install_failed, \
|
||||
u8"服务安装失败。请确认 " \
|
||||
u8"CrossDesk.exe、crossdesk_service.exe、crossdesk_session_helper.exe " \
|
||||
u8"位于同一便携目录中,并在系统弹窗中允许管理员权限。", \
|
||||
"Service installation failed. Make sure CrossDesk.exe, " \
|
||||
"crossdesk_service.exe, and crossdesk_session_helper.exe are in the same " \
|
||||
"portable folder, then approve the administrator prompt.", \
|
||||
u8"Не удалось установить службу. Убедитесь, что CrossDesk.exe, " \
|
||||
u8"crossdesk_service.exe и crossdesk_session_helper.exe находятся в " \
|
||||
u8"одной папке портативной версии, затем подтвердите запрос прав " \
|
||||
u8"администратора.") \
|
||||
X(remote_unlock_requires_secure_desktop, \
|
||||
u8"当前仍需要安全桌面专用采集/输入", \
|
||||
"Secure desktop capture/input is still required", \
|
||||
u8"По-прежнему нужен отдельный захват/ввод для защищенного рабочего " \
|
||||
u8"стола") \
|
||||
X(settings, u8"设置", "Settings", u8"Настройки") \
|
||||
X(language, u8"语言:", "Language:", u8"Язык:") \
|
||||
X(screen_capture_method, u8"采集方式:", "Capture Method:", \
|
||||
u8"Способ захвата:") \
|
||||
X(screen_capture_method_auto, u8"自动", "Auto", u8"Авто") \
|
||||
X(video_settings, u8"画面设置", "Video settings", u8"Настройки видео") \
|
||||
X(remote_update_title, u8"被控端版本过低", "Remote device needs an update", u8"Версия на удалённом устройстве устарела") \
|
||||
X(remote_update, u8"请升级到最新版本。", "Please update to the latest version.", u8"Обновите её до последней версии.") \
|
||||
X(remote_update_dismiss, u8"知道了", "Got it", u8"Понятно") \
|
||||
X(video_settings_pending, u8"正在应用…", "Applying…", u8"Применение…") \
|
||||
X(video_settings_failed, u8"调整失败,请重试。", "Could not apply settings. Try again.", u8"Не удалось применить настройки. Повторите попытку.") \
|
||||
X(video_settings_unavailable, u8"连接建立后可调整,需被控端支持。", "Available when connected to a supported remote device.", u8"Доступно при подключении к поддерживаемому устройству.") \
|
||||
X(video_quality, u8"画面质量:", "Video Quality:", u8"Качество видео:") \
|
||||
X(video_frame_rate, u8"画面采集帧率:", \
|
||||
"Video Capture Frame Rate:", u8"Частота захвата видео:") \
|
||||
X(video_adaptation_policy, u8"画面偏好:", "Video Preference:", \
|
||||
u8"Режим видео:") \
|
||||
X(video_priority_frame_rate, u8"帧率优先", "Frame Rate", \
|
||||
u8"Частота кадров") \
|
||||
X(video_priority_quality, u8"画质优先", "Quality", u8"Качество") \
|
||||
X(video_priority_balanced, u8"平衡", "Balanced", u8"Баланс") \
|
||||
X(video_quality_high, u8"高", "High", u8"Высокое") \
|
||||
X(video_quality_medium, u8"中", "Medium", u8"Среднее") \
|
||||
X(video_quality_low, u8"低", "Low", u8"Низкое") \
|
||||
X(video_encode_format, u8"视频编码格式:", \
|
||||
"Video Encode Format:", u8"Формат кодека видео:") \
|
||||
X(av1, u8"AV1", "AV1", "AV1") \
|
||||
X(h264, u8"H.264", "H.264", "H.264") \
|
||||
X(enable_hardware_video_codec, u8"启用硬件编解码器:", \
|
||||
"Enable Hardware Video Codec:", u8"Использовать аппаратный кодек:") \
|
||||
X(enable_turn, u8"启用中继服务:", \
|
||||
"Enable TURN Service:", u8"Включить TURN-сервис:") \
|
||||
X(force_relay, u8"强制中继连接:", "Force Relay:", u8"Только через реле:") \
|
||||
X(self_hosted_server_config, u8"自托管配置", "Self-Hosted Config", \
|
||||
u8"Конфигурация self-hosted") \
|
||||
X(self_hosted_server_settings, u8"自托管设置", "Self-Hosted Settings", \
|
||||
u8"Настройки self-hosted") \
|
||||
X(self_hosted_server_address, u8"服务器地址:", \
|
||||
"Server Address:", u8"Адрес сервера:") \
|
||||
X(self_hosted_server_port, u8"信令服务端口:", \
|
||||
"Signal Service Port:", u8"Порт сигнального сервиса:") \
|
||||
X(ok, u8"确认", "OK", u8"ОК") \
|
||||
X(cancel, u8"取消", "Cancel", u8"Отмена") \
|
||||
X(new_password, u8"请输入六位密码:", \
|
||||
"Please input a six-char password:", u8"Введите шестизначный пароль:") \
|
||||
X(input_password, u8"请输入密码:", \
|
||||
"Please input password:", u8"Введите пароль:") \
|
||||
X(validate_password, u8"验证密码中...", "Validate password ...", \
|
||||
u8"Проверка пароля...") \
|
||||
X(reinput_password, u8"请重新输入密码", "Please input password again", \
|
||||
u8"Повторно введите пароль") \
|
||||
X(remember_password, u8"记住密码", "Remember password", \
|
||||
u8"Запомнить пароль") \
|
||||
X(signal_connected, u8"已连接服务器", "Connected", u8"Подключено к серверу") \
|
||||
X(signal_disconnected, u8"未连接服务器", "Disconnected", \
|
||||
u8"Нет подключения к серверу") \
|
||||
X(signal_tls_cert_error, u8"证书验证失败,请重新安装自托管根证书", \
|
||||
"Certificate verification failed. Reinstall the self-hosted root " \
|
||||
"certificate.", \
|
||||
u8"Ошибка проверки сертификата. Переустановите корневой сертификат.") \
|
||||
X(p2p_connected, u8"对等连接已建立", "P2P Connected", u8"P2P подключено") \
|
||||
X(p2p_disconnected, u8"对等连接已断开", "P2P Disconnected", \
|
||||
u8"P2P отключено") \
|
||||
X(p2p_connecting, u8"正在建立对等连接...", "P2P Connecting ...", \
|
||||
u8"Подключение P2P...") \
|
||||
X(p2p_gathering, u8"正在收集候选地址...", "Gathering candidates ...", \
|
||||
u8"Сбор кандидатов...") \
|
||||
X(receiving_screen, u8"画面接收中...", "Receiving screen...", \
|
||||
u8"Получение изображения...") \
|
||||
X(p2p_failed, u8"对等连接失败", "P2P Failed", u8"Сбой P2P") \
|
||||
X(p2p_closed, u8"对等连接已关闭", "P2P closed", u8"P2P закрыто") \
|
||||
X(no_such_id, u8"无此ID", "No such ID", u8"ID не найден") \
|
||||
X(about, u8"关于", "About", u8"О программе") \
|
||||
X(notification, u8"通知", "Notification", u8"Уведомление") \
|
||||
X(new_version, u8"新版本", "New version", u8"Новая версия") \
|
||||
X(new_version_available, u8"新版本可用", "New Version Available", \
|
||||
u8"Доступна новая версия") \
|
||||
X(check_for_updates, u8"检查更新", "Check for updates", \
|
||||
u8"Проверить обновления") \
|
||||
X(checking_for_updates, u8"正在检查更新…", "Checking for updates…", \
|
||||
u8"Проверка обновлений…") \
|
||||
X(up_to_date, u8"已是最新版本", "You're up to date", \
|
||||
u8"Установлена последняя версия") \
|
||||
X(update_check_failed, u8"检查失败,请稍后重试", \
|
||||
"Check failed. Please try again later.", \
|
||||
u8"Не удалось проверить обновления. Повторите позже.") \
|
||||
X(release_notes, u8"更新内容", "Release Notes", u8"Содержание обновления") \
|
||||
X(version, u8"版本", "Version", u8"Версия") \
|
||||
X(release_date, u8"发布日期: ", "Release Date: ", u8"Дата релиза: ") \
|
||||
X(access_website, u8"访问官网: ", \
|
||||
"Access Website: ", u8"Официальный сайт: ") \
|
||||
X(update, u8"更新", "Update", u8"Обновить") \
|
||||
X(connection_alias, u8"修改名称", "Edit Alias", \
|
||||
u8"Изменить имя подключения") \
|
||||
X(delete_connection, u8"删除连接", "Delete Connection", \
|
||||
u8"Удалить подключение") \
|
||||
X(connect_to_this_connection, u8"发起连接", "Connect to this connection", \
|
||||
u8"Подключиться") \
|
||||
X(input_connection_alias, u8"请输入连接名称:", \
|
||||
"Please input connection name:", u8"Введите имя подключения:") \
|
||||
X(confirm_delete_connection, u8"确认删除此连接", \
|
||||
"Confirm to delete this connection", u8"Удалить это подключение?") \
|
||||
X(enable_autostart, u8"开机自启:", "Auto Start:", u8"Автозапуск:") \
|
||||
X(enable_daemon, u8"启用守护进程:", "Enable Daemon:", u8"Включить демон:") \
|
||||
X(privacy_on_connect, u8"被连接时开启隐私屏:", "Privacy screen on connect:", \
|
||||
u8"Приватность при подключении:") \
|
||||
X(takes_effect_after_restart, u8"重启后生效", "Takes effect after restart", \
|
||||
u8"Вступит в силу после перезапуска") \
|
||||
X(select_file, u8"选择文件发送", "Select File to Send", \
|
||||
u8"Выбрать файл для отправки") \
|
||||
X(file_transfer_progress, u8"文件传输进度", "File Transfer Progress", \
|
||||
u8"Прогресс передачи файлов") \
|
||||
X(queued, u8"队列中", "Queued", u8"В очереди") \
|
||||
X(sending, u8"正在传输", "Sending", u8"Передача") \
|
||||
X(completed, u8"已完成", "Completed", u8"Завершено") \
|
||||
X(failed, u8"失败", "Failed", u8"Ошибка") \
|
||||
X(controller, u8"控制端:", "Controller:", u8"Контроллер:") \
|
||||
X(file_transfer, u8"文件传输:", "File Transfer:", u8"Передача файлов:") \
|
||||
X(connection_status, u8"连接状态:", \
|
||||
"Connection Status:", u8"Состояние соединения:") \
|
||||
X(file_transfer_save_path, u8"文件接收保存路径:", \
|
||||
"File Transfer Save Path:", u8"Путь сохранения файлов:") \
|
||||
X(default_desktop, u8"桌面", "Desktop", u8"Рабочий стол") \
|
||||
X(resolution, u8"分辨率", "Res", u8"Разрешение") \
|
||||
X(video_latency, u8"视频延时", "Video delay", u8"Задержка") \
|
||||
X(connection_latency, u8"连接延时", "Connection RTT", u8"Задержка соединения") \
|
||||
X(connection_mode, u8"连接模式", "Mode", u8"Режим") \
|
||||
X(connection_mode_direct, u8"直连", "Direct", u8"Прямой") \
|
||||
X(connection_mode_relay, u8"中继", "Relay", u8"Релейный") \
|
||||
X(transport_encryption, u8"加密传输", "Encryption", u8"Шифрование") \
|
||||
X(transport_encryption_enabled, u8"已开启", "Enabled", u8"Включено") \
|
||||
X(transport_encryption_disabled, u8"未开启", "Disabled", u8"Выключено") \
|
||||
X(online, u8"在线", "Online", u8"Онлайн") \
|
||||
X(offline, u8"离线", "Offline", u8"Офлайн") \
|
||||
X(device_offline, u8"设备离线", "Device Offline", u8"Устройство офлайн") \
|
||||
X(request_permissions, u8"权限请求", "Request Permissions", \
|
||||
u8"Запрос разрешений") \
|
||||
X(screen_recording_permission, u8"屏幕录制权限", \
|
||||
"Screen Recording Permission", u8"Разрешение на запись экрана") \
|
||||
X(accessibility_permission, u8"辅助功能权限", "Accessibility Permission", \
|
||||
u8"Разрешение специальных возможностей") \
|
||||
X(permission_required_message, u8"该应用需要授权以下权限:", \
|
||||
"The application requires the following permissions:", \
|
||||
u8"Для работы приложения требуются следующие разрешения:") \
|
||||
X(wayland_notice_title, u8"Wayland 会话提示", "Wayland Session Notice", \
|
||||
u8"Уведомление о сеансе Wayland") \
|
||||
X(wayland_notice_subtitle, u8"检测到当前桌面会话使用 Wayland。", \
|
||||
"The current desktop session uses Wayland.", \
|
||||
u8"Текущий сеанс рабочего стола использует Wayland.") \
|
||||
X(wayland_permission_title, u8"每次共享均需本机授权", \
|
||||
"Local approval required each time", \
|
||||
u8"Разрешение требуется каждый раз") \
|
||||
X(wayland_notice_message, \
|
||||
u8"受 Wayland 安全机制限制,每次启动远程桌面共享时," \
|
||||
u8"均需在本机手动授权屏幕共享与远程控制,授权后方可正常使用。", \
|
||||
"Wayland security restrictions require you to authorize screen sharing " \
|
||||
"and remote control locally each time you start sharing this desktop. " \
|
||||
"Remote access is available only after authorization.", \
|
||||
u8"Из-за ограничений безопасности Wayland при каждом запуске общего " \
|
||||
u8"доступа к рабочему столу необходимо вручную разрешать на этом " \
|
||||
u8"компьютере показ экрана и удалённое управление. Удалённый доступ " \
|
||||
u8"возможен только после разрешения.") \
|
||||
X(wayland_recommendation_title, u8"建议使用 X11(Xorg)", \
|
||||
"X11 (Xorg) is recommended", u8"Рекомендуем X11 (Xorg)") \
|
||||
X(wayland_recommendation_message, \
|
||||
u8"为便于持续使用远程桌面,建议注销当前桌面会话," \
|
||||
u8"在登录界面选择 X11(Xorg)会话后重新登录。", \
|
||||
"For ongoing remote desktop access, we recommend logging out, selecting " \
|
||||
"an X11 (Xorg) session on the login screen, and logging in again.", \
|
||||
u8"Для постоянного удалённого доступа рекомендуем завершить сеанс, " \
|
||||
u8"выбрать X11 (Xorg) на экране входа и войти снова.") \
|
||||
X(wayland_notice_acknowledge, u8"我知道了", "Got it", u8"Понятно") \
|
||||
X(show_main_window, u8"显示主界面", "Show Main Window", \
|
||||
u8"Показать главное окно") \
|
||||
X(privacy_screen_unlock_hint, u8"按下快捷键,解除隐私屏", \
|
||||
"To turn off the privacy screen, press", \
|
||||
u8"Чтобы отключить приватный экран, нажмите") \
|
||||
X(exit_program, u8"退出", "Exit", u8"Выход")
|
||||
|
||||
inline constexpr TranslationRow kTranslationRows[] = {
|
||||
#define CROSSDESK_DECLARE_TRANSLATION_ROW(name, zh, en, ru) {#name, zh, en, ru},
|
||||
CROSSDESK_LOCALIZATION_ALL(CROSSDESK_DECLARE_TRANSLATION_ROW)
|
||||
#undef CROSSDESK_DECLARE_TRANSLATION_ROW
|
||||
};
|
||||
|
||||
} // namespace detail
|
||||
|
||||
} // namespace localization
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -1,737 +0,0 @@
|
||||
#include "features/devices/session_device_manager.h"
|
||||
|
||||
#include <SDL3/SDL_audio.h>
|
||||
#include <SDL3/SDL_error.h>
|
||||
#include <SDL3/SDL_init.h>
|
||||
|
||||
#include <display_stream_id.h>
|
||||
#include <remote_action.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <utility>
|
||||
|
||||
#include "device_controller_factory.h"
|
||||
#include "screen_capturer_factory.h"
|
||||
#include "platform.h"
|
||||
#include "rd_log.h"
|
||||
#include "runtime/gui_runtime.h"
|
||||
#include "speaker_capturer_factory.h"
|
||||
#ifdef _WIN32
|
||||
#include "platform/windows/screen_capturer/screen_capturer_win.h"
|
||||
#include "platform/windows/input/mouse/mouse_controller.h"
|
||||
#endif
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
|
||||
constexpr uint64_t kCaptureResumeKeyFrameGapMs = 500;
|
||||
constexpr size_t kMaxCapturedKeyboardInputs = 512;
|
||||
constexpr auto kFrameDeadlineTolerance = std::chrono::milliseconds(1);
|
||||
|
||||
} // namespace
|
||||
|
||||
SessionDeviceManager::SessionDeviceManager(GuiRuntime &owner)
|
||||
: owner_(owner),
|
||||
speaker_capture_(
|
||||
[] {
|
||||
return std::unique_ptr<SpeakerCapturer>(
|
||||
SpeakerCapturerFactory().Create());
|
||||
},
|
||||
[this](unsigned char *data, size_t size, const char *) {
|
||||
if (!owner_.peer_) return;
|
||||
MiniRtcAudioFrame frame{};
|
||||
frame.data = reinterpret_cast<const char *>(data);
|
||||
frame.size = size;
|
||||
frame.captured_timestamp = GetSystemTimeMicros(owner_.peer_);
|
||||
SendAudioFrame(owner_.peer_, &frame, owner_.audio_label_.c_str());
|
||||
}) {}
|
||||
|
||||
SessionDeviceManager::~SessionDeviceManager() { speaker_capture_.Shutdown(); }
|
||||
|
||||
bool SessionDeviceManager::ShouldSendCapturedFrame(
|
||||
std::chrono::steady_clock::time_point now, int fps) {
|
||||
const auto interval =
|
||||
std::chrono::nanoseconds(std::chrono::seconds(1)) / (fps > 0 ? fps : 1);
|
||||
if (next_frame_deadline_ == std::chrono::steady_clock::time_point{}) {
|
||||
next_frame_deadline_ = now;
|
||||
}
|
||||
if (now + kFrameDeadlineTolerance < next_frame_deadline_) {
|
||||
return false;
|
||||
}
|
||||
next_frame_deadline_ += interval;
|
||||
if (next_frame_deadline_ <= now) {
|
||||
next_frame_deadline_ = now + interval;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void SessionDeviceManager::RecordCaptureCadence(
|
||||
std::chrono::steady_clock::time_point now, int fps,
|
||||
bool from_secure_desktop) {
|
||||
std::lock_guard<std::mutex> lock(capture_metrics_mutex_);
|
||||
if (capture_metrics_started_ == std::chrono::steady_clock::time_point{})
|
||||
capture_metrics_started_ = now;
|
||||
const auto elapsed_us = std::chrono::duration_cast<std::chrono::microseconds>(
|
||||
now - capture_metrics_started_)
|
||||
.count();
|
||||
if (elapsed_us >= 2000000) {
|
||||
if (capture_forwarded_) {
|
||||
const char* source = capture_secure_callbacks_ == 0 ? "normal_desktop"
|
||||
: capture_secure_callbacks_ == capture_callbacks_
|
||||
? "secure_desktop"
|
||||
: "mixed";
|
||||
LOG_DEBUG(
|
||||
"Capture delivery: source={} normal_callbacks={} secure_callbacks={} "
|
||||
"target_fps={} input_fps={:.2f} "
|
||||
"forwarded_fps={:.2f} callbacks={} rate_dropped={} "
|
||||
"forwarded={} send_errors={} max_callback_gap_us={} "
|
||||
"send_avg_us={} send_max_us={} window_ms={}",
|
||||
source, capture_callbacks_ - capture_secure_callbacks_,
|
||||
capture_secure_callbacks_, fps,
|
||||
capture_callbacks_ * 1000000.0 / elapsed_us,
|
||||
capture_forwarded_ * 1000000.0 / elapsed_us, capture_callbacks_,
|
||||
capture_rate_drops_, capture_forwarded_, capture_send_errors_,
|
||||
capture_max_gap_us_, capture_send_us_ / capture_forwarded_,
|
||||
capture_max_send_us_, elapsed_us / 1000);
|
||||
}
|
||||
capture_metrics_started_ = now;
|
||||
capture_callbacks_ = capture_rate_drops_ = capture_forwarded_ = 0;
|
||||
capture_secure_callbacks_ = 0;
|
||||
capture_send_errors_ = 0;
|
||||
capture_max_gap_us_ = capture_send_us_ = capture_max_send_us_ = 0;
|
||||
}
|
||||
if (last_capture_callback_ != std::chrono::steady_clock::time_point{}) {
|
||||
capture_max_gap_us_ =
|
||||
std::max<int64_t>(capture_max_gap_us_,
|
||||
std::chrono::duration_cast<std::chrono::microseconds>(
|
||||
now - last_capture_callback_)
|
||||
.count());
|
||||
}
|
||||
last_capture_callback_ = std::max(last_capture_callback_, now);
|
||||
++capture_callbacks_;
|
||||
if (from_secure_desktop) ++capture_secure_callbacks_;
|
||||
}
|
||||
|
||||
void SessionDeviceManager::Initialize() {
|
||||
InitializeAudioOutput();
|
||||
screen_capturer_factory_ = new ScreenCapturerFactory();
|
||||
device_controller_factory_ = new DeviceControllerFactory();
|
||||
keyboard_capturer_ =
|
||||
static_cast<KeyboardCapturer *>(device_controller_factory_->Create(
|
||||
DeviceControllerFactory::Device::Keyboard));
|
||||
}
|
||||
|
||||
int SessionDeviceManager::InitializeScreenCapturer() {
|
||||
#ifdef __APPLE__
|
||||
if (!owner_.EnsureMacScreenRecordingPermission()) {
|
||||
return -1;
|
||||
}
|
||||
#endif
|
||||
|
||||
if (!screen_capturer_) {
|
||||
screen_capturer_ =
|
||||
static_cast<ScreenCapturer *>(screen_capturer_factory_->Create());
|
||||
}
|
||||
screen_capturer_->SetPrivacyController(&owner_.privacy_);
|
||||
#ifdef _WIN32
|
||||
if (auto windows = dynamic_cast<ScreenCapturerWin*>(screen_capturer_)) {
|
||||
windows->SetCaptureMethod(owner_.config_center_->GetScreenCaptureMethod());
|
||||
}
|
||||
#endif
|
||||
|
||||
last_frame_time_ = {};
|
||||
next_frame_deadline_ = {};
|
||||
capture_metrics_started_ = last_capture_callback_ = {};
|
||||
capture_callbacks_ = capture_rate_drops_ = capture_forwarded_ = 0;
|
||||
capture_secure_callbacks_ = 0;
|
||||
capture_send_errors_ = 0;
|
||||
capture_max_gap_us_ = capture_send_us_ = capture_max_send_us_ = 0;
|
||||
// Keep the shared source ready for either live rate. MiniRTC admits 30/60
|
||||
// frames independently per viewer, so switching never restarts native
|
||||
// capture.
|
||||
const int fps = 60;
|
||||
LOG_INFO("Init screen capturer with {} fps", fps);
|
||||
SetDisplayInfoList({});
|
||||
registered_display_stream_count_ = 0;
|
||||
last_video_frame_stream_id_.clear();
|
||||
invalid_video_stream_id_logged_ = false;
|
||||
|
||||
const int init_ret = screen_capturer_->Init(
|
||||
fps, [this, fps](unsigned char *data, int size, int width, int height,
|
||||
const char *display_name,
|
||||
const MiniRtcNativeVideoFrame *native_frame) {
|
||||
const auto now_time = std::chrono::steady_clock::now();
|
||||
bool from_secure_desktop = false;
|
||||
#ifdef _WIN32
|
||||
from_secure_desktop =
|
||||
ScreenCapturerWin::CurrentFrameIsFromSecureDesktop();
|
||||
#endif
|
||||
RecordCaptureCadence(now_time, fps, from_secure_desktop);
|
||||
if (!ShouldSendCapturedFrame(now_time, fps)) {
|
||||
std::lock_guard<std::mutex> lock(capture_metrics_mutex_);
|
||||
++capture_rate_drops_;
|
||||
return;
|
||||
}
|
||||
|
||||
const bool has_previous_frame =
|
||||
last_frame_time_.time_since_epoch().count() != 0;
|
||||
const auto duration_ms =
|
||||
has_previous_frame
|
||||
? std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
now_time - last_frame_time_)
|
||||
.count()
|
||||
: 0;
|
||||
|
||||
std::vector<std::string> connected_remote_ids;
|
||||
{
|
||||
std::shared_lock lock(owner_.connection_status_mutex_);
|
||||
connected_remote_ids.reserve(owner_.connection_status_.size());
|
||||
for (const auto &[remote_id, status] : owner_.connection_status_) {
|
||||
if (status == ConnectionStatus::Connected) {
|
||||
connected_remote_ids.push_back(remote_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Capture can still deliver frames while ICE is gathering or after
|
||||
// the final controller disconnects. Do not broadcast those frames to
|
||||
// MiniRTC: a broadcast also reaches newly joining peers whose ICE
|
||||
// transport is not ready yet.
|
||||
if (connected_remote_ids.empty()) {
|
||||
last_frame_time_ = now_time;
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string stream_id = ResolveDisplayStreamId(
|
||||
display_name, registered_display_stream_count_, -1,
|
||||
last_video_frame_stream_id_);
|
||||
if (stream_id.empty()) {
|
||||
if (!invalid_video_stream_id_logged_) {
|
||||
LOG_ERROR(
|
||||
"Drop captured frames with an empty or unregistered video "
|
||||
"stream id, reported='{}', registered_streams={}",
|
||||
display_name ? display_name : "",
|
||||
registered_display_stream_count_);
|
||||
invalid_video_stream_id_logged_ = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
invalid_video_stream_id_logged_ = false;
|
||||
const bool resumed_after_gap =
|
||||
has_previous_frame && duration_ms >= kCaptureResumeKeyFrameGapMs;
|
||||
const bool stream_changed = !last_video_frame_stream_id_.empty() &&
|
||||
last_video_frame_stream_id_ != stream_id;
|
||||
if (resumed_after_gap || stream_changed) {
|
||||
if (RequestVideoKeyFrame(owner_.peer_, stream_id.c_str()) == 0) {
|
||||
LOG_DEBUG("Request video key frame before sending captured frame, "
|
||||
"stream='{}', gap_ms={}, stream_changed={}",
|
||||
stream_id, duration_ms, stream_changed);
|
||||
}
|
||||
}
|
||||
|
||||
MiniRtcVideoFrame frame{};
|
||||
frame.data = reinterpret_cast<const char *>(data);
|
||||
frame.size = size;
|
||||
frame.width = width;
|
||||
frame.height = height;
|
||||
frame.captured_timestamp = GetSystemTimeMicros(owner_.peer_);
|
||||
frame.native_frame = native_frame;
|
||||
const auto send_started = std::chrono::steady_clock::now();
|
||||
uint64_t send_errors = 0;
|
||||
for (const std::string &remote_id : connected_remote_ids) {
|
||||
if (SendVideoFrameToPeer(owner_.peer_, &frame, stream_id.c_str(),
|
||||
remote_id.data(), remote_id.size()) != 0)
|
||||
++send_errors;
|
||||
}
|
||||
const auto send_us =
|
||||
std::chrono::duration_cast<std::chrono::microseconds>(
|
||||
std::chrono::steady_clock::now() - send_started)
|
||||
.count();
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(capture_metrics_mutex_);
|
||||
++capture_forwarded_;
|
||||
capture_send_errors_ += send_errors;
|
||||
capture_send_us_ += send_us;
|
||||
capture_max_send_us_ =
|
||||
std::max<int64_t>(capture_max_send_us_, send_us);
|
||||
}
|
||||
last_video_frame_stream_id_ = stream_id;
|
||||
last_frame_time_ = now_time;
|
||||
});
|
||||
|
||||
if (init_ret == 0) {
|
||||
LOG_INFO("Init screen capturer success");
|
||||
auto latest_display_info = screen_capturer_->GetDisplayInfoList();
|
||||
registered_display_stream_count_ = latest_display_info.size();
|
||||
if (!latest_display_info.empty()) {
|
||||
SetDisplayInfoList(std::move(latest_display_info));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
LOG_ERROR("Init screen capturer failed");
|
||||
screen_capturer_->Destroy();
|
||||
delete screen_capturer_;
|
||||
screen_capturer_ = nullptr;
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool SessionDeviceManager::ShouldCaptureCursor() const {
|
||||
std::shared_lock lock(owner_.connection_status_mutex_);
|
||||
return std::any_of(owner_.connection_status_.begin(),
|
||||
owner_.connection_status_.end(), [](const auto& entry) {
|
||||
return entry.second == ConnectionStatus::Connected &&
|
||||
entry.first.find("web") != std::string::npos;
|
||||
});
|
||||
}
|
||||
|
||||
int SessionDeviceManager::StartScreenCapturer() {
|
||||
#ifdef __APPLE__
|
||||
if (!owner_.EnsureMacScreenRecordingPermission()) {
|
||||
return -1;
|
||||
}
|
||||
#endif
|
||||
|
||||
if (!screen_capturer_) {
|
||||
LOG_INFO("Screen capturer instance missing, recreating before start");
|
||||
if (InitializeScreenCapturer() != 0) {
|
||||
LOG_ERROR("Recreate screen capturer failed");
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear before sampling, so a change arriving after the sample remains pending.
|
||||
cursor_capture_dirty_.store(false, std::memory_order_relaxed);
|
||||
const bool show_cursor = ShouldCaptureCursor();
|
||||
LOG_INFO("Start screen capturer, show cursor: {}", show_cursor);
|
||||
const int ret = screen_capturer_->Start(show_cursor);
|
||||
if (ret != 0) {
|
||||
LOG_ERROR("Start screen capturer failed: {}", ret);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
int SessionDeviceManager::StopScreenCapturer() {
|
||||
if (screen_capturer_) {
|
||||
LOG_INFO("Stop screen capturer");
|
||||
screen_capturer_->Stop();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
void SessionDeviceManager::ReleaseRemoteMouseButtons() {
|
||||
if (auto mouse = dynamic_cast<PlatformMouseController*>(mouse_controller_))
|
||||
mouse->ReleasePressedButtons();
|
||||
}
|
||||
#endif
|
||||
|
||||
void SessionDeviceManager::StartSpeakerCapturer() {
|
||||
speaker_capture_.SetEnabled(true);
|
||||
}
|
||||
|
||||
void SessionDeviceManager::StopSpeakerCapturer() {
|
||||
speaker_capture_.SetEnabled(false);
|
||||
}
|
||||
|
||||
bool SessionDeviceManager::SyncDisplayInfo() {
|
||||
if (!screen_capturer_) return false;
|
||||
auto displays = screen_capturer_->GetDisplayInfoList();
|
||||
if (displays.empty()) return false;
|
||||
// Called every UI tick; replace the shared list only when geometry actually
|
||||
// changed so readers on other threads rarely contend for the lock.
|
||||
bool changed = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(display_info_mutex_);
|
||||
changed = !std::equal(
|
||||
displays.begin(), displays.end(), display_info_list_.begin(),
|
||||
display_info_list_.end(), [](const auto &a, const auto &b) {
|
||||
return a.handle == b.handle && a.name == b.name &&
|
||||
a.is_primary == b.is_primary && a.left == b.left &&
|
||||
a.top == b.top && a.width == b.width && a.height == b.height;
|
||||
});
|
||||
if (changed) display_info_list_ = displays;
|
||||
}
|
||||
if (changed && mouse_controller_) mouse_controller_->UpdateDisplayInfoList(displays);
|
||||
return true;
|
||||
}
|
||||
|
||||
void SessionDeviceManager::SetDisplayInfoList(std::vector<DisplayInfo> displays) {
|
||||
std::lock_guard<std::mutex> lock(display_info_mutex_);
|
||||
display_info_list_ = std::move(displays);
|
||||
}
|
||||
|
||||
int SessionDeviceManager::StartMouseController() {
|
||||
#ifdef __APPLE__
|
||||
if (!owner_.EnsureMacAccessibilityPermission()) {
|
||||
return -1;
|
||||
}
|
||||
#endif
|
||||
|
||||
if (!device_controller_factory_) {
|
||||
LOG_INFO("Device controller factory is nullptr");
|
||||
return -1;
|
||||
}
|
||||
|
||||
#if defined(__linux__) && !defined(__APPLE__)
|
||||
if (IsWaylandSession()) {
|
||||
if (!screen_capturer_) {
|
||||
return 1;
|
||||
}
|
||||
const auto latest_display_info = screen_capturer_->GetDisplayInfoList();
|
||||
if (latest_display_info.empty() ||
|
||||
latest_display_info[0].handle == nullptr) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
SyncDisplayInfo();
|
||||
mouse_controller_ =
|
||||
static_cast<MouseController *>(device_controller_factory_->Create(
|
||||
DeviceControllerFactory::Device::Mouse));
|
||||
if (!mouse_controller_) {
|
||||
LOG_ERROR("Create mouse controller failed");
|
||||
return -1;
|
||||
}
|
||||
|
||||
const int init_ret = mouse_controller_->Init(display_info_list());
|
||||
if (init_ret != 0) {
|
||||
LOG_INFO("Destroy mouse controller");
|
||||
mouse_controller_->Destroy();
|
||||
delete mouse_controller_;
|
||||
mouse_controller_ = nullptr;
|
||||
}
|
||||
return init_ret;
|
||||
}
|
||||
|
||||
int SessionDeviceManager::StopMouseController() {
|
||||
if (mouse_controller_) {
|
||||
mouse_controller_->Destroy();
|
||||
delete mouse_controller_;
|
||||
mouse_controller_ = nullptr;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int SessionDeviceManager::StartKeyboardCapturer() {
|
||||
ClearCapturedKeyboardInput();
|
||||
owner_.keyboard_capturer_uses_window_events_ = false;
|
||||
|
||||
#ifdef __APPLE__
|
||||
if (!owner_.EnsureMacAccessibilityPermission()) {
|
||||
owner_.keyboard_capturer_uses_window_events_ = true;
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(__linux__) && !defined(__APPLE__)
|
||||
if (IsWaylandSession()) {
|
||||
owner_.keyboard_capturer_uses_window_events_ = true;
|
||||
LOG_INFO("Start keyboard capturer with Slint Wayland backend");
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
if (!keyboard_capturer_) {
|
||||
owner_.keyboard_capturer_uses_window_events_ = true;
|
||||
LOG_WARN(
|
||||
"keyboard capturer is nullptr, falling back to Slint keyboard events");
|
||||
return 0;
|
||||
}
|
||||
|
||||
const int hook_ret = keyboard_capturer_->Hook(
|
||||
[](int key_code, bool is_down, uint32_t scan_code, bool extended,
|
||||
void *user_ptr) {
|
||||
if (user_ptr) {
|
||||
auto *devices = static_cast<SessionDeviceManager *>(user_ptr);
|
||||
devices->QueueCapturedKeyboardInput(key_code, is_down, scan_code,
|
||||
extended);
|
||||
}
|
||||
},
|
||||
this);
|
||||
if (hook_ret != 0) {
|
||||
keyboard_capturer_->Unhook();
|
||||
owner_.keyboard_capturer_uses_window_events_ = true;
|
||||
LOG_WARN(
|
||||
"Start keyboard capturer failed, falling back to Slint keyboard events");
|
||||
} else {
|
||||
LOG_INFO("Start keyboard capturer with native input");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int SessionDeviceManager::StopKeyboardCapturer() {
|
||||
owner_.keyboard_capturer_is_started_ = false;
|
||||
if (keyboard_capturer_) keyboard_capturer_->Unhook();
|
||||
ClearCapturedKeyboardInput();
|
||||
if (owner_.keyboard_capturer_uses_window_events_) {
|
||||
owner_.keyboard_capturer_uses_window_events_ = false;
|
||||
LOG_INFO("Stop keyboard capturer with Slint keyboard backend");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (keyboard_capturer_) {
|
||||
LOG_INFO("Stop keyboard capturer");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int SessionDeviceManager::InitializeAudioOutput() {
|
||||
SDL_AudioSpec desired_out{};
|
||||
desired_out.freq = 48000;
|
||||
desired_out.format = SDL_AUDIO_S16;
|
||||
desired_out.channels = 1;
|
||||
|
||||
auto open_stream = [&]() {
|
||||
output_stream_ = SDL_OpenAudioDeviceStream(
|
||||
SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, &desired_out, nullptr, nullptr);
|
||||
return output_stream_ != nullptr;
|
||||
};
|
||||
|
||||
if (!open_stream()) {
|
||||
#if defined(__linux__) && !defined(__APPLE__)
|
||||
LOG_WARN("Failed to open output stream with driver [{}]: {}",
|
||||
getenv("SDL_AUDIODRIVER") ? getenv("SDL_AUDIODRIVER")
|
||||
: "(default)",
|
||||
SDL_GetError());
|
||||
|
||||
setenv("SDL_AUDIODRIVER", "dummy", 1);
|
||||
SDL_QuitSubSystem(SDL_INIT_AUDIO);
|
||||
if (!SDL_InitSubSystem(SDL_INIT_AUDIO)) {
|
||||
LOG_ERROR("Failed to reinitialize SDL audio with dummy driver: {}",
|
||||
SDL_GetError());
|
||||
return -1;
|
||||
}
|
||||
if (!open_stream()) {
|
||||
LOG_ERROR("Failed to open output stream with dummy driver: {}",
|
||||
SDL_GetError());
|
||||
return -1;
|
||||
}
|
||||
LOG_WARN("Audio output disabled, using SDL dummy audio driver");
|
||||
#else
|
||||
LOG_ERROR("Failed to open output stream: {}", SDL_GetError());
|
||||
return -1;
|
||||
#endif
|
||||
}
|
||||
|
||||
SDL_ResumeAudioDevice(SDL_GetAudioStreamDevice(output_stream_));
|
||||
return 0;
|
||||
}
|
||||
|
||||
int SessionDeviceManager::DestroyAudioOutput() {
|
||||
if (output_stream_) {
|
||||
SDL_CloseAudioDevice(SDL_GetAudioStreamDevice(output_stream_));
|
||||
SDL_DestroyAudioStream(output_stream_);
|
||||
output_stream_ = nullptr;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
void SessionDeviceManager::PushAudio(const char *data, size_t size) {
|
||||
if (!output_stream_) {
|
||||
return;
|
||||
}
|
||||
const int pushed = SDL_PutAudioStreamData(
|
||||
output_stream_, reinterpret_cast<const Uint8 *>(data),
|
||||
static_cast<int>(size));
|
||||
if (pushed < 0) {
|
||||
LOG_ERROR("Failed to push audio data: {}", SDL_GetError());
|
||||
}
|
||||
}
|
||||
|
||||
void SessionDeviceManager::UpdateInteractions() {
|
||||
#ifdef _WIN32
|
||||
if (owner_.screen_capturer_is_started_ &&
|
||||
cursor_capture_dirty_.load(std::memory_order_relaxed)) {
|
||||
if (auto* windows = dynamic_cast<ScreenCapturerWin*>(screen_capturer_)) {
|
||||
cursor_capture_dirty_.store(false, std::memory_order_relaxed);
|
||||
windows->SetCursorCapture(ShouldCaptureCursor());
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#if defined(__linux__) && !defined(__APPLE__)
|
||||
const bool is_wayland_session = IsWaylandSession();
|
||||
const bool stop_wayland_mouse_before_screen =
|
||||
is_wayland_session && !owner_.start_screen_capturer_ &&
|
||||
owner_.screen_capturer_is_started_ && !owner_.start_mouse_controller_ &&
|
||||
owner_.mouse_controller_is_started_;
|
||||
if (stop_wayland_mouse_before_screen) {
|
||||
LOG_INFO("Stopping Wayland mouse controller before screen capturer to "
|
||||
"cleanly release the shared portal session");
|
||||
StopMouseController();
|
||||
owner_.mouse_controller_is_started_ = false;
|
||||
}
|
||||
#endif
|
||||
|
||||
if (owner_.start_screen_capturer_ && !owner_.screen_capturer_is_started_) {
|
||||
if (StartScreenCapturer() == 0) {
|
||||
owner_.screen_capturer_is_started_ = true;
|
||||
}
|
||||
} else if (!owner_.start_screen_capturer_ &&
|
||||
owner_.screen_capturer_is_started_) {
|
||||
StopScreenCapturer();
|
||||
owner_.screen_capturer_is_started_ = false;
|
||||
}
|
||||
|
||||
if (owner_.start_mouse_controller_ && !owner_.mouse_controller_is_started_) {
|
||||
if (StartMouseController() == 0) {
|
||||
owner_.mouse_controller_is_started_ = true;
|
||||
}
|
||||
} else if (!owner_.start_mouse_controller_ &&
|
||||
owner_.mouse_controller_is_started_) {
|
||||
StopMouseController();
|
||||
owner_.mouse_controller_is_started_ = false;
|
||||
}
|
||||
|
||||
if (owner_.screen_capturer_is_started_ && mouse_controller_ && SyncDisplayInfo()) {
|
||||
#ifdef _WIN32
|
||||
const int active_display = screen_capturer_->GetCurrentMonitorIndex();
|
||||
const auto displays = display_info_list();
|
||||
if (active_display >= 0 &&
|
||||
active_display < static_cast<int>(displays.size())) {
|
||||
owner_.selected_display_ = active_display;
|
||||
if (displays[active_display].width <= 0 ||
|
||||
displays[active_display].height <= 0)
|
||||
ReleaseRemoteMouseButtons();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
if (owner_.start_keyboard_capturer_ && owner_.focus_on_stream_window_) {
|
||||
if (!owner_.keyboard_capturer_is_started_ && StartKeyboardCapturer() == 0) {
|
||||
owner_.keyboard_capturer_is_started_ = true;
|
||||
}
|
||||
if (owner_.keyboard_capturer_is_started_) {
|
||||
if (!owner_.keyboard_capturer_uses_window_events_ &&
|
||||
(!keyboard_capturer_ || !keyboard_capturer_->IsHookActive())) {
|
||||
StopKeyboardCapturer();
|
||||
owner_.keyboard_.ForceReleasePressedKeys();
|
||||
owner_.keyboard_capturer_uses_window_events_ = true;
|
||||
owner_.keyboard_capturer_is_started_ = true;
|
||||
LOG_WARN("Native keyboard capture stopped; using Slint keyboard events");
|
||||
}
|
||||
DrainCapturedKeyboardInput();
|
||||
owner_.keyboard_.SendHeartbeat(false);
|
||||
}
|
||||
} else if (owner_.keyboard_capturer_is_started_) {
|
||||
owner_.keyboard_.ForceReleasePressedKeys();
|
||||
StopKeyboardCapturer();
|
||||
owner_.keyboard_capturer_is_started_ = false;
|
||||
}
|
||||
|
||||
owner_.keyboard_.CheckRemoteTimeouts();
|
||||
}
|
||||
|
||||
void SessionDeviceManager::QueueCapturedKeyboardInput(int key_code,
|
||||
bool is_down,
|
||||
uint32_t scan_code,
|
||||
bool extended) {
|
||||
std::lock_guard<std::mutex> lock(captured_keyboard_inputs_mutex_);
|
||||
if (captured_keyboard_inputs_.size() >= kMaxCapturedKeyboardInputs) {
|
||||
captured_keyboard_inputs_.pop_front();
|
||||
LOG_WARN("Captured keyboard input queue overflow, dropping oldest event");
|
||||
}
|
||||
captured_keyboard_inputs_.push_back(
|
||||
CapturedKeyboardInput{key_code, is_down, scan_code, extended});
|
||||
}
|
||||
|
||||
void SessionDeviceManager::DrainCapturedKeyboardInput() {
|
||||
std::deque<CapturedKeyboardInput> inputs;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(captured_keyboard_inputs_mutex_);
|
||||
inputs.swap(captured_keyboard_inputs_);
|
||||
}
|
||||
if (owner_.privacy_.Snapshot().input_blocked) return;
|
||||
for (const CapturedKeyboardInput &input : inputs) {
|
||||
owner_.keyboard_.SendKeyCommand(input.key_code, input.is_down,
|
||||
input.scan_code, input.extended);
|
||||
}
|
||||
}
|
||||
|
||||
void SessionDeviceManager::ClearCapturedKeyboardInput() {
|
||||
std::lock_guard<std::mutex> lock(captured_keyboard_inputs_mutex_);
|
||||
captured_keyboard_inputs_.clear();
|
||||
}
|
||||
|
||||
bool SessionDeviceManager::SendKeyboardCommand(int key_code, bool is_down,
|
||||
uint32_t scan_code,
|
||||
bool extended) {
|
||||
return keyboard_capturer_ && keyboard_capturer_->SendKeyboardCommand(
|
||||
key_code, is_down, scan_code, extended) == 0;
|
||||
}
|
||||
|
||||
void SessionDeviceManager::SendMouseCommand(const RemoteAction &action,
|
||||
int selected_display) {
|
||||
if (mouse_controller_) {
|
||||
mouse_controller_->SendMouseCommand(action, selected_display);
|
||||
}
|
||||
}
|
||||
|
||||
int SessionDeviceManager::SwitchDisplay(int display_id) {
|
||||
if (!screen_capturer_) return -1;
|
||||
const int ret = screen_capturer_->SwitchTo(display_id);
|
||||
if (ret == 0) SyncDisplayInfo();
|
||||
return ret;
|
||||
}
|
||||
|
||||
void SessionDeviceManager::ResetToInitialDisplay() {
|
||||
if (!screen_capturer_) {
|
||||
return;
|
||||
}
|
||||
const int ret = screen_capturer_->ResetToInitialMonitor();
|
||||
if (ret != 0) {
|
||||
LOG_WARN("Reset to initial display failed, ret={}", ret);
|
||||
return;
|
||||
}
|
||||
// Capture backends start at display 0. Reset the input mapping as well so
|
||||
// the next connection cannot show that display while controlling another.
|
||||
owner_.selected_display_ = 0;
|
||||
SyncDisplayInfo();
|
||||
}
|
||||
|
||||
std::vector<DisplayInfo> SessionDeviceManager::display_info_list() const {
|
||||
std::lock_guard<std::mutex> lock(display_info_mutex_);
|
||||
return display_info_list_;
|
||||
}
|
||||
|
||||
std::vector<HostDisplay> SessionDeviceManager::host_display_list() const {
|
||||
std::lock_guard<std::mutex> lock(display_info_mutex_);
|
||||
std::vector<HostDisplay> displays;
|
||||
displays.reserve(display_info_list_.size());
|
||||
for (const auto &display : display_info_list_) {
|
||||
displays.push_back({display.name, display.left, display.top, display.right,
|
||||
display.bottom});
|
||||
}
|
||||
return displays;
|
||||
}
|
||||
|
||||
void SessionDeviceManager::DestroyDevices() {
|
||||
speaker_capture_.Shutdown();
|
||||
if (mouse_controller_) {
|
||||
mouse_controller_->Destroy();
|
||||
delete mouse_controller_;
|
||||
mouse_controller_ = nullptr;
|
||||
}
|
||||
if (screen_capturer_) {
|
||||
screen_capturer_->Destroy();
|
||||
delete screen_capturer_;
|
||||
screen_capturer_ = nullptr;
|
||||
}
|
||||
if (keyboard_capturer_) {
|
||||
delete keyboard_capturer_;
|
||||
keyboard_capturer_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
void SessionDeviceManager::DestroyFactories() {
|
||||
delete screen_capturer_factory_;
|
||||
screen_capturer_factory_ = nullptr;
|
||||
delete device_controller_factory_;
|
||||
device_controller_factory_ = nullptr;
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
@@ -1,136 +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 <atomic>
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <remote_action.h>
|
||||
|
||||
#include "device_controller.h"
|
||||
#include "display_info.h"
|
||||
#include "speaker_capture_controller.h"
|
||||
|
||||
struct SDL_AudioStream;
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
class GuiRuntime;
|
||||
class DeviceControllerFactory;
|
||||
class ScreenCapturerFactory;
|
||||
class ScreenCapturer;
|
||||
|
||||
// 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();
|
||||
|
||||
// Snapshot of the capture displays. The UI thread refreshes the list every
|
||||
// tick while transport threads build host information and map mouse input
|
||||
// from it, so callers get a copy taken under the lock.
|
||||
std::vector<DisplayInfo> display_info_list() const;
|
||||
// The same snapshot in the wire's host information form.
|
||||
std::vector<HostDisplay> host_display_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;
|
||||
bool SyncDisplayInfo();
|
||||
void SetDisplayInfoList(std::vector<DisplayInfo> displays);
|
||||
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;
|
||||
// Guards display_info_list_ (UI thread writes, transport threads read).
|
||||
mutable std::mutex display_info_mutex_;
|
||||
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,600 +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 <nlohmann/json.hpp>
|
||||
#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_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_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::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,504 +0,0 @@
|
||||
#include "rendering/slint_video_presenter.h"
|
||||
#include "crossdesk_ui.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;
|
||||
VideoLatencyFrame gl_pending_timing;
|
||||
#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 = static_cast<int>(std::lround(
|
||||
(*stream)->get_video_area_top() * scale_factor));
|
||||
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;
|
||||
gl_pending_timing.MarkSubmitted();
|
||||
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, frame.timing);
|
||||
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 = (*impl_->stream)->get_video_area_top();
|
||||
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;
|
||||
impl_->gl_pending_timing = frame.timing;
|
||||
}
|
||||
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)));
|
||||
frame.timing.MarkSubmitted();
|
||||
(*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,89 +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 "rendering/video_latency.h"
|
||||
|
||||
namespace slint {
|
||||
template <typename T> class ComponentHandle;
|
||||
}
|
||||
|
||||
namespace crossdesk::ui {
|
||||
class StreamWindow;
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
struct Frame {
|
||||
std::string remote_id;
|
||||
std::shared_ptr<std::vector<unsigned char>> nv12;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
uint64_t sequence = 0;
|
||||
VideoLatencyFrame timing;
|
||||
};
|
||||
|
||||
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,51 +0,0 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-22
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _VIDEO_GEOMETRY_H_
|
||||
#define _VIDEO_GEOMETRY_H_
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <optional>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
struct VideoPoint {
|
||||
float x;
|
||||
float y;
|
||||
};
|
||||
|
||||
// Area dimensions and pointer positions must use the same coordinate space.
|
||||
// Slint supplies both in logical pixels; remote input is resolution
|
||||
// independent.
|
||||
inline std::optional<VideoPoint> MapVideoPoint(float x, float y,
|
||||
float area_width,
|
||||
float area_height,
|
||||
int video_width,
|
||||
int video_height) {
|
||||
if (!std::isfinite(x) || !std::isfinite(y) || !std::isfinite(area_width) ||
|
||||
!std::isfinite(area_height) || area_width <= 0 || area_height <= 0 ||
|
||||
video_width <= 0 || video_height <= 0) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const double scale =
|
||||
std::min(static_cast<double>(area_width) / video_width,
|
||||
static_cast<double>(area_height) / video_height);
|
||||
const double width = video_width * scale;
|
||||
const double height = video_height * scale;
|
||||
const double left = (area_width - width) * 0.5;
|
||||
const double top = (area_height - height) * 0.5;
|
||||
if (x < left || x > left + width || y < top || y > top + height) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return VideoPoint{static_cast<float>((x - left) / width),
|
||||
static_cast<float>((y - top) / height)};
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -1,110 +0,0 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-26
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _VIDEO_LATENCY_H_
|
||||
#define _VIDEO_LATENCY_H_
|
||||
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
class VideoLatencyStats;
|
||||
|
||||
// Travels with the exact queued pixels, including native frames. Its clock is
|
||||
// translated once at the receive callback; rendering needs no live PeerPtr.
|
||||
struct VideoLatencyFrame {
|
||||
using Clock = std::chrono::steady_clock;
|
||||
std::shared_ptr<VideoLatencyStats> stats;
|
||||
uint64_t id = 0;
|
||||
Clock::time_point capture_time{};
|
||||
void MarkPresented(Clock::time_point now = Clock::now()) const;
|
||||
// Fallback for renderers which expose submission but no presentation time.
|
||||
void MarkSubmitted(Clock::time_point now = Clock::now()) const;
|
||||
};
|
||||
|
||||
class VideoLatencyStats
|
||||
: public std::enable_shared_from_this<VideoLatencyStats> {
|
||||
public:
|
||||
using Clock = VideoLatencyFrame::Clock;
|
||||
struct Snapshot {
|
||||
double average_ms;
|
||||
size_t samples;
|
||||
};
|
||||
|
||||
VideoLatencyFrame Frame(uint64_t captured_us, int64_t local_now_us,
|
||||
Clock::time_point now = Clock::now()) {
|
||||
if (captured_us == 0 || local_now_us <= 0 ||
|
||||
captured_us > uint64_t(local_now_us) ||
|
||||
uint64_t(local_now_us) - captured_us > 5'000'000)
|
||||
return {};
|
||||
std::lock_guard lock(mutex_);
|
||||
return {shared_from_this(), ++issued_id_,
|
||||
now - std::chrono::microseconds(local_now_us - captured_us)};
|
||||
}
|
||||
|
||||
void Record(const VideoLatencyFrame& frame, Clock::time_point now) {
|
||||
std::lock_guard lock(mutex_);
|
||||
// Redraws, cached snapshots and late completions must not count twice.
|
||||
if (frame.id <= last_id_) return;
|
||||
last_id_ = frame.id;
|
||||
const double ms =
|
||||
std::chrono::duration<double, std::milli>(now - frame.capture_time)
|
||||
.count();
|
||||
if (ms < 0 || ms > 5000) return;
|
||||
samples_.push_back({now, ms});
|
||||
Prune(now);
|
||||
}
|
||||
|
||||
std::optional<Snapshot> Get(Clock::time_point now = Clock::now()) {
|
||||
std::lock_guard lock(mutex_);
|
||||
Prune(now);
|
||||
if (samples_.empty()) return std::nullopt;
|
||||
double sum = 0;
|
||||
for (const auto& sample : samples_) {
|
||||
sum += sample.ms;
|
||||
}
|
||||
return Snapshot{sum / samples_.size(), samples_.size()};
|
||||
}
|
||||
|
||||
void Reset() {
|
||||
std::lock_guard lock(mutex_);
|
||||
samples_.clear();
|
||||
last_id_ = issued_id_; // Invalidate frames still held by a renderer.
|
||||
}
|
||||
|
||||
private:
|
||||
void Prune(Clock::time_point now) {
|
||||
while (!samples_.empty() &&
|
||||
(now - samples_.front().time > std::chrono::seconds(1) ||
|
||||
samples_.size() > 240))
|
||||
samples_.pop_front();
|
||||
}
|
||||
struct Sample {
|
||||
Clock::time_point time;
|
||||
double ms;
|
||||
};
|
||||
std::mutex mutex_;
|
||||
uint64_t issued_id_ = 0;
|
||||
uint64_t last_id_ = 0;
|
||||
std::deque<Sample> samples_;
|
||||
};
|
||||
|
||||
inline void VideoLatencyFrame::MarkSubmitted(Clock::time_point now) const {
|
||||
MarkPresented(now);
|
||||
}
|
||||
|
||||
inline void VideoLatencyFrame::MarkPresented(Clock::time_point now) const {
|
||||
if (stats) stats->Record(*this, now);
|
||||
}
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -1,483 +0,0 @@
|
||||
#include "runtime/announcement_inbox.h"
|
||||
|
||||
#include <openssl/sha.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <charconv>
|
||||
#include <fstream>
|
||||
#include <set>
|
||||
#include <system_error>
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#endif
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
using nlohmann::json;
|
||||
bool Integer(const json& object, const char* key, int64_t low, int64_t high) {
|
||||
return object.contains(key) && object[key].is_number_integer() &&
|
||||
object[key] >= low && object[key] <= high;
|
||||
}
|
||||
std::string ScopeFilename(const std::string& scope) {
|
||||
unsigned char digest[SHA256_DIGEST_LENGTH];
|
||||
SHA256(reinterpret_cast<const unsigned char*>(scope.data()), scope.size(),
|
||||
digest);
|
||||
std::string name;
|
||||
constexpr char hex[] = "0123456789abcdef";
|
||||
for (auto byte : digest) {
|
||||
name += hex[byte >> 4];
|
||||
name += hex[byte & 15];
|
||||
}
|
||||
return name + ".json";
|
||||
}
|
||||
} // namespace
|
||||
|
||||
AnnouncementInbox::Snapshot AnnouncementInbox::Get() const {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return state_;
|
||||
}
|
||||
uint64_t AnnouncementInbox::Version() const {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return state_.version;
|
||||
}
|
||||
void AnnouncementInbox::SetStorageDirectory(
|
||||
const std::filesystem::path& directory) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
directory_ = directory;
|
||||
}
|
||||
void AnnouncementInbox::Configure(std::string host, int port,
|
||||
const std::string& device_id) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (directory_.empty() || host.empty() || port <= 0 || device_id.empty())
|
||||
return;
|
||||
for (auto& ch : host)
|
||||
if (ch >= 'A' && ch <= 'Z') ch += 'a' - 'A';
|
||||
if (host.back() == '.') host.pop_back();
|
||||
const auto scope = json::array({host, port, device_id}).dump();
|
||||
if (scope_ == scope) return;
|
||||
scope_ = scope;
|
||||
read_file_ = directory_ / ScopeFilename(scope);
|
||||
const auto version = state_.version + 1;
|
||||
state_ = {};
|
||||
state_.version = version;
|
||||
catalog_.clear();
|
||||
catalog_revision_ = -1;
|
||||
page_ids_.clear();
|
||||
page_bodies_.clear();
|
||||
visible_limit_ = 20;
|
||||
LoadLocalState();
|
||||
BeginCatalog();
|
||||
}
|
||||
void AnnouncementInbox::Reset() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
const auto version = state_.version + 1;
|
||||
state_ = {};
|
||||
state_.version = version;
|
||||
scope_.clear();
|
||||
read_file_.clear();
|
||||
reads_.clear();
|
||||
dismissed_.clear();
|
||||
page_ids_.clear();
|
||||
page_bodies_.clear();
|
||||
visible_limit_ = 20;
|
||||
catalog_.clear();
|
||||
catalog_revision_ = -1;
|
||||
connected_ = false;
|
||||
BeginCatalog();
|
||||
}
|
||||
void AnnouncementInbox::BeginCatalog() {
|
||||
pending_catalog_.clear();
|
||||
pending_revision_ = -1;
|
||||
pending_total_ = 0;
|
||||
phase_ = Phase::Catalog;
|
||||
request_id_.clear();
|
||||
state_.loading = false;
|
||||
refresh_ = connected_;
|
||||
++state_.version;
|
||||
}
|
||||
void AnnouncementInbox::SetConnected(bool connected) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
connected_ = connected;
|
||||
BeginCatalog();
|
||||
}
|
||||
void AnnouncementInbox::Refresh() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
BeginCatalog();
|
||||
}
|
||||
void AnnouncementInbox::LoadMore() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!connected_ || state_.loading || refresh_ || state_.failed ||
|
||||
catalog_revision_ < 0 || visible_limit_ >= state_.total)
|
||||
return;
|
||||
visible_limit_ += (std::min)(20, state_.total - visible_limit_);
|
||||
RebuildVisibleItems();
|
||||
++state_.version;
|
||||
}
|
||||
void AnnouncementInbox::UpdateReadingState() {
|
||||
state_.total = 0;
|
||||
state_.unread = 0;
|
||||
for (const auto& [id, revision] : catalog_) {
|
||||
if (IsDismissed(id, revision)) continue;
|
||||
++state_.total;
|
||||
const auto read = reads_.find(id);
|
||||
if (read == reads_.end() || read->second != revision) ++state_.unread;
|
||||
}
|
||||
for (auto& item : state_.items) {
|
||||
const auto read = reads_.find(item["id"]);
|
||||
item["read"] = read != reads_.end() && read->second == item["revision"];
|
||||
}
|
||||
}
|
||||
bool AnnouncementInbox::Read(int64_t id, int64_t revision) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
for (const auto& item : state_.items) {
|
||||
if (item["id"] != id || item["revision"] != revision) continue;
|
||||
if (item["read"].get<bool>()) return true;
|
||||
auto updated = reads_;
|
||||
updated[id] = revision;
|
||||
if (!SaveLocalState(updated, dismissed_)) {
|
||||
state_.read_save_failed = true;
|
||||
++state_.version;
|
||||
return false;
|
||||
}
|
||||
reads_ = std::move(updated);
|
||||
state_.read_save_failed = false;
|
||||
state_.delete_failed = false;
|
||||
UpdateReadingState();
|
||||
++state_.version;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
bool AnnouncementInbox::IsDismissed(int64_t id, int64_t revision) const {
|
||||
const auto found = dismissed_.find(id);
|
||||
return found != dismissed_.end() && found->second == revision;
|
||||
}
|
||||
|
||||
bool AnnouncementInbox::Dismiss(int64_t id, int64_t revision) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
for (const auto& item : state_.items) {
|
||||
if (item["id"] != id || item["revision"] != revision) continue;
|
||||
auto updated = dismissed_;
|
||||
updated[id] = revision;
|
||||
if (!SaveLocalState(reads_, updated)) {
|
||||
state_.delete_failed = true;
|
||||
++state_.version;
|
||||
return false;
|
||||
}
|
||||
dismissed_ = std::move(updated);
|
||||
state_.delete_failed = false;
|
||||
state_.read_save_failed = false;
|
||||
// An index refresh already in flight must finish before reusing its order.
|
||||
if (phase_ == Phase::Catalog && (state_.loading || refresh_)) {
|
||||
auto visible = json::array();
|
||||
for (const auto& current : state_.items)
|
||||
if (!IsDismissed(current["id"], current["revision"]))
|
||||
visible.push_back(current);
|
||||
state_.items = std::move(visible);
|
||||
UpdateReadingState();
|
||||
} else {
|
||||
request_id_.clear();
|
||||
state_.loading = false;
|
||||
RebuildVisibleItems();
|
||||
}
|
||||
++state_.version;
|
||||
return true;
|
||||
}
|
||||
// The confirmation may refer to an announcement updated while it was open.
|
||||
state_.delete_failed = true;
|
||||
++state_.version;
|
||||
return false;
|
||||
}
|
||||
|
||||
void AnnouncementInbox::RebuildVisibleItems() {
|
||||
UpdateReadingState();
|
||||
const int wanted = (std::min)(visible_limit_, state_.total);
|
||||
auto visible = json::array();
|
||||
std::set<int> retained_pages;
|
||||
int position = 0;
|
||||
phase_ = Phase::Page;
|
||||
refresh_ = false;
|
||||
for (int physical = 0;
|
||||
wanted > 0 && physical < static_cast<int>(catalog_.size());
|
||||
physical += 20) {
|
||||
const auto ids = page_ids_.find(physical);
|
||||
if (ids == page_ids_.end()) {
|
||||
pending_page_offset_ = physical;
|
||||
refresh_ = connected_;
|
||||
break;
|
||||
}
|
||||
std::set<int64_t> page_selection;
|
||||
for (auto id : ids->second) {
|
||||
const auto entry = catalog_.find(id);
|
||||
if (entry == catalog_.end() || IsDismissed(id, entry->second)) continue;
|
||||
if (position < wanted) page_selection.insert(id);
|
||||
++position;
|
||||
}
|
||||
if (page_selection.empty()) {
|
||||
page_bodies_.erase(physical);
|
||||
continue;
|
||||
}
|
||||
retained_pages.insert(physical);
|
||||
const auto bodies = page_bodies_.find(physical);
|
||||
if (bodies == page_bodies_.end()) {
|
||||
pending_page_offset_ = physical;
|
||||
refresh_ = connected_;
|
||||
break;
|
||||
}
|
||||
for (const auto& item : bodies->second)
|
||||
if (page_selection.count(item["id"])) visible.push_back(item);
|
||||
if (visible.size() == static_cast<size_t>(wanted)) break;
|
||||
}
|
||||
if (visible.size() == static_cast<size_t>(wanted)) {
|
||||
for (auto it = page_bodies_.begin(); it != page_bodies_.end();)
|
||||
if (!retained_pages.count(it->first))
|
||||
it = page_bodies_.erase(it);
|
||||
else
|
||||
++it;
|
||||
}
|
||||
// A failed body fetch must not erase announcements already received. Keep
|
||||
// still-published, unchanged versions until the replacement batch arrives.
|
||||
if (visible.size() < static_cast<size_t>(wanted)) {
|
||||
std::set<int64_t> retained_ids;
|
||||
for (const auto& item : visible) retained_ids.insert(item["id"].get<int64_t>());
|
||||
for (const auto& item : state_.items) {
|
||||
const int64_t id = item["id"];
|
||||
const auto current = catalog_.find(id);
|
||||
if (current == catalog_.end() || current->second != item["revision"] ||
|
||||
IsDismissed(id, current->second) || !retained_ids.insert(id).second)
|
||||
continue;
|
||||
visible.push_back(item);
|
||||
if (visible.size() == static_cast<size_t>(wanted)) break;
|
||||
}
|
||||
}
|
||||
state_.items = std::move(visible);
|
||||
state_.loaded = true;
|
||||
UpdateReadingState();
|
||||
}
|
||||
|
||||
void AnnouncementInbox::Fail() {
|
||||
request_id_.clear();
|
||||
refresh_ = false;
|
||||
state_.loading = false;
|
||||
state_.failed = true;
|
||||
++state_.version;
|
||||
}
|
||||
nlohmann::json AnnouncementInbox::TakePendingRequest(
|
||||
std::chrono::steady_clock::time_point now) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!connected_ || scope_.empty()) return nullptr;
|
||||
if (state_.loading && now >= deadline_) Fail();
|
||||
if (!refresh_) return nullptr;
|
||||
refresh_ = false;
|
||||
request_id_ = std::to_string(++serial_);
|
||||
state_.loading = true;
|
||||
state_.failed = false;
|
||||
++state_.version;
|
||||
deadline_ = now + std::chrono::seconds(12);
|
||||
return {{"type", "announcements_list"},
|
||||
{"request_id", request_id_},
|
||||
{"summary_only", phase_ == Phase::Catalog},
|
||||
{"offset", phase_ == Phase::Catalog
|
||||
? static_cast<int>(pending_catalog_.size())
|
||||
: pending_page_offset_}};
|
||||
}
|
||||
void AnnouncementInbox::Failed(const std::string& request_id) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (request_id == request_id_) Fail();
|
||||
}
|
||||
void AnnouncementInbox::Receive(const nlohmann::json& message) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!message.is_object() || !message.contains("request_id") ||
|
||||
message["request_id"] != request_id_ || request_id_.empty())
|
||||
return;
|
||||
if (message.contains("error")) {
|
||||
Fail();
|
||||
return;
|
||||
}
|
||||
const bool summary = phase_ == Phase::Catalog;
|
||||
const int limit = summary ? 200 : 20;
|
||||
if (!Integer(message, "offset", 0, INT32_MAX) ||
|
||||
!Integer(message, "total", 0, INT32_MAX) ||
|
||||
!Integer(message, "catalog_revision", 0, INT64_MAX) ||
|
||||
!message.contains("summary_only") || message["summary_only"] != summary ||
|
||||
!message.contains("items") || !message["items"].is_array() ||
|
||||
message["items"].size() > static_cast<size_t>(limit)) {
|
||||
Fail();
|
||||
return;
|
||||
}
|
||||
const int offset = message["offset"], total = message["total"];
|
||||
const int64_t revision = message["catalog_revision"];
|
||||
if (offset > total ||
|
||||
message["items"].size() !=
|
||||
static_cast<size_t>((std::min)(limit, total - offset))) {
|
||||
Fail();
|
||||
return;
|
||||
}
|
||||
const auto expected_revision =
|
||||
summary ? pending_revision_ : catalog_revision_;
|
||||
if (expected_revision >= 0 && expected_revision != revision) {
|
||||
BeginCatalog();
|
||||
return;
|
||||
}
|
||||
for (const auto& item : message["items"]) {
|
||||
if (!item.is_object() || !Integer(item, "id", 1, INT64_MAX) ||
|
||||
!Integer(item, "revision", 1, INT64_MAX)) {
|
||||
Fail();
|
||||
return;
|
||||
}
|
||||
if (!summary) {
|
||||
if (!Integer(item, "updated_at", 1, 32503680000LL)) {
|
||||
Fail();
|
||||
return;
|
||||
}
|
||||
for (const auto* key : {"title", "body"}) {
|
||||
if (!item.contains(key) || !item[key].is_string() ||
|
||||
item[key].get_ref<const std::string&>().size() >
|
||||
(std::string(key) == "title" ? 240u : 8000u)) {
|
||||
Fail();
|
||||
return;
|
||||
}
|
||||
}
|
||||
const auto found = catalog_.find(item["id"]);
|
||||
if (found == catalog_.end() || found->second != item["revision"]) {
|
||||
Fail();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
request_id_.clear();
|
||||
state_.loading = false;
|
||||
if (summary) {
|
||||
if (pending_revision_ < 0) {
|
||||
pending_revision_ = revision;
|
||||
pending_total_ = total;
|
||||
}
|
||||
if (pending_total_ != total ||
|
||||
offset != static_cast<int>(pending_catalog_.size())) {
|
||||
Fail();
|
||||
return;
|
||||
}
|
||||
for (const auto& item : message["items"]) {
|
||||
if (!pending_catalog_.emplace(item["id"], item["revision"]).second) {
|
||||
Fail();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (static_cast<int>(pending_catalog_.size()) < total) {
|
||||
refresh_ = true;
|
||||
++state_.version;
|
||||
return;
|
||||
}
|
||||
if (catalog_revision_ != revision) {
|
||||
page_ids_.clear();
|
||||
page_bodies_.clear();
|
||||
}
|
||||
catalog_ = std::move(pending_catalog_);
|
||||
catalog_revision_ = revision;
|
||||
// Prune expired records in memory; the next local action persists them.
|
||||
for (auto* records : {&reads_, &dismissed_}) {
|
||||
for (auto it = records->begin(); it != records->end();)
|
||||
if (!catalog_.count(it->first))
|
||||
it = records->erase(it);
|
||||
else
|
||||
++it;
|
||||
}
|
||||
} else {
|
||||
if (total != static_cast<int>(catalog_.size())) {
|
||||
Fail();
|
||||
return;
|
||||
}
|
||||
if (offset != pending_page_offset_) {
|
||||
Fail();
|
||||
return;
|
||||
}
|
||||
std::set<int64_t> unique;
|
||||
std::vector<int64_t> ids;
|
||||
for (const auto& item : message["items"]) {
|
||||
const int64_t id = item["id"];
|
||||
if (!unique.insert(id).second) {
|
||||
Fail();
|
||||
return;
|
||||
}
|
||||
ids.push_back(id);
|
||||
}
|
||||
page_ids_[offset] = std::move(ids);
|
||||
page_bodies_[offset] = message["items"];
|
||||
}
|
||||
RebuildVisibleItems();
|
||||
state_.failed = false;
|
||||
++state_.version;
|
||||
}
|
||||
|
||||
void AnnouncementInbox::LoadLocalState() {
|
||||
reads_.clear();
|
||||
dismissed_.clear();
|
||||
std::error_code ec;
|
||||
const auto size = std::filesystem::file_size(read_file_, ec);
|
||||
if (ec || size > 16 * 1024 * 1024) return;
|
||||
std::ifstream input(read_file_, std::ios::binary);
|
||||
const auto data = json::parse(input, nullptr, false);
|
||||
if (!data.is_object() || !data.contains("scope") || data["scope"] != scope_ ||
|
||||
!data.contains("version") ||
|
||||
(data["version"] != 1 && data["version"] != 2) ||
|
||||
!data.contains("reads") || !data["reads"].is_object())
|
||||
return;
|
||||
const auto load = [](const json& entries, Revisions& destination) {
|
||||
if (!entries.is_object()) return;
|
||||
for (const auto& entry : entries.items()) {
|
||||
int64_t id = 0;
|
||||
const auto parsed = std::from_chars(
|
||||
entry.key().data(), entry.key().data() + entry.key().size(), id);
|
||||
if (parsed.ec == std::errc{} &&
|
||||
parsed.ptr == entry.key().data() + entry.key().size() && id > 0 &&
|
||||
entry.value().is_number_integer() && entry.value() > 0 &&
|
||||
entry.value() <= INT64_MAX)
|
||||
destination[id] = entry.value();
|
||||
}
|
||||
};
|
||||
load(data["reads"], reads_);
|
||||
if (data.contains("dismissed")) load(data["dismissed"], dismissed_);
|
||||
}
|
||||
bool AnnouncementInbox::SaveLocalState(const Revisions& reads,
|
||||
const Revisions& dismissed) const {
|
||||
if (read_file_.empty()) return false;
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(directory_, ec);
|
||||
if (ec) return false;
|
||||
const auto encode = [](const Revisions& records) {
|
||||
auto entries = json::object();
|
||||
for (const auto& [id, revision] : records)
|
||||
entries[std::to_string(id)] = revision;
|
||||
return entries;
|
||||
};
|
||||
const auto payload = json({{"version", 2},
|
||||
{"scope", scope_},
|
||||
{"reads", encode(reads)},
|
||||
{"dismissed", encode(dismissed)}})
|
||||
.dump();
|
||||
auto temporary = read_file_;
|
||||
temporary += ".tmp-" +
|
||||
std::to_string(
|
||||
std::chrono::steady_clock::now().time_since_epoch().count());
|
||||
{
|
||||
std::ofstream output(temporary, std::ios::binary | std::ios::trunc);
|
||||
output.write(payload.data(), static_cast<std::streamsize>(payload.size()));
|
||||
output.close();
|
||||
if (!output) {
|
||||
std::filesystem::remove(temporary, ec);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
#ifdef _WIN32
|
||||
const bool saved =
|
||||
MoveFileExW(temporary.c_str(), read_file_.c_str(),
|
||||
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0;
|
||||
#else
|
||||
std::filesystem::rename(temporary, read_file_, ec);
|
||||
const bool saved = !ec;
|
||||
#endif
|
||||
if (!saved) std::filesystem::remove(temporary, ec);
|
||||
return saved;
|
||||
}
|
||||
} // namespace crossdesk
|
||||
@@ -1,78 +0,0 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-29
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _ANNOUNCEMENT_INBOX_H_
|
||||
#define _ANNOUNCEMENT_INBOX_H_
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk {
|
||||
// Announcement data is shared; read and dismissed versions stay on this device.
|
||||
class AnnouncementInbox {
|
||||
public:
|
||||
struct Snapshot {
|
||||
nlohmann::json items = nlohmann::json::array();
|
||||
int total = 0, unread = 0;
|
||||
bool loaded = false, loading = false, failed = false;
|
||||
bool read_save_failed = false;
|
||||
bool delete_failed = false;
|
||||
uint64_t version = 0;
|
||||
};
|
||||
Snapshot Get() const;
|
||||
uint64_t Version() const;
|
||||
void SetStorageDirectory(const std::filesystem::path& directory);
|
||||
// Called on the UI thread after login supplies the actual device ID.
|
||||
void Configure(std::string host, int port, const std::string& device_id);
|
||||
void Reset();
|
||||
void SetConnected(bool connected);
|
||||
void Refresh();
|
||||
void LoadMore();
|
||||
bool Read(int64_t id, int64_t revision);
|
||||
bool Dismiss(int64_t id, int64_t revision);
|
||||
nlohmann::json TakePendingRequest(std::chrono::steady_clock::time_point now =
|
||||
std::chrono::steady_clock::now());
|
||||
void Failed(const std::string& request_id);
|
||||
void Receive(const nlohmann::json& message);
|
||||
|
||||
private:
|
||||
using Revisions = std::map<int64_t, int64_t>;
|
||||
enum class Phase { Catalog, Page };
|
||||
void BeginCatalog();
|
||||
void Fail();
|
||||
void UpdateReadingState();
|
||||
bool IsDismissed(int64_t id, int64_t revision) const;
|
||||
void RebuildVisibleItems();
|
||||
void LoadLocalState();
|
||||
bool SaveLocalState(const Revisions& reads, const Revisions& dismissed) const;
|
||||
|
||||
mutable std::mutex mutex_;
|
||||
Snapshot state_;
|
||||
std::filesystem::path directory_, read_file_;
|
||||
std::string scope_, request_id_;
|
||||
Revisions reads_, dismissed_, catalog_, pending_catalog_;
|
||||
// Keep bodies for the loaded list prefix. Lightweight page IDs let us skip
|
||||
// locally deleted entries without sending deletion state upstream.
|
||||
std::map<int, std::vector<int64_t>> page_ids_;
|
||||
std::map<int, nlohmann::json> page_bodies_;
|
||||
int pending_page_offset_ = 0;
|
||||
int visible_limit_ = 20;
|
||||
int64_t catalog_revision_ = -1, pending_revision_ = -1;
|
||||
int pending_total_ = 0;
|
||||
Phase phase_ = Phase::Catalog;
|
||||
bool connected_ = false, refresh_ = false;
|
||||
uint64_t serial_ = 0;
|
||||
std::chrono::steady_clock::time_point deadline_{};
|
||||
};
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -1,59 +0,0 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-26
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _CONNECTION_LATENCY_H_
|
||||
#define _CONNECTION_LATENCY_H_
|
||||
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
|
||||
namespace crossdesk::gui_detail {
|
||||
|
||||
// Presentation-only smoothing. The transport's raw RTT remains available for
|
||||
// recovery, congestion control and diagnostics. A missing one-second report
|
||||
// does not flicker, but a disconnected/stalled peer cannot leave a stale value.
|
||||
class ConnectionLatencyStats {
|
||||
public:
|
||||
using Clock = std::chrono::steady_clock;
|
||||
|
||||
void Update(double rtt_ms, Clock::time_point now = Clock::now()) {
|
||||
if (!std::isfinite(rtt_ms) || rtt_ms < 0 || rtt_ms > 2000) return;
|
||||
std::lock_guard lock(mutex_);
|
||||
if (average_ms_ && now < updated_) return;
|
||||
if (!Fresh(now)) {
|
||||
average_ms_ = rtt_ms;
|
||||
} else {
|
||||
*average_ms_ += 0.25 * (rtt_ms - *average_ms_);
|
||||
}
|
||||
updated_ = now;
|
||||
}
|
||||
|
||||
std::optional<double> Get(Clock::time_point now = Clock::now()) const {
|
||||
std::lock_guard lock(mutex_);
|
||||
return Fresh(now) ? average_ms_ : std::nullopt;
|
||||
}
|
||||
|
||||
void Reset() {
|
||||
std::lock_guard lock(mutex_);
|
||||
average_ms_.reset();
|
||||
}
|
||||
|
||||
private:
|
||||
bool Fresh(Clock::time_point now) const {
|
||||
// The UI may snapshot `now` just before the report thread updates us.
|
||||
// A newer valid report must not cause a one-frame unavailable flicker.
|
||||
return average_ms_ && now - updated_ < std::chrono::seconds(3);
|
||||
}
|
||||
mutable std::mutex mutex_;
|
||||
std::optional<double> average_ms_;
|
||||
Clock::time_point updated_{};
|
||||
};
|
||||
|
||||
} // namespace crossdesk::gui_detail
|
||||
|
||||
#endif
|
||||
@@ -1,617 +0,0 @@
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <shared_mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <display_stream_id.h>
|
||||
#include "localization.h"
|
||||
#include "platform.h"
|
||||
#include "platform/video_renderer.h"
|
||||
#include "rd_log.h"
|
||||
#include "runtime/device_presence_request.h"
|
||||
#include "runtime/gui_runtime.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
namespace {
|
||||
constexpr auto kPresenceProbeTimeout = std::chrono::seconds(5);
|
||||
constexpr auto kPresenceRefreshInterval = std::chrono::seconds(30);
|
||||
constexpr auto kPresenceRetryInterval = std::chrono::seconds(1);
|
||||
} // namespace
|
||||
|
||||
bool GuiRuntime::HasActiveSession() {
|
||||
if (stream_window_inited_) return true;
|
||||
std::shared_lock lock(connection_status_mutex_);
|
||||
return std::any_of(connection_status_.begin(), connection_status_.end(),
|
||||
[](const auto& entry) {
|
||||
return entry.second == ConnectionStatus::Connecting ||
|
||||
entry.second == ConnectionStatus::Connected;
|
||||
});
|
||||
}
|
||||
|
||||
void GuiRuntime::HandleConnectionStatusChange() {
|
||||
if (!signal_connected_ || !peer_) {
|
||||
return;
|
||||
}
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
if (now - last_presence_request_attempt_at_ < kPresenceRetryInterval) {
|
||||
return;
|
||||
}
|
||||
// Consume the flag before sending so a concurrent reconnect cannot lose its
|
||||
// refresh request. Keep it pending while disconnected or after a send error.
|
||||
const bool requested = need_to_send_recent_connections_.exchange(false);
|
||||
if (!requested && now < next_presence_refresh_at_) {
|
||||
return;
|
||||
}
|
||||
last_presence_request_attempt_at_ = now;
|
||||
const auto message =
|
||||
BuildDevicePresenceRequest(client_id_, recent_connection_ids_, true).dump();
|
||||
if (SendSignalMessage(peer_, message.data(), message.size()) == 0) {
|
||||
next_presence_refresh_at_ = now + kPresenceRefreshInterval;
|
||||
} else {
|
||||
need_to_send_recent_connections_ = true;
|
||||
}
|
||||
}
|
||||
|
||||
void GuiRuntime::HandlePendingPresenceProbe() {
|
||||
bool has_action = false;
|
||||
bool should_connect = false;
|
||||
bool remember_password = false;
|
||||
std::string remote_id;
|
||||
std::string password;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(pending_presence_probe_mutex_);
|
||||
if (!pending_presence_probe_ || !pending_presence_result_ready_) {
|
||||
return;
|
||||
}
|
||||
|
||||
has_action = true;
|
||||
should_connect = pending_presence_online_;
|
||||
remote_id = pending_presence_remote_id_;
|
||||
password = pending_presence_password_;
|
||||
remember_password = pending_presence_remember_password_;
|
||||
|
||||
pending_presence_probe_ = false;
|
||||
pending_presence_result_ready_ = false;
|
||||
pending_presence_online_ = false;
|
||||
pending_presence_remote_id_.clear();
|
||||
pending_presence_password_.clear();
|
||||
pending_presence_remember_password_ = false;
|
||||
}
|
||||
|
||||
if (!has_action) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (should_connect) {
|
||||
ConnectTo(remote_id, password.c_str(), remember_password, true);
|
||||
return;
|
||||
}
|
||||
|
||||
offline_warning_text_ =
|
||||
localization::device_offline[localization_language_index_];
|
||||
show_offline_warning_window_ = true;
|
||||
}
|
||||
|
||||
void GuiRuntime::HandlePresenceProbeTimeout() {
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
|
||||
bool presence_probe_timed_out = false;
|
||||
std::string presence_remote_id;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(pending_presence_probe_mutex_);
|
||||
if (pending_presence_probe_ && !pending_presence_result_ready_ &&
|
||||
now - pending_presence_probe_started_at_ >= kPresenceProbeTimeout) {
|
||||
presence_probe_timed_out = true;
|
||||
presence_remote_id = pending_presence_remote_id_;
|
||||
pending_presence_probe_ = false;
|
||||
pending_presence_result_ready_ = false;
|
||||
pending_presence_online_ = false;
|
||||
pending_presence_remote_id_.clear();
|
||||
pending_presence_password_.clear();
|
||||
pending_presence_remember_password_ = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (presence_probe_timed_out) {
|
||||
offline_warning_text_ =
|
||||
localization::device_offline[localization_language_index_];
|
||||
show_offline_warning_window_ = true;
|
||||
LOG_WARN("Presence probe timed out for [{}]", presence_remote_id);
|
||||
}
|
||||
}
|
||||
|
||||
void GuiRuntime::HandleServerControllerDisconnected(
|
||||
const std::string& remote_id, const char* reason) {
|
||||
keyboard_.ReleaseRemotePressedKeys(remote_id, reason);
|
||||
#ifdef _WIN32
|
||||
devices_.ReleaseRemoteMouseButtons();
|
||||
#endif
|
||||
{
|
||||
std::lock_guard lock(remote_pointer_input_mutex_);
|
||||
last_remote_pointer_input_time_.erase(remote_id);
|
||||
}
|
||||
|
||||
bool has_connected_controller = false;
|
||||
std::string remaining_controller_id;
|
||||
{
|
||||
std::unique_lock lock(connection_status_mutex_);
|
||||
connection_status_.erase(remote_id);
|
||||
devices_.OnControllerConnectionsChanged();
|
||||
connection_host_names_.erase(remote_id);
|
||||
privacy_sessions_.Disconnected(remote_id);
|
||||
for (const auto& [id, status] : connection_status_) {
|
||||
if (status != ConnectionStatus::Connected) {
|
||||
continue;
|
||||
}
|
||||
has_connected_controller = true;
|
||||
if (remaining_controller_id.empty()) {
|
||||
remaining_controller_id = id;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (has_connected_controller) {
|
||||
remote_client_id_ = remaining_controller_id;
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_INFO("Last remote controller disconnected; recovering privacy, reason={}",
|
||||
reason ? reason : "unknown");
|
||||
privacy_.Disconnected();
|
||||
|
||||
need_to_create_server_window_.store(false, std::memory_order_release);
|
||||
need_to_destroy_server_window_.store(true, std::memory_order_release);
|
||||
is_server_mode_ = false;
|
||||
#if defined(__linux__) && !defined(__APPLE__)
|
||||
if (IsWaylandSession()) {
|
||||
// Keep Wayland capture session warm to avoid black screen on subsequent
|
||||
// reconnects.
|
||||
start_screen_capturer_ = true;
|
||||
LOG_INFO(
|
||||
"Keeping Wayland screen capturer running after disconnect to "
|
||||
"preserve reconnect stability");
|
||||
} else {
|
||||
start_screen_capturer_ = false;
|
||||
}
|
||||
#else
|
||||
start_screen_capturer_ = false;
|
||||
#endif
|
||||
devices_.StopSpeakerCapturer();
|
||||
start_mouse_controller_ = false;
|
||||
start_keyboard_capturer_ = false;
|
||||
remote_client_id_.clear();
|
||||
audio_capture_ = false;
|
||||
devices_.ResetToInitialDisplay();
|
||||
}
|
||||
|
||||
int GuiRuntime::RequestSingleDevicePresence(const std::string& remote_id,
|
||||
const char* password,
|
||||
bool remember_password) {
|
||||
if (!signal_connected_ || !peer_) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(pending_presence_probe_mutex_);
|
||||
pending_presence_probe_ = true;
|
||||
pending_presence_result_ready_ = false;
|
||||
pending_presence_online_ = false;
|
||||
pending_presence_probe_started_at_ = std::chrono::steady_clock::now();
|
||||
pending_presence_remote_id_ = remote_id;
|
||||
pending_presence_password_ = password ? password : "";
|
||||
pending_presence_remember_password_ = remember_password;
|
||||
}
|
||||
|
||||
const auto s = BuildDevicePresenceRequest(
|
||||
client_id_, recent_connection_ids_, false, remote_id)
|
||||
.dump();
|
||||
|
||||
int ret = SendSignalMessage(peer_, s.data(), s.size());
|
||||
if (ret != 0) {
|
||||
std::lock_guard<std::mutex> lock(pending_presence_probe_mutex_);
|
||||
pending_presence_probe_ = false;
|
||||
pending_presence_result_ready_ = false;
|
||||
pending_presence_online_ = false;
|
||||
pending_presence_remote_id_.clear();
|
||||
pending_presence_password_.clear();
|
||||
pending_presence_remember_password_ = false;
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
void GuiRuntime::CloseRemoteSession(std::shared_ptr<RemoteSession> props) {
|
||||
if (!props || props->closing_.exchange(true)) return;
|
||||
if (props->peer_events_) props->peer_events_->Deactivate();
|
||||
props->connection_established_ = false;
|
||||
props->streaming_ = false;
|
||||
props->connection_status_.store(ConnectionStatus::Closed);
|
||||
std::shared_ptr<std::vector<unsigned char>> frame_snapshot;
|
||||
int video_width = 0;
|
||||
int video_height = 0;
|
||||
const bool save_preview = config_center_->IsSaveRemotePreviews();
|
||||
if (save_preview) {
|
||||
std::lock_guard<std::mutex> lock(props->video_frame_mutex_);
|
||||
frame_snapshot = props->front_frame_;
|
||||
video_width = props->video_width_;
|
||||
video_height = props->video_height_;
|
||||
if ((!frame_snapshot || frame_snapshot->empty()) &&
|
||||
props->thumbnail_frame_ && !props->thumbnail_frame_->empty()) {
|
||||
frame_snapshot = props->thumbnail_frame_;
|
||||
video_width = props->thumbnail_width_;
|
||||
video_height = props->thumbnail_height_;
|
||||
}
|
||||
}
|
||||
auto* native_renderer = video_renderer_.get();
|
||||
if (save_preview && (!frame_snapshot || frame_snapshot->empty()) &&
|
||||
native_renderer) {
|
||||
auto native_snapshot = std::make_shared<std::vector<unsigned char>>();
|
||||
if (native_renderer->CopyLatestNv12(props->remote_id_,
|
||||
native_snapshot.get(), &video_width,
|
||||
&video_height)) {
|
||||
frame_snapshot = std::move(native_snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
if (native_renderer) {
|
||||
native_renderer->DiscardStream(props->remote_id_);
|
||||
video_frame_dirty_.store(true, std::memory_order_release);
|
||||
}
|
||||
|
||||
PeerPtr* peer = nullptr;
|
||||
{
|
||||
// Audio/clipboard senders hold a shared map lock while using this pointer.
|
||||
std::unique_lock lock(remote_sessions_mutex_);
|
||||
peer = std::exchange(props->peer_, nullptr);
|
||||
}
|
||||
HostPlatform platform;
|
||||
{
|
||||
std::lock_guard lock(props->remote_version_mutex_);
|
||||
platform = props->recent_platform_;
|
||||
}
|
||||
const auto queued_at = std::chrono::steady_clock::now();
|
||||
session_cleanup_tasks_[props->remote_id_] = session_cleanup_queue_.PostTask(
|
||||
[props, peer, frame_snapshot, video_width, video_height, platform,
|
||||
thumbnail = thumbnail_, queued_at]() mutable {
|
||||
if (peer) {
|
||||
LOG_INFO("[{}] Background leave connection [{}]", props->local_id_,
|
||||
props->remote_id_);
|
||||
LeaveConnection(peer, props->remote_id_.c_str());
|
||||
DestroyPeer(&peer);
|
||||
}
|
||||
if (thumbnail && props->was_connected_) {
|
||||
const char* nv12 = frame_snapshot && !frame_snapshot->empty()
|
||||
? reinterpret_cast<const char*>(frame_snapshot->data()) : nullptr;
|
||||
if (thumbnail->SaveToThumbnail(
|
||||
nv12, video_width, video_height, props->remote_id_,
|
||||
props->remote_host_name_,
|
||||
props->remember_password_ ? props->remote_password_ : "",
|
||||
platform) != 0) {
|
||||
LOG_ERROR("[{}] Failed to save recent connection", props->remote_id_);
|
||||
}
|
||||
}
|
||||
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::steady_clock::now() - queued_at).count();
|
||||
LOG_INFO("[{}] Session cleanup completed in {} ms", props->remote_id_,
|
||||
elapsed);
|
||||
});
|
||||
}
|
||||
|
||||
void GuiRuntime::CloseServerController(const std::string& remote_id) {
|
||||
if (!peer_ || remote_id.empty() || controller_cleanup_tasks_.count(remote_id))
|
||||
return;
|
||||
controller_cleanup_tasks_[remote_id] = session_cleanup_queue_.PostTask(
|
||||
[peer = peer_, remote_id] { LeaveConnection(peer, remote_id.c_str()); });
|
||||
}
|
||||
|
||||
void GuiRuntime::CloseConnectionPeer() {
|
||||
if (!peer_) {
|
||||
announcements_.Reset();
|
||||
return;
|
||||
}
|
||||
peer_events_->Deactivate();
|
||||
announcements_.Reset();
|
||||
std::vector<std::string> controllers;
|
||||
{
|
||||
std::shared_lock lock(connection_status_mutex_);
|
||||
for (const auto& [id, status] : connection_status_) controllers.push_back(id);
|
||||
}
|
||||
for (const auto& id : controllers)
|
||||
HandleServerControllerDisconnected(id, "peer_closed");
|
||||
devices_.StopMouseController();
|
||||
devices_.StopScreenCapturer();
|
||||
devices_.StopSpeakerCapturer();
|
||||
devices_.StopKeyboardCapturer();
|
||||
signal_connected_ = false;
|
||||
device_presence_cache_.SetSignalConnected(false);
|
||||
signal_status_ = SignalStatus::SignalClosed;
|
||||
PeerPtr* peer;
|
||||
{
|
||||
std::unique_lock lock(remote_sessions_mutex_);
|
||||
peer = std::exchange(peer_, nullptr);
|
||||
}
|
||||
// The same serial queue also owns outstanding controller disconnects, so
|
||||
// these finish before the shared peer is destroyed.
|
||||
connection_peer_cleanup_ = session_cleanup_queue_.PostTask(
|
||||
[peer, handler = peer_events_, id = std::string(client_id_)]() mutable {
|
||||
LOG_INFO("[{}] Background connection peer cleanup", id);
|
||||
LeaveConnection(peer, id.c_str());
|
||||
DestroyPeer(&peer);
|
||||
});
|
||||
}
|
||||
|
||||
void GuiRuntime::CloseAllRemoteSessions() {
|
||||
CloseConnectionPeer();
|
||||
is_client_mode_ = false;
|
||||
pending_reconnects_.clear();
|
||||
std::vector<std::shared_ptr<RemoteSession>> sessions;
|
||||
{
|
||||
std::shared_lock lock(remote_sessions_mutex_);
|
||||
for (const auto& [id, props] : remote_sessions_) sessions.push_back(props);
|
||||
}
|
||||
for (const auto& props : sessions) CloseRemoteSession(props);
|
||||
{
|
||||
std::unique_lock lock(remote_sessions_mutex_);
|
||||
remote_sessions_.clear();
|
||||
}
|
||||
}
|
||||
|
||||
void GuiRuntime::HandleSessionCleanup() {
|
||||
auto completed = [](std::future<void>& task) {
|
||||
if (!task.valid() || task.wait_for(std::chrono::seconds(0)) !=
|
||||
std::future_status::ready) return false;
|
||||
try {
|
||||
task.get();
|
||||
} catch (const std::exception& error) {
|
||||
LOG_ERROR("Connection cleanup failed: {}", error.what());
|
||||
}
|
||||
return true;
|
||||
};
|
||||
completed(connection_peer_cleanup_);
|
||||
for (auto it = controller_cleanup_tasks_.begin();
|
||||
it != controller_cleanup_tasks_.end();) {
|
||||
if (completed(it->second)) it = controller_cleanup_tasks_.erase(it);
|
||||
else ++it;
|
||||
}
|
||||
std::vector<std::pair<std::string, PendingReconnect>> reconnects;
|
||||
for (auto it = session_cleanup_tasks_.begin();
|
||||
it != session_cleanup_tasks_.end();) {
|
||||
if (it->second.wait_for(std::chrono::seconds(0)) !=
|
||||
std::future_status::ready) {
|
||||
++it;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
it->second.get();
|
||||
} catch (const std::exception& error) {
|
||||
LOG_ERROR("[{}] Session cleanup failed: {}", it->first, error.what());
|
||||
}
|
||||
if (auto pending = pending_reconnects_.find(it->first);
|
||||
pending != pending_reconnects_.end()) {
|
||||
reconnects.emplace_back(pending->first, std::move(pending->second));
|
||||
pending_reconnects_.erase(pending);
|
||||
}
|
||||
it = session_cleanup_tasks_.erase(it);
|
||||
reload_recent_connections_ = true;
|
||||
recent_connection_image_save_time_ = 0;
|
||||
}
|
||||
for (const auto& [id, request] : reconnects) {
|
||||
ConnectTo(id, request.password.c_str(), request.remember_password);
|
||||
}
|
||||
}
|
||||
|
||||
void GuiRuntime::WaitForSessionCleanup() {
|
||||
pending_reconnects_.clear();
|
||||
auto drain = [](std::future<void>& task) {
|
||||
if (!task.valid()) return;
|
||||
try {
|
||||
task.get();
|
||||
} catch (const std::exception& error) {
|
||||
LOG_ERROR("Connection cleanup failed: {}", error.what());
|
||||
}
|
||||
};
|
||||
drain(connection_peer_cleanup_);
|
||||
for (auto& [id, task] : controller_cleanup_tasks_) drain(task);
|
||||
controller_cleanup_tasks_.clear();
|
||||
for (auto& [id, task] : session_cleanup_tasks_) {
|
||||
try {
|
||||
task.get();
|
||||
} catch (const std::exception& error) {
|
||||
LOG_ERROR("[{}] Session cleanup failed: {}", id, error.what());
|
||||
}
|
||||
}
|
||||
session_cleanup_tasks_.clear();
|
||||
}
|
||||
|
||||
void GuiRuntime::ResetRemoteSessionResources(
|
||||
std::shared_ptr<RemoteSession> props) {
|
||||
props->net_traffic_stats_.Reset();
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(props->video_frame_mutex_);
|
||||
props->video_latency_->Reset();
|
||||
props->video_frame_timing_ = {};
|
||||
props->front_frame_.reset();
|
||||
props->back_frame_.reset();
|
||||
props->thumbnail_frame_.reset();
|
||||
props->thumbnail_width_ = 0;
|
||||
props->thumbnail_height_ = 0;
|
||||
props->background_snapshot_time_ = {};
|
||||
props->video_width_ = 0;
|
||||
props->video_height_ = 0;
|
||||
props->video_size_ = 0;
|
||||
props->render_rect_dirty_ = true;
|
||||
props->stream_cleanup_pending_ = false;
|
||||
}
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(props->remote_cursor_state_mutex_);
|
||||
props->remote_cursor_state_ = {};
|
||||
props->remote_cursor_state_received_ = false;
|
||||
props->cursor_presentation_ = {};
|
||||
}
|
||||
}
|
||||
|
||||
std::shared_ptr<GuiRuntime::RemoteSession> GuiRuntime::FindRemoteSession(
|
||||
const std::string& remote_id) {
|
||||
if (remote_id.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
std::shared_lock lock(remote_sessions_mutex_);
|
||||
auto it = remote_sessions_.find(remote_id);
|
||||
if (it == remote_sessions_.end()) {
|
||||
return nullptr;
|
||||
}
|
||||
return it->second;
|
||||
}
|
||||
|
||||
int GuiRuntime::ConnectTo(const std::string& remote_id, const char* password,
|
||||
bool remember_password, bool bypass_presence_check) {
|
||||
if (session_cleanup_tasks_.count(remote_id)) {
|
||||
pending_reconnects_[remote_id] = {password ? password : "", remember_password};
|
||||
LOG_INFO("[{}] Reconnect queued until previous peer is destroyed", remote_id);
|
||||
return 0;
|
||||
}
|
||||
if (!bypass_presence_check && !device_presence_cache_.IsOnline(remote_id)) {
|
||||
int ret =
|
||||
RequestSingleDevicePresence(remote_id, password, remember_password);
|
||||
if (ret != 0) {
|
||||
offline_warning_text_ =
|
||||
localization::device_offline[localization_language_index_];
|
||||
show_offline_warning_window_ = true;
|
||||
LOG_WARN("Presence probe failed for [{}], ret={}", remote_id, ret);
|
||||
} else {
|
||||
LOG_INFO("Presence probe requested for [{}] before connect", remote_id);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
LOG_INFO("Connect to [{}]", remote_id);
|
||||
focused_remote_id_ = remote_id;
|
||||
|
||||
auto stale = FindRemoteSession(remote_id);
|
||||
bool exists = stale != nullptr;
|
||||
if (stale) {
|
||||
// A failed CreatePeer or a completed close can leave a session record
|
||||
// without a reusable peer. Recreate it instead of leaving the UI in
|
||||
// Connecting forever while JoinConnection has nothing to call.
|
||||
if (stale->peer_ == nullptr) {
|
||||
std::unique_lock lock(remote_sessions_mutex_);
|
||||
auto it = remote_sessions_.find(remote_id);
|
||||
if (it != remote_sessions_.end() && it->second == stale) {
|
||||
remote_sessions_.erase(it);
|
||||
exists = false;
|
||||
LOG_INFO("[{}] Recreating stale remote session before connect",
|
||||
remote_id);
|
||||
}
|
||||
}
|
||||
if (exists && !stale->closing_.load() &&
|
||||
!stale->connection_established_) {
|
||||
const auto status = stale->connection_status_.load();
|
||||
if (status == ConnectionStatus::Disconnected ||
|
||||
status == ConnectionStatus::Failed ||
|
||||
status == ConnectionStatus::Closed) {
|
||||
// The recreated session starts with an empty password; callers that
|
||||
// pass none expect the one stored by the previous attempt to be reused.
|
||||
const bool keep_stored_password = !password || !*password;
|
||||
pending_reconnects_[remote_id] = {
|
||||
keep_stored_password ? std::string(stale->remote_password_)
|
||||
: std::string(password),
|
||||
remember_password};
|
||||
CloseRemoteSession(stale);
|
||||
LOG_INFO("[{}] Reconnecting after terminal peer status {}", remote_id,
|
||||
static_cast<int>(status));
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!exists) {
|
||||
PeerPtr* peer_to_init = nullptr;
|
||||
std::string local_id;
|
||||
|
||||
{
|
||||
std::unique_lock unique_lock(remote_sessions_mutex_);
|
||||
if (remote_sessions_.find(remote_id) == remote_sessions_.end()) {
|
||||
remote_sessions_[remote_id] = std::make_shared<RemoteSession>();
|
||||
auto props = remote_sessions_[remote_id];
|
||||
props->local_id_ = "C-" + std::string(client_id_);
|
||||
props->remote_id_ = remote_id;
|
||||
memcpy(&props->params_, ¶ms_, sizeof(Params));
|
||||
props->params_.user_id = props->local_id_.c_str();
|
||||
props->peer_events_ = std::make_shared<PeerEventHandler>(*this);
|
||||
props->params_.user_data = props->peer_events_.get();
|
||||
props->peer_ = CreatePeer(&props->params_);
|
||||
|
||||
props->connection_status_.store(ConnectionStatus::Connecting);
|
||||
show_connection_status_window_ = true;
|
||||
|
||||
if (!props->peer_) {
|
||||
LOG_INFO("Create peer [{}] instance failed", props->local_id_);
|
||||
return -1;
|
||||
}
|
||||
|
||||
const auto& displays = devices_.display_info_list();
|
||||
for (size_t index = 0; index < displays.size(); ++index) {
|
||||
const std::string stream_id = MakeDisplayStreamId(index);
|
||||
AddVideoStream(props->peer_, stream_id.c_str());
|
||||
}
|
||||
AddAudioStream(props->peer_, props->audio_label_.c_str());
|
||||
AddDataStream(props->peer_, props->data_label_.c_str(), false);
|
||||
AddDataStream(props->peer_, props->mouse_label_.c_str(), true);
|
||||
AddDataStream(props->peer_, props->keyboard_label_.c_str(), true);
|
||||
AddDataStream(props->peer_, props->control_data_label_.c_str(), true);
|
||||
AddDataStream(props->peer_, props->file_label_.c_str(), true);
|
||||
AddDataStream(props->peer_, props->file_feedback_label_.c_str(), true);
|
||||
AddDataStream(props->peer_, props->clipboard_label_.c_str(), true);
|
||||
|
||||
props->connection_status_.store(ConnectionStatus::Connecting);
|
||||
|
||||
peer_to_init = props->peer_;
|
||||
local_id = props->local_id_;
|
||||
}
|
||||
}
|
||||
|
||||
if (peer_to_init) {
|
||||
LOG_INFO("[{}] Create peer instance successful", local_id);
|
||||
Init(peer_to_init);
|
||||
LOG_INFO("[{}] Peer init finish", local_id);
|
||||
}
|
||||
}
|
||||
|
||||
int ret = -1;
|
||||
auto props = FindRemoteSession(remote_id);
|
||||
if (!props || props->closing_) return -1;
|
||||
if (!props->connection_established_) {
|
||||
props->connection_status_.store(ConnectionStatus::Connecting);
|
||||
show_connection_status_window_ = true;
|
||||
|
||||
props->remember_password_ = remember_password;
|
||||
if (strcmp(password, "") != 0 &&
|
||||
strcmp(password, props->remote_password_) != 0) {
|
||||
strncpy(props->remote_password_, password,
|
||||
sizeof(props->remote_password_) - 1);
|
||||
props->remote_password_[sizeof(props->remote_password_) - 1] = '\0';
|
||||
}
|
||||
|
||||
std::string remote_id_with_pwd = remote_id + "@" + password;
|
||||
if (props->peer_) {
|
||||
ret = JoinConnection(props->peer_, remote_id_with_pwd.c_str());
|
||||
if (0 == ret) {
|
||||
props->rejoin_ = false;
|
||||
} else {
|
||||
props->rejoin_ = true;
|
||||
need_to_rejoin_ = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
} // namespace crossdesk
|
||||
@@ -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
|
||||
@@ -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,122 +0,0 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-26
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _NET_TRAFFIC_STATS_CACHE_H_
|
||||
#define _NET_TRAFFIC_STATS_CACHE_H_
|
||||
|
||||
#include <atomic>
|
||||
|
||||
#include "minirtc.h"
|
||||
#include "runtime/connection_latency.h"
|
||||
|
||||
namespace crossdesk::gui_detail {
|
||||
|
||||
// Fields are independent observations and may come from different reports.
|
||||
// Relaxed accesses keep each field safe without publishing any other state.
|
||||
class NetTrafficStatsCache {
|
||||
static_assert(std::atomic<uint32_t>::is_always_lock_free &&
|
||||
std::atomic<float>::is_always_lock_free &&
|
||||
std::atomic<double>::is_always_lock_free &&
|
||||
std::atomic<bool>::is_always_lock_free,
|
||||
"Network statistics require lock-free scalar atomics");
|
||||
|
||||
public:
|
||||
void Store(const MiniRtcNetTrafficStats& stats) {
|
||||
video_inbound_stats_.Store(stats.video_inbound_stats);
|
||||
video_outbound_stats_.Store(stats.video_outbound_stats);
|
||||
audio_inbound_stats_.Store(stats.audio_inbound_stats);
|
||||
audio_outbound_stats_.Store(stats.audio_outbound_stats);
|
||||
data_inbound_stats_.Store(stats.data_inbound_stats);
|
||||
data_outbound_stats_.Store(stats.data_outbound_stats);
|
||||
total_inbound_stats_.Store(stats.total_inbound_stats);
|
||||
total_outbound_stats_.Store(stats.total_outbound_stats);
|
||||
srtp_active_.store(stats.srtp_active, std::memory_order_relaxed);
|
||||
rtt_ms_.store(stats.rtt_ms, std::memory_order_relaxed);
|
||||
connection_latency_.Update(stats.rtt_ms);
|
||||
}
|
||||
|
||||
MiniRtcNetTrafficStats Load() const {
|
||||
MiniRtcNetTrafficStats stats{};
|
||||
stats.video_inbound_stats = video_inbound_stats_.Load();
|
||||
stats.video_outbound_stats = video_outbound_stats_.Load();
|
||||
stats.audio_inbound_stats = audio_inbound_stats_.Load();
|
||||
stats.audio_outbound_stats = audio_outbound_stats_.Load();
|
||||
stats.data_inbound_stats = data_inbound_stats_.Load();
|
||||
stats.data_outbound_stats = data_outbound_stats_.Load();
|
||||
stats.total_inbound_stats = total_inbound_stats_.Load();
|
||||
stats.total_outbound_stats = total_outbound_stats_.Load();
|
||||
stats.srtp_active = srtp_active_.load(std::memory_order_relaxed);
|
||||
stats.rtt_ms = rtt_ms_.load(std::memory_order_relaxed);
|
||||
return stats;
|
||||
}
|
||||
|
||||
uint32_t DataOutboundBitrate() const {
|
||||
return data_outbound_stats_.bitrate.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
std::optional<double> SmoothedRttMs(
|
||||
ConnectionLatencyStats::Clock::time_point now =
|
||||
ConnectionLatencyStats::Clock::now()) const {
|
||||
return connection_latency_.Get(now);
|
||||
}
|
||||
|
||||
void ResetLatency() { connection_latency_.Reset(); }
|
||||
|
||||
void Reset() {
|
||||
Store(MiniRtcNetTrafficStats{});
|
||||
ResetLatency();
|
||||
}
|
||||
|
||||
private:
|
||||
struct InboundStats {
|
||||
void Store(const MiniRtcInboundStats& stats) {
|
||||
bitrate.store(stats.bitrate, std::memory_order_relaxed);
|
||||
rtp_packet_count.store(stats.rtp_packet_count, std::memory_order_relaxed);
|
||||
loss_rate.store(stats.loss_rate, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
MiniRtcInboundStats Load() const {
|
||||
return {bitrate.load(std::memory_order_relaxed),
|
||||
rtp_packet_count.load(std::memory_order_relaxed),
|
||||
loss_rate.load(std::memory_order_relaxed)};
|
||||
}
|
||||
|
||||
std::atomic<uint32_t> bitrate{0};
|
||||
std::atomic<uint32_t> rtp_packet_count{0};
|
||||
std::atomic<float> loss_rate{0};
|
||||
};
|
||||
|
||||
struct OutboundStats {
|
||||
void Store(const MiniRtcOutboundStats& stats) {
|
||||
bitrate.store(stats.bitrate, std::memory_order_relaxed);
|
||||
rtp_packet_count.store(stats.rtp_packet_count, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
MiniRtcOutboundStats Load() const {
|
||||
return {bitrate.load(std::memory_order_relaxed),
|
||||
rtp_packet_count.load(std::memory_order_relaxed)};
|
||||
}
|
||||
|
||||
std::atomic<uint32_t> bitrate{0};
|
||||
std::atomic<uint32_t> rtp_packet_count{0};
|
||||
};
|
||||
|
||||
InboundStats video_inbound_stats_;
|
||||
OutboundStats video_outbound_stats_;
|
||||
InboundStats audio_inbound_stats_;
|
||||
OutboundStats audio_outbound_stats_;
|
||||
InboundStats data_inbound_stats_;
|
||||
OutboundStats data_outbound_stats_;
|
||||
InboundStats total_inbound_stats_;
|
||||
OutboundStats total_outbound_stats_;
|
||||
std::atomic<bool> srtp_active_{false};
|
||||
std::atomic<double> rtt_ms_{-1};
|
||||
ConnectionLatencyStats connection_latency_;
|
||||
};
|
||||
|
||||
} // namespace crossdesk::gui_detail
|
||||
|
||||
#endif
|
||||
@@ -1,213 +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 || !video_frame) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::string remote_id(user_id, user_id_size);
|
||||
auto props = runtime->FindRemoteSession(remote_id);
|
||||
if (!props) return;
|
||||
|
||||
if (props->connection_established_) {
|
||||
const auto timing_now = VideoLatencyFrame::Clock::now();
|
||||
const auto timing = props->video_latency_->Frame(
|
||||
video_frame->captured_timestamp, GetSystemTimeMicros(props->peer_),
|
||||
timing_now);
|
||||
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 (const auto* native = GetNativeVideoFrame(video_frame)) {
|
||||
submit_result =
|
||||
native_renderer->SubmitNativeFrame(remote_id, *native, timing);
|
||||
// A CPU NV12 native frame is valid MiniRTC input, but the macOS Metal
|
||||
// fast path only accepts CVPixelBuffer. Materialize it when that path
|
||||
// rejects a software decoder's descriptor. This also supports peers
|
||||
// using CPU decoding when their platform hardware decoder is unavailable.
|
||||
if (submit_result == VideoRenderer::SubmitResult::failed &&
|
||||
native->type == MiniRtcNativeVideoFrameCpuNv12) {
|
||||
const size_t frame_size =
|
||||
static_cast<size_t>(native->width) * native->height * 3U / 2U;
|
||||
// One decode callback thread per session; keep the buffer across
|
||||
// frames instead of a full-frame allocation per rejected frame.
|
||||
auto& fallback = props->native_cpu_fallback_;
|
||||
if (fallback.size() != frame_size) fallback.resize(frame_size);
|
||||
if (native->copy_to_nv12(native->owner, fallback.data(),
|
||||
fallback.size()) == 0) {
|
||||
submit_result = native_renderer->SubmitNv12(
|
||||
remote_id, fallback.data(), fallback.size(),
|
||||
static_cast<int>(native->width),
|
||||
static_cast<int>(native->height), timing);
|
||||
}
|
||||
}
|
||||
} 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, timing);
|
||||
}
|
||||
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->video_frame_timing_ = timing;
|
||||
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,133 +0,0 @@
|
||||
#include <SDL3/SDL_timer.h>
|
||||
|
||||
/*
|
||||
* @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()) return;
|
||||
if (found->second != ConnectionStatus::Connected) {
|
||||
if (found->second == ConnectionStatus::Connecting ||
|
||||
found->second == ConnectionStatus::Gathering) {
|
||||
privacy_sessions_.RememberSupport(remote_id, supported);
|
||||
LOG_INFO("Privacy: retained early controller capability, peer={}, supported={}",
|
||||
remote_id, supported);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const bool automatic_enable =
|
||||
privacy_sessions_.SetSupported(remote_id, supported);
|
||||
ApplyPrivacyAdmission(automatic_enable &&
|
||||
config_center_->IsEnablePrivacyScreen());
|
||||
}
|
||||
|
||||
void GuiRuntime::ApplyPrivacyAdmission(bool automatic_enable) {
|
||||
if (!privacy_sessions_.CanEnable()) {
|
||||
if (privacy_.Engaged()) privacy_.Disable();
|
||||
} else if (automatic_enable) {
|
||||
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) return;
|
||||
LOG_INFO("Privacy: queued controller command, peer={}, flag={}", remote_id,
|
||||
static_cast<int>(command.flag));
|
||||
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;
|
||||
}
|
||||
PrivacyStatus status{};
|
||||
const uint64_t now = SDL_GetTicks();
|
||||
std::vector<std::string> controllers;
|
||||
{
|
||||
std::shared_lock lock(connection_status_mutex_);
|
||||
status = privacy_.Snapshot();
|
||||
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");
|
||||
}
|
||||
}
|
||||
if (!peer_ || (last_privacy_status_tick_ != 0 &&
|
||||
status.revision == last_privacy_revision_ &&
|
||||
now - last_privacy_status_tick_ < 1000))
|
||||
return;
|
||||
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,80 +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 local
|
||||
// displays, so every connected controller must explicitly support its controls.
|
||||
class PrivacySessionPolicy {
|
||||
public:
|
||||
// Web controllers render through the browser and expose no privacy UI.
|
||||
static bool IsWebController(const std::string& remote_id) {
|
||||
return remote_id.find("web") != std::string::npos;
|
||||
}
|
||||
|
||||
bool 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 &&
|
||||
!IsWebController(remote_id)) {
|
||||
automatic_controller_ = remote_id;
|
||||
}
|
||||
const auto early = early_support_.find(remote_id);
|
||||
if (early == early_support_.end()) return false;
|
||||
const bool supported = early->second;
|
||||
early_support_.erase(early);
|
||||
return SetSupported(remote_id, supported);
|
||||
}
|
||||
|
||||
// Only transport-authenticated metadata from a connecting session is cached
|
||||
// by the caller. It grants no control until Connected admits that session.
|
||||
void RememberSupport(const std::string& remote_id, bool supported) {
|
||||
early_support_[remote_id] = supported;
|
||||
}
|
||||
|
||||
void Disconnected(const std::string& remote_id) {
|
||||
controllers_.erase(remote_id);
|
||||
early_support_.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 && !IsWebController(remote_id);
|
||||
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::unordered_map<std::string, bool> early_support_;
|
||||
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
|
||||
@@ -1,161 +0,0 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-29
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _ANNOUNCEMENT_TEXT_H_
|
||||
#define _ANNOUNCEMENT_TEXT_H_
|
||||
|
||||
#include <slint.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace crossdesk::announcement_text {
|
||||
|
||||
inline size_t WebSchemeLength(std::string_view url) {
|
||||
for (const std::string_view prefix : {"https://", "http://"}) {
|
||||
if (url.size() >= prefix.size() &&
|
||||
std::equal(prefix.begin(), prefix.end(), url.begin(),
|
||||
[](char expected, unsigned char actual) {
|
||||
return expected == std::tolower(actual);
|
||||
})) {
|
||||
return prefix.size();
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
inline bool IsWebUrl(std::string_view url) {
|
||||
const size_t scheme = WebSchemeLength(url);
|
||||
if (!scheme || url.size() == scheme ||
|
||||
std::any_of(url.begin(), url.end(), [](unsigned char ch) {
|
||||
return ch <= 0x20 || ch == 0x7f || ch == '\\' || ch == '<' ||
|
||||
ch == '>' || ch == '"';
|
||||
})) {
|
||||
return false;
|
||||
}
|
||||
const auto authority =
|
||||
url.substr(scheme, url.find_first_of("/?#", scheme) - scheme);
|
||||
return !authority.empty() && authority.back() != '@';
|
||||
}
|
||||
|
||||
namespace detail {
|
||||
|
||||
inline size_t UrlEnd(std::string_view text, size_t start) {
|
||||
// Do not include surrounding prose punctuation in a bare URL. Balanced
|
||||
// parentheses and IPv6 brackets are part of the address when present.
|
||||
const std::string_view punctuation =
|
||||
reinterpret_cast<const char*>(u8",。;:!?、()【】《》「」『』“”‘’");
|
||||
int parentheses = 0, brackets = 0;
|
||||
size_t end = start;
|
||||
for (; end < text.size(); ++end) {
|
||||
const unsigned char ch = text[end];
|
||||
if (ch <= 0x20 || ch == 0x7f ||
|
||||
std::string_view("<>\"'`\\{}").find(ch) != std::string_view::npos ||
|
||||
(ch >= 0xe0 && end + 3 <= text.size() &&
|
||||
punctuation.find(text.substr(end, 3)) != std::string_view::npos)) {
|
||||
break;
|
||||
}
|
||||
if (ch == '(') ++parentheses;
|
||||
if (ch == ')' && parentheses-- == 0) break;
|
||||
if (ch == '[') ++brackets;
|
||||
if (ch == ']' && brackets-- == 0) break;
|
||||
}
|
||||
return end;
|
||||
}
|
||||
|
||||
inline void AppendLiteral(std::string& output, std::string_view text) {
|
||||
for (const unsigned char ch : text) {
|
||||
// Entity-encode spaces so leading indentation is not parsed as code.
|
||||
if (ch == ' ') {
|
||||
output += " ";
|
||||
} else if (ch == '\t') {
|
||||
output += "	";
|
||||
} else {
|
||||
if (ch < 0x80 && std::ispunct(ch)) output += '\\';
|
||||
output += static_cast<char>(ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline void AppendLink(std::string& output, std::string_view label,
|
||||
std::string_view url) {
|
||||
output += '[';
|
||||
AppendLiteral(output, label);
|
||||
output += "](<";
|
||||
for (const char ch : url) {
|
||||
// Markdown decodes entities in link destinations; keep query strings exact.
|
||||
if (ch == '&')
|
||||
output += "&";
|
||||
else
|
||||
output += ch;
|
||||
}
|
||||
output += ">)";
|
||||
}
|
||||
|
||||
} // namespace detail
|
||||
|
||||
inline slint::StyledText Format(std::string_view body) {
|
||||
std::string markdown;
|
||||
markdown.reserve(body.size());
|
||||
bool has_link = false;
|
||||
for (size_t pos = 0; pos < body.size();) {
|
||||
if (body[pos] == '[') {
|
||||
const size_t label_end = body.find("](", pos + 1);
|
||||
if (label_end != std::string_view::npos && label_end > pos + 1 &&
|
||||
body.substr(pos + 1, label_end - pos - 1).find_first_of("[]\r\n") ==
|
||||
std::string_view::npos &&
|
||||
WebSchemeLength(body.substr(label_end + 2))) {
|
||||
const size_t end = detail::UrlEnd(body, label_end + 2);
|
||||
const auto url = body.substr(label_end + 2, end - label_end - 2);
|
||||
if (end < body.size() && body[end] == ')' && IsWebUrl(url)) {
|
||||
detail::AppendLink(markdown,
|
||||
body.substr(pos + 1, label_end - pos - 1), url);
|
||||
pos = end + 1;
|
||||
has_link = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (WebSchemeLength(body.substr(pos))) {
|
||||
size_t end = detail::UrlEnd(body, pos);
|
||||
while (end > pos && std::string_view(".,;:!?").find(body[end - 1]) !=
|
||||
std::string_view::npos) {
|
||||
--end;
|
||||
}
|
||||
const auto url = body.substr(pos, end - pos);
|
||||
if (IsWebUrl(url)) {
|
||||
detail::AppendLink(markdown, url, url);
|
||||
pos = end;
|
||||
has_link = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (body[pos] == '\r' && pos + 1 < body.size() && body[pos + 1] == '\n') {
|
||||
++pos;
|
||||
continue;
|
||||
}
|
||||
if (body[pos] == '\n') {
|
||||
// Preserve empty lines instead of letting Markdown collapse them.
|
||||
if (markdown.empty() || markdown.back() == '\n') markdown += " ";
|
||||
markdown += '\n';
|
||||
} else {
|
||||
detail::AppendLiteral(markdown, body.substr(pos, 1));
|
||||
}
|
||||
++pos;
|
||||
}
|
||||
if (has_link) {
|
||||
if (markdown.back() == '\n') markdown += " ";
|
||||
if (const auto styled = slint::StyledText::from_markdown(markdown))
|
||||
return *styled;
|
||||
}
|
||||
return slint::StyledText::from_plain_text(body);
|
||||
}
|
||||
|
||||
} // namespace crossdesk::announcement_text
|
||||
|
||||
#endif
|
||||
@@ -1,249 +0,0 @@
|
||||
import { ScrollView } from "std-widgets.slint";
|
||||
import { CompactButton, DialogSurface, IconButton, FontAwesomeIcons, ImGuiFontStyle } from "common.slint";
|
||||
|
||||
export struct AnnouncementItem {
|
||||
id: string,
|
||||
revision: string,
|
||||
title: string,
|
||||
date: string,
|
||||
read: bool,
|
||||
}
|
||||
|
||||
export global MessageStrings {
|
||||
in-out property <string> messages: "Messages";
|
||||
in-out property <string> unread: "Unread";
|
||||
in-out property <string> read: "Read";
|
||||
in-out property <string> empty: "No announcements";
|
||||
in-out property <string> select-message: "Select an announcement to read";
|
||||
in-out property <string> loading: "Loading…";
|
||||
in-out property <string> read-save-error: "Unable to save reading status locally. Open the announcement again to retry.";
|
||||
in-out property <string> close: "Close";
|
||||
in-out property <string> delete-message: "Delete";
|
||||
in-out property <string> delete-title: "Delete announcement";
|
||||
in-out property <string> delete-confirmation: "Delete this announcement?";
|
||||
in-out property <string> delete-error: "Unable to delete this announcement. Please try again.";
|
||||
in-out property <string> cancel: "Cancel";
|
||||
}
|
||||
|
||||
export component MessagePanel inherits DialogSurface {
|
||||
in property <[AnnouncementItem]> items;
|
||||
in property <int> unread-count;
|
||||
in property <int> total;
|
||||
in property <bool> loading;
|
||||
in property <bool> failed;
|
||||
in property <bool> read-save-failed;
|
||||
in property <bool> delete-failed;
|
||||
in property <bool> loaded;
|
||||
in property <bool> connected;
|
||||
in property <string> selected-id;
|
||||
in property <string> selected-title;
|
||||
in property <styled-text> selected-body;
|
||||
in property <string> selected-date;
|
||||
in property <bool> selected-read;
|
||||
callback open-message(string, string);
|
||||
callback open-link(string);
|
||||
callback delete-message(string, string);
|
||||
callback load-more;
|
||||
callback close;
|
||||
private property <string> notice-text: root.delete-failed ? MessageStrings.delete-error
|
||||
: root.read-save-failed ? MessageStrings.read-save-error : "";
|
||||
private property <length> content-top: title-bar.height + (root.notice-text == "" ? 14px : 50px);
|
||||
private property <color> separator-color: #e6e8eb;
|
||||
private property <string> pending-delete-id;
|
||||
private property <string> pending-delete-revision;
|
||||
function request-delete(id: string, revision: string) {
|
||||
root.pending-delete-id = id;
|
||||
root.pending-delete-revision = revision;
|
||||
confirmation-keys.focus();
|
||||
}
|
||||
confirmation-keys := FocusScope {
|
||||
width: 0px; height: 0px;
|
||||
key-pressed(event) => {
|
||||
if event.text == Key.Escape {
|
||||
if root.pending-delete-id != "" { root.pending-delete-id = ""; }
|
||||
else { root.close(); }
|
||||
return accept;
|
||||
}
|
||||
return reject;
|
||||
}
|
||||
}
|
||||
background: white;
|
||||
border-radius: 7px;
|
||||
TouchArea { }
|
||||
title-bar := Rectangle {
|
||||
x: 0px; y: 0px; width: parent.width; height: 21px;
|
||||
background: #d3d3d3; border-radius: 7px;
|
||||
Rectangle { y: parent.height - 7px; height: 7px; background: #d3d3d3; }
|
||||
message-heading := Text {
|
||||
x: 6px; width: parent.width - 100px; height: parent.height;
|
||||
text: MessageStrings.messages;
|
||||
font-size: ImGuiFontStyle.base; font-weight: 600; color: #202124;
|
||||
vertical-alignment: center; overflow: elide;
|
||||
}
|
||||
if root.unread-count > 0: Rectangle {
|
||||
x: message-heading.x + message-heading.preferred-width + 8px;
|
||||
y: (parent.height - self.height) / 2; height: 16px;
|
||||
width: max(16px, unread-count-label.preferred-width + 8px);
|
||||
border-radius: 8px; background: #eaf2ff;
|
||||
accessible-role: text;
|
||||
accessible-label: MessageStrings.unread + " " + root.unread-count;
|
||||
unread-count-label := Text {
|
||||
text: "" + root.unread-count; color: #2563eb;
|
||||
font-size: ImGuiFontStyle.small; font-weight: 600;
|
||||
horizontal-alignment: center; vertical-alignment: center;
|
||||
accessible-role: none;
|
||||
}
|
||||
}
|
||||
if root.loading: Text {
|
||||
x: parent.width - 150px; width: 110px; height: parent.height;
|
||||
text: MessageStrings.loading; color: #61656c; font-size: ImGuiFontStyle.small;
|
||||
horizontal-alignment: right; vertical-alignment: center;
|
||||
}
|
||||
IconButton {
|
||||
x: parent.width - 25px; y: 2px; width: 19px; height: parent.height - 4px;
|
||||
border-radius: 3px;
|
||||
icon: FontAwesomeIcons.xmark; icon-size: ImGuiFontStyle.base;
|
||||
icon-color: #202124;
|
||||
hover-background: #bfc2c7; pressed-background: #b2b2b2;
|
||||
accessible-role: button;
|
||||
accessible-label: MessageStrings.close;
|
||||
accessible-action-default => { root.close(); }
|
||||
clicked => { root.close(); }
|
||||
}
|
||||
}
|
||||
if root.notice-text != "": Rectangle {
|
||||
x: 16px; y: title-bar.height + 10px; width: parent.width - 32px; height: 28px;
|
||||
border-radius: 4px;
|
||||
background: #fef2f2;
|
||||
Text {
|
||||
x: 8px; width: parent.width - 16px; height: parent.height;
|
||||
text: root.notice-text; font-size: ImGuiFontStyle.body; overflow: elide;
|
||||
color: #b3261e;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
announcement-list := Rectangle {
|
||||
x: 16px; y: root.content-top; width: 168px; height: parent.height - self.y - 16px;
|
||||
announcements-scroll := ScrollView {
|
||||
width: parent.width; height: parent.height;
|
||||
VerticalLayout {
|
||||
width: announcements-scroll.width; spacing: 0px; padding: 0px;
|
||||
padding-right: 8px;
|
||||
alignment: start;
|
||||
for item[index] in root.items: Rectangle {
|
||||
height: 54px;
|
||||
background: root.selected-id == item.id ? #eaf2ff : entry-touch.has-hover ? #f0f1f2 : transparent;
|
||||
border-radius: 4px;
|
||||
Text { x: 8px; y: 6px; width: parent.width - 32px; height: 18px; text: item.title; overflow: elide; font-size: ImGuiFontStyle.base; font-weight: item.read ? 400 : 600; color: #202124; vertical-alignment: center; }
|
||||
if !item.read: Rectangle {
|
||||
x: parent.width - 17px; y: 13px; width: 5px; height: 5px;
|
||||
border-radius: 2.5px; background: #2563eb;
|
||||
accessible-role: none;
|
||||
}
|
||||
Text { x: 8px; y: 28px; width: parent.width - 36px; height: 16px; text: item.date; color: #61656c; font-size: ImGuiFontStyle.small; overflow: elide; vertical-alignment: center; }
|
||||
accessible-role: button;
|
||||
accessible-label: item.title + " " + (item.read ? MessageStrings.read : MessageStrings.unread);
|
||||
accessible-action-default => { root.open-message(item.id, item.revision); }
|
||||
entry-touch := TouchArea { mouse-cursor: pointer; clicked => { root.open-message(item.id, item.revision); } }
|
||||
IconButton {
|
||||
x: parent.width - 24px; y: 26px; width: 20px; height: 20px;
|
||||
icon: FontAwesomeIcons.xmark; icon-size: ImGuiFontStyle.small;
|
||||
icon-color: #61656c; danger: true;
|
||||
accessible-role: button;
|
||||
accessible-label: MessageStrings.delete-message + " " + item.title;
|
||||
accessible-action-default => { root.request-delete(item.id, item.revision); }
|
||||
clicked => { root.request-delete(item.id, item.revision); }
|
||||
}
|
||||
if index < root.items.length - 1: Rectangle {
|
||||
x: 8px; y: parent.height - 1px;
|
||||
width: parent.width - 16px; height: 1px;
|
||||
background: root.separator-color;
|
||||
accessible-role: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if root.items.length == 0 && root.total == 0 && root.loaded && root.connected && !root.loading && !root.failed: Text {
|
||||
x: 16px; y: root.content-top; width: parent.width - 32px; height: announcement-list.height;
|
||||
text: MessageStrings.empty; wrap: word-wrap;
|
||||
color: #61656c; font-size: ImGuiFontStyle.body;
|
||||
horizontal-alignment: center; vertical-alignment: center;
|
||||
}
|
||||
content-divider := Rectangle {
|
||||
x: announcement-list.x + announcement-list.width + 12px;
|
||||
y: root.content-top; width: 1px; height: announcement-list.height;
|
||||
background: root.separator-color;
|
||||
visible: root.items.length > 0 || root.selected-id != "";
|
||||
accessible-role: none;
|
||||
}
|
||||
announcement-details := Rectangle {
|
||||
x: content-divider.x + content-divider.width + 12px;
|
||||
y: root.content-top; width: parent.width - self.x - 16px; height: announcement-list.height;
|
||||
if root.selected-id == "" && root.items.length > 0: Text { x: 8px; width: parent.width - 16px; text: MessageStrings.select-message; color: #61656c; font-size: ImGuiFontStyle.body; wrap: word-wrap; horizontal-alignment: center; vertical-alignment: center; }
|
||||
if root.selected-id != "": details-scroll := ScrollView {
|
||||
width: parent.width; height: parent.height;
|
||||
VerticalLayout {
|
||||
width: details-scroll.width; padding: 8px; padding-top: 6px; padding-right: 16px;
|
||||
spacing: 12px; alignment: start;
|
||||
VerticalLayout {
|
||||
vertical-stretch: 0; spacing: 4px;
|
||||
Text { vertical-stretch: 0; min-height: 18px; text: root.selected-title; wrap: word-wrap; font-size: ImGuiFontStyle.base; font-weight: 600; color: #202124; vertical-alignment: center; }
|
||||
Text { vertical-stretch: 0; min-height: 16px; text: root.selected-date; accessible-label: root.selected-date + " · " + (root.selected-read ? MessageStrings.read : MessageStrings.unread); color: #61656c; font-size: ImGuiFontStyle.small; wrap: word-wrap; vertical-alignment: center; }
|
||||
}
|
||||
Rectangle { height: 1px; background: root.separator-color; }
|
||||
StyledText {
|
||||
vertical-stretch: 0;
|
||||
text: root.selected-body;
|
||||
default-color: #202124; default-font-size: ImGuiFontStyle.body;
|
||||
link-color: #2563eb;
|
||||
link-clicked(url) => { root.open-link(url); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Timer {
|
||||
interval: 100ms;
|
||||
running: root.connected && !root.loading && !root.failed
|
||||
&& root.pending-delete-id == "" && root.items.length > 0
|
||||
&& root.items.length < root.total && announcements-scroll.viewport-height > 0px
|
||||
&& announcements-scroll.viewport-height + announcements-scroll.viewport-y <= announcements-scroll.height + 80px;
|
||||
triggered => { root.load-more(); }
|
||||
}
|
||||
if root.pending-delete-id != "": Rectangle {
|
||||
background: #00000042; border-radius: root.border-radius; z: 30;
|
||||
TouchArea { clicked => { confirmation-keys.focus(); } }
|
||||
DialogSurface {
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
width: 230px; height: confirmation-content.preferred-height;
|
||||
border-radius: root.border-radius;
|
||||
TouchArea { clicked => { confirmation-keys.focus(); } }
|
||||
confirmation-content := VerticalLayout {
|
||||
padding: 16px;
|
||||
padding-top: 26px;
|
||||
padding-bottom: 26px;
|
||||
spacing: 20px;
|
||||
Text { text: MessageStrings.delete-confirmation; font-size: ImGuiFontStyle.base; color: #202124; wrap: word-wrap; horizontal-alignment: center; }
|
||||
HorizontalLayout {
|
||||
alignment: center; spacing: 8px;
|
||||
cancel-delete-button := CompactButton {
|
||||
width: 72px; text: MessageStrings.cancel;
|
||||
accessible-role: button; accessible-label: MessageStrings.cancel;
|
||||
accessible-action-default => { cancel-delete-button.clicked(); }
|
||||
clicked => { root.pending-delete-id = ""; }
|
||||
}
|
||||
confirm-delete-button := CompactButton {
|
||||
width: 72px; text: MessageStrings.delete-message; primary: true;
|
||||
accessible-role: button; accessible-label: MessageStrings.delete-title;
|
||||
accessible-action-default => { confirm-delete-button.clicked(); }
|
||||
clicked => {
|
||||
root.delete-message(root.pending-delete-id, root.pending-delete-revision);
|
||||
root.pending-delete-id = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,229 +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>();
|
||||
auto& messages = window->global<ui::MessageStrings>();
|
||||
messages.set_messages(Text(localization::messages[language]));
|
||||
messages.set_unread(Text(localization::message_unread[language]));
|
||||
messages.set_read(Text(localization::message_read[language]));
|
||||
messages.set_empty(Text(localization::message_empty[language]));
|
||||
messages.set_select_message(Text(localization::message_select[language]));
|
||||
messages.set_loading(Text(localization::message_loading[language]));
|
||||
messages.set_read_save_error(Text(localization::message_read_save_error[language]));
|
||||
messages.set_close(Text(localization::message_close[language]));
|
||||
messages.set_delete_message(Text(localization::message_delete[language]));
|
||||
messages.set_delete_title(Text(localization::message_delete_title[language]));
|
||||
messages.set_delete_confirmation(Text(localization::message_delete_confirmation[language]));
|
||||
messages.set_delete_error(Text(localization::message_delete_error[language]));
|
||||
messages.set_cancel(Text(localization::cancel[language]));
|
||||
|
||||
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_refresh_password(Text(localization::refresh_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_capture_method(Text(localization::screen_capture_method[language]));
|
||||
strings.set_capture_method_auto(
|
||||
Text(localization::screen_capture_method_auto[language]));
|
||||
strings.set_codec(Text(localization::video_encode_format[language]));
|
||||
strings.set_hardware_codec(
|
||||
Text(localization::enable_hardware_video_codec[language]));
|
||||
strings.set_turn_relay(Text(localization::enable_turn[language]));
|
||||
strings.set_force_relay(Text(localization::force_relay[language]));
|
||||
strings.set_self_hosted(
|
||||
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_save_remote_previews(Text(localization::save_remote_previews[language]));
|
||||
strings.set_unknown_platform(Text(localization::unknown_platform[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_new_version(Text(localization::new_version[language]));
|
||||
strings.set_check_for_updates(Text(localization::check_for_updates[language]));
|
||||
strings.set_checking_for_updates(
|
||||
Text(localization::checking_for_updates[language]));
|
||||
strings.set_up_to_date(Text(localization::up_to_date[language]));
|
||||
strings.set_update_check_failed(
|
||||
Text(localization::update_check_failed[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_wayland_notice_title(
|
||||
Text(localization::wayland_notice_title[language]));
|
||||
strings.set_wayland_notice_subtitle(
|
||||
Text(localization::wayland_notice_subtitle[language]));
|
||||
strings.set_wayland_permission_title(
|
||||
Text(localization::wayland_permission_title[language]));
|
||||
strings.set_wayland_notice_message(
|
||||
Text(localization::wayland_notice_message[language]));
|
||||
strings.set_wayland_recommendation_title(
|
||||
Text(localization::wayland_recommendation_title[language]));
|
||||
strings.set_wayland_recommendation_message(
|
||||
Text(localization::wayland_recommendation_message[language]));
|
||||
strings.set_wayland_notice_acknowledge(
|
||||
Text(localization::wayland_notice_acknowledge[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_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_remote_update_title(Text(localization::remote_update_title[language]));
|
||||
strings.set_remote_update(Text(localization::remote_update[language]));
|
||||
strings.set_remote_update_dismiss(Text(localization::remote_update_dismiss[language]));
|
||||
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_video_settings(Text(localization::video_settings[language]));
|
||||
strings.set_video_settings_unavailable(
|
||||
Text(localization::video_settings_unavailable[language]));
|
||||
strings.set_video_quality(Text(localization::video_quality[language]));
|
||||
strings.set_frame_rate(Text(localization::video_frame_rate[language]));
|
||||
strings.set_adaptation_policy(
|
||||
Text(localization::video_adaptation_policy[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_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_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_video_latency(Text(localization::video_latency[language]));
|
||||
strings.set_stats_connection_latency(Text(localization::connection_latency[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,75 +0,0 @@
|
||||
#include "rd_log.h"
|
||||
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <vector>
|
||||
|
||||
#include "spdlog/sinks/rotating_file_sink.h"
|
||||
#include "spdlog/sinks/stdout_color_sinks.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string g_log_dir = "logs";
|
||||
std::string g_logger_name = LOGGER_NAME;
|
||||
std::once_flag g_logger_once_flag;
|
||||
std::mutex g_logger_mutex;
|
||||
std::shared_ptr<spdlog::logger> g_logger;
|
||||
|
||||
std::string NormalizeLogDirectory(const std::string& log_dir) {
|
||||
const std::filesystem::path path = log_dir.empty() ? "logs" : log_dir;
|
||||
std::error_code ec;
|
||||
const auto absolute = std::filesystem::absolute(path, ec);
|
||||
return (ec ? path : absolute).lexically_normal().string();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void InitLogger(const std::string& log_dir, const std::string& logger_name) {
|
||||
const auto directory = NormalizeLogDirectory(log_dir);
|
||||
std::lock_guard<std::mutex> lock(g_logger_mutex);
|
||||
if (g_logger) {
|
||||
if (directory != g_log_dir || logger_name != g_logger_name) {
|
||||
g_logger->warn(
|
||||
"InitLogger called after logger initialized. Keeping logger {} in {}",
|
||||
g_logger_name, g_log_dir);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
g_log_dir = directory;
|
||||
g_logger_name = logger_name;
|
||||
}
|
||||
|
||||
std::shared_ptr<spdlog::logger> get_logger() {
|
||||
std::call_once(g_logger_once_flag, []() {
|
||||
std::lock_guard<std::mutex> lock(g_logger_mutex);
|
||||
g_log_dir = NormalizeLogDirectory(g_log_dir);
|
||||
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(g_log_dir, ec);
|
||||
|
||||
// Reuse the same rotation set across restarts. Timestamped base names
|
||||
// give each launch its own backups and let the directory grow forever.
|
||||
auto file_sink = std::make_shared<spdlog::sinks::rotating_file_sink_mt>(
|
||||
g_log_dir + "/" + g_logger_name + ".log", 5 * 1024 * 1024, 3);
|
||||
|
||||
std::vector<spdlog::sink_ptr> sinks;
|
||||
sinks.push_back(std::make_shared<spdlog::sinks::stdout_color_sink_mt>());
|
||||
sinks.push_back(file_sink);
|
||||
|
||||
auto logger = std::make_shared<spdlog::logger>(g_logger_name, sinks.begin(),
|
||||
sinks.end());
|
||||
logger->set_level(SPDLOG_ACTIVE_LEVEL <= SPDLOG_LEVEL_DEBUG
|
||||
? spdlog::level::debug
|
||||
: spdlog::level::info);
|
||||
logger->flush_on(spdlog::level::info);
|
||||
spdlog::register_logger(logger);
|
||||
logger->info("Logger initialized: path={}", file_sink->filename());
|
||||
g_logger = std::move(logger);
|
||||
});
|
||||
|
||||
return g_logger;
|
||||
}
|
||||
} // namespace crossdesk
|
||||
@@ -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(©, 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(©, 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,76 +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,
|
||||
VideoLatencyFrame timing = {}) override;
|
||||
SubmitResult SubmitCachedNv12(std::string_view remote_id, const uint8_t *data,
|
||||
size_t size, int width, int height,
|
||||
VideoLatencyFrame timing = {}) override;
|
||||
SubmitResult SubmitNativeFrame(std::string_view remote_id,
|
||||
const MiniRtcNativeVideoFrame &frame,
|
||||
VideoLatencyFrame timing = {}) 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,
|
||||
VideoLatencyFrame timing);
|
||||
|
||||
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,68 +0,0 @@
|
||||
#include "platform/single_instance.h"
|
||||
|
||||
#include <cerrno>
|
||||
#include <cstring>
|
||||
#include <fcntl.h>
|
||||
#include <sys/file.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace crossdesk::platform {
|
||||
|
||||
SingleInstanceGuard::SingleInstanceGuard(std::string application)
|
||||
: application_(std::move(application)) {}
|
||||
|
||||
SingleInstanceGuard::~SingleInstanceGuard() { Release(); }
|
||||
|
||||
InstanceResult SingleInstanceGuard::TryAcquire(InstanceRole role,
|
||||
std::string& error) {
|
||||
error.clear();
|
||||
if (handle_ != -1) return InstanceResult::kAcquired;
|
||||
if (!ValidInstanceApplication(application_)) {
|
||||
error = "Invalid single-instance application name";
|
||||
return InstanceResult::kError;
|
||||
}
|
||||
// A fixed local path also coordinates launches with different TMPDIR/XDG
|
||||
// settings. O_CLOEXEC prevents unrelated desktop/helper processes retaining it.
|
||||
const std::string path = "/tmp/" + application_ + "-" +
|
||||
std::to_string(geteuid()) + "-" +
|
||||
InstanceRoleName(role) + ".lock";
|
||||
const int fd = open(path.c_str(), O_CREAT | O_RDWR | O_CLOEXEC | O_NOFOLLOW, 0600);
|
||||
if (fd < 0) {
|
||||
error = "Cannot open single-instance lock: " + std::string(std::strerror(errno));
|
||||
return InstanceResult::kError;
|
||||
}
|
||||
struct stat info {};
|
||||
if (fstat(fd, &info) != 0 || !S_ISREG(info.st_mode) || info.st_uid != geteuid()) {
|
||||
close(fd);
|
||||
error = "Single-instance lock is not a regular file owned by this user";
|
||||
return InstanceResult::kError;
|
||||
}
|
||||
int result;
|
||||
do {
|
||||
result = flock(fd, LOCK_EX | LOCK_NB);
|
||||
} while (result != 0 && errno == EINTR);
|
||||
if (result == 0) {
|
||||
handle_ = fd;
|
||||
return InstanceResult::kAcquired;
|
||||
}
|
||||
const int lock_error = errno;
|
||||
close(fd);
|
||||
if (lock_error == EWOULDBLOCK || lock_error == EAGAIN)
|
||||
return InstanceResult::kAlreadyRunning;
|
||||
error = "Cannot acquire single-instance lock: " +
|
||||
std::string(std::strerror(lock_error));
|
||||
return InstanceResult::kError;
|
||||
}
|
||||
|
||||
void SingleInstanceGuard::Release() {
|
||||
if (handle_ == -1) return;
|
||||
// Close rather than LOCK_UN: a forked headless application may still own the
|
||||
// same open file description. Never unlink a lock that another launch opened.
|
||||
close(static_cast<int>(handle_));
|
||||
handle_ = -1;
|
||||
}
|
||||
|
||||
} // 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,67 +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* config = std::getenv("XDG_CONFIG_HOME");
|
||||
if (config && *config) return std::filesystem::path(config) / "autostart" / (app_name + ".desktop");
|
||||
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,85 +0,0 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-25
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _DESKTOP_SESSION_SELECTION_H_
|
||||
#define _DESKTOP_SESSION_SELECTION_H_
|
||||
|
||||
#include <algorithm>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
// Metadata for an online X11 user session, already filtered to our uid.
|
||||
struct DesktopLoginSession {
|
||||
std::string display;
|
||||
bool current = false;
|
||||
bool primary = false;
|
||||
bool active = false;
|
||||
bool remote = false;
|
||||
};
|
||||
|
||||
inline std::string NormalizeDesktopDisplay(std::string display) {
|
||||
if (display.compare(0, 5, "unix:") == 0) display.erase(0, 4);
|
||||
if (display.empty() || display[0] != ':') return display;
|
||||
if (display.size() > 2 && display.compare(display.size() - 2, 2, ".0") == 0) {
|
||||
display.resize(display.size() - 2);
|
||||
}
|
||||
return display;
|
||||
}
|
||||
|
||||
inline int DesktopSessionPriority(
|
||||
const std::string& display,
|
||||
const std::vector<DesktopLoginSession>& sessions) {
|
||||
int priority = 0;
|
||||
for (const auto& session : sessions) {
|
||||
if (NormalizeDesktopDisplay(session.display) !=
|
||||
NormalizeDesktopDisplay(display)) continue;
|
||||
const int rank = session.current ? 6
|
||||
: session.primary && session.active ? 5
|
||||
: session.active && !session.remote ? 4
|
||||
: session.active ? 3
|
||||
: session.primary ? 2 : 1;
|
||||
priority = std::max(priority, rank);
|
||||
}
|
||||
return priority;
|
||||
}
|
||||
|
||||
inline bool DesktopDisplayLess(const std::string& left,
|
||||
const std::string& right) {
|
||||
const auto a = NormalizeDesktopDisplay(left);
|
||||
const auto b = NormalizeDesktopDisplay(right);
|
||||
const auto is_number = [](const std::string& value) {
|
||||
return value.size() > 1 && value[0] == ':' &&
|
||||
value.find_first_not_of("0123456789", 1) == std::string::npos;
|
||||
};
|
||||
// Stable numeric order when logind is unavailable or sessions have equal
|
||||
// priority; do not depend on filesystem enumeration order (or sort :10 < :2).
|
||||
if (is_number(a) && is_number(b) && a.size() != b.size()) {
|
||||
return a.size() < b.size();
|
||||
}
|
||||
return a < b;
|
||||
}
|
||||
|
||||
inline std::optional<size_t> SelectDesktopSession(
|
||||
const std::vector<std::string>& available,
|
||||
const std::vector<DesktopLoginSession>& sessions) {
|
||||
if (available.empty()) return std::nullopt;
|
||||
size_t selected = 0;
|
||||
for (size_t i = 1; i < available.size(); ++i) {
|
||||
const int candidate = DesktopSessionPriority(available[i], sessions);
|
||||
const int current = DesktopSessionPriority(available[selected], sessions);
|
||||
if (candidate > current ||
|
||||
(candidate == current &&
|
||||
DesktopDisplayLess(available[i], available[selected]))) selected = i;
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -1,358 +0,0 @@
|
||||
#include "headless_console.h"
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <poll.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <charconv>
|
||||
#include <cerrno>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
|
||||
#include "rd_log.h"
|
||||
#include "config_center.h"
|
||||
#include "localization.h"
|
||||
#include "spdlog/sinks/rotating_file_sink.h"
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
bool ValidPassword(const std::string& password) {
|
||||
if (password.size() != 6) return false;
|
||||
for (unsigned char c : password) {
|
||||
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
|
||||
(c >= '0' && c <= '9'))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
HeadlessConsole& HeadlessConsole::Instance() {
|
||||
static HeadlessConsole console;
|
||||
return console;
|
||||
}
|
||||
|
||||
HeadlessConsole::~HeadlessConsole() {
|
||||
if (!active()) return;
|
||||
std::cout.flush();
|
||||
std::cerr.flush();
|
||||
fflush(nullptr);
|
||||
dup2(output_, STDOUT_FILENO);
|
||||
dup2(error_, STDERR_FILENO);
|
||||
close(output_);
|
||||
close(error_);
|
||||
}
|
||||
|
||||
bool HeadlessConsole::Enable(const std::filesystem::path& log_directory,
|
||||
const std::filesystem::path& config_file) {
|
||||
if (active()) return true;
|
||||
std::error_code error;
|
||||
std::filesystem::create_directories(log_directory, error);
|
||||
if (error) return false;
|
||||
// Existing loggers keep their own rotating files and filenames. Suppress
|
||||
// only stdout/stderr copies; do not create a second diagnostic log stream.
|
||||
const int quiet = open("/dev/null", O_WRONLY | O_CLOEXEC);
|
||||
if (quiet < 0) return false;
|
||||
const int out = fcntl(STDOUT_FILENO, F_DUPFD_CLOEXEC, 3);
|
||||
const int err = fcntl(STDERR_FILENO, F_DUPFD_CLOEXEC, 3);
|
||||
if (out < 0 || err < 0) {
|
||||
if (out >= 0) close(out);
|
||||
if (err >= 0) close(err);
|
||||
close(quiet);
|
||||
return false;
|
||||
}
|
||||
std::cout.flush();
|
||||
std::cerr.flush();
|
||||
fflush(nullptr);
|
||||
if (dup2(quiet, STDOUT_FILENO) < 0 || dup2(quiet, STDERR_FILENO) < 0) {
|
||||
dup2(out, STDOUT_FILENO);
|
||||
dup2(err, STDERR_FILENO);
|
||||
close(out);
|
||||
close(err);
|
||||
close(quiet);
|
||||
return false;
|
||||
}
|
||||
close(quiet);
|
||||
output_ = out;
|
||||
error_ = err;
|
||||
interactive_ = isatty(STDIN_FILENO) && isatty(output_);
|
||||
// Initialize after redirecting output so even the logger's first message
|
||||
// stays in crossdesk.log.
|
||||
try {
|
||||
InitLogger(log_directory.string());
|
||||
for (const auto& sink : get_logger()->sinks()) {
|
||||
if (const auto file =
|
||||
std::dynamic_pointer_cast<spdlog::sinks::rotating_file_sink_mt>(sink)) {
|
||||
diagnostic_path_ = file->filename();
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (const std::exception&) {
|
||||
dup2(output_, STDOUT_FILENO);
|
||||
dup2(error_, STDERR_FILENO);
|
||||
close(output_);
|
||||
close(error_);
|
||||
output_ = error_ = -1;
|
||||
return false;
|
||||
}
|
||||
if (!config_file.empty()) {
|
||||
ConfigCenter config(config_file.string());
|
||||
SetLanguage(static_cast<int>(config.GetLanguage()));
|
||||
}
|
||||
Write(Text("console_title") + "\n" + Text("console_log_file") + ": " + diagnostic_path_ + "\n");
|
||||
Help();
|
||||
return true;
|
||||
}
|
||||
|
||||
void HeadlessConsole::Write(const std::string& text) {
|
||||
if (!active()) return;
|
||||
size_t offset = 0;
|
||||
while (offset < text.size()) {
|
||||
const ssize_t count = write(output_, text.data() + offset, text.size() - offset);
|
||||
if (count < 0 && errno == EINTR) continue;
|
||||
if (count <= 0) break;
|
||||
offset += static_cast<size_t>(count);
|
||||
}
|
||||
}
|
||||
|
||||
void HeadlessConsole::Notify(const std::string& message) {
|
||||
if (active()) Write(message + "\n");
|
||||
}
|
||||
|
||||
void HeadlessConsole::SetIdentity(const std::string& identity) {
|
||||
if (!active()) return;
|
||||
const auto separator = identity.find('@');
|
||||
const std::string id = identity.substr(0, separator);
|
||||
const std::string password = separator == std::string::npos
|
||||
? "" : identity.substr(separator + 1);
|
||||
// Credentials are rendered to a terminal, never interpreted as terminal
|
||||
// escape sequences and never forwarded to the diagnostic log.
|
||||
if (id.find_first_not_of("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_") !=
|
||||
std::string::npos || (!password.empty() && !ValidPassword(password))) return;
|
||||
std::lock_guard<std::mutex> lock(identity_mutex_);
|
||||
id_ = id;
|
||||
password_ = password;
|
||||
}
|
||||
|
||||
const std::string& HeadlessConsole::Text(const char* key) const {
|
||||
return localization::detail::GetTranslatedText(key, language_);
|
||||
}
|
||||
|
||||
void HeadlessConsole::NotifyKey(const char* key) { Notify(Text(key)); }
|
||||
|
||||
void HeadlessConsole::SetLanguage(int language) {
|
||||
language = localization::detail::ClampLanguageIndex(language);
|
||||
if (language_ != language) {
|
||||
language_ = language;
|
||||
displayed_ = false;
|
||||
}
|
||||
}
|
||||
|
||||
void HeadlessConsole::BindSettings(ConfigCenter* config,
|
||||
std::function<bool()> connection_busy,
|
||||
std::function<void(SettingEffect)> applied) {
|
||||
config_ = config;
|
||||
connection_busy_ = std::move(connection_busy);
|
||||
settings_applied_ = std::move(applied);
|
||||
if (config_) SetLanguage(static_cast<int>(config_->GetLanguage()));
|
||||
}
|
||||
|
||||
void HeadlessConsole::Help() { NotifyKey("console_help"); }
|
||||
|
||||
void HeadlessConsole::ShowSettings() {
|
||||
if (!config_) { NotifyKey("console_settings_unavailable"); return; }
|
||||
Write("\n" + Text("console_settings_title") + "\n");
|
||||
const auto settings = GetConsoleSettings(*config_);
|
||||
for (size_t i = 0; i < settings.size(); ++i) {
|
||||
const auto& item = settings[i];
|
||||
std::string value = item.value;
|
||||
for (auto& c : value) if (static_cast<unsigned char>(c) < 32 || c == 127) c = '?';
|
||||
Write(std::to_string(i + 1) + ". " + Text(item.label) + " [" + item.key +
|
||||
"] = " + value + " (" + item.choices + ")\n");
|
||||
}
|
||||
NotifyKey("console_settings_prompt");
|
||||
}
|
||||
|
||||
void HeadlessConsole::ChangeSetting(const std::string& key, const std::string& value) {
|
||||
if (!config_) { NotifyKey("console_settings_unavailable"); return; }
|
||||
const auto result = ApplyConsoleSetting(*config_, key, value,
|
||||
connection_busy_ && connection_busy_());
|
||||
SetLanguage(static_cast<int>(config_->GetLanguage()));
|
||||
if (result.saved && result.effect != SettingEffect::none && settings_applied_) {
|
||||
settings_applied_(result.effect);
|
||||
}
|
||||
NotifyKey(result.message);
|
||||
}
|
||||
|
||||
bool HeadlessConsole::SettingsCommand(const std::string& line) {
|
||||
if (line == "settings" || line == "settings show") {
|
||||
waiting_password_ = false;
|
||||
selected_setting_.clear();
|
||||
settings_menu_ = line == "settings";
|
||||
ShowSettings();
|
||||
return true;
|
||||
}
|
||||
if (line.compare(0, 13, "settings set ") == 0) {
|
||||
waiting_password_ = false;
|
||||
selected_setting_.clear();
|
||||
const auto separator = line.find(' ', 13);
|
||||
if (separator == std::string::npos) NotifyKey("console_settings_invalid");
|
||||
else ChangeSetting(line.substr(13, separator - 13), line.substr(separator + 1));
|
||||
return true;
|
||||
}
|
||||
if (!settings_menu_) return false;
|
||||
if (line == "back" || (line == "cancel" && selected_setting_.empty())) {
|
||||
settings_menu_ = false;
|
||||
selected_setting_.clear();
|
||||
Help();
|
||||
return true;
|
||||
}
|
||||
if (line == "cancel") {
|
||||
selected_setting_.clear();
|
||||
NotifyKey("console_cancelled");
|
||||
ShowSettings();
|
||||
return true;
|
||||
}
|
||||
// Status and quit remain available while navigating the menu.
|
||||
if (line == "status" || line == "help" || line == "?" || line == "quit" || line == "exit") return false;
|
||||
if (!selected_setting_.empty()) {
|
||||
ChangeSetting(selected_setting_, line);
|
||||
selected_setting_.clear();
|
||||
ShowSettings();
|
||||
return true;
|
||||
}
|
||||
if (!config_) { NotifyKey("console_settings_unavailable"); return true; }
|
||||
const auto settings = GetConsoleSettings(*config_);
|
||||
size_t index = 0;
|
||||
const auto parsed = std::from_chars(line.data(), line.data() + line.size(), index);
|
||||
if (parsed.ec == std::errc{} && parsed.ptr == line.data() + line.size() &&
|
||||
index > 0 && index <= settings.size()) {
|
||||
selected_setting_ = settings[index - 1].key;
|
||||
} else {
|
||||
for (const auto& setting : settings) if (setting.key == line) selected_setting_ = setting.key;
|
||||
}
|
||||
if (selected_setting_.empty()) {
|
||||
NotifyKey("console_settings_invalid");
|
||||
} else {
|
||||
const auto item = std::find_if(settings.begin(), settings.end(),
|
||||
[&](const auto& setting) { return setting.key == selected_setting_; });
|
||||
Write(Text(item->label) + " [" + item->choices + "]\n" + Text("console_settings_value") + ":\n");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void HeadlessConsole::ShowStatus(bool connected) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(identity_mutex_);
|
||||
displayed_id_ = id_;
|
||||
displayed_password_ = password_;
|
||||
}
|
||||
displayed_connected_ = connected;
|
||||
displayed_ = true;
|
||||
Write("\n" + Text("console_status") + ": " + Text(connected ? "console_online" : "console_offline") +
|
||||
"\n" + Text("console_id") + ": " + (displayed_id_.empty() ? Text("console_waiting") : displayed_id_) +
|
||||
"\n" + Text("console_password") + ": " +
|
||||
(!interactive_ ? Text("console_password_hidden")
|
||||
: displayed_password_.empty() ? Text("console_waiting") : displayed_password_) + "\n");
|
||||
}
|
||||
|
||||
void HeadlessConsole::Command(
|
||||
const std::string& line, bool connected,
|
||||
const std::function<void(const std::string&)>& password,
|
||||
const std::function<void()>& random_password,
|
||||
const std::function<void()>& quit) {
|
||||
if (line == "quit" || line == "exit") {
|
||||
input_closed_ = true;
|
||||
NotifyKey("console_quitting");
|
||||
quit();
|
||||
return;
|
||||
}
|
||||
if (SettingsCommand(line)) return;
|
||||
if (waiting_password_) {
|
||||
waiting_password_ = false;
|
||||
if (line.empty() || line == "cancel") {
|
||||
NotifyKey("console_cancelled");
|
||||
} else if (!ValidPassword(line)) {
|
||||
NotifyKey("console_password_invalid");
|
||||
} else {
|
||||
password(line);
|
||||
}
|
||||
} else if (line == "status") {
|
||||
ShowStatus(connected);
|
||||
} else if (line == "help" || line == "?") {
|
||||
Help();
|
||||
} else if (line == "password" || line == "passwd") {
|
||||
if (!connected) {
|
||||
NotifyKey("console_password_offline");
|
||||
} else {
|
||||
waiting_password_ = true;
|
||||
NotifyKey("console_password_prompt");
|
||||
}
|
||||
} else if (line.compare(0, 9, "password ") == 0 ||
|
||||
line.compare(0, 7, "passwd ") == 0) {
|
||||
const std::string value = line.substr(line.find(' ') + 1);
|
||||
if (!ValidPassword(value)) {
|
||||
NotifyKey("console_password_invalid");
|
||||
} else if (!connected) {
|
||||
NotifyKey("console_password_offline");
|
||||
} else {
|
||||
password(value);
|
||||
}
|
||||
} else if (line == "random") {
|
||||
if (connected) random_password();
|
||||
else NotifyKey("console_password_offline");
|
||||
} else if (!line.empty()) {
|
||||
NotifyKey("console_unknown_command");
|
||||
}
|
||||
}
|
||||
|
||||
void HeadlessConsole::Poll(
|
||||
bool connected, const std::function<void(const std::string&)>& password,
|
||||
const std::function<void()>& random_password,
|
||||
const std::function<void()>& quit) {
|
||||
if (!active()) return;
|
||||
if (config_) SetLanguage(static_cast<int>(config_->GetLanguage()));
|
||||
bool changed = !displayed_ || displayed_connected_ != connected;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(identity_mutex_);
|
||||
changed |= displayed_id_ != id_ || displayed_password_ != password_;
|
||||
}
|
||||
if (changed) ShowStatus(connected);
|
||||
if (input_closed_) return;
|
||||
// A job launched in the background must not steal input or receive SIGTTIN.
|
||||
if (isatty(STDIN_FILENO) && tcgetpgrp(STDIN_FILENO) != getpgrp()) return;
|
||||
struct pollfd input { STDIN_FILENO, POLLIN, 0 };
|
||||
if (poll(&input, 1, 0) <= 0) return;
|
||||
if (!(input.revents & (POLLIN | POLLHUP))) {
|
||||
if (input.revents & (POLLERR | POLLNVAL)) input_closed_ = true;
|
||||
return;
|
||||
}
|
||||
char buffer[256];
|
||||
const ssize_t count = read(STDIN_FILENO, buffer, sizeof(buffer));
|
||||
if (count <= 0) {
|
||||
if (count == 0 || (errno != EINTR && errno != EAGAIN)) input_closed_ = true;
|
||||
return;
|
||||
}
|
||||
for (ssize_t i = 0; i < count && !input_closed_; ++i) {
|
||||
const char c = buffer[i];
|
||||
if (c == '\n') {
|
||||
if (!discarding_line_) {
|
||||
if (!input_.empty() && input_.back() == '\r') input_.pop_back();
|
||||
Command(input_, connected, password, random_password, quit);
|
||||
}
|
||||
input_.clear();
|
||||
discarding_line_ = false;
|
||||
} else if (!discarding_line_) {
|
||||
if (input_.size() == 1024) {
|
||||
input_.clear();
|
||||
discarding_line_ = true;
|
||||
waiting_password_ = false;
|
||||
NotifyKey("console_line_too_long");
|
||||
} else {
|
||||
input_ += c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} // namespace crossdesk
|
||||
@@ -1,78 +0,0 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-25
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _HEADLESS_CONSOLE_H_
|
||||
#define _HEADLESS_CONSOLE_H_
|
||||
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
#include "headless_settings.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
// Enabled before display discovery and fork, with no input thread. The GUI
|
||||
// thread polls commands; transport callbacks only publish credential snapshots.
|
||||
class HeadlessConsole {
|
||||
public:
|
||||
static HeadlessConsole& Instance();
|
||||
~HeadlessConsole();
|
||||
bool Enable(const std::filesystem::path& log_directory,
|
||||
const std::filesystem::path& config_file = {});
|
||||
bool active() const { return output_ >= 0; }
|
||||
const std::string& diagnostic_path() const { return diagnostic_path_; }
|
||||
void SetIdentity(const std::string& identity);
|
||||
void Notify(const std::string& message);
|
||||
const std::string& Text(const char* key) const;
|
||||
void NotifyKey(const char* key);
|
||||
void SetLanguage(int language);
|
||||
void BindSettings(ConfigCenter* config, std::function<bool()> connection_busy,
|
||||
std::function<void(SettingEffect)> applied);
|
||||
void Poll(bool connected, const std::function<void(const std::string&)>& password,
|
||||
const std::function<void()>& random_password,
|
||||
const std::function<void()>& quit);
|
||||
|
||||
private:
|
||||
HeadlessConsole() = default;
|
||||
void Write(const std::string& text);
|
||||
void ShowStatus(bool connected);
|
||||
void Help();
|
||||
void ShowSettings();
|
||||
void ChangeSetting(const std::string& key, const std::string& value);
|
||||
bool SettingsCommand(const std::string& line);
|
||||
void Command(const std::string& line, bool connected,
|
||||
const std::function<void(const std::string&)>& password,
|
||||
const std::function<void()>& random_password,
|
||||
const std::function<void()>& quit);
|
||||
|
||||
int output_ = -1;
|
||||
int error_ = -1;
|
||||
bool interactive_ = false;
|
||||
bool input_closed_ = false;
|
||||
bool waiting_password_ = false;
|
||||
bool discarding_line_ = false;
|
||||
std::string input_;
|
||||
std::string diagnostic_path_;
|
||||
std::mutex identity_mutex_;
|
||||
std::string id_;
|
||||
std::string password_;
|
||||
std::string displayed_id_;
|
||||
std::string displayed_password_;
|
||||
bool displayed_connected_ = false;
|
||||
bool displayed_ = false;
|
||||
int language_ = 0;
|
||||
ConfigCenter* config_ = nullptr;
|
||||
std::function<bool()> connection_busy_;
|
||||
std::function<void(SettingEffect)> settings_applied_;
|
||||
bool settings_menu_ = false;
|
||||
std::string selected_setting_;
|
||||
};
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -1,657 +0,0 @@
|
||||
#include "headless_session.h"
|
||||
#include "desktop_session_selection.h"
|
||||
#include "rd_log.h"
|
||||
|
||||
#include <X11/Xatom.h>
|
||||
#include <X11/Xlib.h>
|
||||
#include <dlfcn.h>
|
||||
#include <fcntl.h>
|
||||
#include <poll.h>
|
||||
#include <signal.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <sys/random.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cerrno>
|
||||
#include <charconv>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
using Clock = std::chrono::steady_clock;
|
||||
using namespace std::chrono_literals;
|
||||
volatile sig_atomic_t stop_signal = 0;
|
||||
void RequestStop(int signal) { stop_signal = signal; }
|
||||
|
||||
// Bootstrap runs before GUI initialization, and may fork. Reuse the existing
|
||||
// application logger when the headless console has silenced standard streams.
|
||||
// Callers without a prepared console retain their normal stderr diagnostics.
|
||||
bool log_diagnostics_to_file = false;
|
||||
class Diagnostic {
|
||||
public:
|
||||
explicit Diagnostic(spdlog::level::level_enum level = spdlog::level::err)
|
||||
: level_(level) {}
|
||||
template <typename T> Diagnostic& operator<<(const T& value) {
|
||||
text_ << value;
|
||||
return *this;
|
||||
}
|
||||
~Diagnostic() {
|
||||
std::string message = text_.str();
|
||||
if (!log_diagnostics_to_file) {
|
||||
std::cerr << message;
|
||||
return;
|
||||
}
|
||||
if (!message.empty() && message.back() == '\n') message.pop_back();
|
||||
get_logger()->log(level_, "{}", message);
|
||||
}
|
||||
private:
|
||||
spdlog::level::level_enum level_;
|
||||
std::ostringstream text_;
|
||||
};
|
||||
|
||||
bool HasEnv(const char* name) {
|
||||
const char* value = std::getenv(name);
|
||||
return value && *value;
|
||||
}
|
||||
|
||||
bool ParseDimension(const std::string& value, int& result) {
|
||||
const auto parsed =
|
||||
std::from_chars(value.data(), value.data() + value.size(), result);
|
||||
return parsed.ec == std::errc{} && parsed.ptr == value.data() + value.size() &&
|
||||
result >= 320 && result <= 8192 && result % 2 == 0;
|
||||
}
|
||||
|
||||
struct Options {
|
||||
bool force = false;
|
||||
bool disabled = false;
|
||||
bool help = false;
|
||||
bool new_session = false;
|
||||
std::string size = "1920x1080";
|
||||
std::string session;
|
||||
std::string display;
|
||||
};
|
||||
|
||||
bool ParseOptions(int argc, char* argv[], Options& options) {
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
const std::string arg = argv[i];
|
||||
if (arg == "--headless") {
|
||||
options.force = true;
|
||||
} else if (arg == "--no-headless") {
|
||||
options.disabled = true;
|
||||
} else if (arg == "--headless-help") {
|
||||
options.help = true;
|
||||
} else if (arg == "--headless-size" || arg == "--headless-session" ||
|
||||
arg == "--headless-display") {
|
||||
if (++i == argc || argv[i][0] == '\0') {
|
||||
Diagnostic() << arg << " requires a value\n";
|
||||
return false;
|
||||
}
|
||||
if (arg == "--headless-size") {
|
||||
options.size = argv[i];
|
||||
options.new_session = true;
|
||||
} else if (arg == "--headless-session") {
|
||||
options.session = argv[i];
|
||||
options.new_session = true;
|
||||
} else {
|
||||
options.display = argv[i];
|
||||
}
|
||||
options.force = true;
|
||||
} else if (arg.rfind("--headless", 0) == 0) {
|
||||
Diagnostic() << "Unknown option: " << arg << '\n';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
int width = 0, height = 0;
|
||||
const auto separator = options.size.find('x');
|
||||
if (separator == std::string::npos ||
|
||||
!ParseDimension(options.size.substr(0, separator), width) ||
|
||||
!ParseDimension(options.size.substr(separator + 1), height)) {
|
||||
Diagnostic() << "--headless-size requires WIDTHxHEIGHT with even dimensions "
|
||||
"between 320 and 8192\n";
|
||||
return false;
|
||||
}
|
||||
if (options.force && options.disabled) {
|
||||
Diagnostic() << "--no-headless conflicts with --headless options\n";
|
||||
return false;
|
||||
}
|
||||
if (!options.display.empty() && options.new_session) {
|
||||
Diagnostic() << "--headless-display reuses an existing desktop and cannot be "
|
||||
"combined with --headless-size or --headless-session\n";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
struct ExistingDesktop {
|
||||
std::string display;
|
||||
std::optional<std::string> authority;
|
||||
};
|
||||
|
||||
std::vector<DesktopLoginSession> GetDesktopLoginSessions() {
|
||||
// Optional runtime integration: non-systemd distributions and containers
|
||||
// still discover X11 desktops without a build/runtime dependency on logind.
|
||||
struct Library {
|
||||
void* handle = dlopen("libsystemd.so.0", RTLD_LAZY | RTLD_LOCAL);
|
||||
~Library() { if (handle) dlclose(handle); }
|
||||
} library;
|
||||
if (!library.handle) return {};
|
||||
using UserSessions = int (*)(uid_t, int, char***);
|
||||
using SessionString = int (*)(const char*, char**);
|
||||
using SessionFlag = int (*)(const char*);
|
||||
using UserDisplay = int (*)(uid_t, char**);
|
||||
using PidSession = int (*)(pid_t, char**);
|
||||
const auto list = reinterpret_cast<UserSessions>(
|
||||
dlsym(library.handle, "sd_uid_get_sessions"));
|
||||
const auto type = reinterpret_cast<SessionString>(
|
||||
dlsym(library.handle, "sd_session_get_type"));
|
||||
const auto session_class = reinterpret_cast<SessionString>(
|
||||
dlsym(library.handle, "sd_session_get_class"));
|
||||
const auto state = reinterpret_cast<SessionString>(
|
||||
dlsym(library.handle, "sd_session_get_state"));
|
||||
const auto display = reinterpret_cast<SessionString>(
|
||||
dlsym(library.handle, "sd_session_get_display"));
|
||||
const auto active = reinterpret_cast<SessionFlag>(
|
||||
dlsym(library.handle, "sd_session_is_active"));
|
||||
const auto remote = reinterpret_cast<SessionFlag>(
|
||||
dlsym(library.handle, "sd_session_is_remote"));
|
||||
const auto primary = reinterpret_cast<UserDisplay>(
|
||||
dlsym(library.handle, "sd_uid_get_display"));
|
||||
const auto current = reinterpret_cast<PidSession>(
|
||||
dlsym(library.handle, "sd_pid_get_session"));
|
||||
if (!list || !type || !session_class || !state || !display) return {};
|
||||
auto read = [](SessionString function, const char* id) {
|
||||
char* value = nullptr;
|
||||
const int status = function(id, &value);
|
||||
std::string result = status >= 0 && value ? value : "";
|
||||
free(value);
|
||||
return result;
|
||||
};
|
||||
char* value = nullptr;
|
||||
std::string primary_id, current_id;
|
||||
if (primary && primary(getuid(), &value) >= 0 && value) primary_id = value;
|
||||
free(value);
|
||||
value = nullptr;
|
||||
if (current && current(0, &value) >= 0 && value) current_id = value;
|
||||
free(value);
|
||||
if (current_id.empty() && HasEnv("XDG_SESSION_ID")) {
|
||||
current_id = std::getenv("XDG_SESSION_ID");
|
||||
}
|
||||
char** ids = nullptr;
|
||||
const int count = list(getuid(), 0, &ids);
|
||||
if (count < 0 || !ids) return {};
|
||||
std::vector<DesktopLoginSession> sessions;
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const std::string kind = read(session_class, ids[i]);
|
||||
const std::string status = read(state, ids[i]);
|
||||
if (read(type, ids[i]) == "x11" &&
|
||||
(kind == "user" || kind == "user-early") &&
|
||||
(status == "online" || status == "active")) {
|
||||
const auto address = NormalizeDesktopDisplay(read(display, ids[i]));
|
||||
// Only local displays, never greeters, SSH forwarding or other users.
|
||||
if (address.size() > 1 && address[0] == ':' &&
|
||||
address.find_first_not_of("0123456789.", 1) == std::string::npos) {
|
||||
sessions.push_back({address, current_id == ids[i], primary_id == ids[i],
|
||||
active && active(ids[i]) > 0,
|
||||
remote && remote(ids[i]) > 0});
|
||||
}
|
||||
}
|
||||
free(ids[i]);
|
||||
}
|
||||
free(ids);
|
||||
return sessions;
|
||||
}
|
||||
|
||||
void SetAuthority(const std::optional<std::string>& authority) {
|
||||
if (authority) {
|
||||
setenv("XAUTHORITY", authority->c_str(), 1);
|
||||
} else {
|
||||
unsetenv("XAUTHORITY");
|
||||
}
|
||||
}
|
||||
|
||||
Window ReadWindowProperty(Display* display, Window window, Atom property) {
|
||||
Atom type = None;
|
||||
int format = 0;
|
||||
unsigned long count = 0, remaining = 0;
|
||||
unsigned char* data = nullptr;
|
||||
Window result = None;
|
||||
if (XGetWindowProperty(display, window, property, 0, 1, False, XA_WINDOW,
|
||||
&type, &format, &count, &remaining, &data) == Success &&
|
||||
type == XA_WINDOW && format == 32 && count == 1 && data) {
|
||||
result = *reinterpret_cast<Window*>(data);
|
||||
}
|
||||
if (data) XFree(data);
|
||||
return result;
|
||||
}
|
||||
|
||||
bool HasDesktop(const std::string& address) {
|
||||
Display* display = XOpenDisplay(address.c_str());
|
||||
if (!display) return false;
|
||||
// A root framebuffer alone may be the empty Xvfb that caused the black
|
||||
// background. Require a live EWMH window manager, including its self-check.
|
||||
// Discovery runs before any threads; a WM disappearing mid-probe is benign.
|
||||
auto previous = XSetErrorHandler([](Display*, XErrorEvent*) { return 0; });
|
||||
const Window root = DefaultRootWindow(display);
|
||||
XWindowAttributes geometry{};
|
||||
const Atom property = XInternAtom(display, "_NET_SUPPORTING_WM_CHECK", True);
|
||||
const Window manager = property == None ? None
|
||||
: ReadWindowProperty(display, root, property);
|
||||
const bool valid = manager != None &&
|
||||
ReadWindowProperty(display, manager, property) == manager &&
|
||||
XGetWindowAttributes(display, root, &geometry) &&
|
||||
geometry.width > 1 && geometry.height > 1;
|
||||
XCloseDisplay(display);
|
||||
XSetErrorHandler(previous);
|
||||
return valid;
|
||||
}
|
||||
|
||||
std::vector<ExistingDesktop> FindExistingDesktops(
|
||||
const std::string& requested,
|
||||
const std::vector<DesktopLoginSession>& sessions) {
|
||||
const char* value = std::getenv("XAUTHORITY");
|
||||
const std::optional<std::string> original =
|
||||
value ? std::optional<std::string>(value) : std::nullopt;
|
||||
std::vector<std::optional<std::string>> authorities{original};
|
||||
// An explicit authority must be respected, including for automation and
|
||||
// isolated tests. Otherwise also try GDM's per-user authority file.
|
||||
if (!original) {
|
||||
const std::string gdm = "/run/user/" + std::to_string(getuid()) +
|
||||
"/gdm/Xauthority";
|
||||
struct stat info{};
|
||||
if (stat(gdm.c_str(), &info) == 0 && info.st_uid == getuid() &&
|
||||
S_ISREG(info.st_mode) && access(gdm.c_str(), R_OK) == 0) {
|
||||
authorities.emplace_back(gdm);
|
||||
}
|
||||
}
|
||||
auto probe = [&](const std::string& address) -> std::optional<ExistingDesktop> {
|
||||
for (const auto& authority : authorities) {
|
||||
SetAuthority(authority);
|
||||
if (HasDesktop(address)) return ExistingDesktop{address, authority};
|
||||
}
|
||||
return std::nullopt;
|
||||
};
|
||||
std::vector<ExistingDesktop> matches;
|
||||
if (!requested.empty()) {
|
||||
if (auto match = probe(requested)) matches.push_back(*match);
|
||||
} else if (HasEnv("DISPLAY")) {
|
||||
// An explicit, usable desktop is unambiguous even with several logins.
|
||||
if (auto match = probe(std::getenv("DISPLAY"))) matches.push_back(*match);
|
||||
}
|
||||
if (requested.empty() && matches.empty()) {
|
||||
std::vector<std::string> candidates;
|
||||
// logind may identify a desktop whose X socket is owned by root. Its
|
||||
// session still belongs to our uid and must pass the same X11/WM probes.
|
||||
for (const auto& session : sessions) candidates.push_back(session.display);
|
||||
std::error_code error;
|
||||
std::filesystem::directory_iterator it("/tmp/.X11-unix", error), end;
|
||||
for (; !error && it != end; it.increment(error)) {
|
||||
const std::string name = it->path().filename().string();
|
||||
struct stat info{};
|
||||
if (name.size() > 1 && name[0] == 'X' &&
|
||||
name.find_first_not_of("0123456789", 1) == std::string::npos &&
|
||||
lstat(it->path().c_str(), &info) == 0 && S_ISSOCK(info.st_mode) &&
|
||||
info.st_uid == getuid()) {
|
||||
candidates.push_back(":" + name.substr(1));
|
||||
}
|
||||
}
|
||||
std::sort(candidates.begin(), candidates.end(), DesktopDisplayLess);
|
||||
candidates.erase(std::unique(candidates.begin(), candidates.end()),
|
||||
candidates.end());
|
||||
for (const auto& address : candidates) {
|
||||
if (auto match = probe(address)) matches.push_back(*match);
|
||||
}
|
||||
}
|
||||
SetAuthority(original);
|
||||
return matches;
|
||||
}
|
||||
|
||||
void ConfigureHeadlessX11(const std::string& display) {
|
||||
// Capture, input and all GUI components must use the same X11 desktop,
|
||||
// including when inheriting Wayland/DRM or NVIDIA PRIME hints from a shell.
|
||||
setenv("DISPLAY", display.c_str(), 1);
|
||||
setenv("XDG_SESSION_TYPE", "x11", 1);
|
||||
unsetenv("WAYLAND_DISPLAY");
|
||||
unsetenv("WAYLAND_SOCKET");
|
||||
setenv("CROSSDESK_SCREEN_BACKEND", "x11", 1);
|
||||
setenv("SDL_VIDEODRIVER", "x11", 1);
|
||||
setenv("SLINT_BACKEND", "winit-software", 1);
|
||||
setenv("LIBGL_ALWAYS_SOFTWARE", "1", 1);
|
||||
unsetenv("__NV_PRIME_RENDER_OFFLOAD");
|
||||
unsetenv("__NV_PRIME_RENDER_OFFLOAD_PROVIDER");
|
||||
unsetenv("__GLX_VENDOR_LIBRARY_NAME");
|
||||
}
|
||||
|
||||
std::string FindExecutable(const std::string& name) {
|
||||
if (name.find('/') != std::string::npos) {
|
||||
return access(name.c_str(), X_OK) == 0 ? name : std::string{};
|
||||
}
|
||||
const char* path = std::getenv("PATH");
|
||||
std::string paths = path ? path : "/usr/bin:/bin";
|
||||
size_t start = 0;
|
||||
do {
|
||||
const auto end = paths.find(':', start);
|
||||
const std::string directory = paths.substr(start, end - start);
|
||||
const std::string candidate =
|
||||
(directory.empty() ? "." : directory) + "/" + name;
|
||||
if (access(candidate.c_str(), X_OK) == 0) return candidate;
|
||||
if (end == std::string::npos) break;
|
||||
start = end + 1;
|
||||
} while (start <= paths.size());
|
||||
return {};
|
||||
}
|
||||
|
||||
// Xauthority's binary format consists of a family and four counted strings,
|
||||
// all lengths in network byte order. FamilyWild and an empty display number
|
||||
// allow the cookie to be prepared before Xvfb allocates its display number.
|
||||
class PrivateAuthority {
|
||||
public:
|
||||
bool Create() {
|
||||
char directory[] = "/tmp/crossdesk-headless-XXXXXX";
|
||||
if (!mkdtemp(directory)) return false; // mode 0700
|
||||
directory_ = directory;
|
||||
path_ = directory_ + "/Xauthority";
|
||||
unsigned char cookie[16];
|
||||
size_t offset = 0;
|
||||
while (offset < sizeof(cookie)) {
|
||||
const ssize_t count = getrandom(cookie + offset, sizeof(cookie) - offset, 0);
|
||||
if (count < 0 && errno == EINTR) continue;
|
||||
if (count <= 0) return false;
|
||||
offset += count;
|
||||
}
|
||||
const int fd = open(path_.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC,
|
||||
S_IRUSR | S_IWUSR);
|
||||
if (fd < 0) return false;
|
||||
FILE* file = fdopen(fd, "wb");
|
||||
if (!file) {
|
||||
close(fd);
|
||||
return false;
|
||||
}
|
||||
auto word = [file](unsigned int value) {
|
||||
return fputc((value >> 8) & 255, file) != EOF &&
|
||||
fputc(value & 255, file) != EOF;
|
||||
};
|
||||
constexpr char protocol[] = "MIT-MAGIC-COOKIE-1";
|
||||
const bool written = word(65535) && word(0) && word(0) &&
|
||||
word(sizeof(protocol) - 1) &&
|
||||
fwrite(protocol, 1, sizeof(protocol) - 1, file) ==
|
||||
sizeof(protocol) - 1 &&
|
||||
word(sizeof(cookie)) &&
|
||||
fwrite(cookie, 1, sizeof(cookie), file) == sizeof(cookie);
|
||||
const bool closed = fclose(file) == 0;
|
||||
return written && closed;
|
||||
}
|
||||
~PrivateAuthority() {
|
||||
if (!path_.empty()) unlink(path_.c_str());
|
||||
if (!directory_.empty()) rmdir(directory_.c_str());
|
||||
}
|
||||
const std::string& path() const { return path_; }
|
||||
|
||||
private:
|
||||
std::string directory_;
|
||||
std::string path_;
|
||||
};
|
||||
|
||||
class SignalGuard {
|
||||
public:
|
||||
SignalGuard() {
|
||||
stop_signal = 0;
|
||||
struct sigaction action {};
|
||||
action.sa_handler = RequestStop;
|
||||
sigemptyset(&action.sa_mask);
|
||||
sigaction(SIGINT, &action, &old_int_);
|
||||
sigaction(SIGTERM, &action, &old_term_);
|
||||
sigaction(SIGHUP, &action, &old_hup_);
|
||||
}
|
||||
~SignalGuard() {
|
||||
sigaction(SIGINT, &old_int_, nullptr);
|
||||
sigaction(SIGTERM, &old_term_, nullptr);
|
||||
sigaction(SIGHUP, &old_hup_, nullptr);
|
||||
}
|
||||
|
||||
private:
|
||||
struct sigaction old_int_ {}, old_term_ {}, old_hup_ {};
|
||||
};
|
||||
|
||||
class Child {
|
||||
public:
|
||||
Child() = default;
|
||||
Child(const Child&) = delete;
|
||||
Child& operator=(const Child&) = delete;
|
||||
~Child() { Stop(); }
|
||||
|
||||
bool Start(const std::function<int()>& run, bool own_group = true) {
|
||||
own_group_ = own_group;
|
||||
const pid_t parent = getpid();
|
||||
pid_ = fork();
|
||||
if (pid_ == 0) {
|
||||
if (own_group_) setpgid(0, 0);
|
||||
signal(SIGINT, SIG_DFL);
|
||||
signal(SIGTERM, SIG_DFL);
|
||||
signal(SIGHUP, SIG_DFL);
|
||||
// Also stop direct children if the supervisor itself is killed.
|
||||
if (prctl(PR_SET_PDEATHSIG, SIGTERM) != 0 || getppid() != parent) _exit(1);
|
||||
_exit(run());
|
||||
}
|
||||
if (pid_ < 0) return false;
|
||||
if (own_group_) setpgid(pid_, pid_);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Exited(int* code = nullptr) const {
|
||||
if (pid_ <= 0) return false;
|
||||
siginfo_t info{};
|
||||
// Leave the child waitable until Stop(), preventing pid/group reuse.
|
||||
if (waitid(P_PID, pid_, &info, WEXITED | WNOHANG | WNOWAIT) != 0 ||
|
||||
info.si_pid == 0) return false;
|
||||
if (code) {
|
||||
*code = info.si_code == CLD_EXITED ? info.si_status : 128 + info.si_status;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void Stop() {
|
||||
if (pid_ <= 0) return;
|
||||
const pid_t target = own_group_ ? -pid_ : pid_;
|
||||
kill(target, SIGTERM);
|
||||
const auto deadline = Clock::now() + 2s;
|
||||
while (!Exited() && Clock::now() < deadline) std::this_thread::sleep_for(25ms);
|
||||
kill(target, SIGKILL); // Include descendants of desktop session scripts.
|
||||
while (waitpid(pid_, nullptr, 0) < 0 && errno == EINTR) {}
|
||||
pid_ = -1;
|
||||
}
|
||||
|
||||
private:
|
||||
pid_t pid_ = -1;
|
||||
bool own_group_ = true;
|
||||
};
|
||||
|
||||
int Exec(const std::vector<std::string>& arguments) {
|
||||
std::vector<char*> argv;
|
||||
for (const auto& arg : arguments) argv.push_back(const_cast<char*>(arg.c_str()));
|
||||
argv.push_back(nullptr);
|
||||
execv(argv[0], argv.data());
|
||||
Diagnostic() << "Cannot start " << arguments[0] << ": " << std::strerror(errno)
|
||||
<< '\n';
|
||||
return 127;
|
||||
}
|
||||
|
||||
bool StartServer(Child& server, const std::string& executable,
|
||||
const Options& options, const PrivateAuthority& authority,
|
||||
std::string& display) {
|
||||
int pipe_fds[2];
|
||||
if (pipe2(pipe_fds, O_CLOEXEC | O_NONBLOCK) != 0) return false;
|
||||
const bool started = server.Start([&] {
|
||||
close(pipe_fds[0]);
|
||||
fcntl(pipe_fds[1], F_SETFD, 0);
|
||||
return Exec({executable, "-displayfd", std::to_string(pipe_fds[1]),
|
||||
"-screen", "0", options.size + "x24", "-nolisten", "tcp",
|
||||
"-auth", authority.path(), "-noreset"});
|
||||
});
|
||||
close(pipe_fds[1]);
|
||||
std::string number;
|
||||
const auto deadline = Clock::now() + 10s;
|
||||
while (started && !stop_signal && !server.Exited() && Clock::now() < deadline) {
|
||||
struct pollfd descriptor { pipe_fds[0], POLLIN, 0 };
|
||||
if (poll(&descriptor, 1, 50) <= 0) continue;
|
||||
char buffer[32];
|
||||
const ssize_t size = read(pipe_fds[0], buffer, sizeof(buffer));
|
||||
if (size <= 0) break;
|
||||
number.append(buffer, size);
|
||||
if (number.find('\n') != std::string::npos || number.size() > 16) break;
|
||||
}
|
||||
close(pipe_fds[0]);
|
||||
if (number.size() < 2 || number.back() != '\n') return false;
|
||||
number.pop_back();
|
||||
if (number.find_first_not_of("0123456789") != std::string::npos) return false;
|
||||
display = ":" + number;
|
||||
return true;
|
||||
}
|
||||
|
||||
int RunHeadless(const Options& options, const std::function<int()>& run) {
|
||||
const std::string xvfb = FindExecutable("Xvfb");
|
||||
if (xvfb.empty()) {
|
||||
Diagnostic() << "No usable display. Headless mode requires Xvfb; on Debian/Ubuntu "
|
||||
"install it with: sudo apt install xvfb\n";
|
||||
return 1;
|
||||
}
|
||||
std::string session;
|
||||
std::string dbus;
|
||||
if (!options.session.empty() && options.session != "none") {
|
||||
session = FindExecutable(options.session);
|
||||
dbus = FindExecutable("dbus-run-session");
|
||||
if (session.empty() || dbus.empty()) {
|
||||
Diagnostic() << "Headless desktop requires an executable --headless-session "
|
||||
"and dbus-run-session (Debian/Ubuntu: dbus-daemon)\n";
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
SignalGuard signals;
|
||||
PrivateAuthority authority;
|
||||
if (!authority.Create()) {
|
||||
Diagnostic() << "Cannot create private headless Xauthority: "
|
||||
<< std::strerror(errno) << '\n';
|
||||
return 1;
|
||||
}
|
||||
Child server;
|
||||
std::string display;
|
||||
if (!StartServer(server, xvfb, options, authority, display)) {
|
||||
Diagnostic() << "Xvfb failed to become ready within 10 seconds\n";
|
||||
return stop_signal ? 128 + stop_signal : 1;
|
||||
}
|
||||
ConfigureHeadlessX11(display);
|
||||
setenv("XAUTHORITY", authority.path().c_str(), 1);
|
||||
setenv("CROSSDESK_HEADLESS_ACTIVE", "1", 1);
|
||||
|
||||
Display* probe = XOpenDisplay(nullptr);
|
||||
if (!probe) {
|
||||
Diagnostic() << "Cannot connect to the private Xvfb display\n";
|
||||
return 1;
|
||||
}
|
||||
XSetScreenSaver(probe, 0, 0, DefaultBlanking, DefaultExposures);
|
||||
XCloseDisplay(probe);
|
||||
Diagnostic(spdlog::level::info) << "CrossDesk headless display " << display << " (" << options.size
|
||||
<< "), XAUTHORITY=" << authority.path() << '\n';
|
||||
Child desktop;
|
||||
if (!session.empty() && !desktop.Start([&] {
|
||||
unsetenv("DBUS_SESSION_BUS_ADDRESS");
|
||||
unsetenv("SESSION_MANAGER");
|
||||
return Exec({dbus, "--", session});
|
||||
})) return 1;
|
||||
Child application;
|
||||
// Keep an interactive application in the foreground terminal group so its
|
||||
// console can read commands. Xvfb and the optional desktop remain isolated.
|
||||
if (!application.Start(run, !isatty(STDIN_FILENO))) return 1;
|
||||
int result = 1;
|
||||
while (!stop_signal) {
|
||||
if (application.Exited(&result)) return result;
|
||||
if (server.Exited() || desktop.Exited()) {
|
||||
Diagnostic() << "Headless display or desktop session exited; stopping CrossDesk\n";
|
||||
return 1;
|
||||
}
|
||||
std::this_thread::sleep_for(50ms);
|
||||
}
|
||||
return 128 + stop_signal;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int RunWithLinuxDisplay(int argc, char* argv[],
|
||||
const std::function<int()>& run_application,
|
||||
const std::function<bool()>& prepare_headless) {
|
||||
log_diagnostics_to_file = false;
|
||||
Options options;
|
||||
if (!ParseOptions(argc, argv, options)) return 2;
|
||||
if (options.help) {
|
||||
std::cout << "Linux headless options:\n"
|
||||
" --headless Reuse an existing desktop when available\n"
|
||||
" --headless-display DISPLAY Advanced: override automatic desktop selection\n"
|
||||
" --headless-size WIDTHxHEIGHT New Xvfb; default 1920x1080; even, 320..8192\n"
|
||||
" --headless-session EXECUTABLE New desktop (startxfce4, or none for bare Xvfb)\n"
|
||||
" --no-headless Disable automatic Xvfb fallback\n"
|
||||
"Normal startup automatically discovers and selects your existing "
|
||||
"desktop. No display number is required. A new Xvfb does not "
|
||||
"contain your existing apps.\n";
|
||||
return 0;
|
||||
}
|
||||
XInitThreads();
|
||||
if (!options.force) {
|
||||
if (options.disabled || HasEnv("WAYLAND_DISPLAY") || HasEnv("WAYLAND_SOCKET")) {
|
||||
return run_application();
|
||||
}
|
||||
// Preserve normal graphical launches before SSH/headless discovery.
|
||||
if (HasEnv("DISPLAY")) {
|
||||
if (Display* display = XOpenDisplay(nullptr)) {
|
||||
XCloseDisplay(display);
|
||||
return run_application();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (prepare_headless) {
|
||||
if (!prepare_headless()) return 1;
|
||||
log_diagnostics_to_file = true;
|
||||
}
|
||||
if (!options.new_session) {
|
||||
const auto sessions = GetDesktopLoginSessions();
|
||||
const auto desktops = FindExistingDesktops(options.display, sessions);
|
||||
std::vector<std::string> available;
|
||||
for (const auto& desktop : desktops) available.push_back(desktop.display);
|
||||
if (auto selected = SelectDesktopSession(available, sessions)) {
|
||||
const auto& desktop = desktops[*selected];
|
||||
SetAuthority(desktop.authority);
|
||||
ConfigureHeadlessX11(desktop.display);
|
||||
Diagnostic(spdlog::level::info) << "CrossDesk reusing existing desktop " << desktop.display
|
||||
<< "; Dock, panels and running applications are preserved\n";
|
||||
if (desktops.size() > 1) {
|
||||
Diagnostic(spdlog::level::info) << "Automatically selected from " << desktops.size()
|
||||
<< " available desktops using login-session priority\n";
|
||||
}
|
||||
// The desktop belongs to the user, not this process. Never supervise or
|
||||
// terminate its X server, window manager or applications on exit.
|
||||
return run_application();
|
||||
}
|
||||
if (!options.display.empty()) {
|
||||
Diagnostic() << "No accessible desktop/window manager on " << options.display
|
||||
<< ". Check DISPLAY and XAUTHORITY; no new desktop was created.\n";
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
if (options.session.empty() || options.session == "none") {
|
||||
Diagnostic(spdlog::level::warn) << "Starting a separate bare Xvfb display: it has no Dock, panels "
|
||||
"or existing applications. To include a desktop environment, "
|
||||
"use --headless-session startxfce4 after installing Xfce.\n";
|
||||
}
|
||||
return RunHeadless(options, run_application);
|
||||
}
|
||||
} // namespace crossdesk
|
||||
@@ -1,23 +0,0 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-25
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _HEADLESS_SESSION_H_
|
||||
#define _HEADLESS_SESSION_H_
|
||||
|
||||
#include <functional>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
// Must run before logging, SDL, Slint or any worker threads are initialized.
|
||||
// Automatically selects the user's existing desktop, or supervises a private Xvfb
|
||||
// server and the application as children for the duration of a headless session.
|
||||
int RunWithLinuxDisplay(int argc, char* argv[],
|
||||
const std::function<int()>& run_application,
|
||||
const std::function<bool()>& prepare_headless = {});
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -1,153 +0,0 @@
|
||||
#include "headless_settings.h"
|
||||
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <charconv>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
|
||||
#include "config_center.h"
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
std::string Lower(std::string value) {
|
||||
for (auto& c : value) if (c >= 'A' && c <= 'Z') c += 'a' - 'A';
|
||||
return value;
|
||||
}
|
||||
std::optional<bool> Boolean(const std::string& value) {
|
||||
if (value == "on" || value == "true" || value == "1") return true;
|
||||
if (value == "off" || value == "false" || value == "0") return false;
|
||||
return std::nullopt;
|
||||
}
|
||||
const char* OnOff(bool value) { return value ? "on" : "off"; }
|
||||
const char* languages[] = {"zh-CN", "en-US", "ru-RU"};
|
||||
const char* turn_modes[] = {"off", "auto", "udp", "tcp"};
|
||||
} // namespace
|
||||
|
||||
std::vector<ConsoleSetting> GetConsoleSettings(const ConfigCenter& config) {
|
||||
const auto language = std::clamp(static_cast<int>(config.GetLanguage()), 0, 2);
|
||||
const auto turn = std::clamp(static_cast<int>(config.GetTurnMode()), 0, 3);
|
||||
return {
|
||||
{"language", "console_setting_language", languages[language], "zh-CN / en-US / ru-RU"},
|
||||
{"codec", "console_setting_codec", config.GetVideoEncodeFormat() == ConfigCenter::VIDEO_ENCODE_FORMAT::AV1 ? "av1" : "h264", "h264 / av1"},
|
||||
{"hardware", "console_setting_hardware", OnOff(config.IsHardwareVideoCodec()), "on / off"},
|
||||
{"turn", "console_setting_turn", turn_modes[turn], "off / auto / udp / tcp"},
|
||||
{"self_hosted", "console_setting_self_hosted", OnOff(config.IsSelfHosted()), "on / off"},
|
||||
{"server_host", "console_setting_host", config.GetSignalServerHost(), "example.com"},
|
||||
{"server_port", "console_setting_port", std::to_string(config.GetSignalServerPort()), "1..65535"},
|
||||
{"privacy", "console_setting_privacy", OnOff(config.IsEnablePrivacyScreen()), "on / off"},
|
||||
{"file_path", "console_setting_files", config.GetFileTransferSavePath().empty() ? "default" : config.GetFileTransferSavePath(), "/path/to/directory / default"},
|
||||
{"autostart", "console_setting_autostart", OnOff(config.IsEnableAutostart()), "on / off"},
|
||||
{"daemon", "console_setting_daemon", OnOff(config.IsEnableDaemon()), "on / off"},
|
||||
};
|
||||
}
|
||||
|
||||
SettingResult ApplyConsoleSetting(ConfigCenter& config, const std::string& key,
|
||||
const std::string& input, bool connection_busy) {
|
||||
SettingResult result;
|
||||
if (key == "capture_method" || key == "fps" || key == "quality") {
|
||||
result.message = "console_settings_readonly";
|
||||
return result;
|
||||
}
|
||||
const bool network = key == "codec" || key == "hardware" || key == "turn" ||
|
||||
key == "self_hosted" || key == "server_host" || key == "server_port";
|
||||
if (network && connection_busy) {
|
||||
result.message = "console_settings_busy";
|
||||
return result;
|
||||
}
|
||||
const std::string value = Lower(input);
|
||||
int status = -1;
|
||||
SettingEffect effect = SettingEffect::immediate;
|
||||
const char* message = "console_settings_saved";
|
||||
std::string canonical = input;
|
||||
std::function<int()> save;
|
||||
if (key == "language") {
|
||||
int language = value == "zh-cn" || value == "zh" || value == "0" ? 0
|
||||
: value == "en-us" || value == "en" || value == "1" ? 1
|
||||
: value == "ru-ru" || value == "ru" || value == "2" ? 2 : -1;
|
||||
if (language < 0) return result;
|
||||
canonical = languages[language];
|
||||
save = [&config, language] { return config.SetLanguage(static_cast<ConfigCenter::LANGUAGE>(language)); };
|
||||
} else if (key == "codec") {
|
||||
if (value != "h264" && value != "av1") return result;
|
||||
canonical = value;
|
||||
save = [&] { return config.SetVideoEncodeFormat(value == "h264" ? ConfigCenter::VIDEO_ENCODE_FORMAT::H264 : ConfigCenter::VIDEO_ENCODE_FORMAT::AV1); };
|
||||
} else if (key == "turn") {
|
||||
const auto found = std::find(std::begin(turn_modes), std::end(turn_modes), value);
|
||||
if (found == std::end(turn_modes)) return result;
|
||||
const int mode = static_cast<int>(found - std::begin(turn_modes));
|
||||
canonical = value;
|
||||
save = [&config, mode] { return config.SetTurnMode(static_cast<ConfigCenter::TURN_MODE>(mode)); };
|
||||
} else if (key == "server_host") {
|
||||
if (input.empty() || input.size() > 253 ||
|
||||
input.find_first_of("/\\@?#") != std::string::npos ||
|
||||
std::any_of(input.begin(), input.end(), [](unsigned char c) { return c <= 32 || c == 127; })) return result;
|
||||
save = [&] { return config.SetServerHost(input); };
|
||||
} else if (key == "server_port") {
|
||||
int port = 0;
|
||||
const auto parsed = std::from_chars(input.data(), input.data() + input.size(), port);
|
||||
if (parsed.ec != std::errc{} || parsed.ptr != input.data() + input.size() || port < 1 || port > 65535) return result;
|
||||
canonical = std::to_string(port);
|
||||
save = [&config, port] { return config.SetServerPort(port); };
|
||||
} else if (key == "file_path") {
|
||||
std::string path = input == "default" ? "" : input;
|
||||
if (path.compare(0, 2, "~/") == 0 && std::getenv("HOME")) path.replace(0, 1, std::getenv("HOME"));
|
||||
std::error_code error;
|
||||
if (!path.empty() && (path.size() >= 512 || !std::filesystem::path(path).is_absolute() ||
|
||||
!std::filesystem::is_directory(path, error) || access(path.c_str(), W_OK | X_OK) != 0 ||
|
||||
std::any_of(path.begin(), path.end(), [](unsigned char c) { return c < 32 || c == 127; }))) {
|
||||
result.message = "console_settings_path_invalid";
|
||||
return result;
|
||||
}
|
||||
canonical = path.empty() ? "default" : path;
|
||||
save = [&config, path] { return config.SetFileTransferSavePath(path); };
|
||||
effect = SettingEffect::next_file;
|
||||
message = "console_settings_next_file";
|
||||
} else {
|
||||
const auto boolean = Boolean(value);
|
||||
if (!boolean) return result;
|
||||
const bool enabled = *boolean;
|
||||
canonical = OnOff(enabled);
|
||||
if (key == "hardware") {
|
||||
if (enabled && !ConfigCenter::IsHardwareVideoCodecAvailable()) {
|
||||
result.message = "console_settings_hardware_unavailable";
|
||||
return result;
|
||||
}
|
||||
save = [&config, enabled] { return config.SetHardwareVideoCodec(enabled); };
|
||||
} else if (key == "self_hosted") {
|
||||
if (enabled && (config.GetSignalServerHost().empty() || config.GetSignalServerPort() <= 0 || config.GetSignalServerPort() > 65535)) {
|
||||
result.message = "console_settings_server_required";
|
||||
return result;
|
||||
}
|
||||
save = [&config, enabled] { return config.SetSelfHosted(enabled); };
|
||||
} else if (key == "privacy") {
|
||||
save = [&config, enabled] { return config.SetPrivacyScreen(enabled); };
|
||||
effect = SettingEffect::next_connection;
|
||||
message = "console_settings_next_connection";
|
||||
} else if (key == "autostart") {
|
||||
save = [&config, enabled] { return config.SetAutostart(enabled); };
|
||||
effect = SettingEffect::next_login;
|
||||
message = "console_settings_autostart";
|
||||
} else if (key == "daemon") {
|
||||
save = [&config, enabled] { return config.SetDaemon(enabled); };
|
||||
effect = SettingEffect::normal_restart;
|
||||
message = "console_settings_daemon";
|
||||
} else return result;
|
||||
}
|
||||
for (const auto& setting : GetConsoleSettings(config)) {
|
||||
if (key != "autostart" && key != "hardware" && setting.key == key && setting.value == canonical) {
|
||||
return {true, SettingEffect::none, "console_settings_unchanged"};
|
||||
}
|
||||
}
|
||||
status = save();
|
||||
if (status != 0) return {false, SettingEffect::none, "console_settings_save_failed"};
|
||||
if (network && ((key != "server_host" && key != "server_port") || config.IsSelfHosted())) {
|
||||
effect = SettingEffect::reconnect;
|
||||
message = "console_settings_reconnect";
|
||||
}
|
||||
return {true, effect, message};
|
||||
}
|
||||
} // namespace crossdesk
|
||||
@@ -1,36 +0,0 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2026-09-25
|
||||
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _HEADLESS_SETTINGS_H_
|
||||
#define _HEADLESS_SETTINGS_H_
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk {
|
||||
class ConfigCenter;
|
||||
|
||||
enum class SettingEffect { none, immediate, reconnect, next_connection, next_file,
|
||||
next_login, normal_restart };
|
||||
struct ConsoleSetting {
|
||||
std::string key;
|
||||
const char* label;
|
||||
std::string value;
|
||||
const char* choices;
|
||||
};
|
||||
|
||||
struct SettingResult {
|
||||
bool saved = false;
|
||||
SettingEffect effect = SettingEffect::none;
|
||||
const char* message = "console_settings_invalid";
|
||||
};
|
||||
|
||||
std::vector<ConsoleSetting> GetConsoleSettings(const ConfigCenter& config);
|
||||
SettingResult ApplyConsoleSetting(ConfigCenter& config, const std::string& key,
|
||||
const std::string& value, bool connection_busy);
|
||||
} // 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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user