Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e38c9f2ae9 | ||
|
|
18c1c6535c | ||
|
|
26ffd25194 | ||
|
|
8870d69bac |
@@ -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:
|
||||
|
||||
@@ -36,12 +36,12 @@ jobs:
|
||||
- arch: amd64
|
||||
runner: ubuntu-24.04
|
||||
image: crossdesk/ubuntu20.04:buildenv-cuda12.6.3-gcc10-cmake3.31.6-pipewire0.3.48-xmake3.0.9-rust1.92.0
|
||||
package_script: ./apps/desktop/scripts/linux/pkg_amd64.sh
|
||||
package_script: ./scripts/linux/pkg_amd64.sh
|
||||
|
||||
- arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
image: crossdesk/ubuntu20.04:buildenv-cuda12.6.3-gcc10-cmake3.31.6-pipewire0.3.48-xmake3.0.9-rust1.92.0
|
||||
package_script: ./apps/desktop/scripts/linux/pkg_arm64.sh
|
||||
package_script: ./scripts/linux/pkg_arm64.sh
|
||||
|
||||
container:
|
||||
image: ${{ matrix.image }}
|
||||
@@ -138,17 +138,6 @@ jobs:
|
||||
--cc=gcc-10 --cxx=g++-10 --root -y
|
||||
xmake b -vy --root crossdesk
|
||||
|
||||
- name: Verify repository and wire boundaries
|
||||
env:
|
||||
XMAKE_GLOBALDIR: /data
|
||||
run: |
|
||||
xmake b -vy --root repository_structure_test
|
||||
xmake b -vy --root crossdesk_wire_test
|
||||
xmake b -vy --root keyboard_state_wire_test
|
||||
xmake run --root repository_structure_test
|
||||
xmake run --root crossdesk_wire_test
|
||||
xmake run --root keyboard_state_wire_test
|
||||
|
||||
- name: Package
|
||||
run: |
|
||||
chmod +x ${{ matrix.package_script }}
|
||||
@@ -189,13 +178,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
|
||||
@@ -293,7 +282,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
./apps/desktop/scripts/macos/verify_app.sh "${APP_PATH}" "${{ matrix.binary_arch }}" "${EXPECTED_MACOS_SDK}"
|
||||
./scripts/macosx/verify_app.sh "${APP_PATH}" "${{ matrix.binary_arch }}" "${EXPECTED_MACOS_SDK}"
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v6
|
||||
@@ -306,173 +295,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
|
||||
env:
|
||||
IOS_DEPLOYMENT_TARGET: "16.0"
|
||||
EXPECTED_IOS_SDK: "26.5"
|
||||
XMAKE_VERSION: "3.1.1"
|
||||
XMAKE_REPO_REVISION: "121109352da272cd1a3d4ed52f2dcda34f839955"
|
||||
|
||||
steps:
|
||||
- name: Extract version number
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION_REF="${GITHUB_REF##*/}"
|
||||
VERSION_BASE="${VERSION_REF#v}"
|
||||
BUILD_DATE=$(TZ=Asia/Shanghai date +%Y%m%d)
|
||||
PATCH_NUMBER="${{ github.event.inputs.patch }}"
|
||||
|
||||
if [[ ! "${PATCH_NUMBER}" =~ ^[0-9]+$ ]]; then
|
||||
PATCH_NUMBER=0
|
||||
fi
|
||||
|
||||
if [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)-([0-9]{8})$ ]]; then
|
||||
VERSION_BASE="${BASH_REMATCH[1]}"
|
||||
PATCH_NUMBER="${BASH_REMATCH[3]}"
|
||||
BUILD_DATE="${BASH_REMATCH[4]}"
|
||||
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})-([0-9]+)$ ]]; then
|
||||
VERSION_BASE="${BASH_REMATCH[1]}"
|
||||
BUILD_DATE="${BASH_REMATCH[3]}"
|
||||
PATCH_NUMBER="${BASH_REMATCH[4]}"
|
||||
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})$ ]]; then
|
||||
VERSION_BASE="${BASH_REMATCH[1]}"
|
||||
BUILD_DATE="${BASH_REMATCH[3]}"
|
||||
elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)$ && "${PATCH_NUMBER}" == "0" ]]; then
|
||||
VERSION_BASE="${BASH_REMATCH[1]}"
|
||||
PATCH_NUMBER="${BASH_REMATCH[3]}"
|
||||
fi
|
||||
|
||||
if [[ "${PATCH_NUMBER}" != "0" ]]; then
|
||||
VERSION_NUM="v${VERSION_BASE}-${PATCH_NUMBER}-${BUILD_DATE}"
|
||||
else
|
||||
VERSION_NUM="v${VERSION_BASE}-${BUILD_DATE}"
|
||||
fi
|
||||
|
||||
echo "VERSION_NUM=${VERSION_NUM}" >> "${GITHUB_ENV}"
|
||||
echo "VERSION_NUM=${VERSION_NUM}"
|
||||
|
||||
- name: Select Xcode 26.6
|
||||
shell: bash
|
||||
run: |
|
||||
sudo xcode-select -s /Applications/Xcode_26.6.app/Contents/Developer
|
||||
xcodebuild -version
|
||||
SDK_VERSION="$(xcrun --sdk iphoneos --show-sdk-version)"
|
||||
if [[ "${SDK_VERSION}" != "${EXPECTED_IOS_SDK}" ]]; then
|
||||
echo "Unexpected iOS SDK: expected ${EXPECTED_IOS_SDK}, got ${SDK_VERSION}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Initialize submodules
|
||||
run: git submodule update --init --recursive
|
||||
|
||||
- name: Cache xmake dependencies
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: apps/ios/.xmake/packages
|
||||
key: "${{ runner.os }}-xmake${{ env.XMAKE_VERSION }}-deps-ios-arm64-xcode26.6-xrepo1211093-min16-${{ github.run_id }}"
|
||||
restore-keys: |
|
||||
${{ runner.os }}-xmake${{ env.XMAKE_VERSION }}-deps-ios-arm64-xcode26.6-xrepo1211093-min16-
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install xmake
|
||||
uses: xmake-io/github-action-setup-xmake@v1
|
||||
with:
|
||||
xmake-version: "${{ env.XMAKE_VERSION }}"
|
||||
|
||||
- name: Verify xmake version
|
||||
shell: bash
|
||||
run: |
|
||||
XMAKE_VERSION_OUTPUT="$(xmake --version)"
|
||||
echo "${XMAKE_VERSION_OUTPUT}"
|
||||
if [[ "${XMAKE_VERSION_OUTPUT}" != *"xmake v${XMAKE_VERSION}"* ]]; then
|
||||
echo "Unexpected xmake version: expected ${XMAKE_VERSION}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Pin xmake package repository
|
||||
shell: bash
|
||||
run: |
|
||||
xmake repo -u
|
||||
XMAKE_REPO_DIR="${HOME}/.xmake/repositories/xmake-repo"
|
||||
git -C "${XMAKE_REPO_DIR}" fetch --depth=1 origin "${XMAKE_REPO_REVISION}"
|
||||
git -C "${XMAKE_REPO_DIR}" checkout --detach "${XMAKE_REPO_REVISION}"
|
||||
test "$(git -C "${XMAKE_REPO_DIR}" rev-parse HEAD)" = "${XMAKE_REPO_REVISION}"
|
||||
|
||||
- name: Build CrossDesk Mobile
|
||||
shell: bash
|
||||
run: |
|
||||
DERIVED_DATA="${RUNNER_TEMP}/CrossDeskMobileDerivedData"
|
||||
echo "DERIVED_DATA=${DERIVED_DATA}" >> "${GITHUB_ENV}"
|
||||
xcodebuild \
|
||||
-project apps/ios/CrossDeskMobile.xcodeproj \
|
||||
-scheme CrossDeskMobile \
|
||||
-configuration Release \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-derivedDataPath "${DERIVED_DATA}" \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
CODE_SIGNING_REQUIRED=NO \
|
||||
COMPILER_INDEX_STORE_ENABLE=NO \
|
||||
IPHONEOS_DEPLOYMENT_TARGET="${IOS_DEPLOYMENT_TARGET}" \
|
||||
build
|
||||
|
||||
- name: Verify CrossDesk Mobile
|
||||
shell: bash
|
||||
run: |
|
||||
APP_PATH="${DERIVED_DATA}/Build/Products/Release-iphoneos/CrossDeskMobile.app"
|
||||
EXECUTABLE_PATH="${APP_PATH}/CrossDeskMobile"
|
||||
INFO_PLIST="${APP_PATH}/Info.plist"
|
||||
|
||||
if [[ ! -x "${EXECUTABLE_PATH}" ]]; then
|
||||
echo "CrossDeskMobile executable is missing from ${APP_PATH}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
xcrun lipo "${EXECUTABLE_PATH}" -verify_arch arm64
|
||||
MINIMUM_OS=$(/usr/libexec/PlistBuddy -c 'Print :MinimumOSVersion' "${INFO_PLIST}")
|
||||
if [[ "${MINIMUM_OS}" != "${IOS_DEPLOYMENT_TARGET}" ]]; then
|
||||
echo "Unexpected minimum iOS version: expected ${IOS_DEPLOYMENT_TARGET}, got ${MINIMUM_OS}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
xcrun lipo -info "${EXECUTABLE_PATH}"
|
||||
echo "Minimum iOS version: ${MINIMUM_OS}"
|
||||
|
||||
- name: Package CrossDesk Mobile
|
||||
shell: bash
|
||||
run: |
|
||||
APP_PATH="${DERIVED_DATA}/Build/Products/Release-iphoneos/CrossDeskMobile.app"
|
||||
PACKAGE_DIR="${RUNNER_TEMP}/crossdesk-ios-arm64-unsigned-${VERSION_NUM}"
|
||||
PACKAGE_FILE="${GITHUB_WORKSPACE}/crossdesk-ios-arm64-unsigned-${VERSION_NUM}.zip"
|
||||
|
||||
mkdir -p "${PACKAGE_DIR}"
|
||||
cp -R "${APP_PATH}" "${PACKAGE_DIR}/"
|
||||
|
||||
DSYM_PATH="${DERIVED_DATA}/Build/Products/Release-iphoneos/CrossDeskMobile.app.dSYM"
|
||||
if [[ -d "${DSYM_PATH}" ]]; then
|
||||
cp -R "${DSYM_PATH}" "${PACKAGE_DIR}/"
|
||||
fi
|
||||
|
||||
ditto -c -k --sequesterRsrc --keepParent "${PACKAGE_DIR}" "${PACKAGE_FILE}"
|
||||
echo "PACKAGE_FILE=${PACKAGE_FILE}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: crossdesk-ios-arm64-unsigned-${{ env.VERSION_NUM }}
|
||||
path: ${{ env.PACKAGE_FILE }}
|
||||
if-no-files-found: error
|
||||
|
||||
# Windows
|
||||
build-windows-x64:
|
||||
name: Build on Windows x64
|
||||
@@ -582,7 +404,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: |
|
||||
@@ -592,14 +414,14 @@ jobs:
|
||||
- name: Package
|
||||
shell: pwsh
|
||||
run: |
|
||||
& "${{ github.workspace }}\apps\desktop\scripts\windows\pkg_x64.ps1" $env:VERSION_NUM
|
||||
& "${{ github.workspace }}\scripts\windows\pkg_x64.ps1" $env:VERSION_NUM
|
||||
|
||||
- name: Upload unsigned Windows installer for test signing
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.sign_windows_test
|
||||
id: upload-unsigned-windows-installer
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
path: ${{ github.workspace }}/apps/desktop/scripts/windows/crossdesk-win-x64-${{ env.VERSION_NUM }}.exe
|
||||
path: ${{ github.workspace }}/scripts/windows/crossdesk-win-x64-${{ env.VERSION_NUM }}.exe
|
||||
archive: false
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
@@ -625,7 +447,7 @@ jobs:
|
||||
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
|
||||
$destination = Join-Path "${{ github.workspace }}\scripts\windows" $installerName
|
||||
|
||||
if (!(Test-Path $signedInstaller)) {
|
||||
throw "SignPath did not return the signed installer: $signedInstaller"
|
||||
@@ -682,7 +504,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
|
||||
@@ -693,7 +515,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:
|
||||
@@ -756,7 +578,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-${{ steps.version.outputs.VERSION_WITH_V }}/* release/crossdesk-ios-arm64-unsigned-${{ steps.version.outputs.VERSION_WITH_V }}.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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -171,10 +171,10 @@ xmake b -vy crossdesk
|
||||
|
||||
```bash
|
||||
# amd64
|
||||
./apps/desktop/scripts/linux/pkg_amd64.sh 1.0.0
|
||||
./scripts/linux/pkg_amd64.sh 1.0.0
|
||||
|
||||
# arm64
|
||||
./apps/desktop/scripts/linux/pkg_arm64.sh 1.0.0
|
||||
./scripts/linux/pkg_arm64.sh 1.0.0
|
||||
```
|
||||
|
||||
打包脚本会将 Slint 共享运行库安装到软件包私有目录 `/usr/lib/crossdesk`,无需用户另外安装 `libslint_cpp.so`。
|
||||
|
||||
@@ -179,10 +179,10 @@ Build a Debian package after compiling a release binary on the matching architec
|
||||
|
||||
```bash
|
||||
# amd64
|
||||
./apps/desktop/scripts/linux/pkg_amd64.sh 1.0.0
|
||||
./scripts/linux/pkg_amd64.sh 1.0.0
|
||||
|
||||
# arm64
|
||||
./apps/desktop/scripts/linux/pkg_arm64.sh 1.0.0
|
||||
./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.
|
||||
|
||||
@@ -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,52 +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;
|
||||
virtual int SendKeyboardCommand(int key_code, bool is_down,
|
||||
uint32_t scan_code = 0,
|
||||
bool extended = false) = 0;
|
||||
};
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -1,28 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <iterator>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk::server_window_state {
|
||||
|
||||
inline int ReconcileSelectedController(const std::vector<std::string>& ids,
|
||||
std::string* selected_id) {
|
||||
if (!selected_id) {
|
||||
return 0;
|
||||
}
|
||||
if (ids.empty()) {
|
||||
selected_id->clear();
|
||||
return 0;
|
||||
}
|
||||
|
||||
const auto selected = std::find(ids.begin(), ids.end(), *selected_id);
|
||||
if (selected == ids.end()) {
|
||||
*selected_id = ids.front();
|
||||
return 0;
|
||||
}
|
||||
return static_cast<int>(std::distance(ids.begin(), selected));
|
||||
}
|
||||
|
||||
} // namespace crossdesk::server_window_state
|
||||
@@ -1,40 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace crossdesk::window_geometry {
|
||||
|
||||
struct PhysicalRect {
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
};
|
||||
|
||||
struct PhysicalSize {
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
};
|
||||
|
||||
struct PhysicalPosition {
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
};
|
||||
|
||||
constexpr PhysicalPosition CenteredPosition(const PhysicalRect& bounds,
|
||||
const PhysicalSize& size) {
|
||||
return {
|
||||
bounds.x + (bounds.width - std::min(bounds.width, size.width)) / 2,
|
||||
bounds.y + (bounds.height - std::min(bounds.height, size.height)) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
constexpr PhysicalPosition BottomRightPosition(const PhysicalRect& bounds,
|
||||
const PhysicalSize& size) {
|
||||
return {
|
||||
bounds.x + std::max(0, bounds.width - size.width),
|
||||
bounds.y + std::max(0, bounds.height - size.height),
|
||||
};
|
||||
}
|
||||
|
||||
} // namespace crossdesk::window_geometry
|
||||
@@ -1,514 +0,0 @@
|
||||
#include "rendering/slint_video_presenter.h"
|
||||
|
||||
#include <libyuv.h>
|
||||
#include <slint.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <mutex>
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
|
||||
#include "nv12_scaler.h"
|
||||
#include "platform/video_renderer.h"
|
||||
#include "rd_log.h"
|
||||
|
||||
#if defined(__APPLE__)
|
||||
#include "platform/macos/gui/metal_video_renderer.h"
|
||||
#else
|
||||
#if defined(_WIN32)
|
||||
#include <windows.h>
|
||||
#endif
|
||||
#include <GL/gl.h>
|
||||
|
||||
#include "platform/common/gui/opengl_video_renderer.h"
|
||||
#endif
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
|
||||
#if !defined(__APPLE__)
|
||||
constexpr GLint kGlClampToEdge = 0x812F;
|
||||
constexpr float kStreamWindowCornerRadius = 12.0f;
|
||||
#else
|
||||
constexpr int kMetalAttachmentAttemptLimit = 30;
|
||||
#endif
|
||||
|
||||
struct VideoRenderSize {
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
|
||||
bool operator==(const VideoRenderSize&) const = default;
|
||||
};
|
||||
|
||||
VideoRenderSize FitVideoToRenderArea(int source_width, int source_height,
|
||||
int area_width, int area_height) {
|
||||
if (source_width <= 0 || source_height <= 0 || area_width <= 0 ||
|
||||
area_height <= 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const double scale =
|
||||
std::min({1.0, static_cast<double>(area_width) / source_width,
|
||||
static_cast<double>(area_height) / source_height});
|
||||
int width = std::max(2, static_cast<int>(std::floor(source_width * scale)));
|
||||
int height = std::max(2, static_cast<int>(std::floor(source_height * scale)));
|
||||
|
||||
width &= ~1;
|
||||
height &= ~1;
|
||||
return {std::min(width, source_width & ~1),
|
||||
std::min(height, source_height & ~1)};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
struct SlintVideoPresenter::Impl {
|
||||
explicit Impl(VideoRenderer& renderer) : renderer(renderer) {}
|
||||
|
||||
VideoRenderer& renderer;
|
||||
slint::ComponentHandle<ui::StreamWindow>* stream = nullptr;
|
||||
StateProvider state_provider;
|
||||
DirtyCallback dirty_callback;
|
||||
std::unordered_map<std::string, uint64_t> displayed_frame_sequence;
|
||||
std::unordered_map<std::string, uint64_t> seeded_frame_sequence;
|
||||
std::vector<uint8_t> scaled_video_frame;
|
||||
std::vector<uint8_t> scaled_video_frame_scratch;
|
||||
|
||||
#if defined(__APPLE__)
|
||||
int attachment_attempts = 0;
|
||||
bool skip_attachment_once = false;
|
||||
#else
|
||||
std::mutex gl_mutex;
|
||||
std::vector<uint8_t> gl_conversion_frame;
|
||||
std::vector<uint8_t> gl_pending_frame;
|
||||
uint32_t gl_texture = 0;
|
||||
VideoRenderSize gl_texture_size;
|
||||
VideoRenderSize gl_image_size;
|
||||
VideoRenderSize gl_pending_size;
|
||||
bool gl_pending_dirty = false;
|
||||
#endif
|
||||
|
||||
SurfaceState CurrentState() const {
|
||||
return state_provider ? state_provider() : SurfaceState{};
|
||||
}
|
||||
|
||||
void MarkDirty() const {
|
||||
if (dirty_callback) {
|
||||
dirty_callback();
|
||||
}
|
||||
}
|
||||
|
||||
bool MarkPresented(const std::string& remote_id, uint64_t sequence) {
|
||||
if (remote_id.empty() || sequence == 0 ||
|
||||
displayed_frame_sequence[remote_id] == sequence) {
|
||||
return false;
|
||||
}
|
||||
displayed_frame_sequence[remote_id] = sequence;
|
||||
return true;
|
||||
}
|
||||
|
||||
#if !defined(__APPLE__)
|
||||
void ConfigureOpenGlNotifier() {
|
||||
if (!stream) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto error = (*stream)->window().set_rendering_notifier(
|
||||
[this](slint::RenderingState state, slint::GraphicsAPI graphics_api) {
|
||||
if (graphics_api != slint::GraphicsAPI::NativeOpenGL) {
|
||||
return;
|
||||
}
|
||||
|
||||
auto* opengl_renderer =
|
||||
dynamic_cast<OpenGlVideoRenderer*>(&renderer);
|
||||
if (state == slint::RenderingState::AfterRendering && stream &&
|
||||
opengl_renderer &&
|
||||
(*stream)->get_native_video_enabled() &&
|
||||
opengl_renderer->ShouldContinueRendering()) {
|
||||
// Queue the next frame directly from the display callback. A
|
||||
// 16/17 ms polling timer can drift across vsync and make Windows
|
||||
// coalesce every second request, effectively capping video near
|
||||
// 30 fps even when decode and submission stay near 60 fps.
|
||||
(*stream)->window().request_redraw();
|
||||
return;
|
||||
}
|
||||
|
||||
std::lock_guard lock(gl_mutex);
|
||||
if (state == slint::RenderingState::RenderingSetup) {
|
||||
GLuint texture = 0;
|
||||
glGenTextures(1, &texture);
|
||||
if (texture == 0) {
|
||||
return;
|
||||
}
|
||||
GLint previous_texture = 0;
|
||||
glGetIntegerv(GL_TEXTURE_BINDING_2D, &previous_texture);
|
||||
glBindTexture(GL_TEXTURE_2D, texture);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, kGlClampToEdge);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, kGlClampToEdge);
|
||||
glBindTexture(GL_TEXTURE_2D, static_cast<GLuint>(previous_texture));
|
||||
gl_texture = texture;
|
||||
if (opengl_renderer && opengl_renderer->Setup()) {
|
||||
const std::string selected_stream =
|
||||
CurrentState().selected_stream;
|
||||
opengl_renderer->SetSelectedStream(selected_stream);
|
||||
seeded_frame_sequence.erase(selected_stream);
|
||||
MarkDirty();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (state == slint::RenderingState::BeforeRendering && stream &&
|
||||
opengl_renderer && opengl_renderer->IsReady() &&
|
||||
(*stream)->get_native_video_enabled()) {
|
||||
const SurfaceState surface = CurrentState();
|
||||
const auto window_size = (*stream)->window().size();
|
||||
const float scale_factor =
|
||||
std::max(1.0f, (*stream)->window().scale_factor());
|
||||
const int top_inset =
|
||||
!surface.fullscreen && surface.tab_count > 1
|
||||
? static_cast<int>(std::lround(30.0f * scale_factor))
|
||||
: 0;
|
||||
const bool rounded_window = (*stream)->get_custom_titlebar() &&
|
||||
!surface.fullscreen &&
|
||||
!(*stream)->window().is_maximized();
|
||||
const int corner_radius =
|
||||
rounded_window ? static_cast<int>(std::lround(
|
||||
kStreamWindowCornerRadius * scale_factor))
|
||||
: 0;
|
||||
opengl_renderer->RenderLatest(
|
||||
surface.selected_stream, static_cast<int>(window_size.width),
|
||||
static_cast<int>(window_size.height), top_inset, corner_radius);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state == slint::RenderingState::BeforeRendering &&
|
||||
gl_texture != 0 && gl_pending_dirty &&
|
||||
!gl_pending_frame.empty()) {
|
||||
GLint previous_texture = 0;
|
||||
GLint previous_unpack_alignment = 0;
|
||||
glGetIntegerv(GL_TEXTURE_BINDING_2D, &previous_texture);
|
||||
glGetIntegerv(GL_UNPACK_ALIGNMENT, &previous_unpack_alignment);
|
||||
glBindTexture(GL_TEXTURE_2D, gl_texture);
|
||||
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
|
||||
if (gl_texture_size != gl_pending_size) {
|
||||
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, gl_pending_size.width,
|
||||
gl_pending_size.height, 0, GL_RGBA, GL_UNSIGNED_BYTE,
|
||||
gl_pending_frame.data());
|
||||
gl_texture_size = gl_pending_size;
|
||||
} else {
|
||||
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, gl_pending_size.width,
|
||||
gl_pending_size.height, GL_RGBA, GL_UNSIGNED_BYTE,
|
||||
gl_pending_frame.data());
|
||||
}
|
||||
glPixelStorei(GL_UNPACK_ALIGNMENT, previous_unpack_alignment);
|
||||
glBindTexture(GL_TEXTURE_2D, static_cast<GLuint>(previous_texture));
|
||||
gl_pending_dirty = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (state == slint::RenderingState::RenderingTeardown) {
|
||||
if (opengl_renderer) {
|
||||
opengl_renderer->Teardown();
|
||||
}
|
||||
if (gl_texture != 0) {
|
||||
const GLuint texture = gl_texture;
|
||||
glDeleteTextures(1, &texture);
|
||||
}
|
||||
gl_texture = 0;
|
||||
gl_texture_size = {};
|
||||
gl_image_size = {};
|
||||
gl_pending_size = {};
|
||||
gl_pending_dirty = false;
|
||||
gl_pending_frame.clear();
|
||||
}
|
||||
});
|
||||
|
||||
if (error.has_value()) {
|
||||
LOG_WARN("Slint OpenGL video renderer unavailable, using pixel buffers");
|
||||
} else {
|
||||
renderer.SetSelectedStream(CurrentState().selected_stream);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
};
|
||||
|
||||
SlintVideoPresenter::SlintVideoPresenter(VideoRenderer& renderer)
|
||||
: impl_(std::make_unique<Impl>(renderer)) {}
|
||||
|
||||
SlintVideoPresenter::~SlintVideoPresenter() = default;
|
||||
|
||||
void SlintVideoPresenter::PrepareWindow(
|
||||
slint::ComponentHandle<ui::StreamWindow>& stream,
|
||||
StateProvider state_provider, DirtyCallback dirty_callback) {
|
||||
impl_->stream = &stream;
|
||||
impl_->state_provider = std::move(state_provider);
|
||||
impl_->dirty_callback = std::move(dirty_callback);
|
||||
#if defined(__APPLE__)
|
||||
stream->set_native_video_enabled(false);
|
||||
impl_->attachment_attempts = kMetalAttachmentAttemptLimit;
|
||||
impl_->skip_attachment_once = true;
|
||||
#else
|
||||
impl_->ConfigureOpenGlNotifier();
|
||||
#endif
|
||||
}
|
||||
|
||||
bool SlintVideoPresenter::EnsureAttached() {
|
||||
#if defined(__APPLE__)
|
||||
if (!impl_->stream || impl_->attachment_attempts <= 0) {
|
||||
return false;
|
||||
}
|
||||
if (impl_->skip_attachment_once) {
|
||||
impl_->skip_attachment_once = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
auto* metal_renderer =
|
||||
dynamic_cast<MacMetalVideoRenderer*>(&impl_->renderer);
|
||||
if (!metal_renderer) {
|
||||
impl_->attachment_attempts = 0;
|
||||
(*impl_->stream)->set_native_video_enabled(false);
|
||||
return false;
|
||||
}
|
||||
if (!metal_renderer->IsReady()) {
|
||||
impl_->attachment_attempts = 0;
|
||||
(*impl_->stream)->set_native_video_enabled(false);
|
||||
return false;
|
||||
}
|
||||
if (metal_renderer->IsAttached()) {
|
||||
impl_->attachment_attempts = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (void* view = (*impl_->stream)->window().appkit_view()) {
|
||||
(*impl_->stream)->set_native_video_enabled(true);
|
||||
if (metal_renderer->Attach(view)) {
|
||||
impl_->attachment_attempts = 0;
|
||||
const SurfaceState surface = impl_->CurrentState();
|
||||
impl_->displayed_frame_sequence.erase(surface.selected_stream);
|
||||
impl_->seeded_frame_sequence.erase(surface.selected_stream);
|
||||
if (metal_renderer->SetSelectedStream(surface.selected_stream)) {
|
||||
impl_->MarkDirty();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
(*impl_->stream)->set_native_video_enabled(false);
|
||||
}
|
||||
|
||||
if (--impl_->attachment_attempts == 0) {
|
||||
(*impl_->stream)->set_native_video_enabled(false);
|
||||
LOG_WARN("Unable to attach the Metal video surface; using CPU rendering");
|
||||
}
|
||||
#endif
|
||||
return false;
|
||||
}
|
||||
|
||||
bool SlintVideoPresenter::NeedsRedraw() const {
|
||||
#if defined(__APPLE__)
|
||||
const auto* metal_renderer =
|
||||
dynamic_cast<const MacMetalVideoRenderer*>(&impl_->renderer);
|
||||
return metal_renderer && metal_renderer->IsActive() &&
|
||||
metal_renderer->NeedsSurfaceRedraw();
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool SlintVideoPresenter::SelectStream(std::string remote_id) {
|
||||
const bool changed = impl_->renderer.SetSelectedStream(remote_id);
|
||||
if (changed && !remote_id.empty()) {
|
||||
impl_->seeded_frame_sequence.erase(remote_id);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
SlintVideoPresenter::PresentResult SlintVideoPresenter::Present(
|
||||
const Frame& frame) {
|
||||
PresentResult result;
|
||||
if (!impl_->stream || frame.remote_id.empty()) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (impl_->renderer.IsActive() && frame.nv12 && frame.width > 0 &&
|
||||
frame.height > 0 && frame.sequence > 0 &&
|
||||
impl_->seeded_frame_sequence[frame.remote_id] != frame.sequence) {
|
||||
const auto submit_result = impl_->renderer.SubmitCachedNv12(
|
||||
frame.remote_id, frame.nv12->data(), frame.nv12->size(), frame.width,
|
||||
frame.height);
|
||||
if (submit_result == VideoRenderer::SubmitResult::submitted ||
|
||||
submit_result == VideoRenderer::SubmitResult::dropped) {
|
||||
impl_->seeded_frame_sequence[frame.remote_id] = frame.sequence;
|
||||
}
|
||||
}
|
||||
|
||||
#if defined(__APPLE__)
|
||||
auto* metal_renderer =
|
||||
dynamic_cast<MacMetalVideoRenderer*>(&impl_->renderer);
|
||||
if (metal_renderer && metal_renderer->IsActive()) {
|
||||
const SurfaceState surface = impl_->CurrentState();
|
||||
const double top_inset =
|
||||
!surface.fullscreen && surface.tab_count > 1 ? 30.0 : 0.0;
|
||||
const auto outcome = metal_renderer->RenderLatest(
|
||||
frame.remote_id, top_inset, !surface.fullscreen);
|
||||
if (outcome.result == VideoRenderer::RenderResult::rendered) {
|
||||
(*impl_->stream)->set_has_frame(true);
|
||||
(*impl_->stream)->set_receiving_text("");
|
||||
result.width = outcome.width;
|
||||
result.height = outcome.height;
|
||||
result.new_frame =
|
||||
impl_->MarkPresented(frame.remote_id, outcome.sequence);
|
||||
} else if (outcome.result == VideoRenderer::RenderResult::empty) {
|
||||
(*impl_->stream)->set_has_frame(false);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
#else
|
||||
auto* opengl_renderer =
|
||||
dynamic_cast<OpenGlVideoRenderer*>(&impl_->renderer);
|
||||
if (opengl_renderer && opengl_renderer->IsReady()) {
|
||||
opengl_renderer->SetSelectedStream(frame.remote_id);
|
||||
if (!(*impl_->stream)->get_native_video_enabled()) {
|
||||
(*impl_->stream)->set_native_video_enabled(true);
|
||||
}
|
||||
|
||||
const bool has_frame =
|
||||
frame.width > 0 && frame.height > 0 && frame.sequence > 0;
|
||||
(*impl_->stream)->set_has_frame(has_frame);
|
||||
if (has_frame) {
|
||||
(*impl_->stream)->set_receiving_text("");
|
||||
result.width = frame.width;
|
||||
result.height = frame.height;
|
||||
result.new_frame = impl_->MarkPresented(frame.remote_id, frame.sequence);
|
||||
}
|
||||
(*impl_->stream)->window().request_redraw();
|
||||
return result;
|
||||
}
|
||||
if ((*impl_->stream)->get_native_video_enabled()) {
|
||||
(*impl_->stream)->set_native_video_enabled(false);
|
||||
}
|
||||
#endif
|
||||
|
||||
const size_t nv12_size =
|
||||
static_cast<size_t>(frame.width) * frame.height * 3 / 2;
|
||||
if (!frame.nv12 || frame.width <= 0 || frame.height <= 0 ||
|
||||
frame.nv12->size() < nv12_size) {
|
||||
(*impl_->stream)->set_has_frame(false);
|
||||
return result;
|
||||
}
|
||||
if (impl_->displayed_frame_sequence[frame.remote_id] == frame.sequence) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const auto window_size = (*impl_->stream)->window().size();
|
||||
const VideoRenderSize render_size = FitVideoToRenderArea(
|
||||
frame.width, frame.height, static_cast<int>(window_size.width),
|
||||
static_cast<int>(window_size.height));
|
||||
const uint8_t* y_plane = frame.nv12->data();
|
||||
const uint8_t* uv_plane =
|
||||
frame.nv12->data() + static_cast<size_t>(frame.width) * frame.height;
|
||||
int output_width = frame.width;
|
||||
int output_height = frame.height;
|
||||
|
||||
if (render_size.width > 0 && render_size.height > 0 &&
|
||||
(render_size.width != frame.width ||
|
||||
render_size.height != frame.height)) {
|
||||
const size_t scaled_nv12_size =
|
||||
static_cast<size_t>(render_size.width) * render_size.height * 3 / 2;
|
||||
impl_->scaled_video_frame.resize(scaled_nv12_size);
|
||||
uint8_t* scaled_y = impl_->scaled_video_frame.data();
|
||||
uint8_t* scaled_uv =
|
||||
scaled_y + static_cast<size_t>(render_size.width) * render_size.height;
|
||||
if (ScaleNv12ViaI420(y_plane, frame.width, uv_plane, frame.width,
|
||||
frame.width, frame.height, scaled_y, render_size.width,
|
||||
scaled_uv, render_size.width, render_size.width,
|
||||
render_size.height, libyuv::kFilterBox,
|
||||
&impl_->scaled_video_frame_scratch) == 0) {
|
||||
y_plane = scaled_y;
|
||||
uv_plane = scaled_uv;
|
||||
output_width = render_size.width;
|
||||
output_height = render_size.height;
|
||||
}
|
||||
}
|
||||
|
||||
#if !defined(__APPLE__)
|
||||
uint32_t texture_id = 0;
|
||||
{
|
||||
std::lock_guard lock(impl_->gl_mutex);
|
||||
texture_id = impl_->gl_texture;
|
||||
}
|
||||
if (texture_id != 0) {
|
||||
impl_->gl_conversion_frame.resize(static_cast<size_t>(output_width) *
|
||||
output_height * 4);
|
||||
if (libyuv::NV12ToABGR(y_plane, output_width, uv_plane, output_width,
|
||||
impl_->gl_conversion_frame.data(), output_width * 4,
|
||||
output_width, output_height) != 0) {
|
||||
return result;
|
||||
}
|
||||
{
|
||||
std::lock_guard lock(impl_->gl_mutex);
|
||||
if (impl_->gl_texture == 0) {
|
||||
return result;
|
||||
}
|
||||
texture_id = impl_->gl_texture;
|
||||
impl_->gl_pending_frame.swap(impl_->gl_conversion_frame);
|
||||
impl_->gl_pending_size = {output_width, output_height};
|
||||
impl_->gl_pending_dirty = true;
|
||||
}
|
||||
const VideoRenderSize output_size{output_width, output_height};
|
||||
if (impl_->gl_image_size != output_size) {
|
||||
(*impl_->stream)
|
||||
->set_frame(slint::Image::create_from_borrowed_gl_2d_rgba_texture(
|
||||
texture_id,
|
||||
slint::Size<uint32_t>{static_cast<uint32_t>(output_width),
|
||||
static_cast<uint32_t>(output_height)}));
|
||||
impl_->gl_image_size = output_size;
|
||||
}
|
||||
(*impl_->stream)->set_has_frame(true);
|
||||
(*impl_->stream)->set_receiving_text("");
|
||||
(*impl_->stream)->window().request_redraw();
|
||||
result.width = frame.width;
|
||||
result.height = frame.height;
|
||||
result.new_frame = impl_->MarkPresented(frame.remote_id, frame.sequence);
|
||||
return result;
|
||||
}
|
||||
#endif
|
||||
|
||||
slint::SharedPixelBuffer<slint::Rgb8Pixel> pixels(output_width,
|
||||
output_height);
|
||||
if (libyuv::NV12ToRAW(y_plane, output_width, uv_plane, output_width,
|
||||
reinterpret_cast<uint8_t*>(pixels.begin()),
|
||||
output_width * 3, output_width, output_height) != 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
(*impl_->stream)->set_frame(slint::Image(std::move(pixels)));
|
||||
(*impl_->stream)->set_has_frame(true);
|
||||
(*impl_->stream)->set_receiving_text("");
|
||||
result.width = frame.width;
|
||||
result.height = frame.height;
|
||||
result.new_frame = impl_->MarkPresented(frame.remote_id, frame.sequence);
|
||||
return result;
|
||||
}
|
||||
|
||||
void SlintVideoPresenter::ForgetStream(const std::string& remote_id) {
|
||||
impl_->displayed_frame_sequence.erase(remote_id);
|
||||
impl_->seeded_frame_sequence.erase(remote_id);
|
||||
}
|
||||
|
||||
void SlintVideoPresenter::Detach() {
|
||||
impl_->renderer.SetSelectedStream({});
|
||||
#if defined(__APPLE__)
|
||||
if (auto* metal_renderer =
|
||||
dynamic_cast<MacMetalVideoRenderer*>(&impl_->renderer)) {
|
||||
metal_renderer->Detach();
|
||||
}
|
||||
impl_->attachment_attempts = 0;
|
||||
impl_->skip_attachment_once = false;
|
||||
#endif
|
||||
impl_->stream = nullptr;
|
||||
impl_->state_provider = {};
|
||||
impl_->dirty_callback = {};
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
@@ -1,75 +0,0 @@
|
||||
#ifndef CROSSDESK_GUI_RENDERING_SLINT_VIDEO_PRESENTER_H_
|
||||
#define CROSSDESK_GUI_RENDERING_SLINT_VIDEO_PRESENTER_H_
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "crossdesk_ui.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
class VideoRenderer;
|
||||
|
||||
// Owns the bridge between decoded NV12 frames, the selected native graphics
|
||||
// backend and Slint's StreamWindow. Platform macros and graphics-API lifecycle
|
||||
// details are intentionally contained in the implementation file.
|
||||
class SlintVideoPresenter {
|
||||
public:
|
||||
struct SurfaceState {
|
||||
std::string selected_stream;
|
||||
bool fullscreen = false;
|
||||
size_t tab_count = 0;
|
||||
};
|
||||
|
||||
struct Frame {
|
||||
std::string remote_id;
|
||||
std::shared_ptr<std::vector<unsigned char>> nv12;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
uint64_t sequence = 0;
|
||||
};
|
||||
|
||||
struct PresentResult {
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
bool new_frame = false;
|
||||
};
|
||||
|
||||
using StateProvider = std::function<SurfaceState()>;
|
||||
using DirtyCallback = std::function<void()>;
|
||||
|
||||
explicit SlintVideoPresenter(VideoRenderer& renderer);
|
||||
~SlintVideoPresenter();
|
||||
|
||||
SlintVideoPresenter(const SlintVideoPresenter&) = delete;
|
||||
SlintVideoPresenter& operator=(const SlintVideoPresenter&) = delete;
|
||||
|
||||
// Called before the window is shown. OpenGL installs Slint's rendering
|
||||
// notifier here; macOS deliberately defers native view attachment until a
|
||||
// later UI pass, after AppKit has committed the ordinary window style.
|
||||
void PrepareWindow(slint::ComponentHandle<ui::StreamWindow>& stream,
|
||||
StateProvider state_provider,
|
||||
DirtyCallback dirty_callback);
|
||||
|
||||
// Returns true only when a deferred native surface became attached during
|
||||
// this call, allowing the caller to seed it from a cached frame once.
|
||||
bool EnsureAttached();
|
||||
bool NeedsRedraw() const;
|
||||
|
||||
bool SelectStream(std::string remote_id);
|
||||
PresentResult Present(const Frame& frame);
|
||||
void ForgetStream(const std::string& remote_id);
|
||||
void Detach();
|
||||
|
||||
private:
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif // CROSSDESK_GUI_RENDERING_SLINT_VIDEO_PRESENTER_H_
|
||||
@@ -1,67 +0,0 @@
|
||||
#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,136 +0,0 @@
|
||||
#include "runtime/cursor_state_provider.h"
|
||||
|
||||
#include <remote_action.h>
|
||||
|
||||
#if defined(_WIN32)
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include "runtime/cursor_position.h"
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
|
||||
bool IsSystemCursor(HCURSOR cursor, LPCWSTR resource) {
|
||||
return cursor != nullptr && cursor == LoadCursorW(nullptr, resource);
|
||||
}
|
||||
|
||||
RemoteCursorShape ShapeFromWindowsCursor(HCURSOR cursor) {
|
||||
if (IsSystemCursor(cursor, IDC_HELP)) return RemoteCursorShape::help;
|
||||
if (IsSystemCursor(cursor, IDC_HAND)) return RemoteCursorShape::pointer;
|
||||
if (IsSystemCursor(cursor, IDC_APPSTARTING))
|
||||
return RemoteCursorShape::progress;
|
||||
if (IsSystemCursor(cursor, IDC_WAIT)) return RemoteCursorShape::wait;
|
||||
if (IsSystemCursor(cursor, IDC_CROSS)) return RemoteCursorShape::crosshair;
|
||||
if (IsSystemCursor(cursor, IDC_IBEAM)) return RemoteCursorShape::text;
|
||||
if (IsSystemCursor(cursor, IDC_NO))
|
||||
return RemoteCursorShape::not_allowed;
|
||||
if (IsSystemCursor(cursor, IDC_SIZEALL)) return RemoteCursorShape::move;
|
||||
if (IsSystemCursor(cursor, IDC_SIZEWE)) return RemoteCursorShape::ew_resize;
|
||||
if (IsSystemCursor(cursor, IDC_SIZENS)) return RemoteCursorShape::ns_resize;
|
||||
if (IsSystemCursor(cursor, IDC_SIZENESW))
|
||||
return RemoteCursorShape::nesw_resize;
|
||||
if (IsSystemCursor(cursor, IDC_SIZENWSE))
|
||||
return RemoteCursorShape::nwse_resize;
|
||||
if (IsSystemCursor(cursor, IDC_UPARROW)) return RemoteCursorShape::n_resize;
|
||||
return RemoteCursorShape::default_cursor;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
struct CursorStateProvider::Impl {};
|
||||
|
||||
CursorStateProvider::CursorStateProvider() : impl_(std::make_unique<Impl>()) {}
|
||||
CursorStateProvider::~CursorStateProvider() = default;
|
||||
|
||||
bool CursorStateProvider::Sample(const std::vector<DisplayInfo>& displays,
|
||||
int preferred_display, CursorState* state) {
|
||||
if (!state) return false;
|
||||
|
||||
CURSORINFO info{};
|
||||
info.cbSize = sizeof(info);
|
||||
if (!GetCursorInfo(&info)) return false;
|
||||
|
||||
state->seq = 0;
|
||||
state->visible = (info.flags & CURSOR_SHOWING) != 0;
|
||||
state->shape = state->visible ? ShapeFromWindowsCursor(info.hCursor)
|
||||
: RemoteCursorShape::none;
|
||||
NormalizeCursorPosition(info.ptScreenPos.x, info.ptScreenPos.y, displays,
|
||||
preferred_display, 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,33 +0,0 @@
|
||||
#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,198 +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);
|
||||
|
||||
#if defined(_WIN32)
|
||||
const XWindowsVideoFrame* GetWindowsNativeFrame(const XVideoFrame* frame) {
|
||||
if (!frame ||
|
||||
frame->native_handle_type != XVideoFrameNativeHandleWindowsNv12 ||
|
||||
!frame->native_handle) {
|
||||
return nullptr;
|
||||
}
|
||||
const auto* native =
|
||||
static_cast<const XWindowsVideoFrame*>(frame->native_handle);
|
||||
return native->struct_size >=
|
||||
static_cast<uint32_t>(sizeof(XWindowsVideoFrame)) &&
|
||||
native->owner && native->copy_to_cpu
|
||||
? native
|
||||
: nullptr;
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace
|
||||
|
||||
void PeerEventHandler::OnReceiveVideoBuffer(
|
||||
const XVideoFrame* 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);
|
||||
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr;
|
||||
if (!runtime) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::string remote_id(user_id, user_id_size);
|
||||
// std::shared_lock lock(runtime->remote_sessions_mutex_);
|
||||
if (runtime->remote_sessions_.find(remote_id) ==
|
||||
runtime->remote_sessions_.end()) {
|
||||
return;
|
||||
}
|
||||
GuiRuntime::RemoteSession* props =
|
||||
runtime->remote_sessions_.find(remote_id)->second.get();
|
||||
|
||||
if (props->connection_established_) {
|
||||
bool background_snapshot_only = false;
|
||||
auto* native_renderer = runtime->video_renderer_.get();
|
||||
if (native_renderer && native_renderer->IsActive()) {
|
||||
VideoRenderer::SubmitResult submit_result =
|
||||
VideoRenderer::SubmitResult::failed;
|
||||
#if defined(_WIN32)
|
||||
if (GetWindowsNativeFrame(video_frame)) {
|
||||
submit_result =
|
||||
native_renderer->SubmitNativeFrame(remote_id, *video_frame);
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
submit_result = native_renderer->SubmitNv12(
|
||||
remote_id, reinterpret_cast<const uint8_t*>(video_frame->data),
|
||||
video_frame->size, video_frame->width, video_frame->height);
|
||||
}
|
||||
if (submit_result == VideoRenderer::SubmitResult::submitted) {
|
||||
std::lock_guard<std::mutex> lock(props->video_frame_mutex_);
|
||||
const bool size_changed = (props->video_width_ != video_frame->width) ||
|
||||
(props->video_height_ != video_frame->height);
|
||||
if (size_changed) {
|
||||
props->render_rect_dirty_ = true;
|
||||
}
|
||||
props->video_width_ = video_frame->width;
|
||||
props->video_height_ = video_frame->height;
|
||||
props->video_size_ = video_frame->size;
|
||||
// Once the native renderer owns the current stream, do not leave an
|
||||
// older CPU frame ahead of the renderer's close snapshot.
|
||||
props->front_frame_.reset();
|
||||
props->back_frame_.reset();
|
||||
props->thumbnail_frame_.reset();
|
||||
props->thumbnail_width_ = 0;
|
||||
props->thumbnail_height_ = 0;
|
||||
props->background_snapshot_time_ = {};
|
||||
++props->video_frame_sequence_;
|
||||
props->streaming_ = true;
|
||||
runtime->video_frame_dirty_.store(true, std::memory_order_release);
|
||||
return;
|
||||
}
|
||||
if (submit_result == VideoRenderer::SubmitResult::dropped ||
|
||||
submit_result == VideoRenderer::SubmitResult::failed) {
|
||||
// Keep presenting the last native frame. A later decoded frame can
|
||||
// reuse the renderer without changing ownership mid-window.
|
||||
props->streaming_ = true;
|
||||
return;
|
||||
}
|
||||
if (submit_result == VideoRenderer::SubmitResult::not_selected) {
|
||||
background_snapshot_only = true;
|
||||
}
|
||||
}
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(props->video_frame_mutex_);
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
if (background_snapshot_only && props->thumbnail_frame_ &&
|
||||
!props->thumbnail_frame_->empty() &&
|
||||
props->background_snapshot_time_ !=
|
||||
std::chrono::steady_clock::time_point{} &&
|
||||
now - props->background_snapshot_time_ <
|
||||
kBackgroundSnapshotInterval) {
|
||||
props->streaming_ = true;
|
||||
return;
|
||||
}
|
||||
|
||||
size_t frame_size = video_frame->size;
|
||||
#if defined(_WIN32)
|
||||
const XWindowsVideoFrame* windows_native_frame =
|
||||
GetWindowsNativeFrame(video_frame);
|
||||
if (windows_native_frame) {
|
||||
frame_size = static_cast<size_t>(windows_native_frame->width) *
|
||||
windows_native_frame->height * 3U / 2U;
|
||||
}
|
||||
#endif
|
||||
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 defined(_WIN32)
|
||||
if (windows_native_frame) {
|
||||
if (windows_native_frame->copy_to_cpu(
|
||||
windows_native_frame->owner, props->back_frame_->data(),
|
||||
props->back_frame_->size()) != 0) {
|
||||
return;
|
||||
}
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
if (!video_frame->data || video_frame->size < frame_size) {
|
||||
return;
|
||||
}
|
||||
std::memcpy(props->back_frame_->data(), video_frame->data, frame_size);
|
||||
}
|
||||
|
||||
const bool size_changed = (props->video_width_ != video_frame->width) ||
|
||||
(props->video_height_ != video_frame->height);
|
||||
if (size_changed) {
|
||||
props->render_rect_dirty_ = true;
|
||||
}
|
||||
|
||||
props->video_width_ = video_frame->width;
|
||||
props->video_height_ = video_frame->height;
|
||||
props->video_size_ = frame_size;
|
||||
|
||||
props->front_frame_.swap(props->back_frame_);
|
||||
props->thumbnail_frame_ = props->front_frame_;
|
||||
props->thumbnail_width_ = video_frame->width;
|
||||
props->thumbnail_height_ = video_frame->height;
|
||||
if (background_snapshot_only) {
|
||||
props->background_snapshot_time_ = now;
|
||||
}
|
||||
++props->video_frame_sequence_;
|
||||
}
|
||||
|
||||
props->streaming_ = true;
|
||||
if (background_snapshot_only) {
|
||||
return;
|
||||
}
|
||||
runtime->video_frame_dirty_.store(true, std::memory_order_release);
|
||||
}
|
||||
}
|
||||
|
||||
void PeerEventHandler::OnReceiveAudioBuffer(
|
||||
const char* data, size_t size, const char* user_id, size_t user_id_size,
|
||||
const char* src_id, size_t src_id_size, void* user_data) {
|
||||
auto* handler = static_cast<PeerEventHandler*>(user_data);
|
||||
GuiRuntime* runtime = handler ? &handler->owner_ : nullptr;
|
||||
if (!runtime) {
|
||||
return;
|
||||
}
|
||||
|
||||
runtime->audio_buffer_fresh_ = true;
|
||||
|
||||
runtime->devices_.PushAudio(data, size);
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
@@ -1,165 +0,0 @@
|
||||
#ifndef CROSSDESK_GUI_UI_UI_LOCALIZATION_H_
|
||||
#define CROSSDESK_GUI_UI_UI_LOCALIZATION_H_
|
||||
|
||||
#include <slint.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "crossdesk_ui.h"
|
||||
#include "localization.h"
|
||||
|
||||
namespace crossdesk::ui_localization {
|
||||
|
||||
inline slint::SharedString Text(const std::string& value) {
|
||||
return slint::SharedString(value);
|
||||
}
|
||||
|
||||
inline int ApplyMainWindowStrings(
|
||||
const slint::ComponentHandle<ui::MainWindow>& window,
|
||||
int language_index) {
|
||||
const int language =
|
||||
localization::detail::ClampLanguageIndex(language_index);
|
||||
auto& strings = window->global<ui::UiStrings>();
|
||||
strings.set_local_desktop(Text(localization::local_desktop[language]));
|
||||
strings.set_remote_desktop(Text(localization::remote_desktop[language]));
|
||||
strings.set_recent_connections(
|
||||
Text(localization::recent_connections[language]));
|
||||
strings.set_local_id(Text(localization::local_id[language]));
|
||||
strings.set_device_name(Text(localization::device_name[language]));
|
||||
strings.set_remote_id(Text(localization::remote_id[language]));
|
||||
strings.set_online(Text(localization::online[language]));
|
||||
strings.set_offline(Text(localization::offline[language]));
|
||||
strings.set_password(Text(localization::password[language]));
|
||||
strings.set_copied(
|
||||
Text(localization::local_id_copied_to_clipboard[language]));
|
||||
strings.set_connect(Text(localization::connect[language]));
|
||||
strings.set_settings(Text(localization::settings[language]));
|
||||
strings.set_about(Text(localization::about[language]));
|
||||
strings.set_ok(Text(localization::ok[language]));
|
||||
strings.set_cancel(Text(localization::cancel[language]));
|
||||
strings.set_new_password(Text(localization::new_password[language]));
|
||||
strings.set_invalid_password(Text(localization::max_password_len[language]));
|
||||
strings.set_edit_alias(
|
||||
Text(localization::input_connection_alias[language]));
|
||||
strings.set_delete_connection(
|
||||
Text(localization::delete_connection[language]));
|
||||
strings.set_confirm_delete(
|
||||
Text(localization::confirm_delete_connection[language]));
|
||||
strings.set_language(Text(localization::language[language]));
|
||||
strings.set_video_quality(Text(localization::video_quality[language]));
|
||||
strings.set_frame_rate(Text(localization::video_frame_rate[language]));
|
||||
strings.set_adaptation_policy(
|
||||
Text(localization::video_adaptation_policy[language]));
|
||||
strings.set_priority_frame_rate(
|
||||
Text(localization::video_priority_frame_rate[language]));
|
||||
strings.set_priority_quality(
|
||||
Text(localization::video_priority_quality[language]));
|
||||
strings.set_priority_balanced(
|
||||
Text(localization::video_priority_balanced[language]));
|
||||
strings.set_codec(Text(localization::video_encode_format[language]));
|
||||
strings.set_hardware_codec(
|
||||
Text(localization::enable_hardware_video_codec[language]));
|
||||
strings.set_turn_relay(Text(localization::enable_turn[language]));
|
||||
strings.set_srtp(Text(localization::enable_srtp[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_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_coturn_port(
|
||||
Text(localization::self_hosted_server_coturn_server_port[language]));
|
||||
strings.set_version(Text(localization::version[language]));
|
||||
strings.set_signal_connected(Text(localization::signal_connected[language]));
|
||||
strings.set_signal_disconnected(
|
||||
Text(localization::signal_disconnected[language]));
|
||||
strings.set_tls_error(Text(localization::signal_tls_cert_error[language]));
|
||||
strings.set_update_available(
|
||||
Text(localization::new_version_available[language]));
|
||||
strings.set_release_notes(Text(localization::release_notes[language]));
|
||||
strings.set_download(Text(localization::update[language]));
|
||||
strings.set_input_password(Text(localization::input_password[language]));
|
||||
strings.set_reinput_password(Text(localization::reinput_password[language]));
|
||||
strings.set_remember_password(
|
||||
Text(localization::remember_password[language]));
|
||||
strings.set_validate_password(
|
||||
Text(localization::validate_password[language]));
|
||||
strings.set_request_permissions(
|
||||
Text(localization::request_permissions[language]));
|
||||
strings.set_permission_required(
|
||||
Text(localization::permission_required_message[language]));
|
||||
strings.set_screen_recording_permission(
|
||||
Text(localization::screen_recording_permission[language]));
|
||||
strings.set_accessibility_permission(
|
||||
Text(localization::accessibility_permission[language]));
|
||||
strings.set_service_setup_title(
|
||||
Text(localization::windows_service_setup_title[language]));
|
||||
strings.set_service_setup_message(
|
||||
Text(localization::windows_service_setup_message[language]));
|
||||
strings.set_service_settings_label(
|
||||
Text(localization::windows_service_settings_label[language]));
|
||||
strings.set_install_service(
|
||||
Text(localization::install_windows_service[language]));
|
||||
strings.set_service_installed(
|
||||
Text(localization::windows_service_installed[language]));
|
||||
strings.set_do_not_remind(
|
||||
Text(localization::do_not_remind_again[language]));
|
||||
strings.set_notification(Text(localization::notification[language]));
|
||||
strings.set_service_suppressed_message(
|
||||
Text(localization::windows_service_prompt_suppressed_message[language]));
|
||||
strings.set_quality_low(Text(localization::video_quality_low[language]));
|
||||
strings.set_quality_medium(
|
||||
Text(localization::video_quality_medium[language]));
|
||||
strings.set_quality_high(Text(localization::video_quality_high[language]));
|
||||
strings.set_codec_h264(Text(localization::h264[language]));
|
||||
strings.set_codec_av1(Text(localization::av1[language]));
|
||||
strings.set_self_hosted_settings(
|
||||
Text(localization::self_hosted_server_settings[language]));
|
||||
strings.set_access_website(Text(localization::access_website[language]));
|
||||
strings.set_release_date_label(Text(localization::release_date[language]));
|
||||
strings.set_later(Text(localization::cancel[language]));
|
||||
return language;
|
||||
}
|
||||
|
||||
template <typename Window>
|
||||
inline int ApplyStreamWindowStrings(
|
||||
const slint::ComponentHandle<Window>& window, int language_index) {
|
||||
const int language =
|
||||
localization::detail::ClampLanguageIndex(language_index);
|
||||
auto& strings = window->template global<ui::StreamStrings>();
|
||||
strings.set_select_display(Text(localization::select_display[language]));
|
||||
strings.set_send_shortcut(Text(localization::send_shortcut[language]));
|
||||
strings.set_control_mouse(Text(localization::control_mouse[language]));
|
||||
strings.set_release_mouse(Text(localization::release_mouse[language]));
|
||||
strings.set_audio(Text(localization::audio_capture[language]));
|
||||
strings.set_mute(Text(localization::mute[language]));
|
||||
strings.set_select_file(Text(localization::select_file[language]));
|
||||
strings.set_show_stats(
|
||||
Text(localization::show_net_traffic_stats[language]));
|
||||
strings.set_hide_stats(
|
||||
Text(localization::hide_net_traffic_stats[language]));
|
||||
strings.set_fullscreen(Text(localization::fullscreen[language]));
|
||||
strings.set_exit_fullscreen(Text(localization::exit_fullscreen[language]));
|
||||
strings.set_disconnect(Text(localization::disconnect[language]));
|
||||
strings.set_file_transfer(
|
||||
Text(localization::file_transfer_progress[language]));
|
||||
strings.set_expand_control(Text(localization::expand_control_bar[language]));
|
||||
strings.set_collapse_control(
|
||||
Text(localization::collapse_control_bar[language]));
|
||||
strings.set_stats_in(Text(localization::in[language]));
|
||||
strings.set_stats_out(Text(localization::out[language]));
|
||||
strings.set_stats_loss_rate(Text(localization::loss_rate[language]));
|
||||
strings.set_stats_resolution(Text(localization::resolution[language]));
|
||||
strings.set_stats_connection_mode(
|
||||
Text(localization::connection_mode[language]));
|
||||
return language;
|
||||
}
|
||||
|
||||
} // namespace crossdesk::ui_localization
|
||||
|
||||
#endif // CROSSDESK_GUI_UI_UI_LOCALIZATION_H_
|
||||
@@ -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,78 +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 either ref-counted Windows native NV12 frames or packed CPU
|
||||
// NV12 fallback frames from its decode callback thread. CUDA-backed frames use
|
||||
// CUDA/OpenGL interop; CPU-backed frames are retained without an intermediate
|
||||
// queue copy. The Slint UI thread updates Y and UV textures and performs color
|
||||
// conversion and scaling before Slint draws its overlay.
|
||||
class OpenGlVideoRenderer final : public VideoRenderer {
|
||||
public:
|
||||
OpenGlVideoRenderer();
|
||||
~OpenGlVideoRenderer() override;
|
||||
|
||||
OpenGlVideoRenderer(const OpenGlVideoRenderer &) = delete;
|
||||
OpenGlVideoRenderer &operator=(const OpenGlVideoRenderer &) = delete;
|
||||
|
||||
// These methods must run while Slint's OpenGL context is current, from the
|
||||
// RenderingSetup and RenderingTeardown notifier states respectively.
|
||||
bool Setup();
|
||||
void Teardown();
|
||||
bool IsReady() const override;
|
||||
bool IsActive() const override;
|
||||
|
||||
// Keeps Slint's native surface on a display-driven redraw loop while decoded
|
||||
// frames are arriving. This avoids timer/vsync phase drift that otherwise
|
||||
// causes every second redraw request to be coalesced on Windows.
|
||||
bool ShouldContinueRendering() const;
|
||||
|
||||
// Only the selected stream is queued for upload. Frames for other tabs are
|
||||
// retained by GuiRuntime at a throttled rate for thumbnail/tab restoration.
|
||||
bool SetSelectedStream(std::string remote_id) override;
|
||||
void DiscardStream(std::string_view remote_id) override;
|
||||
|
||||
// Thread-safe; called from MiniRTC's decode callback thread.
|
||||
SubmitResult SubmitNv12(std::string_view remote_id, const uint8_t *data,
|
||||
size_t size, int width, int height) override;
|
||||
SubmitResult SubmitCachedNv12(std::string_view remote_id, const uint8_t *data,
|
||||
size_t size, int width, int height) override;
|
||||
SubmitResult SubmitNativeFrame(std::string_view remote_id,
|
||||
const XVideoFrame &frame) override;
|
||||
|
||||
// Draws below Slint while its OpenGL context is current. All dimensions are
|
||||
// physical pixels in the window client area. corner_radius_pixels clips the
|
||||
// underlay to Slint's custom rounded window surface.
|
||||
RenderOutcome RenderLatest(std::string_view remote_id, int target_width,
|
||||
int target_height, int top_inset_pixels,
|
||||
int corner_radius_pixels);
|
||||
|
||||
// Used during disconnect cleanup so thumbnail generation does not require a
|
||||
// second per-frame CPU copy during normal playback.
|
||||
bool CopyLatestNv12(std::string_view remote_id,
|
||||
std::vector<unsigned char> *output, int *width,
|
||||
int *height) const override;
|
||||
|
||||
private:
|
||||
SubmitResult SubmitNv12Internal(std::string_view remote_id,
|
||||
const uint8_t *data, size_t size, int width,
|
||||
int height, bool replace_pending);
|
||||
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif // CROSSDESK_GUI_PLATFORM_OPENGL_VIDEO_RENDERER_H_
|
||||
@@ -1,42 +0,0 @@
|
||||
#include "runtime/gui_runtime.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
void GuiRuntime::ResetRemoteServiceStatus(RemoteSession& props) {
|
||||
props.remote_service_status_received_ = false;
|
||||
props.remote_service_available_ = false;
|
||||
props.remote_interactive_stage_.clear();
|
||||
}
|
||||
|
||||
void GuiRuntime::ApplyRemoteServiceStatus(RemoteSession& props,
|
||||
const ServiceStatus& status) {
|
||||
props.remote_service_status_received_ = true;
|
||||
props.remote_service_available_ = status.available;
|
||||
props.remote_interactive_stage_ = status.interactive_stage;
|
||||
}
|
||||
|
||||
GuiRuntime::RemoteUnlockState GuiRuntime::GetRemoteUnlockState(
|
||||
const RemoteSession& props) const {
|
||||
if (!props.remote_service_status_received_) {
|
||||
return RemoteUnlockState::none;
|
||||
}
|
||||
if (!props.remote_service_available_) {
|
||||
return RemoteUnlockState::service_unavailable;
|
||||
}
|
||||
if (props.remote_interactive_stage_ == "credential-ui") {
|
||||
return RemoteUnlockState::credential_ui;
|
||||
}
|
||||
if (props.remote_interactive_stage_ == "lock-screen") {
|
||||
return RemoteUnlockState::lock_screen;
|
||||
}
|
||||
if (props.remote_interactive_stage_ == "secure-desktop") {
|
||||
return RemoteUnlockState::secure_desktop;
|
||||
}
|
||||
return RemoteUnlockState::none;
|
||||
}
|
||||
|
||||
#if !defined(_WIN32)
|
||||
void GuiRuntime::HandleWindowsServiceIntegration() {}
|
||||
#endif
|
||||
|
||||
} // namespace crossdesk
|
||||
@@ -1,12 +0,0 @@
|
||||
#include <memory>
|
||||
|
||||
#include "platform/common/gui/opengl_video_renderer.h"
|
||||
#include "platform/video_renderer.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
std::unique_ptr<VideoRenderer> CreateVideoRenderer() {
|
||||
return std::make_unique<OpenGlVideoRenderer>();
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
@@ -1,56 +0,0 @@
|
||||
#include "platform/daemon_backend.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <signal.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <thread>
|
||||
|
||||
namespace crossdesk::platform {
|
||||
|
||||
volatile std::sig_atomic_t g_daemon_stop_requested = 0;
|
||||
|
||||
void ResetDaemonStop() { g_daemon_stop_requested = 0; }
|
||||
|
||||
void RequestDaemonStop() { g_daemon_stop_requested = 1; }
|
||||
|
||||
bool DaemonStopRequested() { return g_daemon_stop_requested != 0; }
|
||||
|
||||
DaemonChildResult RunDaemonChild(const std::string& executable_path,
|
||||
const std::function<bool()>& keep_running) {
|
||||
DaemonChildResult result;
|
||||
const pid_t child = fork();
|
||||
if (child == 0) {
|
||||
execl(executable_path.c_str(), executable_path.c_str(), "--child",
|
||||
nullptr);
|
||||
_exit(1);
|
||||
}
|
||||
if (child < 0) return result;
|
||||
|
||||
result.started = true;
|
||||
int status = 0;
|
||||
pid_t waited = -1;
|
||||
while (keep_running()) {
|
||||
waited = waitpid(child, &status, WNOHANG);
|
||||
if (waited == child || (waited < 0 && errno != EINTR)) break;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(200));
|
||||
}
|
||||
if (!keep_running() && waited != child) {
|
||||
kill(child, SIGTERM);
|
||||
waited = waitpid(child, &status, 0);
|
||||
}
|
||||
if (waited < 0) return result;
|
||||
|
||||
if (WIFEXITED(status)) {
|
||||
result.exit_code = WEXITSTATUS(status);
|
||||
result.normal_exit = result.exit_code == 0;
|
||||
} else if (WIFSIGNALED(status)) {
|
||||
result.terminated_by_signal = true;
|
||||
result.exit_code = WTERMSIG(status);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace crossdesk::platform
|
||||
@@ -1,29 +0,0 @@
|
||||
#ifndef CROSSDESK_PLATFORM_DAEMON_BACKEND_H_
|
||||
#define CROSSDESK_PLATFORM_DAEMON_BACKEND_H_
|
||||
|
||||
#include <csignal>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace crossdesk::platform {
|
||||
|
||||
extern volatile std::sig_atomic_t g_daemon_stop_requested;
|
||||
|
||||
struct DaemonChildResult {
|
||||
bool started = false;
|
||||
bool normal_exit = false;
|
||||
bool terminated_by_signal = false;
|
||||
int exit_code = 0;
|
||||
};
|
||||
|
||||
void ResetDaemonStop();
|
||||
void RequestDaemonStop();
|
||||
bool DaemonStopRequested();
|
||||
bool PrepareDaemon();
|
||||
std::string GetDaemonExecutablePath();
|
||||
DaemonChildResult RunDaemonChild(const std::string& executable_path,
|
||||
const std::function<bool()>& keep_running);
|
||||
|
||||
} // namespace crossdesk::platform
|
||||
|
||||
#endif // CROSSDESK_PLATFORM_DAEMON_BACKEND_H_
|
||||
@@ -1,65 +0,0 @@
|
||||
#include "platform/autostart_backend.h"
|
||||
|
||||
#include <limits.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
|
||||
namespace crossdesk::platform {
|
||||
namespace {
|
||||
|
||||
std::filesystem::path AutostartPath(const std::string& app_name) {
|
||||
const char* home = std::getenv("HOME");
|
||||
if (!home || !home[0]) return {};
|
||||
return std::filesystem::path(home) / ".config" / "autostart" /
|
||||
(app_name + ".desktop");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string GetAutostartExecutablePath() {
|
||||
char path[PATH_MAX] = {};
|
||||
const ssize_t count = readlink("/proc/self/exe", path, sizeof(path) - 1);
|
||||
return count > 0 ? std::string(path, static_cast<size_t>(count))
|
||||
: std::string();
|
||||
}
|
||||
|
||||
bool EnableAutostart(const std::string& app_name,
|
||||
const std::string& executable_path) {
|
||||
const std::filesystem::path path = AutostartPath(app_name);
|
||||
if (path.empty()) return false;
|
||||
|
||||
std::error_code error;
|
||||
std::filesystem::create_directories(path.parent_path(), error);
|
||||
if (error) return false;
|
||||
|
||||
std::ofstream file(path);
|
||||
if (!file.is_open()) return false;
|
||||
file << "[Desktop Entry]\n"
|
||||
<< "Type=Application\n"
|
||||
<< "Exec=" << executable_path << "\n"
|
||||
<< "Hidden=false\n"
|
||||
<< "NoDisplay=false\n"
|
||||
<< "X-GNOME-Autostart-enabled=true\n"
|
||||
<< "Terminal=false\n"
|
||||
<< "StartupNotify=false\n"
|
||||
<< "Name=" << app_name << "\n";
|
||||
file.close();
|
||||
return file.good();
|
||||
}
|
||||
|
||||
bool DisableAutostart(const std::string& app_name) {
|
||||
const std::filesystem::path path = AutostartPath(app_name);
|
||||
if (path.empty()) return false;
|
||||
std::error_code error;
|
||||
return std::filesystem::remove(path, error) && !error;
|
||||
}
|
||||
|
||||
bool IsAutostartEnabled(const std::string& app_name) {
|
||||
const std::filesystem::path path = AutostartPath(app_name);
|
||||
return !path.empty() && std::filesystem::is_regular_file(path);
|
||||
}
|
||||
|
||||
} // namespace crossdesk::platform
|
||||
@@ -1,202 +0,0 @@
|
||||
#include "clipboard.h"
|
||||
|
||||
#include <X11/Xatom.h>
|
||||
#include <X11/Xlib.h>
|
||||
#include <X11/extensions/Xfixes.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <climits>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#include "platform/clipboard_backend.h"
|
||||
#include "rd_log.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
std::string Clipboard::GetText() {
|
||||
Display* display = XOpenDisplay(nullptr);
|
||||
if (!display) {
|
||||
LOG_ERROR("Clipboard::GetText: failed to open X display");
|
||||
return {};
|
||||
}
|
||||
|
||||
Window owner = XGetSelectionOwner(display, XA_PRIMARY);
|
||||
if (owner == None) {
|
||||
owner =
|
||||
XGetSelectionOwner(display, XInternAtom(display, "CLIPBOARD", False));
|
||||
if (owner == None) {
|
||||
XCloseDisplay(display);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
Atom selection = XA_PRIMARY;
|
||||
Atom target = XInternAtom(display, "UTF8_STRING", False);
|
||||
if (target == None) target = XA_STRING;
|
||||
const Window window = XCreateSimpleWindow(
|
||||
display, DefaultRootWindow(display), 0, 0, 1, 1, 0, 0, 0);
|
||||
XSelectInput(display, window, PropertyChangeMask);
|
||||
XConvertSelection(display, selection, target, XA_PRIMARY, window,
|
||||
CurrentTime);
|
||||
|
||||
std::string result;
|
||||
bool done = false;
|
||||
while (!done) {
|
||||
XEvent event = {};
|
||||
XNextEvent(display, &event);
|
||||
if (event.type != SelectionNotify) continue;
|
||||
if (event.xselection.property == None) {
|
||||
if (selection == XA_PRIMARY) {
|
||||
selection = XInternAtom(display, "CLIPBOARD", False);
|
||||
XConvertSelection(display, selection, target, XA_PRIMARY, window,
|
||||
CurrentTime);
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
Atom actual_type = None;
|
||||
int actual_format = 0;
|
||||
unsigned long item_count = 0;
|
||||
unsigned long bytes_after = 0;
|
||||
unsigned char* data = nullptr;
|
||||
if (XGetWindowProperty(display, window, XA_PRIMARY, 0, LONG_MAX / 4,
|
||||
False, AnyPropertyType, &actual_type,
|
||||
&actual_format, &item_count, &bytes_after,
|
||||
&data) == Success) {
|
||||
if (data) {
|
||||
result.assign(reinterpret_cast<char*>(data), item_count);
|
||||
XFree(data);
|
||||
}
|
||||
done = true;
|
||||
}
|
||||
}
|
||||
|
||||
XDestroyWindow(display, window);
|
||||
XCloseDisplay(display);
|
||||
return result;
|
||||
}
|
||||
|
||||
bool Clipboard::SetText(const std::string& text) {
|
||||
Display* display = XOpenDisplay(nullptr);
|
||||
if (!display) {
|
||||
LOG_ERROR("Clipboard::SetText: failed to open X display");
|
||||
return false;
|
||||
}
|
||||
|
||||
const Window window = XCreateSimpleWindow(
|
||||
display, DefaultRootWindow(display), 0, 0, 1, 1, 0, 0, 0);
|
||||
const Atom clipboard = XInternAtom(display, "CLIPBOARD", False);
|
||||
const Atom utf8 = XInternAtom(display, "UTF8_STRING", False);
|
||||
const Atom targets = XInternAtom(display, "TARGETS", False);
|
||||
XSetSelectionOwner(display, clipboard, window, CurrentTime);
|
||||
if (XGetSelectionOwner(display, clipboard) != window) {
|
||||
XDestroyWindow(display, window);
|
||||
XCloseDisplay(display);
|
||||
return false;
|
||||
}
|
||||
|
||||
XChangeProperty(display, window, XA_PRIMARY, utf8, 8, PropModeReplace,
|
||||
reinterpret_cast<const unsigned char*>(text.data()),
|
||||
static_cast<int>(text.size()));
|
||||
while (true) {
|
||||
XEvent event = {};
|
||||
XNextEvent(display, &event);
|
||||
if (event.type == SelectionClear) break;
|
||||
if (event.type != SelectionRequest) continue;
|
||||
|
||||
const XSelectionRequestEvent& request = event.xselectionrequest;
|
||||
XSelectionEvent response = {};
|
||||
response.type = SelectionNotify;
|
||||
response.display = request.display;
|
||||
response.requestor = request.requestor;
|
||||
response.selection = request.selection;
|
||||
response.time = request.time;
|
||||
response.target = request.target;
|
||||
response.property = request.property;
|
||||
if (request.target == targets) {
|
||||
Atom supported[] = {utf8, XA_STRING, targets};
|
||||
XChangeProperty(display, request.requestor, request.property, XA_ATOM,
|
||||
32, PropModeReplace,
|
||||
reinterpret_cast<unsigned char*>(supported), 3);
|
||||
} else if (request.target == utf8 || request.target == XA_STRING) {
|
||||
XChangeProperty(display, request.requestor, request.property,
|
||||
request.target, 8, PropModeReplace,
|
||||
reinterpret_cast<const unsigned char*>(text.data()),
|
||||
static_cast<int>(text.size()));
|
||||
} else {
|
||||
response.property = None;
|
||||
}
|
||||
XSendEvent(display, request.requestor, False, 0,
|
||||
reinterpret_cast<XEvent*>(&response));
|
||||
XSync(display, False);
|
||||
}
|
||||
|
||||
XDestroyWindow(display, window);
|
||||
XCloseDisplay(display);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Clipboard::HasText() {
|
||||
Display* display = XOpenDisplay(nullptr);
|
||||
if (!display) return false;
|
||||
const Atom clipboard = XInternAtom(display, "CLIPBOARD", False);
|
||||
Window owner = XGetSelectionOwner(display, clipboard);
|
||||
if (owner == None) owner = XGetSelectionOwner(display, XA_PRIMARY);
|
||||
XCloseDisplay(display);
|
||||
return owner != None;
|
||||
}
|
||||
|
||||
namespace platform {
|
||||
|
||||
void RunClipboardMonitor(int check_interval_ms) {
|
||||
Display* display = XOpenDisplay(nullptr);
|
||||
if (!display) {
|
||||
LOG_ERROR("Failed to open X display for clipboard monitoring");
|
||||
return;
|
||||
}
|
||||
|
||||
int event_base = 0;
|
||||
int error_base = 0;
|
||||
if (!XFixesQueryExtension(display, &event_base, &error_base)) {
|
||||
LOG_WARN("XFixes extension not available, falling back to polling");
|
||||
XCloseDisplay(display);
|
||||
while (ClipboardMonitoring()) {
|
||||
std::this_thread::sleep_for(
|
||||
std::chrono::milliseconds(check_interval_ms > 0 ? check_interval_ms
|
||||
: 100));
|
||||
if (ClipboardMonitoring()) HandleClipboardChange();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const Atom clipboard = XInternAtom(display, "CLIPBOARD", False);
|
||||
const Window event_window = XCreateSimpleWindow(
|
||||
display, DefaultRootWindow(display), 0, 0, 1, 1, 0, 0, 0);
|
||||
XFixesSelectSelectionInput(display, event_window, clipboard,
|
||||
XFixesSetSelectionOwnerNotifyMask |
|
||||
XFixesSelectionWindowDestroyNotifyMask |
|
||||
XFixesSelectionClientCloseNotifyMask);
|
||||
LOG_INFO("Clipboard event monitoring started (Linux XFixes)");
|
||||
|
||||
while (ClipboardMonitoring()) {
|
||||
while (ClipboardMonitoring() && XPending(display) > 0) {
|
||||
XEvent event = {};
|
||||
XNextEvent(display, &event);
|
||||
if (event.type == event_base + XFixesSelectionNotify) {
|
||||
HandleClipboardChange();
|
||||
}
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
}
|
||||
|
||||
XFixesSelectSelectionInput(display, event_window, clipboard, 0);
|
||||
XDestroyWindow(display, event_window);
|
||||
XCloseDisplay(display);
|
||||
}
|
||||
|
||||
void WakeClipboardMonitor() {}
|
||||
|
||||
} // namespace platform
|
||||
} // namespace crossdesk
|
||||
@@ -1,404 +0,0 @@
|
||||
#include "linux_cursor_shape.h"
|
||||
|
||||
#include <remote_action.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cctype>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <limits>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#if defined(__linux__) && !defined(__APPLE__)
|
||||
#include <X11/Xcursor/Xcursor.h>
|
||||
#include <X11/Xlib.h>
|
||||
#endif
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
|
||||
constexpr LinuxCursorAlias kCursorAliases[] = {
|
||||
{"help", RemoteCursorShape::help},
|
||||
{"question-arrow", RemoteCursorShape::help},
|
||||
{"left-ptr-help", RemoteCursorShape::help},
|
||||
{"whats-this", RemoteCursorShape::help},
|
||||
{"dnd-ask", RemoteCursorShape::help},
|
||||
{"pointer", RemoteCursorShape::pointer},
|
||||
{"hand", RemoteCursorShape::pointer},
|
||||
{"hand1", RemoteCursorShape::pointer},
|
||||
{"hand2", RemoteCursorShape::pointer},
|
||||
{"link", RemoteCursorShape::pointer},
|
||||
{"pointing-hand", RemoteCursorShape::pointer},
|
||||
{"progress", RemoteCursorShape::progress},
|
||||
{"left-ptr-watch", RemoteCursorShape::progress},
|
||||
{"half-busy", RemoteCursorShape::progress},
|
||||
{"wait", RemoteCursorShape::wait},
|
||||
{"watch", RemoteCursorShape::wait},
|
||||
{"crosshair", RemoteCursorShape::crosshair},
|
||||
{"cross", RemoteCursorShape::crosshair},
|
||||
{"cross-reverse", RemoteCursorShape::crosshair},
|
||||
{"tcross", RemoteCursorShape::crosshair},
|
||||
{"cell", RemoteCursorShape::crosshair},
|
||||
{"plus", RemoteCursorShape::crosshair},
|
||||
{"target", RemoteCursorShape::crosshair},
|
||||
{"dotbox", RemoteCursorShape::crosshair},
|
||||
{"dot-box-mask", RemoteCursorShape::crosshair},
|
||||
{"text", RemoteCursorShape::text},
|
||||
{"xterm", RemoteCursorShape::text},
|
||||
{"ibeam", RemoteCursorShape::text},
|
||||
{"vertical-text", RemoteCursorShape::text},
|
||||
{"alias", RemoteCursorShape::alias},
|
||||
{"dnd-link", RemoteCursorShape::alias},
|
||||
{"copy", RemoteCursorShape::copy},
|
||||
{"dnd-copy", RemoteCursorShape::copy},
|
||||
{"move", RemoteCursorShape::move},
|
||||
{"fleur", RemoteCursorShape::move},
|
||||
{"size-all", RemoteCursorShape::move},
|
||||
{"all-scroll", RemoteCursorShape::move},
|
||||
{"dnd-move", RemoteCursorShape::move},
|
||||
{"no-drop", RemoteCursorShape::no_drop},
|
||||
{"dnd-no-drop", RemoteCursorShape::no_drop},
|
||||
{"dnd-none", RemoteCursorShape::no_drop},
|
||||
{"not-allowed", RemoteCursorShape::not_allowed},
|
||||
{"forbidden", RemoteCursorShape::not_allowed},
|
||||
{"crossed-circle", RemoteCursorShape::not_allowed},
|
||||
{"circle", RemoteCursorShape::not_allowed},
|
||||
{"pirate", RemoteCursorShape::not_allowed},
|
||||
{"grab", RemoteCursorShape::grab},
|
||||
{"openhand", RemoteCursorShape::grab},
|
||||
{"open-hand", RemoteCursorShape::grab},
|
||||
{"grabbing", RemoteCursorShape::grabbing},
|
||||
{"closedhand", RemoteCursorShape::grabbing},
|
||||
{"closed-hand", RemoteCursorShape::grabbing},
|
||||
{"col-resize", RemoteCursorShape::col_resize},
|
||||
{"row-resize", RemoteCursorShape::row_resize},
|
||||
{"n-resize", RemoteCursorShape::n_resize},
|
||||
{"top-side", RemoteCursorShape::n_resize},
|
||||
{"top-tee", RemoteCursorShape::n_resize},
|
||||
{"sb-up-arrow", RemoteCursorShape::n_resize},
|
||||
{"e-resize", RemoteCursorShape::e_resize},
|
||||
{"right-side", RemoteCursorShape::e_resize},
|
||||
{"right-tee", RemoteCursorShape::e_resize},
|
||||
{"sb-right-arrow", RemoteCursorShape::e_resize},
|
||||
{"s-resize", RemoteCursorShape::s_resize},
|
||||
{"bottom-side", RemoteCursorShape::s_resize},
|
||||
{"bottom-tee", RemoteCursorShape::s_resize},
|
||||
{"sb-down-arrow", RemoteCursorShape::s_resize},
|
||||
{"w-resize", RemoteCursorShape::w_resize},
|
||||
{"left-side", RemoteCursorShape::w_resize},
|
||||
{"left-tee", RemoteCursorShape::w_resize},
|
||||
{"sb-left-arrow", RemoteCursorShape::w_resize},
|
||||
{"ne-resize", RemoteCursorShape::ne_resize},
|
||||
{"top-right-corner", RemoteCursorShape::ne_resize},
|
||||
{"ur-angle", RemoteCursorShape::ne_resize},
|
||||
{"nw-resize", RemoteCursorShape::nw_resize},
|
||||
{"top-left-corner", RemoteCursorShape::nw_resize},
|
||||
{"ul-angle", RemoteCursorShape::nw_resize},
|
||||
{"se-resize", RemoteCursorShape::se_resize},
|
||||
{"bottom-right-corner", RemoteCursorShape::se_resize},
|
||||
{"lr-angle", RemoteCursorShape::se_resize},
|
||||
{"sw-resize", RemoteCursorShape::sw_resize},
|
||||
{"bottom-left-corner", RemoteCursorShape::sw_resize},
|
||||
{"ll-angle", RemoteCursorShape::sw_resize},
|
||||
{"ew-resize", RemoteCursorShape::ew_resize},
|
||||
{"sb-h-double-arrow", RemoteCursorShape::ew_resize},
|
||||
{"h-double-arrow", RemoteCursorShape::ew_resize},
|
||||
{"size-hor", RemoteCursorShape::ew_resize},
|
||||
{"split-h", RemoteCursorShape::ew_resize},
|
||||
{"exchange", RemoteCursorShape::ew_resize},
|
||||
{"ns-resize", RemoteCursorShape::ns_resize},
|
||||
{"sb-v-double-arrow", RemoteCursorShape::ns_resize},
|
||||
{"v-double-arrow", RemoteCursorShape::ns_resize},
|
||||
{"double-arrow", RemoteCursorShape::ns_resize},
|
||||
{"size-ver", RemoteCursorShape::ns_resize},
|
||||
{"split-v", RemoteCursorShape::ns_resize},
|
||||
{"nesw-resize", RemoteCursorShape::nesw_resize},
|
||||
{"fd-double-arrow", RemoteCursorShape::nesw_resize},
|
||||
{"size-bdiag", RemoteCursorShape::nesw_resize},
|
||||
{"nwse-resize", RemoteCursorShape::nwse_resize},
|
||||
{"bd-double-arrow", RemoteCursorShape::nwse_resize},
|
||||
{"size-fdiag", RemoteCursorShape::nwse_resize},
|
||||
{"default", RemoteCursorShape::default_cursor},
|
||||
{"left-ptr", RemoteCursorShape::default_cursor},
|
||||
{"left-arrow", RemoteCursorShape::default_cursor},
|
||||
{"arrow", RemoteCursorShape::default_cursor},
|
||||
{"top-left-arrow", RemoteCursorShape::default_cursor},
|
||||
{"right-ptr", RemoteCursorShape::default_cursor},
|
||||
{"center-ptr", RemoteCursorShape::default_cursor},
|
||||
{"context-menu", RemoteCursorShape::default_cursor},
|
||||
};
|
||||
|
||||
std::string NormalizeCursorName(const std::string& raw_name) {
|
||||
std::string normalized;
|
||||
normalized.reserve(raw_name.size());
|
||||
for (unsigned char ch : raw_name) {
|
||||
if (ch == '_' || std::isspace(ch)) {
|
||||
normalized.push_back('-');
|
||||
} else {
|
||||
normalized.push_back(static_cast<char>(std::tolower(ch)));
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
#if defined(__linux__) && !defined(__APPLE__)
|
||||
|
||||
struct CursorCandidate {
|
||||
RemoteCursorShape shape = RemoteCursorShape::default_cursor;
|
||||
uint32_t width = 0;
|
||||
uint32_t height = 0;
|
||||
uint32_t xhot = 0;
|
||||
uint32_t yhot = 0;
|
||||
std::vector<uint32_t> argb;
|
||||
};
|
||||
|
||||
uint8_t Alpha(uint32_t argb) {
|
||||
return static_cast<uint8_t>((argb >> 24U) & 0xffU);
|
||||
}
|
||||
|
||||
double BilinearAlpha(const CursorCandidate& candidate, double x, double y) {
|
||||
if (candidate.width == 0 || candidate.height == 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
x = std::max(0.0, std::min(x, static_cast<double>(candidate.width - 1)));
|
||||
y = std::max(0.0, std::min(y, static_cast<double>(candidate.height - 1)));
|
||||
const uint32_t x0 = static_cast<uint32_t>(std::floor(x));
|
||||
const uint32_t y0 = static_cast<uint32_t>(std::floor(y));
|
||||
const uint32_t x1 = std::min(x0 + 1, candidate.width - 1);
|
||||
const uint32_t y1 = std::min(y0 + 1, candidate.height - 1);
|
||||
const double fx = x - x0;
|
||||
const double fy = y - y0;
|
||||
|
||||
const auto sample = [&](uint32_t px, uint32_t py) {
|
||||
return static_cast<double>(
|
||||
Alpha(candidate.argb[static_cast<size_t>(py) * candidate.width + px]));
|
||||
};
|
||||
const double top = sample(x0, y0) * (1.0 - fx) + sample(x1, y0) * fx;
|
||||
const double bottom = sample(x0, y1) * (1.0 - fx) + sample(x1, y1) * fx;
|
||||
return top * (1.0 - fy) + bottom * fy;
|
||||
}
|
||||
|
||||
double SimilarityError(const LinuxCursorImageView& image,
|
||||
const CursorCandidate& candidate) {
|
||||
if (!image.argb || image.width == 0 || image.height == 0 ||
|
||||
candidate.argb.empty()) {
|
||||
return std::numeric_limits<double>::infinity();
|
||||
}
|
||||
|
||||
double alpha_error = 0.0;
|
||||
for (uint32_t y = 0; y < image.height; ++y) {
|
||||
const double source_y =
|
||||
(static_cast<double>(y) + 0.5) * candidate.height / image.height - 0.5;
|
||||
for (uint32_t x = 0; x < image.width; ++x) {
|
||||
const double source_x = (static_cast<double>(x) + 0.5) *
|
||||
candidate.width / image.width -
|
||||
0.5;
|
||||
const double expected = BilinearAlpha(candidate, source_x, source_y);
|
||||
const double actual = static_cast<double>(
|
||||
Alpha(image.argb[static_cast<size_t>(y) * image.width + x]));
|
||||
alpha_error += std::abs(actual - expected) / 255.0;
|
||||
}
|
||||
}
|
||||
alpha_error /= static_cast<double>(image.width) * image.height;
|
||||
|
||||
const double observed_hot_x =
|
||||
static_cast<double>(image.xhot) / std::max(1u, image.width);
|
||||
const double observed_hot_y =
|
||||
static_cast<double>(image.yhot) / std::max(1u, image.height);
|
||||
const double candidate_hot_x =
|
||||
static_cast<double>(candidate.xhot) / std::max(1u, candidate.width);
|
||||
const double candidate_hot_y =
|
||||
static_cast<double>(candidate.yhot) / std::max(1u, candidate.height);
|
||||
const double hotspot_error =
|
||||
std::abs(observed_hot_x - candidate_hot_x) +
|
||||
std::abs(observed_hot_y - candidate_hot_y);
|
||||
|
||||
const double observed_aspect =
|
||||
static_cast<double>(image.width) / image.height;
|
||||
const double candidate_aspect =
|
||||
static_cast<double>(candidate.width) / candidate.height;
|
||||
const double aspect_error =
|
||||
std::abs(std::log(observed_aspect / candidate_aspect));
|
||||
|
||||
return alpha_error + hotspot_error * 0.12 + aspect_error * 0.08;
|
||||
}
|
||||
|
||||
bool SameImage(const LinuxCursorImageView& image,
|
||||
const CursorCandidate& candidate) {
|
||||
if (image.width != candidate.width || image.height != candidate.height ||
|
||||
image.xhot != candidate.xhot || image.yhot != candidate.yhot) {
|
||||
return false;
|
||||
}
|
||||
const size_t pixel_count =
|
||||
static_cast<size_t>(image.width) * static_cast<size_t>(image.height);
|
||||
return std::equal(image.argb, image.argb + pixel_count,
|
||||
candidate.argb.begin());
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
} // namespace
|
||||
|
||||
const LinuxCursorAlias* LinuxCursorAliases(size_t* count) {
|
||||
if (count) {
|
||||
*count = sizeof(kCursorAliases) / sizeof(kCursorAliases[0]);
|
||||
}
|
||||
return kCursorAliases;
|
||||
}
|
||||
|
||||
RemoteCursorShape ShapeFromLinuxCursorName(const std::string& raw_name) {
|
||||
const std::string name = NormalizeCursorName(raw_name);
|
||||
for (const auto& alias : kCursorAliases) {
|
||||
if (name == alias.name) {
|
||||
return alias.shape;
|
||||
}
|
||||
}
|
||||
|
||||
// Some toolkits prefix a theme or toolkit name to the standard cursor name.
|
||||
// Restrict the fallback to separators so names such as "pointer-progress"
|
||||
// still prefer their exact semantic alias above.
|
||||
for (const auto& alias : kCursorAliases) {
|
||||
const std::string suffix = std::string("-") + alias.name;
|
||||
if (name.size() > suffix.size() &&
|
||||
name.compare(name.size() - suffix.size(), suffix.size(), suffix) == 0) {
|
||||
return alias.shape;
|
||||
}
|
||||
}
|
||||
return RemoteCursorShape::default_cursor;
|
||||
}
|
||||
|
||||
#if defined(__linux__) && !defined(__APPLE__)
|
||||
|
||||
struct LinuxCursorThemeMatcher::Impl {
|
||||
Display* display = XOpenDisplay(nullptr);
|
||||
std::string theme;
|
||||
int default_size = 24;
|
||||
std::unordered_map<int, std::vector<CursorCandidate>> candidates_by_size;
|
||||
|
||||
Impl() {
|
||||
if (!display) {
|
||||
return;
|
||||
}
|
||||
if (const char* active_theme = XcursorGetTheme(display)) {
|
||||
theme = active_theme;
|
||||
}
|
||||
const int active_size = XcursorGetDefaultSize(display);
|
||||
if (active_size > 0) {
|
||||
default_size = active_size;
|
||||
}
|
||||
}
|
||||
|
||||
~Impl() {
|
||||
if (display) {
|
||||
XCloseDisplay(display);
|
||||
}
|
||||
}
|
||||
|
||||
const std::vector<CursorCandidate>& Candidates(int requested_size) {
|
||||
requested_size = std::max(1, requested_size);
|
||||
auto existing = candidates_by_size.find(requested_size);
|
||||
if (existing != candidates_by_size.end()) {
|
||||
return existing->second;
|
||||
}
|
||||
|
||||
std::vector<CursorCandidate> candidates;
|
||||
const char* theme_name = theme.empty() ? nullptr : theme.c_str();
|
||||
for (const auto& alias : kCursorAliases) {
|
||||
XcursorImages* images =
|
||||
XcursorLibraryLoadImages(alias.name, theme_name, requested_size);
|
||||
if (!images) {
|
||||
continue;
|
||||
}
|
||||
for (int index = 0; index < images->nimage; ++index) {
|
||||
const XcursorImage* image = images->images[index];
|
||||
if (!image || !image->pixels || image->width == 0 ||
|
||||
image->height == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
CursorCandidate candidate;
|
||||
candidate.shape = alias.shape;
|
||||
candidate.width = image->width;
|
||||
candidate.height = image->height;
|
||||
candidate.xhot = image->xhot;
|
||||
candidate.yhot = image->yhot;
|
||||
const size_t pixel_count = static_cast<size_t>(image->width) *
|
||||
static_cast<size_t>(image->height);
|
||||
candidate.argb.assign(image->pixels, image->pixels + pixel_count);
|
||||
|
||||
const bool duplicate =
|
||||
std::any_of(candidates.begin(), candidates.end(),
|
||||
[&](const CursorCandidate& current) {
|
||||
return current.shape == candidate.shape &&
|
||||
current.width == candidate.width &&
|
||||
current.height == candidate.height &&
|
||||
current.xhot == candidate.xhot &&
|
||||
current.yhot == candidate.yhot &&
|
||||
current.argb == candidate.argb;
|
||||
});
|
||||
if (!duplicate) {
|
||||
candidates.push_back(std::move(candidate));
|
||||
}
|
||||
}
|
||||
XcursorImagesDestroy(images);
|
||||
}
|
||||
|
||||
return candidates_by_size
|
||||
.emplace(requested_size, std::move(candidates))
|
||||
.first->second;
|
||||
}
|
||||
};
|
||||
|
||||
#else
|
||||
|
||||
struct LinuxCursorThemeMatcher::Impl {};
|
||||
|
||||
#endif
|
||||
|
||||
LinuxCursorThemeMatcher::LinuxCursorThemeMatcher()
|
||||
: impl_(std::make_unique<Impl>()) {}
|
||||
|
||||
LinuxCursorThemeMatcher::~LinuxCursorThemeMatcher() = default;
|
||||
|
||||
RemoteCursorShape LinuxCursorThemeMatcher::Match(
|
||||
const LinuxCursorImageView& image) {
|
||||
#if defined(__linux__) && !defined(__APPLE__)
|
||||
if (!impl_ || !image.argb || image.width == 0 || image.height == 0) {
|
||||
return RemoteCursorShape::default_cursor;
|
||||
}
|
||||
|
||||
const int requested_size =
|
||||
static_cast<int>(std::max(image.width, image.height));
|
||||
const auto& candidates = impl_->Candidates(requested_size);
|
||||
for (const auto& candidate : candidates) {
|
||||
if (SameImage(image, candidate)) {
|
||||
return candidate.shape;
|
||||
}
|
||||
}
|
||||
|
||||
double best_error = std::numeric_limits<double>::infinity();
|
||||
RemoteCursorShape best_shape = RemoteCursorShape::default_cursor;
|
||||
for (const auto& candidate : candidates) {
|
||||
const double error = SimilarityError(image, candidate);
|
||||
if (error < best_error) {
|
||||
best_error = error;
|
||||
best_shape = candidate.shape;
|
||||
}
|
||||
}
|
||||
|
||||
// Scaled theme cursors normally remain well below this threshold, while an
|
||||
// unrelated custom application cursor should fall back to the default.
|
||||
return best_error <= 0.20 ? best_shape
|
||||
: RemoteCursorShape::default_cursor;
|
||||
#else
|
||||
(void)image;
|
||||
return RemoteCursorShape::default_cursor;
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
@@ -1,49 +0,0 @@
|
||||
#ifndef CROSSDESK_COMMON_LINUX_CURSOR_SHAPE_H_
|
||||
#define CROSSDESK_COMMON_LINUX_CURSOR_SHAPE_H_
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include <remote_cursor_shape.h>
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
struct LinuxCursorAlias {
|
||||
const char* name;
|
||||
RemoteCursorShape shape;
|
||||
};
|
||||
|
||||
const LinuxCursorAlias* LinuxCursorAliases(size_t* count);
|
||||
RemoteCursorShape ShapeFromLinuxCursorName(const std::string& name);
|
||||
|
||||
struct LinuxCursorImageView {
|
||||
uint32_t width = 0;
|
||||
uint32_t height = 0;
|
||||
uint32_t xhot = 0;
|
||||
uint32_t yhot = 0;
|
||||
const uint32_t* argb = nullptr;
|
||||
};
|
||||
|
||||
// Matches a compositor-provided cursor bitmap against the active Xcursor
|
||||
// theme. This is needed on Wayland, where XFixes only observes XWayland
|
||||
// cursors and cannot report cursors owned by native Wayland surfaces.
|
||||
class LinuxCursorThemeMatcher {
|
||||
public:
|
||||
LinuxCursorThemeMatcher();
|
||||
~LinuxCursorThemeMatcher();
|
||||
|
||||
LinuxCursorThemeMatcher(const LinuxCursorThemeMatcher&) = delete;
|
||||
LinuxCursorThemeMatcher& operator=(const LinuxCursorThemeMatcher&) = delete;
|
||||
|
||||
RemoteCursorShape Match(const LinuxCursorImageView& image);
|
||||
|
||||
private:
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif // CROSSDESK_COMMON_LINUX_CURSOR_SHAPE_H_
|
||||
@@ -1,58 +0,0 @@
|
||||
#include "platform/daemon_backend.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <limits.h>
|
||||
#include <signal.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
|
||||
namespace crossdesk::platform {
|
||||
|
||||
bool PrepareDaemon() {
|
||||
const bool from_terminal =
|
||||
isatty(STDIN_FILENO) != 0 || isatty(STDOUT_FILENO) != 0;
|
||||
pid_t process = fork();
|
||||
if (process < 0) return false;
|
||||
if (process > 0) _exit(0);
|
||||
if (setsid() < 0) return false;
|
||||
|
||||
process = fork();
|
||||
if (process < 0) return false;
|
||||
if (process > 0) _exit(0);
|
||||
|
||||
umask(0);
|
||||
if (chdir("/") != 0) {
|
||||
std::cerr << "Failed to change daemon working directory to /: "
|
||||
<< std::strerror(errno) << std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
const int null_fd = open("/dev/null", O_RDWR);
|
||||
if (null_fd >= 0) {
|
||||
dup2(null_fd, STDIN_FILENO);
|
||||
if (!from_terminal) {
|
||||
dup2(null_fd, STDOUT_FILENO);
|
||||
dup2(null_fd, STDERR_FILENO);
|
||||
}
|
||||
if (null_fd > STDERR_FILENO) close(null_fd);
|
||||
}
|
||||
|
||||
signal(SIGTERM, [](int) { g_daemon_stop_requested = 1; });
|
||||
signal(SIGINT, [](int) { g_daemon_stop_requested = 1; });
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string GetDaemonExecutablePath() {
|
||||
char path[PATH_MAX] = {};
|
||||
const ssize_t count = readlink("/proc/self/exe", path, sizeof(path) - 1);
|
||||
return count > 0 ? std::string(path, static_cast<size_t>(count))
|
||||
: std::string();
|
||||
}
|
||||
|
||||
} // namespace crossdesk::platform
|
||||
@@ -1,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,51 +0,0 @@
|
||||
#include "platform/path_backend.h"
|
||||
|
||||
#include <limits.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk::platform {
|
||||
namespace {
|
||||
|
||||
std::filesystem::path Home() {
|
||||
const char* value = std::getenv("HOME");
|
||||
return value ? std::filesystem::path(value) : std::filesystem::path();
|
||||
}
|
||||
|
||||
std::filesystem::path EnvOrDefault(const char* name,
|
||||
const std::filesystem::path& fallback) {
|
||||
const char* value = std::getenv(name);
|
||||
return value ? std::filesystem::path(value) : fallback;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::filesystem::path GetExecutableDirectory() {
|
||||
std::vector<char> buffer(PATH_MAX);
|
||||
while (true) {
|
||||
const ssize_t length =
|
||||
readlink("/proc/self/exe", buffer.data(), buffer.size() - 1);
|
||||
if (length <= 0) return {};
|
||||
if (static_cast<size_t>(length) < buffer.size() - 1) {
|
||||
buffer[static_cast<size_t>(length)] = '\0';
|
||||
return std::filesystem::path(buffer.data()).parent_path();
|
||||
}
|
||||
buffer.resize(buffer.size() * 2);
|
||||
}
|
||||
}
|
||||
|
||||
std::filesystem::path GetConfigPath(const std::string& app_name) {
|
||||
return EnvOrDefault("XDG_CONFIG_HOME", Home() / ".config") / app_name;
|
||||
}
|
||||
|
||||
std::filesystem::path GetCachePath(const std::string& app_name) {
|
||||
return EnvOrDefault("XDG_CACHE_HOME", Home() / ".cache") / app_name;
|
||||
}
|
||||
|
||||
std::filesystem::path GetLogPath(const std::string& app_name) {
|
||||
return GetCachePath(app_name) / "logs";
|
||||
}
|
||||
|
||||
} // namespace crossdesk::platform
|
||||
@@ -1,11 +0,0 @@
|
||||
#include "screen_capturer_factory.h"
|
||||
|
||||
#include "screen_capturer_linux.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
ScreenCapturer* ScreenCapturerFactory::Create() {
|
||||
return new ScreenCapturerLinux();
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
@@ -1,11 +0,0 @@
|
||||
#include "speaker_capturer_factory.h"
|
||||
|
||||
#include "speaker_capturer_linux.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
SpeakerCapturer* SpeakerCapturerFactory::Create() {
|
||||
return new SpeakerCapturerLinux();
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
@@ -1,81 +0,0 @@
|
||||
#include "platform.h"
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <net/if.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
#include "rd_log.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
std::string GetMac() {
|
||||
const int socket_fd = socket(AF_INET, SOCK_DGRAM, 0);
|
||||
if (socket_fd < 0) return {};
|
||||
|
||||
char buffer[1024] = {};
|
||||
struct ifconf config = {};
|
||||
config.ifc_len = sizeof(buffer);
|
||||
config.ifc_buf = buffer;
|
||||
if (ioctl(socket_fd, SIOCGIFCONF, &config) < 0) {
|
||||
close(socket_fd);
|
||||
return {};
|
||||
}
|
||||
|
||||
std::string result;
|
||||
struct ifreq request = {};
|
||||
struct ifreq* cursor = config.ifc_req;
|
||||
const struct ifreq* end = cursor + config.ifc_len / sizeof(struct ifreq);
|
||||
for (; cursor != end; ++cursor) {
|
||||
std::strncpy(request.ifr_name, cursor->ifr_name,
|
||||
sizeof(request.ifr_name) - 1);
|
||||
if (ioctl(socket_fd, SIOCGIFFLAGS, &request) < 0 ||
|
||||
(request.ifr_flags & IFF_LOOPBACK) ||
|
||||
ioctl(socket_fd, SIOCGIFHWADDR, &request) < 0) {
|
||||
continue;
|
||||
}
|
||||
char address[16] = {};
|
||||
int length = 0;
|
||||
for (int index = 0; index < 6; ++index) {
|
||||
length += std::snprintf(
|
||||
address + length, sizeof(address) - length, "%.2X",
|
||||
static_cast<unsigned char>(request.ifr_hwaddr.sa_data[index]));
|
||||
}
|
||||
result = address;
|
||||
break;
|
||||
}
|
||||
close(socket_fd);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string GetHostName() {
|
||||
char hostname[256] = {};
|
||||
if (gethostname(hostname, sizeof(hostname)) == -1) {
|
||||
LOG_ERROR("gethostname failed");
|
||||
return {};
|
||||
}
|
||||
return hostname;
|
||||
}
|
||||
|
||||
bool IsWaylandSession() {
|
||||
const char* session_type = std::getenv("XDG_SESSION_TYPE");
|
||||
if (session_type) {
|
||||
if (std::strcmp(session_type, "wayland") == 0 ||
|
||||
std::strcmp(session_type, "Wayland") == 0) {
|
||||
return true;
|
||||
}
|
||||
if (std::strcmp(session_type, "x11") == 0 ||
|
||||
std::strcmp(session_type, "X11") == 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const char* display = std::getenv("WAYLAND_DISPLAY");
|
||||
return display && display[0] != '\0';
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
@@ -1,74 +0,0 @@
|
||||
#include "platform/autostart_backend.h"
|
||||
|
||||
#include <limits.h>
|
||||
#include <mach-o/dyld.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
|
||||
namespace crossdesk::platform {
|
||||
namespace {
|
||||
|
||||
std::filesystem::path AutostartPath(const std::string& app_name) {
|
||||
const char* home = std::getenv("HOME");
|
||||
if (!home || !home[0]) return {};
|
||||
return std::filesystem::path(home) / "Library" / "LaunchAgents" /
|
||||
(app_name + ".plist");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string GetAutostartExecutablePath() {
|
||||
char path[PATH_MAX] = {};
|
||||
uint32_t size = sizeof(path);
|
||||
if (_NSGetExecutablePath(path, &size) != 0) return {};
|
||||
char resolved[PATH_MAX] = {};
|
||||
return realpath(path, resolved) ? std::string(resolved) : std::string(path);
|
||||
}
|
||||
|
||||
bool EnableAutostart(const std::string& app_name,
|
||||
const std::string& executable_path) {
|
||||
const std::filesystem::path path = AutostartPath(app_name);
|
||||
if (path.empty()) return false;
|
||||
|
||||
std::error_code error;
|
||||
std::filesystem::create_directories(path.parent_path(), error);
|
||||
if (error) return false;
|
||||
|
||||
std::ofstream file(path);
|
||||
if (!file.is_open()) return false;
|
||||
file << R"(<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>)"
|
||||
<< app_name << R"(</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>)"
|
||||
<< executable_path << R"(</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>)";
|
||||
file.close();
|
||||
return file.good();
|
||||
}
|
||||
|
||||
bool DisableAutostart(const std::string& app_name) {
|
||||
const std::filesystem::path path = AutostartPath(app_name);
|
||||
if (path.empty()) return false;
|
||||
std::error_code error;
|
||||
return std::filesystem::remove(path, error) && !error;
|
||||
}
|
||||
|
||||
bool IsAutostartEnabled(const std::string& app_name) {
|
||||
const std::filesystem::path path = AutostartPath(app_name);
|
||||
return !path.empty() && std::filesystem::is_regular_file(path);
|
||||
}
|
||||
|
||||
} // namespace crossdesk::platform
|
||||
@@ -1,20 +0,0 @@
|
||||
#include "platform/daemon_backend.h"
|
||||
|
||||
#include <limits.h>
|
||||
#include <mach-o/dyld.h>
|
||||
|
||||
#include <cstdlib>
|
||||
|
||||
namespace crossdesk::platform {
|
||||
|
||||
bool PrepareDaemon() { return true; }
|
||||
|
||||
std::string GetDaemonExecutablePath() {
|
||||
char path[PATH_MAX] = {};
|
||||
uint32_t size = sizeof(path);
|
||||
if (_NSGetExecutablePath(path, &size) != 0) return {};
|
||||
char resolved[PATH_MAX] = {};
|
||||
return realpath(path, resolved) ? std::string(resolved) : std::string(path);
|
||||
}
|
||||
|
||||
} // namespace crossdesk::platform
|
||||
@@ -1,82 +0,0 @@
|
||||
#ifndef CROSSDESK_GUI_PLATFORM_METAL_VIDEO_RENDERER_H_
|
||||
#define CROSSDESK_GUI_PLATFORM_METAL_VIDEO_RENDERER_H_
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
#include "platform/video_renderer.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
// macOS video compositor used by CrossDesk only. MiniRTC continues to expose
|
||||
// tightly packed CPU NV12 frames; this renderer performs the single required
|
||||
// copy into shared Metal buffers and converts NV12 to RGB in the fragment
|
||||
// shader. AppKit attachment and rendering are main-thread operations, while
|
||||
// SubmitNv12() is safe to call from MiniRTC's decode callback thread.
|
||||
class MacMetalVideoRenderer final : public VideoRenderer {
|
||||
public:
|
||||
MacMetalVideoRenderer();
|
||||
~MacMetalVideoRenderer() override;
|
||||
|
||||
MacMetalVideoRenderer(const MacMetalVideoRenderer&) = delete;
|
||||
MacMetalVideoRenderer& operator=(const MacMetalVideoRenderer&) = delete;
|
||||
|
||||
bool IsReady() const override;
|
||||
bool IsActive() const override;
|
||||
|
||||
// The selected stream is the only stream uploaded. Frames for background
|
||||
// tabs are intentionally dropped to avoid wasting memory bandwidth.
|
||||
bool SetSelectedStream(std::string remote_id) override;
|
||||
void DiscardStream(std::string_view remote_id) override;
|
||||
|
||||
// Copies one tightly packed NV12 frame into a free shared Metal slot.
|
||||
SubmitResult SubmitNv12(std::string_view remote_id, const uint8_t* data,
|
||||
size_t size, int width, int height) override;
|
||||
|
||||
// Seeds a newly selected stream from its retained CPU snapshot without
|
||||
// replacing a newer decoded frame that is already queued or rendering.
|
||||
SubmitResult SubmitCachedNv12(std::string_view remote_id,
|
||||
const uint8_t* data, size_t size, int width,
|
||||
int height) override;
|
||||
|
||||
// Attaches a native CAMetalLayer-backed sibling below Slint's NSView and
|
||||
// restores the containing AppKit window's ordinary opaque titlebar.
|
||||
// |slint_view| is an NSView* kept opaque so this header remains valid C++.
|
||||
bool Attach(void* slint_view);
|
||||
void Detach();
|
||||
bool IsAttached() const;
|
||||
|
||||
// Cheap main-thread geometry check used by the UI frame timer. New decoded
|
||||
// frames are tracked by GuiRuntime's dirty flag; this covers native resize,
|
||||
// scale, stream selection, and retry work without a full session sync.
|
||||
bool NeedsSurfaceRedraw() const;
|
||||
|
||||
// Draws the newest queued frame for |remote_id|. top_inset_points reserves
|
||||
// Slint's tab strip above the native video surface. A rendered outcome
|
||||
// carries metadata from the exact slot submitted to the command buffer.
|
||||
RenderOutcome RenderLatest(std::string_view remote_id,
|
||||
double top_inset_points,
|
||||
bool native_titlebar_visible);
|
||||
|
||||
// Used only when a session closes, so thumbnail generation does not require
|
||||
// a second per-frame CPU copy during normal playback.
|
||||
bool CopyLatestNv12(std::string_view remote_id,
|
||||
std::vector<unsigned char>* output, int* width,
|
||||
int* height) const override;
|
||||
|
||||
private:
|
||||
SubmitResult SubmitNv12Internal(std::string_view remote_id,
|
||||
const uint8_t* data, size_t size, int width,
|
||||
int height, bool replace_pending);
|
||||
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif // CROSSDESK_GUI_PLATFORM_METAL_VIDEO_RENDERER_H_
|
||||
@@ -1,193 +0,0 @@
|
||||
#include "runtime/cursor_state_provider.h"
|
||||
|
||||
#include <remote_action.h>
|
||||
|
||||
#if defined(__APPLE__)
|
||||
|
||||
#import <AppKit/AppKit.h>
|
||||
#import <CoreGraphics/CGRemoteOperation.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "runtime/cursor_position.h"
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
|
||||
// Quartz reports the arrow cursor's event hotspot. The visible apex in the
|
||||
// current macOS system artwork is about 1.6 logical points above that hotspot
|
||||
// (NSCursor.arrowCursor has a (5, 5) hotspot). Send this as presentation-only
|
||||
// metadata so controllers can align their glyph without changing input.
|
||||
constexpr double kDefaultArrowVisualTipYOffset = -1.6;
|
||||
|
||||
struct CursorFingerprint {
|
||||
uint64_t pixel_hash = 0;
|
||||
size_t width = 0;
|
||||
size_t height = 0;
|
||||
size_t bytes_per_row = 0;
|
||||
CFIndex pixel_bytes = 0;
|
||||
NSPoint hot_spot = NSZeroPoint;
|
||||
};
|
||||
|
||||
struct KnownCursor {
|
||||
CursorFingerprint fingerprint;
|
||||
RemoteCursorShape shape = RemoteCursorShape::default_cursor;
|
||||
};
|
||||
|
||||
bool FingerprintCursor(NSCursor* cursor, CursorFingerprint* fingerprint) {
|
||||
if (!cursor || !fingerprint || !cursor.image) return false;
|
||||
|
||||
NSRect proposed_rect = {NSZeroPoint, cursor.image.size};
|
||||
CGImageRef image =
|
||||
[cursor.image CGImageForProposedRect:&proposed_rect
|
||||
context:nil
|
||||
hints:nil];
|
||||
if (!image) return false;
|
||||
|
||||
CGDataProviderRef provider = CGImageGetDataProvider(image);
|
||||
if (!provider) return false;
|
||||
|
||||
CFDataRef data = CGDataProviderCopyData(provider);
|
||||
if (!data) return false;
|
||||
|
||||
// NSCursor.currentSystemCursor returns a fresh NSCursor/NSImage wrapper on
|
||||
// each query. Hash the decoded CGImage pixels so it can still be matched to
|
||||
// AppKit's semantic cursor instances without encoding a large TIFF every
|
||||
// frame.
|
||||
constexpr uint64_t kFnvOffsetBasis = 1469598103934665603ULL;
|
||||
constexpr uint64_t kFnvPrime = 1099511628211ULL;
|
||||
uint64_t hash = kFnvOffsetBasis;
|
||||
const UInt8* bytes = CFDataGetBytePtr(data);
|
||||
const CFIndex length = CFDataGetLength(data);
|
||||
for (CFIndex index = 0; index < length; ++index) {
|
||||
hash ^= bytes[index];
|
||||
hash *= kFnvPrime;
|
||||
}
|
||||
|
||||
fingerprint->pixel_hash = hash;
|
||||
fingerprint->width = CGImageGetWidth(image);
|
||||
fingerprint->height = CGImageGetHeight(image);
|
||||
fingerprint->bytes_per_row = CGImageGetBytesPerRow(image);
|
||||
fingerprint->pixel_bytes = length;
|
||||
fingerprint->hot_spot = cursor.hotSpot;
|
||||
CFRelease(data);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SameCursor(const CursorFingerprint& left,
|
||||
const CursorFingerprint& right) {
|
||||
return left.pixel_hash == right.pixel_hash && left.width == right.width &&
|
||||
left.height == right.height &&
|
||||
left.bytes_per_row == right.bytes_per_row &&
|
||||
left.pixel_bytes == right.pixel_bytes &&
|
||||
NSEqualPoints(left.hot_spot, right.hot_spot);
|
||||
}
|
||||
|
||||
void AddKnownCursor(std::vector<KnownCursor>* cursors, NSCursor* cursor,
|
||||
RemoteCursorShape shape) {
|
||||
CursorFingerprint fingerprint;
|
||||
if (FingerprintCursor(cursor, &fingerprint)) {
|
||||
cursors->push_back({fingerprint, shape});
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<KnownCursor> BuildKnownCursors() {
|
||||
std::vector<KnownCursor> cursors;
|
||||
cursors.reserve(16);
|
||||
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
|
||||
AddKnownCursor(&cursors, NSCursor.arrowCursor,
|
||||
RemoteCursorShape::default_cursor);
|
||||
AddKnownCursor(&cursors, NSCursor.pointingHandCursor,
|
||||
RemoteCursorShape::pointer);
|
||||
AddKnownCursor(&cursors, NSCursor.crosshairCursor,
|
||||
RemoteCursorShape::crosshair);
|
||||
AddKnownCursor(&cursors, NSCursor.IBeamCursor, RemoteCursorShape::text);
|
||||
AddKnownCursor(&cursors, NSCursor.IBeamCursorForVerticalLayout,
|
||||
RemoteCursorShape::text);
|
||||
AddKnownCursor(&cursors, NSCursor.operationNotAllowedCursor,
|
||||
RemoteCursorShape::not_allowed);
|
||||
AddKnownCursor(&cursors, NSCursor.dragLinkCursor, RemoteCursorShape::alias);
|
||||
AddKnownCursor(&cursors, NSCursor.dragCopyCursor, RemoteCursorShape::copy);
|
||||
AddKnownCursor(&cursors, NSCursor.openHandCursor, RemoteCursorShape::grab);
|
||||
AddKnownCursor(&cursors, NSCursor.closedHandCursor,
|
||||
RemoteCursorShape::grabbing);
|
||||
AddKnownCursor(&cursors, NSCursor.resizeLeftRightCursor,
|
||||
RemoteCursorShape::ew_resize);
|
||||
AddKnownCursor(&cursors, NSCursor.resizeUpDownCursor,
|
||||
RemoteCursorShape::ns_resize);
|
||||
AddKnownCursor(&cursors, NSCursor.resizeUpCursor,
|
||||
RemoteCursorShape::n_resize);
|
||||
AddKnownCursor(&cursors, NSCursor.resizeRightCursor,
|
||||
RemoteCursorShape::e_resize);
|
||||
AddKnownCursor(&cursors, NSCursor.resizeDownCursor,
|
||||
RemoteCursorShape::s_resize);
|
||||
AddKnownCursor(&cursors, NSCursor.resizeLeftCursor,
|
||||
RemoteCursorShape::w_resize);
|
||||
#pragma clang diagnostic pop
|
||||
|
||||
return cursors;
|
||||
}
|
||||
|
||||
RemoteCursorShape ShapeFromMacCursor(NSCursor* cursor) {
|
||||
CursorFingerprint fingerprint;
|
||||
if (!FingerprintCursor(cursor, &fingerprint)) {
|
||||
return RemoteCursorShape::default_cursor;
|
||||
}
|
||||
|
||||
// Sampling begins from the UI tick, after AppKit has initialized its cursor
|
||||
// catalog. Cache those fingerprints because only the current cursor needs
|
||||
// to be decoded on subsequent frames.
|
||||
static const std::vector<KnownCursor> known_cursors = BuildKnownCursors();
|
||||
for (const KnownCursor& known : known_cursors) {
|
||||
if (SameCursor(fingerprint, known.fingerprint)) return known.shape;
|
||||
}
|
||||
return RemoteCursorShape::default_cursor;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
struct CursorStateProvider::Impl {};
|
||||
|
||||
CursorStateProvider::CursorStateProvider() : impl_(std::make_unique<Impl>()) {}
|
||||
CursorStateProvider::~CursorStateProvider() = default;
|
||||
|
||||
bool CursorStateProvider::Sample(const std::vector<DisplayInfo>& displays,
|
||||
int preferred_display, CursorState* state) {
|
||||
if (!state) return false;
|
||||
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
|
||||
NSCursor* cursor = NSCursor.currentSystemCursor;
|
||||
const bool visible = CGCursorIsVisible();
|
||||
#pragma clang diagnostic pop
|
||||
|
||||
state->seq = 0;
|
||||
state->visible = visible && cursor != nil;
|
||||
state->shape = state->visible ? ShapeFromMacCursor(cursor)
|
||||
: RemoteCursorShape::none;
|
||||
ResetCursorPosition(state);
|
||||
CGEventRef event = CGEventCreate(nullptr);
|
||||
if (event) {
|
||||
const CGPoint location = CGEventGetLocation(event);
|
||||
NormalizeCursorPosition(location.x, location.y, displays,
|
||||
preferred_display, state);
|
||||
if (state->position_valid && state->visible &&
|
||||
state->shape == RemoteCursorShape::default_cursor &&
|
||||
state->display_id >= 0 &&
|
||||
state->display_id < static_cast<int>(displays.size())) {
|
||||
const double display_height =
|
||||
std::max(displays[state->display_id].height, 1);
|
||||
state->visual_offset_y = static_cast<float>(
|
||||
kDefaultArrowVisualTipYOffset / display_height);
|
||||
}
|
||||
CFRelease(event);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif
|
||||
@@ -1,13 +0,0 @@
|
||||
#include "platform/video_renderer.h"
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include "platform/macos/gui/metal_video_renderer.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
std::unique_ptr<VideoRenderer> CreateVideoRenderer() {
|
||||
return std::make_unique<MacMetalVideoRenderer>();
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
@@ -1,19 +0,0 @@
|
||||
#include "device_controller_factory.h"
|
||||
|
||||
#include "keyboard_capturer.h"
|
||||
#include "mouse_controller.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
DeviceController* DeviceControllerFactory::Create(Device device) {
|
||||
switch (device) {
|
||||
case Mouse:
|
||||
return new PlatformMouseController();
|
||||
case Keyboard:
|
||||
return new PlatformKeyboardCapturer();
|
||||
default:
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
@@ -1,48 +0,0 @@
|
||||
#include "platform/path_backend.h"
|
||||
|
||||
#include <mach-o/dyld.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk::platform {
|
||||
namespace {
|
||||
|
||||
std::filesystem::path Home() {
|
||||
const char* value = std::getenv("HOME");
|
||||
return value ? std::filesystem::path(value) : std::filesystem::path();
|
||||
}
|
||||
|
||||
std::filesystem::path EnvOrDefault(const char* name,
|
||||
const std::filesystem::path& fallback) {
|
||||
const char* value = std::getenv(name);
|
||||
return value ? std::filesystem::path(value) : fallback;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::filesystem::path GetExecutableDirectory() {
|
||||
uint32_t size = 0;
|
||||
_NSGetExecutablePath(nullptr, &size);
|
||||
std::vector<char> buffer(size + 1);
|
||||
if (_NSGetExecutablePath(buffer.data(), &size) != 0) return {};
|
||||
std::error_code error;
|
||||
std::filesystem::path executable =
|
||||
std::filesystem::weakly_canonical(buffer.data(), error);
|
||||
if (error) executable = buffer.data();
|
||||
return executable.parent_path();
|
||||
}
|
||||
|
||||
std::filesystem::path GetConfigPath(const std::string& app_name) {
|
||||
return EnvOrDefault("XDG_CONFIG_HOME", Home() / ".config") / app_name;
|
||||
}
|
||||
|
||||
std::filesystem::path GetCachePath(const std::string& app_name) {
|
||||
return EnvOrDefault("XDG_CACHE_HOME", Home() / ".cache") / app_name;
|
||||
}
|
||||
|
||||
std::filesystem::path GetLogPath(const std::string& app_name) {
|
||||
return Home() / "Library" / "Logs" / app_name;
|
||||
}
|
||||
|
||||
} // namespace crossdesk::platform
|
||||
@@ -1,11 +0,0 @@
|
||||
#include "screen_capturer_factory.h"
|
||||
|
||||
#include "screen_capturer_sck.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
ScreenCapturer* ScreenCapturerFactory::Create() {
|
||||
return new ScreenCapturerSck();
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
@@ -1,11 +0,0 @@
|
||||
#include "speaker_capturer_factory.h"
|
||||
|
||||
#include "speaker_capturer_macosx.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
SpeakerCapturer* SpeakerCapturerFactory::Create() {
|
||||
return new SpeakerCapturerMacosx();
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
@@ -1,55 +0,0 @@
|
||||
#include "platform.h"
|
||||
|
||||
#include <ifaddrs.h>
|
||||
#include <net/if_dl.h>
|
||||
#include <net/if_types.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
#include "rd_log.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
std::string GetMac() {
|
||||
struct ifaddrs* addresses = nullptr;
|
||||
if (getifaddrs(&addresses) != 0) return {};
|
||||
|
||||
std::string result;
|
||||
for (struct ifaddrs* cursor = addresses; cursor; cursor = cursor->ifa_next) {
|
||||
if (!cursor->ifa_addr || cursor->ifa_addr->sa_family != AF_LINK ||
|
||||
std::strcmp(cursor->ifa_name, "en0") != 0) {
|
||||
continue;
|
||||
}
|
||||
const auto* link =
|
||||
reinterpret_cast<const struct sockaddr_dl*>(cursor->ifa_addr);
|
||||
if (link->sdl_type != IFT_ETHER) continue;
|
||||
const auto* bytes = reinterpret_cast<const unsigned char*>(
|
||||
&link->sdl_data[link->sdl_nlen]);
|
||||
char address[16] = {};
|
||||
int length = 0;
|
||||
for (int index = 0; index < link->sdl_alen; ++index) {
|
||||
length += std::snprintf(address + length, sizeof(address) - length,
|
||||
"%.2X", bytes[index]);
|
||||
}
|
||||
result = address;
|
||||
break;
|
||||
}
|
||||
freeifaddrs(addresses);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string GetHostName() {
|
||||
char hostname[256] = {};
|
||||
if (gethostname(hostname, sizeof(hostname)) == -1) {
|
||||
LOG_ERROR("gethostname failed");
|
||||
return {};
|
||||
}
|
||||
return hostname;
|
||||
}
|
||||
|
||||
bool IsWaylandSession() { return false; }
|
||||
|
||||
} // namespace crossdesk
|
||||
@@ -1,16 +0,0 @@
|
||||
#ifndef CROSSDESK_PLATFORM_PATH_BACKEND_H_
|
||||
#define CROSSDESK_PLATFORM_PATH_BACKEND_H_
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
|
||||
namespace crossdesk::platform {
|
||||
|
||||
std::filesystem::path GetExecutableDirectory();
|
||||
std::filesystem::path GetConfigPath(const std::string& app_name);
|
||||
std::filesystem::path GetCachePath(const std::string& app_name);
|
||||
std::filesystem::path GetLogPath(const std::string& app_name);
|
||||
|
||||
} // namespace crossdesk::platform
|
||||
|
||||
#endif // CROSSDESK_PLATFORM_PATH_BACKEND_H_
|
||||
@@ -1,76 +0,0 @@
|
||||
#ifndef CROSSDESK_GUI_PLATFORM_VIDEO_RENDERER_H_
|
||||
#define CROSSDESK_GUI_PLATFORM_VIDEO_RENDERER_H_
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
#include "minirtc.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
// Platform-independent contract for the native stream video renderer.
|
||||
//
|
||||
// Frame submission and snapshot methods may be called from MiniRTC callback
|
||||
// threads. Surface setup, rendering and teardown remain backend-specific and
|
||||
// are driven by the Slint presentation module on the UI thread.
|
||||
class VideoRenderer {
|
||||
public:
|
||||
enum class SubmitResult {
|
||||
submitted,
|
||||
not_selected,
|
||||
dropped,
|
||||
failed,
|
||||
};
|
||||
|
||||
enum class RenderResult {
|
||||
rendered,
|
||||
idle,
|
||||
empty,
|
||||
failed,
|
||||
};
|
||||
|
||||
struct RenderOutcome {
|
||||
RenderResult result = RenderResult::failed;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
uint64_t sequence = 0;
|
||||
};
|
||||
|
||||
virtual ~VideoRenderer() = default;
|
||||
|
||||
virtual bool IsReady() const = 0;
|
||||
|
||||
// True only while the renderer can accept and present native frames. Metal
|
||||
// additionally requires attachment to an AppKit view; OpenGL requires an
|
||||
// active Slint rendering context.
|
||||
virtual bool IsActive() const = 0;
|
||||
|
||||
virtual bool SetSelectedStream(std::string remote_id) = 0;
|
||||
virtual void DiscardStream(std::string_view remote_id) = 0;
|
||||
|
||||
virtual SubmitResult SubmitNv12(std::string_view remote_id,
|
||||
const uint8_t* data, size_t size, int width,
|
||||
int height) = 0;
|
||||
virtual SubmitResult SubmitCachedNv12(std::string_view remote_id,
|
||||
const uint8_t* data, size_t size,
|
||||
int width, int height) = 0;
|
||||
virtual SubmitResult SubmitNativeFrame(std::string_view,
|
||||
const XVideoFrame&) {
|
||||
return SubmitResult::failed;
|
||||
}
|
||||
|
||||
virtual bool CopyLatestNv12(std::string_view remote_id,
|
||||
std::vector<unsigned char>* output, int* width,
|
||||
int* height) const = 0;
|
||||
};
|
||||
|
||||
// Exactly one factory implementation is linked for the target platform.
|
||||
std::unique_ptr<VideoRenderer> CreateVideoRenderer();
|
||||
|
||||
} // namespace crossdesk
|
||||
|
||||
#endif // CROSSDESK_GUI_PLATFORM_VIDEO_RENDERER_H_
|
||||
@@ -1,69 +0,0 @@
|
||||
#include "platform/autostart_backend.h"
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
|
||||
namespace crossdesk::platform {
|
||||
namespace {
|
||||
|
||||
constexpr const char* kRunKey =
|
||||
"Software\\Microsoft\\Windows\\CurrentVersion\\Run";
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string GetAutostartExecutablePath() {
|
||||
char path[32768] = {};
|
||||
const DWORD length = GetModuleFileNameA(nullptr, path, sizeof(path));
|
||||
return length > 0 && length < sizeof(path) ? std::string(path, length)
|
||||
: std::string();
|
||||
}
|
||||
|
||||
bool EnableAutostart(const std::string& app_name,
|
||||
const std::string& executable_path) {
|
||||
if (!std::filesystem::exists(executable_path)) return false;
|
||||
|
||||
HKEY key = nullptr;
|
||||
if (RegOpenKeyExA(HKEY_CURRENT_USER, kRunKey, 0, KEY_WRITE, &key) !=
|
||||
ERROR_SUCCESS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string value = executable_path;
|
||||
if (value.find(' ') != std::string::npos &&
|
||||
(value.front() != '"' || value.back() != '"')) {
|
||||
value = "\"" + value + "\"";
|
||||
}
|
||||
const LONG result = RegSetValueExA(
|
||||
key, app_name.c_str(), 0, REG_SZ,
|
||||
reinterpret_cast<const BYTE*>(value.c_str()),
|
||||
static_cast<DWORD>(value.size() + 1));
|
||||
RegCloseKey(key);
|
||||
return result == ERROR_SUCCESS;
|
||||
}
|
||||
|
||||
bool DisableAutostart(const std::string& app_name) {
|
||||
HKEY key = nullptr;
|
||||
if (RegOpenKeyExA(HKEY_CURRENT_USER, kRunKey, 0, KEY_WRITE, &key) !=
|
||||
ERROR_SUCCESS) {
|
||||
return false;
|
||||
}
|
||||
const LONG result = RegDeleteValueA(key, app_name.c_str());
|
||||
RegCloseKey(key);
|
||||
return result == ERROR_SUCCESS || result == ERROR_FILE_NOT_FOUND;
|
||||
}
|
||||
|
||||
bool IsAutostartEnabled(const std::string& app_name) {
|
||||
HKEY key = nullptr;
|
||||
if (RegOpenKeyExA(HKEY_CURRENT_USER, kRunKey, 0, KEY_READ, &key) !=
|
||||
ERROR_SUCCESS) {
|
||||
return false;
|
||||
}
|
||||
const LONG result =
|
||||
RegQueryValueExA(key, app_name.c_str(), nullptr, nullptr, nullptr, nullptr);
|
||||
RegCloseKey(key);
|
||||
return result == ERROR_SUCCESS;
|
||||
}
|
||||
|
||||
} // namespace crossdesk::platform
|
||||
@@ -1,139 +0,0 @@
|
||||
#include "clipboard.h"
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "platform/clipboard_backend.h"
|
||||
#include "rd_log.h"
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
|
||||
HWND g_clipboard_window = nullptr;
|
||||
constexpr const char* kClipboardWindowClass = "CrossDeskClipboardMonitor";
|
||||
|
||||
LRESULT CALLBACK ClipboardWindowProc(HWND window, UINT message, WPARAM wparam,
|
||||
LPARAM lparam) {
|
||||
if (message == WM_CLIPBOARDUPDATE) {
|
||||
platform::HandleClipboardChange();
|
||||
return 0;
|
||||
}
|
||||
return DefWindowProc(window, message, wparam, lparam);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string Clipboard::GetText() {
|
||||
if (!OpenClipboard(nullptr)) {
|
||||
LOG_ERROR("Clipboard::GetText: failed to open clipboard");
|
||||
return {};
|
||||
}
|
||||
|
||||
std::string result;
|
||||
HANDLE data = GetClipboardData(CF_UNICODETEXT);
|
||||
if (data) {
|
||||
const wchar_t* text = static_cast<const wchar_t*>(GlobalLock(data));
|
||||
if (text) {
|
||||
const int size =
|
||||
WideCharToMultiByte(CP_UTF8, 0, text, -1, nullptr, 0, nullptr, nullptr);
|
||||
if (size > 1) {
|
||||
result.resize(static_cast<size_t>(size));
|
||||
WideCharToMultiByte(CP_UTF8, 0, text, -1, result.data(), size,
|
||||
nullptr, nullptr);
|
||||
result.resize(static_cast<size_t>(size - 1));
|
||||
}
|
||||
GlobalUnlock(data);
|
||||
}
|
||||
}
|
||||
CloseClipboard();
|
||||
return result;
|
||||
}
|
||||
|
||||
bool Clipboard::SetText(const std::string& text) {
|
||||
if (!OpenClipboard(nullptr)) {
|
||||
LOG_ERROR("Clipboard::SetText: failed to open clipboard");
|
||||
return false;
|
||||
}
|
||||
if (!EmptyClipboard()) {
|
||||
LOG_ERROR("Clipboard::SetText: failed to empty clipboard");
|
||||
CloseClipboard();
|
||||
return false;
|
||||
}
|
||||
|
||||
const int size =
|
||||
MultiByteToWideChar(CP_UTF8, 0, text.c_str(), -1, nullptr, 0);
|
||||
HGLOBAL memory =
|
||||
size > 0 ? GlobalAlloc(GMEM_MOVEABLE, size * sizeof(wchar_t)) : nullptr;
|
||||
if (!memory) {
|
||||
LOG_ERROR("Clipboard::SetText: failed to allocate memory");
|
||||
CloseClipboard();
|
||||
return false;
|
||||
}
|
||||
|
||||
wchar_t* output = static_cast<wchar_t*>(GlobalLock(memory));
|
||||
if (!output) {
|
||||
GlobalFree(memory);
|
||||
CloseClipboard();
|
||||
return false;
|
||||
}
|
||||
MultiByteToWideChar(CP_UTF8, 0, text.c_str(), -1, output, size);
|
||||
GlobalUnlock(memory);
|
||||
if (!SetClipboardData(CF_UNICODETEXT, memory)) {
|
||||
GlobalFree(memory);
|
||||
CloseClipboard();
|
||||
return false;
|
||||
}
|
||||
CloseClipboard();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Clipboard::HasText() {
|
||||
if (!OpenClipboard(nullptr)) return false;
|
||||
const bool result = IsClipboardFormatAvailable(CF_UNICODETEXT) ||
|
||||
IsClipboardFormatAvailable(CF_TEXT);
|
||||
CloseClipboard();
|
||||
return result;
|
||||
}
|
||||
|
||||
namespace platform {
|
||||
|
||||
void RunClipboardMonitor(int) {
|
||||
WNDCLASSA window_class = {};
|
||||
window_class.lpfnWndProc = ClipboardWindowProc;
|
||||
window_class.hInstance = GetModuleHandle(nullptr);
|
||||
window_class.lpszClassName = kClipboardWindowClass;
|
||||
RegisterClassA(&window_class);
|
||||
|
||||
g_clipboard_window = CreateWindowA(
|
||||
kClipboardWindowClass, nullptr, 0, 0, 0, 0, 0, HWND_MESSAGE, nullptr,
|
||||
nullptr, nullptr);
|
||||
if (!g_clipboard_window ||
|
||||
!AddClipboardFormatListener(g_clipboard_window)) {
|
||||
LOG_ERROR("Failed to initialize clipboard monitor window");
|
||||
if (g_clipboard_window) DestroyWindow(g_clipboard_window);
|
||||
g_clipboard_window = nullptr;
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_INFO("Clipboard event monitoring started (Windows)");
|
||||
MSG message = {};
|
||||
while (ClipboardMonitoring()) {
|
||||
const BOOL result = GetMessage(&message, nullptr, 0, 0);
|
||||
if (result == 0 || result == -1) break;
|
||||
TranslateMessage(&message);
|
||||
DispatchMessage(&message);
|
||||
}
|
||||
|
||||
RemoveClipboardFormatListener(g_clipboard_window);
|
||||
DestroyWindow(g_clipboard_window);
|
||||
g_clipboard_window = nullptr;
|
||||
UnregisterClassA(kClipboardWindowClass, GetModuleHandle(nullptr));
|
||||
}
|
||||
|
||||
void WakeClipboardMonitor() {
|
||||
if (g_clipboard_window) PostMessage(g_clipboard_window, WM_QUIT, 0, 0);
|
||||
}
|
||||
|
||||
} // namespace platform
|
||||
} // namespace crossdesk
|
||||
@@ -1,64 +0,0 @@
|
||||
#include "platform/daemon_backend.h"
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk::platform {
|
||||
namespace {
|
||||
|
||||
std::atomic<bool> g_stop_requested{false};
|
||||
|
||||
} // namespace
|
||||
|
||||
void ResetDaemonStop() { g_stop_requested.store(false); }
|
||||
|
||||
void RequestDaemonStop() { g_stop_requested.store(true); }
|
||||
|
||||
bool DaemonStopRequested() { return g_stop_requested.load(); }
|
||||
|
||||
bool PrepareDaemon() { return true; }
|
||||
|
||||
std::string GetDaemonExecutablePath() {
|
||||
char path[32768] = {};
|
||||
const DWORD length = GetModuleFileNameA(nullptr, path, sizeof(path));
|
||||
return length > 0 && length < sizeof(path) ? std::string(path, length)
|
||||
: std::string();
|
||||
}
|
||||
|
||||
DaemonChildResult RunDaemonChild(const std::string& executable_path,
|
||||
const std::function<bool()>& keep_running) {
|
||||
DaemonChildResult result;
|
||||
STARTUPINFOA startup = {sizeof(startup)};
|
||||
PROCESS_INFORMATION process = {};
|
||||
std::string command = "\"" + executable_path + "\" --child";
|
||||
std::vector<char> command_buffer(command.begin(), command.end());
|
||||
command_buffer.push_back('\0');
|
||||
if (!CreateProcessA(nullptr, command_buffer.data(), nullptr, nullptr, FALSE,
|
||||
0, nullptr, nullptr, &startup, &process)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result.started = true;
|
||||
while (keep_running()) {
|
||||
const DWORD wait_result = WaitForSingleObject(process.hProcess, 200);
|
||||
if (wait_result == WAIT_OBJECT_0 || wait_result == WAIT_FAILED) break;
|
||||
}
|
||||
if (!keep_running()) {
|
||||
TerminateProcess(process.hProcess, 1);
|
||||
WaitForSingleObject(process.hProcess, 3000);
|
||||
}
|
||||
|
||||
DWORD exit_code = 0;
|
||||
GetExitCodeProcess(process.hProcess, &exit_code);
|
||||
result.exit_code = static_cast<int>(exit_code);
|
||||
result.normal_exit = exit_code == 0;
|
||||
CloseHandle(process.hProcess);
|
||||
CloseHandle(process.hThread);
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace crossdesk::platform
|
||||
@@ -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,51 +0,0 @@
|
||||
#include "platform/path_backend.h"
|
||||
|
||||
#include <shlobj.h>
|
||||
#include <windows.h>
|
||||
|
||||
#include <vector>
|
||||
|
||||
namespace crossdesk::platform {
|
||||
namespace {
|
||||
|
||||
std::filesystem::path GetKnownFolder(REFKNOWNFOLDERID id) {
|
||||
PWSTR value = nullptr;
|
||||
if (FAILED(SHGetKnownFolderPath(id, 0, nullptr, &value))) return {};
|
||||
const std::filesystem::path result(value);
|
||||
CoTaskMemFree(value);
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::filesystem::path GetExecutableDirectory() {
|
||||
std::vector<wchar_t> buffer(MAX_PATH);
|
||||
while (true) {
|
||||
const DWORD length = GetModuleFileNameW(
|
||||
nullptr, buffer.data(), static_cast<DWORD>(buffer.size()));
|
||||
if (length == 0 || buffer.size() >= 32768) return {};
|
||||
if (length < buffer.size()) {
|
||||
return std::filesystem::path(buffer.data(), buffer.data() + length)
|
||||
.parent_path();
|
||||
}
|
||||
buffer.resize(buffer.size() * 2);
|
||||
}
|
||||
}
|
||||
|
||||
std::filesystem::path GetConfigPath(const std::string& app_name) {
|
||||
return GetKnownFolder(FOLDERID_RoamingAppData) / app_name;
|
||||
}
|
||||
|
||||
std::filesystem::path GetCachePath(const std::string& app_name) {
|
||||
#ifdef CROSSDESK_DEBUG
|
||||
return "cache";
|
||||
#else
|
||||
return GetKnownFolder(FOLDERID_LocalAppData) / app_name / "cache";
|
||||
#endif
|
||||
}
|
||||
|
||||
std::filesystem::path GetLogPath(const std::string& app_name) {
|
||||
return GetKnownFolder(FOLDERID_LocalAppData) / app_name / "logs";
|
||||
}
|
||||
|
||||
} // namespace crossdesk::platform
|
||||
@@ -1,11 +0,0 @@
|
||||
#include "screen_capturer_factory.h"
|
||||
|
||||
#include "screen_capturer_win.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
ScreenCapturer* ScreenCapturerFactory::Create() {
|
||||
return new ScreenCapturerWin();
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
@@ -1,11 +0,0 @@
|
||||
#include "speaker_capturer_factory.h"
|
||||
|
||||
#include "speaker_capturer_wasapi.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
SpeakerCapturer* SpeakerCapturerFactory::Create() {
|
||||
return new SpeakerCapturerWasapi();
|
||||
}
|
||||
|
||||
} // namespace crossdesk
|
||||
@@ -1,45 +0,0 @@
|
||||
#include "platform.h"
|
||||
|
||||
#include <Winsock2.h>
|
||||
#include <iphlpapi.h>
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
#include "rd_log.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
std::string GetMac() {
|
||||
IP_ADAPTER_INFO adapters[16] = {};
|
||||
DWORD size = sizeof(adapters);
|
||||
if (GetAdaptersInfo(adapters, &size) != ERROR_SUCCESS) return {};
|
||||
|
||||
char address[16] = {};
|
||||
int length = 0;
|
||||
const PIP_ADAPTER_INFO adapter = adapters;
|
||||
for (UINT index = 0; index < adapter->AddressLength; ++index) {
|
||||
length += sprintf_s(address + length, sizeof(address) - length, "%.2X",
|
||||
adapter->Address[index]);
|
||||
}
|
||||
return address;
|
||||
}
|
||||
|
||||
std::string GetHostName() {
|
||||
WSADATA data = {};
|
||||
if (WSAStartup(MAKEWORD(2, 2), &data) != 0) {
|
||||
LOG_ERROR("WSAStartup failed");
|
||||
return {};
|
||||
}
|
||||
char hostname[256] = {};
|
||||
if (gethostname(hostname, sizeof(hostname)) == SOCKET_ERROR) {
|
||||
LOG_ERROR("gethostname failed: {}", WSAGetLastError());
|
||||
WSACleanup();
|
||||
return {};
|
||||
}
|
||||
WSACleanup();
|
||||
return hostname;
|
||||
}
|
||||
|
||||
bool IsWaylandSession() { return false; }
|
||||
|
||||
} // namespace crossdesk
|
||||
@@ -1,22 +0,0 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2023-12-15
|
||||
* Copyright (c) 2023 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _SCREEN_CAPTURER_FACTORY_H_
|
||||
#define _SCREEN_CAPTURER_FACTORY_H_
|
||||
|
||||
#include "screen_capturer.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
class ScreenCapturerFactory {
|
||||
public:
|
||||
virtual ~ScreenCapturerFactory() {}
|
||||
|
||||
public:
|
||||
ScreenCapturer* Create();
|
||||
};
|
||||
} // namespace crossdesk
|
||||
#endif
|
||||
@@ -1,22 +0,0 @@
|
||||
/*
|
||||
* @Author: DI JUNKUN
|
||||
* @Date: 2024-07-22
|
||||
* Copyright (c) 2024 by DI JUNKUN, All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef _SPEAKER_CAPTURER_FACTORY_H_
|
||||
#define _SPEAKER_CAPTURER_FACTORY_H_
|
||||
|
||||
#include "speaker_capturer.h"
|
||||
|
||||
namespace crossdesk {
|
||||
|
||||
class SpeakerCapturerFactory {
|
||||
public:
|
||||
virtual ~SpeakerCapturerFactory() {}
|
||||
|
||||
public:
|
||||
SpeakerCapturer* Create();
|
||||
};
|
||||
} // namespace crossdesk
|
||||
#endif
|
||||
@@ -1,86 +0,0 @@
|
||||
#include "clipboard.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
|
||||
#include "platform/clipboard_backend.h"
|
||||
#include "rd_log.h"
|
||||
|
||||
namespace crossdesk {
|
||||
namespace {
|
||||
|
||||
std::atomic<bool> g_monitoring{false};
|
||||
std::thread g_monitor_thread;
|
||||
std::mutex g_monitor_mutex;
|
||||
std::string g_last_clipboard_text;
|
||||
Clipboard::OnClipboardChanged g_on_clipboard_changed;
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace platform {
|
||||
|
||||
bool ClipboardMonitoring() { return g_monitoring.load(); }
|
||||
|
||||
void HandleClipboardChange() {
|
||||
if (!Clipboard::HasText()) {
|
||||
std::lock_guard<std::mutex> lock(g_monitor_mutex);
|
||||
if (!g_last_clipboard_text.empty()) {
|
||||
g_last_clipboard_text.clear();
|
||||
LOG_INFO("Clipboard content cleared");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string current_text = Clipboard::GetText();
|
||||
std::lock_guard<std::mutex> lock(g_monitor_mutex);
|
||||
if (current_text == g_last_clipboard_text) return;
|
||||
g_last_clipboard_text = current_text;
|
||||
if (!current_text.empty() && g_on_clipboard_changed) {
|
||||
const int result =
|
||||
g_on_clipboard_changed(current_text.c_str(), current_text.length());
|
||||
if (result != 0) {
|
||||
LOG_WARN("Clipboard callback returned error: {}", result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace platform
|
||||
|
||||
void Clipboard::StartMonitoring(int check_interval_ms,
|
||||
OnClipboardChanged on_changed) {
|
||||
if (g_monitoring.load()) {
|
||||
LOG_WARN("Clipboard monitoring is already running");
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(g_monitor_mutex);
|
||||
g_on_clipboard_changed = std::move(on_changed);
|
||||
g_last_clipboard_text = HasText() ? GetText() : std::string();
|
||||
}
|
||||
g_monitoring.store(true);
|
||||
g_monitor_thread = std::thread(
|
||||
[check_interval_ms] { platform::RunClipboardMonitor(check_interval_ms); });
|
||||
LOG_INFO("Clipboard event monitoring started");
|
||||
}
|
||||
|
||||
void Clipboard::StopMonitoring() {
|
||||
if (!g_monitoring.exchange(false)) return;
|
||||
|
||||
platform::WakeClipboardMonitor();
|
||||
if (g_monitor_thread.joinable()) g_monitor_thread.join();
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(g_monitor_mutex);
|
||||
g_last_clipboard_text.clear();
|
||||
g_on_clipboard_changed = nullptr;
|
||||
}
|
||||
LOG_INFO("Clipboard monitoring stopped");
|
||||
}
|
||||
|
||||
bool Clipboard::IsMonitoring() { return g_monitoring.load(); }
|
||||
|
||||
} // namespace crossdesk
|
||||
@@ -1,30 +0,0 @@
|
||||
#include <iostream>
|
||||
#include <vector>
|
||||
|
||||
#include <remote_action.h>
|
||||
|
||||
#include "cursor_position.h"
|
||||
#include "display_info.h"
|
||||
|
||||
namespace {
|
||||
|
||||
bool Expect(bool condition, const char* message) {
|
||||
if (condition) return true;
|
||||
std::cerr << message << '\n';
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
const std::vector<crossdesk::DisplayInfo> displays = {
|
||||
crossdesk::DisplayInfo("Test Display", 0, 0, 1920, 1080)};
|
||||
crossdesk::CursorState normalized_cursor{};
|
||||
const bool ok = Expect(
|
||||
crossdesk::NormalizeCursorPosition(960.0, 540.0, displays, 0,
|
||||
&normalized_cursor) &&
|
||||
normalized_cursor.position_valid &&
|
||||
normalized_cursor.x == 0.5f && normalized_cursor.y == 0.5f,
|
||||
"cursor position should use continuous display extents");
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
#include <array>
|
||||
#include <cctype>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace {
|
||||
|
||||
std::filesystem::path FindRepoRoot() {
|
||||
std::filesystem::path current = std::filesystem::current_path();
|
||||
while (!current.empty()) {
|
||||
if (std::filesystem::exists(current / "xmake.lua") &&
|
||||
std::filesystem::exists(
|
||||
current /
|
||||
"libs/wire/include/remote_action.h") &&
|
||||
std::filesystem::exists(
|
||||
current / "apps/desktop/xmake/targets.lua")) {
|
||||
return current;
|
||||
}
|
||||
current = current.parent_path();
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
std::string ReadFile(const std::filesystem::path& path) {
|
||||
std::ifstream file(path, std::ios::binary);
|
||||
if (!file) {
|
||||
return {};
|
||||
}
|
||||
std::ostringstream contents;
|
||||
contents << file.rdbuf();
|
||||
return contents.str();
|
||||
}
|
||||
|
||||
bool IsCheckedWireFile(const std::filesystem::path& path) {
|
||||
static constexpr std::array<std::string_view, 8> kExtensions = {
|
||||
".h", ".hpp", ".c", ".cc", ".cpp", ".m", ".mm", ".lua"};
|
||||
const std::string extension = path.extension().string();
|
||||
for (std::string_view candidate : kExtensions) {
|
||||
if (extension == candidate) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool CheckWireBoundary(const std::filesystem::path& repo_root) {
|
||||
static constexpr std::array<std::string_view, 12> kForbiddenReferences = {
|
||||
"apps/desktop", "../desktop", "platform/common", "<windows.h>",
|
||||
"<Windows.h>", "<AppKit/", "<Cocoa/", "<CoreGraphics/",
|
||||
"<X11/", "<wayland-", "<linux/", "<d3d"};
|
||||
|
||||
bool ok = true;
|
||||
const std::filesystem::path wire_root = repo_root / "libs/wire";
|
||||
for (const auto& entry :
|
||||
std::filesystem::recursive_directory_iterator(wire_root)) {
|
||||
if (!entry.is_regular_file() || !IsCheckedWireFile(entry.path())) {
|
||||
continue;
|
||||
}
|
||||
const std::string contents = ReadFile(entry.path());
|
||||
for (std::string_view forbidden : kForbiddenReferences) {
|
||||
if (contents.find(forbidden) == std::string::npos) {
|
||||
continue;
|
||||
}
|
||||
std::cerr << entry.path().lexically_relative(repo_root).string()
|
||||
<< " crosses the wire/app boundary with: " << forbidden
|
||||
<< '\n';
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool CheckPlatformIncludesArePrivate(
|
||||
const std::filesystem::path& repo_root) {
|
||||
const std::filesystem::path targets_path =
|
||||
repo_root / "apps/desktop/xmake/targets.lua";
|
||||
const std::string targets = ReadFile(targets_path);
|
||||
if (targets.empty()) {
|
||||
std::cerr << "failed to read "
|
||||
<< targets_path.lexically_relative(repo_root).string() << '\n';
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ok = true;
|
||||
std::size_t cursor = 0;
|
||||
constexpr std::string_view kCall = "add_includedirs(";
|
||||
constexpr std::string_view kPlatformPath =
|
||||
"apps/desktop/src/platform/";
|
||||
while ((cursor = targets.find(kCall, cursor)) != std::string::npos) {
|
||||
const std::size_t call_end = targets.find(')', cursor + kCall.size());
|
||||
if (call_end == std::string::npos) {
|
||||
std::cerr << "unterminated add_includedirs call in "
|
||||
<< targets_path.lexically_relative(repo_root).string() << '\n';
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::string_view call(targets.data() + cursor,
|
||||
call_end - cursor + 1);
|
||||
std::string compact_call;
|
||||
compact_call.reserve(call.size());
|
||||
for (char character : call) {
|
||||
if (!std::isspace(static_cast<unsigned char>(character))) {
|
||||
compact_call.push_back(character);
|
||||
}
|
||||
}
|
||||
if (call.find(kPlatformPath) != std::string_view::npos &&
|
||||
compact_call.find("public=true") != std::string::npos) {
|
||||
std::cerr << "platform implementation include directory is public: "
|
||||
<< call << '\n';
|
||||
ok = false;
|
||||
}
|
||||
cursor = call_end + 1;
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool CheckWireLayout(const std::filesystem::path& repo_root) {
|
||||
static constexpr std::array<std::string_view, 5> kForbiddenPaths = {
|
||||
"libs/wire/include/crossdesk", "libs/wire/xmake.lua", "libs/wire/.xmake",
|
||||
"xmake.protocol.lua", "xmake.wire.lua"};
|
||||
bool ok = true;
|
||||
for (std::string_view relative_path : kForbiddenPaths) {
|
||||
if (!std::filesystem::exists(repo_root / relative_path)) {
|
||||
continue;
|
||||
}
|
||||
std::cerr << "forbidden legacy layer, standalone project, or wire cache: "
|
||||
<< relative_path << '\n';
|
||||
ok = false;
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
const std::filesystem::path repo_root = FindRepoRoot();
|
||||
if (repo_root.empty()) {
|
||||
std::cerr << "failed to locate repository root\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
bool ok = true;
|
||||
static constexpr std::array<std::string_view, 11> kLegacyRootDirectories = {
|
||||
"src", "ios", "scripts", "icons",
|
||||
"tests", "xmake", "shared", "platforms",
|
||||
"wire", "submodules", "thirdparty"};
|
||||
for (std::string_view directory : kLegacyRootDirectories) {
|
||||
if (!std::filesystem::exists(repo_root / directory)) {
|
||||
continue;
|
||||
}
|
||||
std::cerr << "legacy root directory still exists: " << directory << '\n';
|
||||
ok = false;
|
||||
}
|
||||
|
||||
ok &= CheckWireBoundary(repo_root);
|
||||
ok &= CheckPlatformIncludesArePrivate(repo_root);
|
||||
ok &= CheckWireLayout(repo_root);
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
@@ -1,592 +0,0 @@
|
||||
#include "crossdesk_ui.h"
|
||||
#include "fa_solid_900.h"
|
||||
#include "ui/ui_localization.h"
|
||||
|
||||
#include <cassert>
|
||||
#include <charconv>
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
// Set the CROSSDESK_*_UI_SNAPSHOT variables while using Slint's software
|
||||
// renderer to export deterministic active and inactive visual-check images.
|
||||
bool WriteWindowSnapshot(const slint::Window &window, const char *path) {
|
||||
const auto snapshot = window.take_snapshot();
|
||||
if (!snapshot) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::ofstream output(path, std::ios::binary);
|
||||
output << "P6\n"
|
||||
<< snapshot->width() << ' ' << snapshot->height() << "\n255\n";
|
||||
for (const auto &pixel : *snapshot) {
|
||||
output.put(static_cast<char>(pixel.r));
|
||||
output.put(static_cast<char>(pixel.g));
|
||||
output.put(static_cast<char>(pixel.b));
|
||||
}
|
||||
return output.good();
|
||||
}
|
||||
|
||||
bool RegisterFontAwesome(slint::Window &window) {
|
||||
return !window.window_handle()
|
||||
.register_font_from_data(fa_solid_900_ttf,
|
||||
fa_solid_900_ttf_len)
|
||||
.has_value();
|
||||
}
|
||||
|
||||
struct CaptureOptions {
|
||||
bool enabled = false;
|
||||
std::string page;
|
||||
int language = 0;
|
||||
std::string snapshot_path;
|
||||
};
|
||||
|
||||
// Compatibility entry point for the former application-level debug capture
|
||||
// mode. CROSSDESK_UI_CAPTURE keeps the interactive workflow, while
|
||||
// CROSSDESK_UI_CAPTURE_SNAPSHOT writes a deterministic PPM and exits so every
|
||||
// page can be exercised in automation. The legacy /tmp overrides still win.
|
||||
std::string ReadFirstLine(const char *path) {
|
||||
std::ifstream input(path);
|
||||
std::string value;
|
||||
std::getline(input, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
CaptureOptions LoadCaptureOptions() {
|
||||
CaptureOptions options;
|
||||
if (const char *capture = std::getenv("CROSSDESK_UI_CAPTURE")) {
|
||||
options.enabled = std::string_view(capture) != "0";
|
||||
}
|
||||
if (!options.enabled) {
|
||||
return options;
|
||||
}
|
||||
|
||||
if (const char *page = std::getenv("CROSSDESK_UI_CAPTURE_PAGE")) {
|
||||
options.page = page;
|
||||
}
|
||||
if (const std::string page_override =
|
||||
ReadFirstLine("/tmp/crossdesk-ui-capture-page");
|
||||
!page_override.empty()) {
|
||||
options.page = page_override;
|
||||
}
|
||||
|
||||
std::string language_value =
|
||||
ReadFirstLine("/tmp/crossdesk-ui-capture-language");
|
||||
if (language_value.empty()) {
|
||||
if (const char *language =
|
||||
std::getenv("CROSSDESK_UI_CAPTURE_LANGUAGE")) {
|
||||
language_value = language;
|
||||
}
|
||||
}
|
||||
if (!language_value.empty()) {
|
||||
int language = 0;
|
||||
const auto [end, error] = std::from_chars(
|
||||
language_value.data(), language_value.data() + language_value.size(),
|
||||
language);
|
||||
if (error == std::errc{} &&
|
||||
end == language_value.data() + language_value.size()) {
|
||||
options.language =
|
||||
crossdesk::localization::detail::ClampLanguageIndex(language);
|
||||
}
|
||||
}
|
||||
if (const char *snapshot =
|
||||
std::getenv("CROSSDESK_UI_CAPTURE_SNAPSHOT")) {
|
||||
options.snapshot_path = snapshot;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
bool HasNonEmptyEnvironmentVariable(const char *name) {
|
||||
const char *value = std::getenv(name);
|
||||
return value != nullptr && value[0] != '\0';
|
||||
}
|
||||
|
||||
void ResetMainCaptureState(
|
||||
const slint::ComponentHandle<crossdesk::ui::MainWindow> &window) {
|
||||
window->set_settings_open(false);
|
||||
window->set_self_host_settings_open(false);
|
||||
window->set_about_open(false);
|
||||
window->set_update_open(false);
|
||||
window->set_update_available(false);
|
||||
window->set_reset_password_open(false);
|
||||
window->set_reset_password_invalid(false);
|
||||
window->set_new_password_input("");
|
||||
window->set_alias_open(false);
|
||||
window->set_alias_input("");
|
||||
window->set_delete_open(false);
|
||||
window->set_offline_warning("");
|
||||
window->set_connection_dialog_open(false);
|
||||
window->set_connection_status_text("");
|
||||
window->set_connection_pending(false);
|
||||
window->set_connection_password_required(false);
|
||||
window->set_connection_validating(false);
|
||||
window->set_permission_dialog_open(false);
|
||||
window->set_screen_recording_granted(true);
|
||||
window->set_accessibility_granted(true);
|
||||
window->set_settings_session_active(false);
|
||||
window->set_portable_service_settings_visible(false);
|
||||
window->set_portable_service_dialog_open(false);
|
||||
window->set_portable_service_suppressed_notice_open(false);
|
||||
}
|
||||
|
||||
void ConfigureMainCapturePage(
|
||||
const slint::ComponentHandle<crossdesk::ui::MainWindow> &window,
|
||||
const CaptureOptions &options) {
|
||||
const int language = crossdesk::ui_localization::ApplyMainWindowStrings(
|
||||
window, options.language);
|
||||
crossdesk::ui_localization::ApplyStreamWindowStrings(window, language);
|
||||
window->set_language_index(language);
|
||||
ResetMainCaptureState(window);
|
||||
|
||||
#if _WIN32
|
||||
window->set_custom_titlebar(true);
|
||||
window->set_wayland_titlebar(false);
|
||||
#if CROSSDESK_PORTABLE
|
||||
window->set_portable_service_settings_visible(true);
|
||||
#endif
|
||||
#elif defined(__linux__)
|
||||
const bool has_x11 = HasNonEmptyEnvironmentVariable("DISPLAY");
|
||||
const bool use_xwayland =
|
||||
has_x11 && HasNonEmptyEnvironmentVariable("WAYLAND_DISPLAY");
|
||||
window->set_custom_titlebar(has_x11);
|
||||
window->set_wayland_titlebar(use_xwayland);
|
||||
#else
|
||||
window->set_custom_titlebar(false);
|
||||
window->set_wayland_titlebar(false);
|
||||
#endif
|
||||
|
||||
if (options.page == "settings") {
|
||||
window->set_settings_open(true);
|
||||
} else if (options.page == "self-hosted") {
|
||||
window->set_settings_open(true);
|
||||
window->set_self_host_settings_open(true);
|
||||
} else if (options.page == "about") {
|
||||
window->set_current_version("0.0.0");
|
||||
window->set_about_open(true);
|
||||
} else if (options.page == "update") {
|
||||
window->set_update_available(true);
|
||||
window->set_latest_version("v0.0.1");
|
||||
window->set_release_name("CrossDesk Preview");
|
||||
window->set_release_date("2026-08-25");
|
||||
window->set_update_open(true);
|
||||
} else if (options.page == "reset-password") {
|
||||
window->set_reset_password_open(true);
|
||||
} else if (options.page == "reset-password-invalid") {
|
||||
window->set_reset_password_open(true);
|
||||
window->set_reset_password_invalid(true);
|
||||
window->set_new_password_input("123");
|
||||
} else if (options.page == "alias") {
|
||||
window->set_alias_input("Office workstation");
|
||||
window->set_alias_open(true);
|
||||
} else if (options.page == "delete") {
|
||||
window->set_delete_open(true);
|
||||
} else if (options.page == "offline") {
|
||||
window->set_offline_warning(crossdesk::ui_localization::Text(
|
||||
crossdesk::localization::device_offline[language]));
|
||||
} else if (options.page == "connection") {
|
||||
window->set_connection_status_text(crossdesk::ui_localization::Text(
|
||||
crossdesk::localization::p2p_connecting[language]));
|
||||
window->set_connection_pending(true);
|
||||
window->set_connection_dialog_open(true);
|
||||
} else if (options.page == "connection-password") {
|
||||
window->set_connection_status_text(crossdesk::ui_localization::Text(
|
||||
crossdesk::localization::reinput_password[language]));
|
||||
window->set_connection_password_required(true);
|
||||
window->set_connection_dialog_open(true);
|
||||
} else if (options.page == "service") {
|
||||
window->set_portable_service_settings_visible(true);
|
||||
window->set_portable_service_dialog_open(true);
|
||||
} else if (options.page == "service-notice") {
|
||||
window->set_portable_service_settings_visible(true);
|
||||
window->set_portable_service_suppressed_notice_open(true);
|
||||
} else if (options.page == "permission") {
|
||||
window->set_permission_dialog_open(true);
|
||||
window->set_screen_recording_granted(false);
|
||||
window->set_accessibility_granted(false);
|
||||
}
|
||||
}
|
||||
|
||||
slint::Image CreateStreamPreviewImage() {
|
||||
constexpr int preview_width = 640;
|
||||
constexpr int preview_height = 360;
|
||||
slint::SharedPixelBuffer<slint::Rgb8Pixel> pixels(preview_width,
|
||||
preview_height);
|
||||
auto *data = reinterpret_cast<uint8_t *>(pixels.begin());
|
||||
for (int y = 0; y < preview_height; ++y) {
|
||||
for (int x = 0; x < preview_width; ++x) {
|
||||
const bool border = x < 8 || y < 8 || x >= preview_width - 8 ||
|
||||
y >= preview_height - 8;
|
||||
const size_t offset = (static_cast<size_t>(y) * preview_width + x) * 3;
|
||||
data[offset] =
|
||||
border ? 240 : static_cast<uint8_t>(35 + 150 * x / preview_width);
|
||||
data[offset + 1] =
|
||||
border ? 70 : static_cast<uint8_t>(35 + 150 * y / preview_height);
|
||||
data[offset + 2] = border ? 70 : 90;
|
||||
}
|
||||
}
|
||||
return slint::Image(std::move(pixels));
|
||||
}
|
||||
|
||||
void ConfigureStreamCapturePage(
|
||||
const slint::ComponentHandle<crossdesk::ui::StreamWindow> &stream,
|
||||
int language) {
|
||||
language = crossdesk::ui_localization::ApplyStreamWindowStrings(
|
||||
stream, language);
|
||||
#if defined(__linux__)
|
||||
stream->set_custom_titlebar(
|
||||
HasNonEmptyEnvironmentVariable("DISPLAY") &&
|
||||
HasNonEmptyEnvironmentVariable("WAYLAND_DISPLAY"));
|
||||
#else
|
||||
stream->set_custom_titlebar(false);
|
||||
#endif
|
||||
stream->set_native_video_enabled(false);
|
||||
stream->set_window_maximized(false);
|
||||
stream->set_fullscreen_enabled(false);
|
||||
stream->set_stats_visible(false);
|
||||
stream->set_file_transfer_visible(false);
|
||||
stream->set_receiving_text("");
|
||||
stream->set_status_text("");
|
||||
|
||||
crossdesk::ui::StreamTab tab;
|
||||
tab.remote_id = "589173341";
|
||||
tab.title = "Mac";
|
||||
tab.connected = true;
|
||||
stream->set_tabs(
|
||||
std::make_shared<slint::VectorModel<crossdesk::ui::StreamTab>>(
|
||||
std::vector{tab}));
|
||||
stream->set_displays(
|
||||
std::make_shared<slint::VectorModel<slint::SharedString>>(
|
||||
std::vector{slint::SharedString("Display 1")}));
|
||||
stream->set_frame(CreateStreamPreviewImage());
|
||||
stream->set_has_frame(true);
|
||||
const float height = stream->get_custom_titlebar() ? 752.0f : 720.0f;
|
||||
stream->window().set_size(
|
||||
slint::LogicalSize(slint::Size<float>{1280.0f, height}));
|
||||
}
|
||||
|
||||
void ConfigureServerCapturePage(
|
||||
const slint::ComponentHandle<crossdesk::ui::ServerWindow> &server,
|
||||
int language) {
|
||||
language = crossdesk::localization::detail::ClampLanguageIndex(language);
|
||||
crossdesk::ui::ControllerEntry controller;
|
||||
controller.remote_id = "589173341";
|
||||
controller.display_name = "Mac";
|
||||
server->set_controllers(
|
||||
std::make_shared<slint::VectorModel<crossdesk::ui::ControllerEntry>>(
|
||||
std::vector{controller}));
|
||||
server->set_controller_names(
|
||||
std::make_shared<slint::VectorModel<slint::SharedString>>(
|
||||
std::vector{slint::SharedString("Mac")}));
|
||||
server->set_language_index(language);
|
||||
server->set_controller_label(crossdesk::ui_localization::Text(
|
||||
crossdesk::localization::controller[language]));
|
||||
server->set_connection_label(crossdesk::ui_localization::Text(
|
||||
crossdesk::localization::connection_status[language]));
|
||||
server->set_connection_status(crossdesk::ui_localization::Text(
|
||||
crossdesk::localization::p2p_connected[language]));
|
||||
server->set_file_transfer_label(crossdesk::ui_localization::Text(
|
||||
crossdesk::localization::file_transfer[language]));
|
||||
server->set_select_file_label(crossdesk::ui_localization::Text(
|
||||
crossdesk::localization::select_file[language]));
|
||||
server->set_file_transfer_visible(false);
|
||||
server->set_sending_file(false);
|
||||
const float width = language == 0 ? 250.0f : language == 1 ? 330.0f : 430.0f;
|
||||
server->window().set_size(
|
||||
slint::LogicalSize(slint::Size<float>{width, 150.0f}));
|
||||
}
|
||||
|
||||
int RunCaptureMode(
|
||||
const CaptureOptions &options,
|
||||
slint::ComponentHandle<crossdesk::ui::MainWindow> &window,
|
||||
slint::ComponentHandle<crossdesk::ui::StreamWindow> &stream,
|
||||
slint::ComponentHandle<crossdesk::ui::ServerWindow> &server) {
|
||||
ConfigureMainCapturePage(window, options);
|
||||
stream->hide();
|
||||
server->hide();
|
||||
|
||||
slint::Window *capture_window = &window->window();
|
||||
if (options.page == "stream") {
|
||||
ConfigureStreamCapturePage(stream, options.language);
|
||||
window->hide();
|
||||
stream->show();
|
||||
capture_window = &stream->window();
|
||||
} else if (options.page == "server") {
|
||||
ConfigureServerCapturePage(server, options.language);
|
||||
window->hide();
|
||||
server->show();
|
||||
capture_window = &server->window();
|
||||
} else {
|
||||
window->show();
|
||||
}
|
||||
|
||||
if (!options.snapshot_path.empty()) {
|
||||
return WriteWindowSnapshot(*capture_window, options.snapshot_path.c_str())
|
||||
? 0
|
||||
: 7;
|
||||
}
|
||||
|
||||
if (options.page == "stream" || options.page == "server") {
|
||||
slint::run_event_loop();
|
||||
} else {
|
||||
window->run();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
const CaptureOptions capture_options = LoadCaptureOptions();
|
||||
auto window = crossdesk::ui::MainWindow::create();
|
||||
auto stream = crossdesk::ui::StreamWindow::create();
|
||||
auto server = crossdesk::ui::ServerWindow::create();
|
||||
if (!RegisterFontAwesome(window->window()) ||
|
||||
!RegisterFontAwesome(stream->window()) ||
|
||||
!RegisterFontAwesome(server->window())) {
|
||||
return 1;
|
||||
}
|
||||
window->set_local_id("123 456 789");
|
||||
window->set_local_password("123456");
|
||||
window->set_connection_dialog_open(true);
|
||||
window->set_connection_password_required(true);
|
||||
window->set_custom_titlebar(true);
|
||||
window->set_wayland_titlebar(true);
|
||||
window->set_settings_session_active(true);
|
||||
window->set_hardware_codec_available(false);
|
||||
window->set_portable_service_settings_visible(true);
|
||||
assert(std::string(window->get_local_id()) == "123 456 789");
|
||||
assert(window->get_connection_dialog_open());
|
||||
assert(window->get_connection_password_required());
|
||||
assert(window->get_custom_titlebar());
|
||||
assert(window->get_wayland_titlebar());
|
||||
assert(window->get_settings_session_active());
|
||||
assert(!window->get_hardware_codec_available());
|
||||
assert(window->get_portable_service_settings_visible());
|
||||
if (const char *snapshot_path =
|
||||
std::getenv("CROSSDESK_MAIN_UI_SNAPSHOT")) {
|
||||
window->show();
|
||||
const bool snapshot_written =
|
||||
WriteWindowSnapshot(window->window(), snapshot_path);
|
||||
window->hide();
|
||||
if (!snapshot_written) {
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
if (const char *snapshot_path =
|
||||
std::getenv("CROSSDESK_MAIN_INACTIVE_UI_SNAPSHOT")) {
|
||||
window->set_window_active(false);
|
||||
window->show();
|
||||
const bool snapshot_written =
|
||||
WriteWindowSnapshot(window->window(), snapshot_path);
|
||||
window->hide();
|
||||
window->set_window_active(true);
|
||||
if (!snapshot_written) {
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
window->set_settings_open(true);
|
||||
window->set_about_open(true);
|
||||
assert(window->get_settings_open());
|
||||
assert(window->get_about_open());
|
||||
if (const char *snapshot_path =
|
||||
std::getenv("CROSSDESK_SETTINGS_UI_SNAPSHOT")) {
|
||||
window->set_about_open(false);
|
||||
window->set_connection_dialog_open(false);
|
||||
window->show();
|
||||
const bool snapshot_written =
|
||||
WriteWindowSnapshot(window->window(), snapshot_path);
|
||||
window->hide();
|
||||
if (!snapshot_written) {
|
||||
return 6;
|
||||
}
|
||||
window->set_connection_dialog_open(true);
|
||||
window->set_about_open(true);
|
||||
}
|
||||
window->set_remote_id_input("987654321");
|
||||
window->invoke_reset_remote_id();
|
||||
assert(std::string(window->get_remote_id_input()).empty());
|
||||
|
||||
std::vector<crossdesk::ui::RecentConnection> connections;
|
||||
crossdesk::ui::RecentConnection connection;
|
||||
connection.remote_id = "987654321";
|
||||
connection.display_name = "Office PC";
|
||||
connection.host_name = "office-pc";
|
||||
connection.online = true;
|
||||
connections.emplace_back(std::move(connection));
|
||||
window->set_recent_connections(
|
||||
std::make_shared<slint::VectorModel<crossdesk::ui::RecentConnection>>(
|
||||
std::move(connections)));
|
||||
assert(window->get_recent_connections()->row_count() == 1);
|
||||
|
||||
bool connect_requested = false;
|
||||
window->on_connect_requested([&](slint::SharedString remote_id) {
|
||||
connect_requested = std::string(remote_id) == "987654321";
|
||||
});
|
||||
window->invoke_connect_requested("987654321");
|
||||
assert(connect_requested);
|
||||
|
||||
bool password_submitted = false;
|
||||
window->on_connection_submit_password(
|
||||
[&](slint::SharedString password, bool remember) {
|
||||
password_submitted = std::string(password) == "654321" && remember;
|
||||
});
|
||||
window->invoke_connection_submit_password("654321", true);
|
||||
assert(password_submitted);
|
||||
|
||||
crossdesk::ui::StreamTab tab;
|
||||
tab.remote_id = "123456789";
|
||||
tab.title = "Remote host";
|
||||
tab.connected = true;
|
||||
stream->set_tabs(
|
||||
std::make_shared<slint::VectorModel<crossdesk::ui::StreamTab>>(
|
||||
std::vector{tab}));
|
||||
stream->set_custom_titlebar(true);
|
||||
stream->set_native_video_enabled(true);
|
||||
stream->set_window_maximized(true);
|
||||
assert(stream->get_tabs()->row_count() == 1);
|
||||
assert(stream->get_custom_titlebar());
|
||||
assert(stream->get_native_video_enabled());
|
||||
assert(stream->get_window_maximized());
|
||||
// Keep snapshots on the CPU-rendered UI path; the native Metal video view
|
||||
// is intentionally outside Slint's component snapshot.
|
||||
stream->set_native_video_enabled(false);
|
||||
if (const char *snapshot_path =
|
||||
std::getenv("CROSSDESK_STREAM_UI_SNAPSHOT")) {
|
||||
stream->set_window_maximized(false);
|
||||
stream->show();
|
||||
const bool snapshot_written =
|
||||
WriteWindowSnapshot(stream->window(), snapshot_path);
|
||||
stream->hide();
|
||||
stream->set_window_maximized(true);
|
||||
if (!snapshot_written) {
|
||||
return 4;
|
||||
}
|
||||
}
|
||||
if (const char *snapshot_path =
|
||||
std::getenv("CROSSDESK_STREAM_INACTIVE_UI_SNAPSHOT")) {
|
||||
stream->set_window_maximized(false);
|
||||
stream->set_window_active(false);
|
||||
stream->show();
|
||||
const bool snapshot_written =
|
||||
WriteWindowSnapshot(stream->window(), snapshot_path);
|
||||
stream->hide();
|
||||
stream->set_window_active(true);
|
||||
stream->set_window_maximized(true);
|
||||
if (!snapshot_written) {
|
||||
return 5;
|
||||
}
|
||||
}
|
||||
|
||||
bool tab_reordered = false;
|
||||
stream->on_reorder_tab([&](int index, float x, float width) {
|
||||
tab_reordered = index == 0 && x == 120.0f && width == 110.0f;
|
||||
});
|
||||
stream->invoke_reorder_tab(0, 120.0f, 110.0f);
|
||||
assert(tab_reordered);
|
||||
|
||||
bool maximize_requested = false;
|
||||
stream->on_toggle_maximize_stream_window(
|
||||
[&] { maximize_requested = true; });
|
||||
stream->invoke_toggle_maximize_stream_window();
|
||||
assert(maximize_requested);
|
||||
|
||||
bool keyboard_focus_changed = false;
|
||||
bool keyboard_input_received = false;
|
||||
stream->on_keyboard_focus_changed(
|
||||
[&](bool focused) { keyboard_focus_changed = focused; });
|
||||
stream->on_key_input(
|
||||
[&](slint::SharedString text, bool pressed, bool, bool, bool, bool) {
|
||||
keyboard_input_received = std::string(text) == "a" && pressed;
|
||||
});
|
||||
stream->window().set_size(
|
||||
slint::LogicalSize(slint::Size<float>{1280.0f, 720.0f}));
|
||||
stream->window().dispatch_pointer_press_event(
|
||||
slint::LogicalPosition(slint::Point<float>{640.0f, 360.0f}),
|
||||
slint::PointerEventButton::Left);
|
||||
stream->window().dispatch_pointer_release_event(
|
||||
slint::LogicalPosition(slint::Point<float>{640.0f, 360.0f}),
|
||||
slint::PointerEventButton::Left);
|
||||
stream->window().dispatch_key_press_event("a");
|
||||
assert(keyboard_focus_changed);
|
||||
assert(keyboard_input_received);
|
||||
|
||||
crossdesk::ui::FileTransferEntry transfer;
|
||||
transfer.name = "archive.zip";
|
||||
transfer.status = "Sending";
|
||||
transfer.progress = 0.5f;
|
||||
transfer.speed = "1.0 mbps";
|
||||
transfer.size = "2.00 MB";
|
||||
stream->set_file_transfers(
|
||||
std::make_shared<
|
||||
slint::VectorModel<crossdesk::ui::FileTransferEntry>>(
|
||||
std::vector{transfer}));
|
||||
stream->set_file_transfer_visible(true);
|
||||
crossdesk::ui::NetworkStatsRow video_stats;
|
||||
video_stats.label = "Video";
|
||||
video_stats.inbound = "427 kbps";
|
||||
video_stats.outbound = "5 kbps";
|
||||
video_stats.loss_rate = "0%";
|
||||
stream->set_stats_rows(
|
||||
std::make_shared<
|
||||
slint::VectorModel<crossdesk::ui::NetworkStatsRow>>(
|
||||
std::vector{video_stats}));
|
||||
stream->set_stats_fps("53");
|
||||
stream->set_stats_resolution("3024x1964");
|
||||
stream->set_stats_connection_mode("Direct");
|
||||
stream->set_stats_visible(true);
|
||||
stream->set_fullscreen_enabled(true);
|
||||
assert(stream->get_file_transfers()->row_count() == 1);
|
||||
assert(stream->get_file_transfer_visible());
|
||||
assert(stream->get_stats_rows()->row_count() == 1);
|
||||
assert(std::string(stream->get_stats_fps()) == "53");
|
||||
assert(std::string(stream->get_stats_resolution()) == "3024x1964");
|
||||
assert(std::string(stream->get_stats_connection_mode()) == "Direct");
|
||||
assert(stream->get_stats_visible());
|
||||
assert(stream->get_fullscreen_enabled());
|
||||
|
||||
bool display_switched = false;
|
||||
stream->on_switch_display(
|
||||
[&](int index) { display_switched = index == 1; });
|
||||
stream->invoke_switch_display(1);
|
||||
assert(display_switched);
|
||||
|
||||
auto &stream_strings = stream->global<crossdesk::ui::StreamStrings>();
|
||||
stream_strings.set_select_display("Select Display");
|
||||
stream_strings.set_expand_control("Expand Control Bar");
|
||||
assert(std::string(stream_strings.get_select_display()) == "Select Display");
|
||||
assert(std::string(stream_strings.get_expand_control()) ==
|
||||
"Expand Control Bar");
|
||||
|
||||
crossdesk::ui::ControllerEntry controller;
|
||||
controller.remote_id = "123456789";
|
||||
controller.display_name = "Remote host";
|
||||
server->set_controllers(
|
||||
std::make_shared<slint::VectorModel<crossdesk::ui::ControllerEntry>>(
|
||||
std::vector{controller}));
|
||||
server->set_file_transfer_visible(true);
|
||||
server->set_sending_file(true);
|
||||
server->set_file_progress(0.5f);
|
||||
server->set_current_file_name("archive.zip");
|
||||
server->set_file_size_text("1.00 MB / 2.00 MB");
|
||||
assert(server->get_controllers()->row_count() == 1);
|
||||
assert(server->get_file_transfer_visible());
|
||||
assert(server->get_sending_file());
|
||||
assert(server->get_file_progress() == 0.5f);
|
||||
|
||||
bool controller_selected = false;
|
||||
server->on_controller_selected(
|
||||
[&](int index) { controller_selected = index == 0; });
|
||||
server->invoke_controller_selected(0);
|
||||
assert(controller_selected);
|
||||
|
||||
if (capture_options.enabled) {
|
||||
return RunCaptureMode(capture_options, window, stream, server);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
#include <cstdint>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
|
||||
#include "video_receive_scheduler.h"
|
||||
|
||||
namespace {
|
||||
|
||||
std::unique_ptr<minirtc::ReceivedFrame>
|
||||
MakeFrame(uint32_t rtp_timestamp, int64_t received_us, bool key_frame = false) {
|
||||
const uint8_t payload = key_frame ? 0x65 : 0x41;
|
||||
auto frame =
|
||||
std::make_unique<minirtc::ReceivedFrame>(&payload, sizeof(payload));
|
||||
frame->SetRtpTimestamp(rtp_timestamp);
|
||||
frame->SetReceivedTimestamp(received_us);
|
||||
frame->SetCapturedTimestamp(received_us - 5000);
|
||||
frame->SetFrameType(key_frame ? minirtc::kVideoFrameKey
|
||||
: minirtc::kVideoFrameDelta);
|
||||
return frame;
|
||||
}
|
||||
|
||||
bool Expect(bool condition, const char *message) {
|
||||
if (!condition) {
|
||||
std::cerr << message << '\n';
|
||||
}
|
||||
return condition;
|
||||
}
|
||||
|
||||
bool TestOneFramePerPlaybackCycle() {
|
||||
minirtc::VideoReceiveScheduler scheduler(60);
|
||||
scheduler.Enqueue(MakeFrame(90000, 1000000), 1000000);
|
||||
scheduler.Enqueue(MakeFrame(91500, 1016667), 1016667);
|
||||
|
||||
auto first = scheduler.Poll(1016667, false);
|
||||
bool ok = Expect(first.frame != nullptr, "first frame was not released");
|
||||
ok &= Expect(first.frame && first.frame->RtpTimestamp() == 90000,
|
||||
"scheduler did not preserve RTP order");
|
||||
|
||||
auto same_cycle = scheduler.Poll(1016667, false);
|
||||
ok &= Expect(!same_cycle.frame,
|
||||
"scheduler released more than one frame in a cycle");
|
||||
ok &= Expect(same_cycle.next_delay_us > 0,
|
||||
"scheduler did not schedule the next playback cycle");
|
||||
|
||||
auto second = scheduler.Poll(1033334, false);
|
||||
ok &= Expect(second.frame != nullptr,
|
||||
"second frame was not released on the next cycle");
|
||||
ok &= Expect(second.frame && second.frame->RtpTimestamp() == 91500,
|
||||
"second RTP timestamp was incorrect");
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool TestSevereLagJumpsToNewestKeyFrame() {
|
||||
minirtc::VideoReceiveScheduler scheduler(60);
|
||||
for (uint32_t i = 0; i < 10; ++i) {
|
||||
scheduler.Enqueue(MakeFrame(90000 + i * 1500, 1000000 + i * 16667, i == 8),
|
||||
1000000 + i * 16667);
|
||||
}
|
||||
|
||||
auto result = scheduler.Poll(1200000, false);
|
||||
const auto metrics = scheduler.GetMetrics(1200000);
|
||||
bool ok = Expect(result.frame && result.frame->IsKeyFrame(),
|
||||
"catch-up did not jump to the newest key frame");
|
||||
ok &= Expect(result.dropped_frames == 8,
|
||||
"catch-up dropped an unexpected number of frames");
|
||||
ok &= Expect(metrics.catch_up_count == 1,
|
||||
"catch-up metric was not incremented");
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool TestSevereLagRequestsKeyFrameWhenNoSyncPointExists() {
|
||||
minirtc::VideoReceiveScheduler scheduler(60);
|
||||
for (uint32_t i = 0; i < 10; ++i) {
|
||||
scheduler.Enqueue(MakeFrame(180000 + i * 1500, 2000000 + i * 16667),
|
||||
2000000 + i * 16667);
|
||||
}
|
||||
|
||||
auto result = scheduler.Poll(2200000, false);
|
||||
const auto metrics = scheduler.GetMetrics(2200000);
|
||||
bool ok = Expect(!result.frame,
|
||||
"undecodable delta frame was released during catch-up");
|
||||
ok &=
|
||||
Expect(result.request_key_frame, "catch-up did not request a key frame");
|
||||
ok &= Expect(metrics.buffered_frames == 0 && metrics.awaiting_key_frame,
|
||||
"scheduler did not enter key-frame wait state");
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool TestAdaptiveBufferStaysWithinOneToThreeFrames() {
|
||||
minirtc::VideoReceiveScheduler scheduler(60);
|
||||
int64_t arrival_us = 3000000;
|
||||
for (uint32_t i = 0; i < 40; ++i) {
|
||||
arrival_us += (i % 2 == 0) ? 5000 : 30000;
|
||||
scheduler.Enqueue(MakeFrame(270000 + i * 1500, arrival_us), arrival_us);
|
||||
}
|
||||
|
||||
const auto metrics = scheduler.GetMetrics(arrival_us);
|
||||
bool ok = Expect(metrics.target_buffer_frames >= 1 &&
|
||||
metrics.target_buffer_frames <= 3,
|
||||
"adaptive target left the 1-3 frame range");
|
||||
ok &= Expect(metrics.target_buffer_frames > 1,
|
||||
"arrival jitter did not increase the target buffer");
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool TestLegacyMicrosecondTimestampFallsBackToArrivalCadence() {
|
||||
minirtc::VideoReceiveScheduler scheduler(60);
|
||||
int64_t arrival_us = 4000000;
|
||||
for (uint32_t i = 0; i < 20; ++i) {
|
||||
scheduler.Enqueue(MakeFrame(500000 + i * 16667, arrival_us), arrival_us);
|
||||
scheduler.Poll(arrival_us, false);
|
||||
arrival_us += 16667;
|
||||
}
|
||||
scheduler.Poll(arrival_us, false);
|
||||
|
||||
const auto metrics = scheduler.GetMetrics(arrival_us);
|
||||
bool ok = Expect(metrics.estimated_frame_interval_us >= 15000 &&
|
||||
metrics.estimated_frame_interval_us <= 20000,
|
||||
"legacy RTP units slowed the playback interval");
|
||||
ok &= Expect(metrics.timestamp_fallbacks > 0,
|
||||
"legacy RTP units did not activate timestamp fallback");
|
||||
ok &= Expect(metrics.released_frames == 20 && metrics.dropped_frames == 0,
|
||||
"legacy RTP units caused scheduler frame loss");
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool TestSixtyFpsCadenceDoesNotTriggerCatchUp() {
|
||||
minirtc::VideoReceiveScheduler scheduler(60);
|
||||
constexpr int64_t kStartUs = 6000000;
|
||||
constexpr int64_t kFrameIntervalUs = 16667;
|
||||
constexpr uint32_t kStartRtpTimestamp = 700000;
|
||||
constexpr uint32_t kRtpFrameInterval = 1500;
|
||||
|
||||
for (uint32_t i = 0; i < 60; ++i) {
|
||||
const int64_t now_us = kStartUs + static_cast<int64_t>(i) *
|
||||
kFrameIntervalUs;
|
||||
scheduler.Enqueue(
|
||||
MakeFrame(kStartRtpTimestamp + i * kRtpFrameInterval, now_us),
|
||||
now_us);
|
||||
scheduler.Poll(now_us, false);
|
||||
}
|
||||
scheduler.Poll(kStartUs + 60 * kFrameIntervalUs, false);
|
||||
|
||||
const auto metrics =
|
||||
scheduler.GetMetrics(kStartUs + 60 * kFrameIntervalUs);
|
||||
bool ok = Expect(metrics.released_frames == 60,
|
||||
"60 fps input was not released at 60 fps cadence");
|
||||
ok &= Expect(metrics.dropped_frames == 0 && metrics.catch_up_count == 0,
|
||||
"steady 60 fps input incorrectly triggered catch-up");
|
||||
ok &= Expect(metrics.estimated_frame_interval_us >= 15000 &&
|
||||
metrics.estimated_frame_interval_us <= 18000,
|
||||
"steady 60 fps interval estimate drifted");
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool TestKeyFrameRequestCooldownKeepsDecodableProgress() {
|
||||
minirtc::VideoReceiveScheduler scheduler(60);
|
||||
for (uint32_t i = 0; i < 10; ++i) {
|
||||
scheduler.Enqueue(MakeFrame(600000 + i * 1500,
|
||||
5000000 + static_cast<int64_t>(i) * 16667),
|
||||
5000000 + static_cast<int64_t>(i) * 16667);
|
||||
}
|
||||
auto first_catch_up = scheduler.Poll(5200000, false);
|
||||
bool ok = Expect(first_catch_up.request_key_frame,
|
||||
"initial severe lag did not request a key frame");
|
||||
|
||||
scheduler.Enqueue(MakeFrame(615000, 5220000, true), 5220000);
|
||||
auto key_frame = scheduler.Poll(5240000, false);
|
||||
ok &= Expect(key_frame.frame && key_frame.frame->IsKeyFrame(),
|
||||
"requested key frame was not released");
|
||||
|
||||
for (uint32_t i = 0; i < 10; ++i) {
|
||||
scheduler.Enqueue(MakeFrame(616500 + i * 1500,
|
||||
5250000 + static_cast<int64_t>(i) * 16667),
|
||||
5250000 + static_cast<int64_t>(i) * 16667);
|
||||
}
|
||||
auto cooldown_catch_up = scheduler.Poll(5450000, false);
|
||||
const auto metrics = scheduler.GetMetrics(5450000);
|
||||
ok &= Expect(!cooldown_catch_up.request_key_frame,
|
||||
"key frame request cooldown was ignored");
|
||||
ok &= Expect(cooldown_catch_up.frame != nullptr,
|
||||
"cooldown prevented forward playback progress");
|
||||
ok &= Expect(metrics.key_frame_requests == 1,
|
||||
"key frame request metric ignored cooldown");
|
||||
return ok;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
bool ok = true;
|
||||
ok &= TestOneFramePerPlaybackCycle();
|
||||
ok &= TestSevereLagJumpsToNewestKeyFrame();
|
||||
ok &= TestSevereLagRequestsKeyFrameWhenNoSyncPointExists();
|
||||
ok &= TestAdaptiveBufferStaysWithinOneToThreeFrames();
|
||||
ok &= TestLegacyMicrosecondTimestampFallsBackToArrivalCadence();
|
||||
ok &= TestSixtyFpsCadenceDoesNotTriggerCatchUp();
|
||||
ok &= TestKeyFrameRequestCooldownKeepsDecodableProgress();
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
#include <cstdint>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#include "rtp_defines.h"
|
||||
#include "rtp_packetizer_av1.h"
|
||||
#include "rtp_packetizer_h264.h"
|
||||
|
||||
namespace {
|
||||
|
||||
bool Expect(bool condition, const char *message) {
|
||||
if (!condition) {
|
||||
std::cerr << message << '\n';
|
||||
}
|
||||
return condition;
|
||||
}
|
||||
|
||||
bool AllPacketsUseTimestamp(
|
||||
const std::vector<std::unique_ptr<minirtc::RtpPacket>> &packets,
|
||||
uint32_t expected_timestamp) {
|
||||
if (packets.empty()) {
|
||||
return false;
|
||||
}
|
||||
for (const auto &packet : packets) {
|
||||
if (!packet || packet->Timestamp() != expected_timestamp) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TestTimestampConversion() {
|
||||
bool ok = Expect(minirtc::rtp::VideoTimestampFromMicroseconds(1000000) ==
|
||||
90000,
|
||||
"one second did not convert to 90 kHz");
|
||||
ok &= Expect(minirtc::rtp::VideoTimestampFromMicroseconds(16667) == 1500,
|
||||
"60 fps interval did not convert to 1500 RTP ticks");
|
||||
ok &= Expect(minirtc::rtp::VideoTimestampToMicroseconds(90000) == 1000000,
|
||||
"90 kHz timestamp did not convert to one second");
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool TestH264PacketizerPreservesTimestamp() {
|
||||
constexpr uint32_t kTimestamp = 0x89abcdef;
|
||||
minirtc::RtpPacketizerH264 packetizer(1234);
|
||||
std::vector<uint8_t> small_nalu = {0x65, 0x01, 0x02};
|
||||
auto single_packet =
|
||||
packetizer.Build(small_nalu.data(), small_nalu.size(), kTimestamp, true);
|
||||
bool ok = Expect(AllPacketsUseTimestamp(single_packet, kTimestamp),
|
||||
"H.264 single-NAL packetizer changed RTP timestamp");
|
||||
|
||||
std::vector<uint8_t> large_nalu(MAX_NALU_LEN + 100, 0x55);
|
||||
large_nalu.front() = 0x65;
|
||||
auto fragmented =
|
||||
packetizer.Build(large_nalu.data(), large_nalu.size(), kTimestamp, true);
|
||||
ok &= Expect(AllPacketsUseTimestamp(fragmented, kTimestamp),
|
||||
"H.264 FU-A packetizer changed RTP timestamp");
|
||||
|
||||
auto padding = packetizer.BuildPadding(2000, kTimestamp, true);
|
||||
ok &= Expect(AllPacketsUseTimestamp(padding, kTimestamp),
|
||||
"H.264 padding packetizer changed RTP timestamp");
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool TestAv1PacketizerPreservesTimestamp() {
|
||||
constexpr uint32_t kTimestamp = 0x12345678;
|
||||
minirtc::RtpPacketizerAv1 packetizer(5678);
|
||||
// One frame OBU with a one-byte payload.
|
||||
std::vector<uint8_t> obu = {0x32, 0x01, 0x00};
|
||||
auto packets = packetizer.Build(obu.data(), obu.size(), kTimestamp, true);
|
||||
return Expect(AllPacketsUseTimestamp(packets, kTimestamp),
|
||||
"AV1 packetizer changed RTP timestamp");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
bool ok = true;
|
||||
ok &= TestTimestampConversion();
|
||||
ok &= TestH264PacketizerPreservesTimestamp();
|
||||
ok &= TestAv1PacketizerPreservesTimestamp();
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
@@ -1,425 +0,0 @@
|
||||
function setup_targets()
|
||||
add_packages("spdlog", "libsdl3", "nlohmann_json")
|
||||
|
||||
includes("deps/submodules", "deps/thirdparty")
|
||||
|
||||
local function copy_slint_runtime(target)
|
||||
if not target:is_plat("windows") then
|
||||
return
|
||||
end
|
||||
|
||||
local slint = target:pkg("slint")
|
||||
if not slint and target:dep("gui") then
|
||||
slint = target:dep("gui"):pkg("slint")
|
||||
end
|
||||
assert(slint, "the Slint package is required to copy its Windows runtime")
|
||||
local runtime_dir = path.join(slint:installdir(), "lib")
|
||||
local runtime_dll = path.join(runtime_dir, "slint_cpp.dll")
|
||||
assert(os.isfile(runtime_dll), "Slint runtime not found: " .. runtime_dll)
|
||||
os.cp(runtime_dll, target:targetdir())
|
||||
end
|
||||
|
||||
local crossdesk_windows_resource = "apps/desktop/resources/windows/crossdesk.rc"
|
||||
if is_config("CROSSDESK_PORTABLE", true) then
|
||||
crossdesk_windows_resource = "apps/desktop/resources/windows/crossdesk_portable.rc"
|
||||
end
|
||||
|
||||
target("rd_log")
|
||||
set_kind("object")
|
||||
add_packages("spdlog")
|
||||
add_files("apps/desktop/src/log/rd_log.cpp")
|
||||
add_includedirs("apps/desktop/src/log", {public = true})
|
||||
|
||||
target("common")
|
||||
set_kind("object")
|
||||
add_deps("rd_log", "crossdesk_wire")
|
||||
add_packages("libyuv")
|
||||
add_files("apps/desktop/src/common/*.cpp")
|
||||
remove_files("apps/desktop/src/common/rounded_corner_button.cpp")
|
||||
if is_os("windows") then
|
||||
add_files("apps/desktop/src/platform/windows/system_info.cpp")
|
||||
elseif is_os("macosx") then
|
||||
add_files("apps/desktop/src/platform/macos/system_info.cpp")
|
||||
elseif is_os("linux") then
|
||||
add_files("apps/desktop/src/platform/linux/system_info.cpp",
|
||||
"apps/desktop/src/platform/linux/common/*.cpp")
|
||||
add_includedirs("apps/desktop/src/platform/linux/common")
|
||||
end
|
||||
add_includedirs("apps/desktop/src/common", {public = true})
|
||||
|
||||
target("path_manager")
|
||||
set_kind("object")
|
||||
add_deps("rd_log")
|
||||
add_files("apps/desktop/src/path_manager/*.cpp")
|
||||
if is_os("windows") then
|
||||
add_files("apps/desktop/src/platform/windows/path_backend.cpp")
|
||||
elseif is_os("macosx") then
|
||||
add_files("apps/desktop/src/platform/macos/path_backend.cpp")
|
||||
elseif is_os("linux") then
|
||||
add_files("apps/desktop/src/platform/linux/path_backend.cpp")
|
||||
end
|
||||
add_includedirs("apps/desktop/src", {public = true})
|
||||
add_includedirs("apps/desktop/src/path_manager", {public = true})
|
||||
|
||||
target("path_manager_portable_test")
|
||||
set_kind("binary")
|
||||
set_default(false)
|
||||
set_policy("build.ccache", false)
|
||||
add_defines("CROSSDESK_PORTABLE=1")
|
||||
add_includedirs("apps/desktop/src", "apps/desktop/src/path_manager")
|
||||
add_files("apps/desktop/tests/path_manager_portable_test.cpp",
|
||||
"apps/desktop/src/path_manager/path_manager.cpp")
|
||||
if is_os("windows") then
|
||||
add_files("apps/desktop/src/platform/windows/path_backend.cpp")
|
||||
elseif is_os("macosx") then
|
||||
add_files("apps/desktop/src/platform/macos/path_backend.cpp")
|
||||
elseif is_os("linux") then
|
||||
add_files("apps/desktop/src/platform/linux/path_backend.cpp")
|
||||
end
|
||||
|
||||
target("macos_keyboard_modifier_state_test")
|
||||
set_kind("binary")
|
||||
set_default(false)
|
||||
add_includedirs("apps/desktop/src/platform/macos/input")
|
||||
add_files("apps/desktop/tests/macos_keyboard_modifier_state_test.cpp")
|
||||
|
||||
target("cursor_position_test")
|
||||
set_kind("binary")
|
||||
set_default(false)
|
||||
add_deps("crossdesk_wire")
|
||||
add_includedirs("apps/desktop/src/common",
|
||||
"apps/desktop/src/gui/runtime")
|
||||
add_files("apps/desktop/tests/cursor_position_test.cpp")
|
||||
|
||||
target("repository_structure_test")
|
||||
set_kind("binary")
|
||||
set_default(false)
|
||||
add_files("apps/desktop/tests/repository_structure_test.cpp")
|
||||
|
||||
target("connection_status_protocol_test")
|
||||
set_kind("binary")
|
||||
set_default(false)
|
||||
add_files("apps/desktop/tests/connection_status_protocol_test.cpp")
|
||||
|
||||
target("video_callback_lifetime_test")
|
||||
set_kind("binary")
|
||||
set_default(false)
|
||||
add_files("apps/desktop/tests/video_callback_lifetime_test.cpp")
|
||||
|
||||
target("video_receive_scheduler_test")
|
||||
set_kind("binary")
|
||||
set_default(false)
|
||||
add_deps("minirtc")
|
||||
add_files("apps/desktop/tests/video_receive_scheduler_test.cpp")
|
||||
|
||||
target("video_rtp_timestamp_test")
|
||||
set_kind("binary")
|
||||
set_default(false)
|
||||
add_deps("minirtc")
|
||||
add_files("apps/desktop/tests/video_rtp_timestamp_test.cpp")
|
||||
|
||||
target("windows_manifest_resource_test")
|
||||
set_kind("binary")
|
||||
set_default(false)
|
||||
add_files("apps/desktop/tests/windows_manifest_resource_test.cpp")
|
||||
|
||||
target("windows_service_mouse_ipc_test")
|
||||
set_kind("binary")
|
||||
set_default(false)
|
||||
add_files("apps/desktop/tests/windows_service_mouse_ipc_test.cpp")
|
||||
|
||||
target("windows_mouse_controller_safety_test")
|
||||
set_kind("binary")
|
||||
set_default(false)
|
||||
add_files("apps/desktop/tests/windows_mouse_controller_safety_test.cpp")
|
||||
|
||||
target("windows_sas_guard_test")
|
||||
set_kind("binary")
|
||||
set_default(false)
|
||||
add_includedirs("apps/desktop/src/platform/windows/service")
|
||||
add_files("apps/desktop/tests/windows_sas_guard_test.cpp")
|
||||
|
||||
target("slint_ui_smoke_test")
|
||||
set_kind("binary")
|
||||
set_languages("c++20")
|
||||
set_default(false)
|
||||
if is_os("windows") then
|
||||
add_cxxflags("/bigobj")
|
||||
end
|
||||
add_packages("slint")
|
||||
add_includedirs("apps/desktop/src/gui", "apps/desktop/src/gui/assets/fonts",
|
||||
"apps/desktop/src/gui/assets/localization")
|
||||
add_rules("slint")
|
||||
add_files("apps/desktop/src/gui/ui/crossdesk_ui.slint")
|
||||
add_files("apps/desktop/tests/slint_ui_smoke_test.cpp")
|
||||
after_build(copy_slint_runtime)
|
||||
|
||||
target("version_checker_test")
|
||||
set_kind("binary")
|
||||
set_default(false)
|
||||
add_packages("cpp-httplib")
|
||||
add_deps("rd_log")
|
||||
add_includedirs("apps/desktop/src/version_checker")
|
||||
add_files("apps/desktop/tests/version_checker_test.cpp",
|
||||
"apps/desktop/src/version_checker/version_checker.cpp")
|
||||
if is_os("macosx") then
|
||||
add_defines("CPPHTTPLIB_USE_CERTS_FROM_MACOSX_KEYCHAIN")
|
||||
add_frameworks("Security", "CoreFoundation")
|
||||
end
|
||||
|
||||
target("screen_capturer")
|
||||
set_kind("object")
|
||||
add_deps("rd_log", "common", "crossdesk_wire")
|
||||
add_includedirs("apps/desktop/src/screen_capturer", {public = true})
|
||||
if is_os("windows") then
|
||||
add_packages("libyuv")
|
||||
add_files("apps/desktop/src/platform/windows/screen_capturer/screen_capturer_dxgi.cpp",
|
||||
"apps/desktop/src/platform/windows/screen_capturer/screen_capturer_gdi.cpp",
|
||||
"apps/desktop/src/platform/windows/screen_capturer/screen_capturer_win.cpp",
|
||||
"apps/desktop/src/platform/windows/screen_capturer/screen_capturer_factory.cpp")
|
||||
add_includedirs("apps/desktop/src/platform/windows/screen_capturer",
|
||||
"apps/desktop/src/platform/windows/service")
|
||||
elseif is_os("macosx") then
|
||||
add_files("apps/desktop/src/platform/macos/screen_capturer/*.cpp",
|
||||
"apps/desktop/src/platform/macos/screen_capturer/*.mm")
|
||||
add_includedirs("apps/desktop/src/platform/macos/screen_capturer")
|
||||
elseif is_os("linux") then
|
||||
add_packages("libyuv")
|
||||
add_files("apps/desktop/src/platform/linux/screen_capturer/screen_capturer_linux.cpp")
|
||||
add_files("apps/desktop/src/platform/linux/screen_capturer/screen_capturer_x11.cpp")
|
||||
add_files("apps/desktop/src/platform/linux/screen_capturer/screen_capturer_drm.cpp")
|
||||
add_files("apps/desktop/src/platform/linux/screen_capturer/screen_capturer_factory.cpp")
|
||||
if is_config("USE_WAYLAND", true) then
|
||||
add_files("apps/desktop/src/platform/linux/screen_capturer/screen_capturer_wayland.cpp")
|
||||
add_files("apps/desktop/src/platform/linux/screen_capturer/screen_capturer_wayland_portal.cpp")
|
||||
add_files("apps/desktop/src/platform/linux/screen_capturer/screen_capturer_wayland_pipewire.cpp")
|
||||
end
|
||||
add_includedirs("apps/desktop/src/platform/linux/screen_capturer",
|
||||
"apps/desktop/src/platform/linux/common")
|
||||
end
|
||||
|
||||
target("speaker_capturer")
|
||||
set_kind("object")
|
||||
add_deps("rd_log", "crossdesk_wire")
|
||||
add_includedirs("apps/desktop/src/speaker_capturer", {public = true})
|
||||
if is_os("windows") then
|
||||
add_packages("miniaudio")
|
||||
add_files("apps/desktop/src/platform/windows/speaker_capturer/*.cpp")
|
||||
add_includedirs("apps/desktop/src/platform/windows/speaker_capturer")
|
||||
elseif is_os("macosx") then
|
||||
add_files("apps/desktop/src/platform/macos/speaker_capturer/*.cpp",
|
||||
"apps/desktop/src/platform/macos/speaker_capturer/*.mm")
|
||||
add_includedirs("apps/desktop/src/platform/macos/speaker_capturer")
|
||||
elseif is_os("linux") then
|
||||
add_files("apps/desktop/src/platform/linux/speaker_capturer/*.cpp")
|
||||
add_includedirs("apps/desktop/src/platform/linux/speaker_capturer")
|
||||
end
|
||||
|
||||
target("device_controller")
|
||||
set_kind("object")
|
||||
add_deps("rd_log", "common", "crossdesk_wire")
|
||||
add_includedirs("apps/desktop/src/device_controller", {public = true})
|
||||
add_includedirs("apps/desktop/src/platform/common/input")
|
||||
if is_os("windows") then
|
||||
add_files("apps/desktop/src/platform/windows/input/mouse/*.cpp",
|
||||
"apps/desktop/src/platform/windows/input/keyboard/*.cpp",
|
||||
"apps/desktop/src/platform/windows/input/device_controller_factory.cpp")
|
||||
add_includedirs("apps/desktop/src/platform/windows/input/mouse",
|
||||
"apps/desktop/src/platform/windows/input/keyboard",
|
||||
"apps/desktop/src/platform/windows/input")
|
||||
elseif is_os("macosx") then
|
||||
add_files("apps/desktop/src/platform/macos/input/mouse/*.cpp",
|
||||
"apps/desktop/src/platform/macos/input/keyboard/*.cpp",
|
||||
"apps/desktop/src/platform/macos/input/device_controller_factory.cpp")
|
||||
add_includedirs("apps/desktop/src/platform/macos/input/mouse",
|
||||
"apps/desktop/src/platform/macos/input/keyboard",
|
||||
"apps/desktop/src/platform/macos/input")
|
||||
elseif is_os("linux") then
|
||||
add_files("apps/desktop/src/platform/linux/input/mouse/*.cpp",
|
||||
"apps/desktop/src/platform/linux/input/keyboard/*.cpp",
|
||||
"apps/desktop/src/platform/linux/input/device_controller_factory.cpp")
|
||||
add_includedirs("apps/desktop/src/platform/linux/input/mouse",
|
||||
"apps/desktop/src/platform/linux/input/keyboard",
|
||||
"apps/desktop/src/platform/linux/input",
|
||||
"apps/desktop/src/platform/linux/common")
|
||||
end
|
||||
|
||||
if is_os("linux") then
|
||||
target("linux_keyboard_x11_integration_test")
|
||||
set_kind("binary")
|
||||
set_default(false)
|
||||
add_deps("device_controller")
|
||||
add_files("apps/desktop/tests/linux_keyboard_x11_integration_test.cpp")
|
||||
end
|
||||
|
||||
target("thumbnail")
|
||||
set_kind("object")
|
||||
add_packages("libyuv", "openssl3")
|
||||
add_deps("rd_log", "common")
|
||||
add_files("apps/desktop/src/thumbnail/*.cpp")
|
||||
add_includedirs("apps/desktop/src/thumbnail", {public = true})
|
||||
|
||||
target("autostart")
|
||||
set_kind("object")
|
||||
add_deps("rd_log")
|
||||
add_files("apps/desktop/src/autostart/*.cpp")
|
||||
if is_os("windows") then
|
||||
add_files("apps/desktop/src/platform/windows/autostart.cpp")
|
||||
elseif is_os("macosx") then
|
||||
add_files("apps/desktop/src/platform/macos/autostart.cpp")
|
||||
elseif is_os("linux") then
|
||||
add_files("apps/desktop/src/platform/linux/autostart.cpp")
|
||||
end
|
||||
add_includedirs("apps/desktop/src")
|
||||
add_includedirs("apps/desktop/src/autostart", {public = true})
|
||||
|
||||
target("config_center")
|
||||
set_kind("object")
|
||||
add_deps("rd_log", "autostart")
|
||||
add_files("apps/desktop/src/config_center/*.cpp")
|
||||
add_includedirs("apps/desktop/src/config_center", {public = true})
|
||||
|
||||
target("assets")
|
||||
set_kind("headeronly")
|
||||
add_includedirs("apps/desktop/src/gui/assets/localization",
|
||||
"apps/desktop/src/gui/assets/fonts",
|
||||
"apps/desktop/src/gui/assets/icons",
|
||||
"apps/desktop/src/gui/assets/layouts", {public = true})
|
||||
|
||||
target("version_checker")
|
||||
set_kind("object")
|
||||
add_packages("cpp-httplib")
|
||||
add_defines("CROSSDESK_VERSION=\"" .. (get_config("CROSSDESK_VERSION") or "Unknown") .. "\"")
|
||||
add_deps("rd_log")
|
||||
add_files("apps/desktop/src/version_checker/*.cpp")
|
||||
add_includedirs("apps/desktop/src/version_checker", {public = true})
|
||||
if is_os("macosx") then
|
||||
add_defines("CPPHTTPLIB_USE_CERTS_FROM_MACOSX_KEYCHAIN")
|
||||
add_frameworks("Security", "CoreFoundation")
|
||||
end
|
||||
|
||||
target("tools")
|
||||
set_kind("object")
|
||||
add_deps("rd_log", "common", "crossdesk_wire")
|
||||
add_files("apps/desktop/src/tools/*.cpp")
|
||||
if is_os("windows") then
|
||||
add_files("apps/desktop/src/platform/windows/clipboard.cpp")
|
||||
elseif is_os("macosx") then
|
||||
add_files("apps/desktop/src/platform/macos/clipboard.mm")
|
||||
elseif is_os("linux") then
|
||||
add_files("apps/desktop/src/platform/linux/clipboard.cpp")
|
||||
end
|
||||
add_includedirs("apps/desktop/src", "apps/desktop/src/tools", {public = true})
|
||||
|
||||
target("gui")
|
||||
set_kind("object")
|
||||
set_languages("c++20")
|
||||
-- spdlog 1.14 bundles fmt 10, whose consteval parser is rejected by
|
||||
-- current Apple Clang in C++20 mode. Keep the established dependency
|
||||
-- version and use fmt's supported runtime-parser fallback here.
|
||||
add_defines("FMT_CONSTEVAL=")
|
||||
add_packages("slint", {public = true})
|
||||
add_packages("libyuv", "tinyfiledialogs")
|
||||
add_rules("slint")
|
||||
add_defines("CROSSDESK_VERSION=\"" .. (get_config("CROSSDESK_VERSION") or "Unknown") .. "\"")
|
||||
add_deps("rd_log", "common", "assets", "config_center", "minirtc",
|
||||
"path_manager", "screen_capturer", "speaker_capturer",
|
||||
"device_controller", "thumbnail", "version_checker", "tools",
|
||||
"crossdesk_wire")
|
||||
add_files("apps/desktop/src/gui/render.cpp", "apps/desktop/src/gui/application/gui_application.cpp",
|
||||
"apps/desktop/src/gui/rendering/*.cpp",
|
||||
"apps/desktop/src/gui/runtime/*.cpp",
|
||||
"apps/desktop/src/platform/common/gui/service_status_runtime.cpp",
|
||||
"apps/desktop/src/gui/features/devices/*.cpp", "apps/desktop/src/gui/features/input/*.cpp",
|
||||
"apps/desktop/src/gui/features/clipboard/*.cpp", "apps/desktop/src/gui/features/file_transfer/*.cpp",
|
||||
"apps/desktop/src/gui/features/settings/*.cpp", "apps/desktop/src/gui/ui/crossdesk_ui.slint")
|
||||
add_includedirs("apps/desktop/src", "apps/desktop/src/gui", {public = true})
|
||||
add_includedirs("apps/desktop/src/platform/common/input")
|
||||
if is_os("windows") then
|
||||
add_cxxflags("/bigobj")
|
||||
add_links("opengl32")
|
||||
add_files("apps/desktop/src/platform/windows/gui/tray/win_tray.cpp",
|
||||
"apps/desktop/src/platform/common/gui/opengl_video_renderer.cpp",
|
||||
"apps/desktop/src/platform/common/gui/video_renderer_factory_opengl.cpp",
|
||||
"apps/desktop/src/platform/windows/gui/runtime/windows_service_runtime.cpp",
|
||||
"apps/desktop/src/platform/windows/gui/application/portable_service_integration.cpp")
|
||||
add_includedirs("apps/desktop/src/platform/common/gui",
|
||||
"apps/desktop/src/platform/windows/gui/tray")
|
||||
add_includedirs("apps/desktop/src/platform/windows/service")
|
||||
elseif is_os("macosx") then
|
||||
add_files("apps/desktop/src/platform/macos/gui/runtime/*.mm",
|
||||
"apps/desktop/src/platform/macos/gui/tray/*.mm",
|
||||
"apps/desktop/src/platform/macos/gui/window_drag_mac.mm",
|
||||
"apps/desktop/src/platform/macos/gui/metal_video_renderer.mm",
|
||||
"apps/desktop/src/platform/macos/gui/video_renderer_factory_metal.mm")
|
||||
add_includedirs("apps/desktop/src/platform/macos/gui",
|
||||
"apps/desktop/src/platform/macos/gui/tray")
|
||||
elseif is_os("linux") then
|
||||
add_links("GL")
|
||||
add_files("apps/desktop/src/platform/linux/gui/tray/linux_tray.cpp",
|
||||
"apps/desktop/src/platform/common/gui/opengl_video_renderer.cpp",
|
||||
"apps/desktop/src/platform/common/gui/video_renderer_factory_opengl.cpp")
|
||||
add_includedirs("apps/desktop/src/platform/common/gui",
|
||||
"apps/desktop/src/platform/linux/gui/tray",
|
||||
"apps/desktop/src/platform/linux/common")
|
||||
end
|
||||
|
||||
if is_os("windows") then
|
||||
target("wgc_plugin")
|
||||
set_kind("shared")
|
||||
add_packages("libyuv")
|
||||
add_deps("rd_log", "path_manager", "crossdesk_wire")
|
||||
add_defines("CROSSDESK_WGC_PLUGIN_BUILD=1")
|
||||
-- Keep the project on C++17 while C++/WinRT still falls back to
|
||||
-- MSVC's deprecated experimental coroutine header.
|
||||
add_defines("_SILENCE_EXPERIMENTAL_COROUTINE_DEPRECATION_WARNINGS")
|
||||
add_links("windowsapp")
|
||||
add_files("apps/desktop/src/platform/windows/screen_capturer/screen_capturer_wgc.cpp",
|
||||
"apps/desktop/src/platform/windows/screen_capturer/wgc_session_impl.cpp",
|
||||
"apps/desktop/src/platform/windows/screen_capturer/wgc_plugin_entry.cpp")
|
||||
add_files("apps/desktop/resources/windows/wgc_plugin.rc")
|
||||
add_includedirs("apps/desktop/src/common", "apps/desktop/src/screen_capturer",
|
||||
"apps/desktop/src/platform/windows/screen_capturer")
|
||||
|
||||
target("crossdesk_service")
|
||||
set_kind("binary")
|
||||
add_deps("rd_log", "path_manager")
|
||||
add_links("Advapi32", "Wtsapi32", "Ole32", "Userenv")
|
||||
add_files("apps/desktop/src/platform/windows/service/main.cpp",
|
||||
"apps/desktop/src/platform/windows/service/service_host.cpp")
|
||||
add_files("apps/desktop/resources/windows/crossdesk_service.rc")
|
||||
add_includedirs("apps/desktop/src/platform/windows/service")
|
||||
|
||||
target("crossdesk_session_helper")
|
||||
set_kind("binary")
|
||||
add_packages("libyuv")
|
||||
add_deps("rd_log", "path_manager")
|
||||
add_links("Advapi32", "User32", "Wtsapi32", "Gdi32")
|
||||
add_files("apps/desktop/src/platform/windows/service/session_helper_main.cpp")
|
||||
add_files("apps/desktop/resources/windows/crossdesk_session_helper.rc")
|
||||
add_includedirs("apps/desktop/src/common",
|
||||
"apps/desktop/src/platform/windows/service")
|
||||
add_includedirs("apps/desktop/src/platform/windows/input")
|
||||
end
|
||||
|
||||
target("crossdesk")
|
||||
set_kind("binary")
|
||||
add_deps("rd_log", "common", "gui")
|
||||
add_files("apps/desktop/src/app/*.cpp")
|
||||
add_includedirs("apps/desktop/src", "apps/desktop/src/app", {public = true})
|
||||
if is_os("windows") then
|
||||
add_files("apps/desktop/src/platform/windows/daemon_backend.cpp")
|
||||
add_files("apps/desktop/src/platform/windows/service/service_host.cpp")
|
||||
add_includedirs("apps/desktop/src/platform/windows/service")
|
||||
add_links("Advapi32", "Wtsapi32", "Ole32", "Userenv")
|
||||
add_deps("wgc_plugin", "crossdesk_service", "crossdesk_session_helper")
|
||||
add_files(crossdesk_windows_resource)
|
||||
elseif is_os("macosx") then
|
||||
add_files("apps/desktop/src/platform/common/posix_daemon_backend.cpp",
|
||||
"apps/desktop/src/platform/macos/daemon_backend.cpp")
|
||||
elseif is_os("linux") then
|
||||
add_files("apps/desktop/src/platform/common/posix_daemon_backend.cpp",
|
||||
"apps/desktop/src/platform/linux/daemon_backend.cpp")
|
||||
end
|
||||
after_build(copy_slint_runtime)
|
||||
end
|
||||
@@ -1,384 +0,0 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 60;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
100000000000000000000001 /* CrossDeskMobileApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000001 /* CrossDeskMobileApp.swift */; };
|
||||
100000000000000000000002 /* RemoteSessionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000002 /* RemoteSessionModel.swift */; };
|
||||
100000000000000000000003 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000003 /* ContentView.swift */; };
|
||||
100000000000000000000004 /* RemoteSessionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000004 /* RemoteSessionView.swift */; };
|
||||
100000000000000000000005 /* CrossDeskRTCBridge.mm in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000006 /* CrossDeskRTCBridge.mm */; };
|
||||
100000000000000000000006 /* NativeMetalVideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000009 /* NativeMetalVideoView.swift */; };
|
||||
100000000000000000000007 /* RemoteTouchInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000000A /* RemoteTouchInputView.swift */; };
|
||||
100000000000000000000008 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000000D /* Assets.xcassets */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
200000000000000000000001 /* CrossDeskMobileApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossDeskMobileApp.swift; sourceTree = "<group>"; };
|
||||
200000000000000000000002 /* RemoteSessionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSessionModel.swift; sourceTree = "<group>"; };
|
||||
200000000000000000000003 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
200000000000000000000004 /* RemoteSessionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSessionView.swift; sourceTree = "<group>"; };
|
||||
200000000000000000000005 /* CrossDeskRTCBridge.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CrossDeskRTCBridge.h; sourceTree = "<group>"; };
|
||||
200000000000000000000006 /* CrossDeskRTCBridge.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = CrossDeskRTCBridge.mm; sourceTree = "<group>"; };
|
||||
200000000000000000000007 /* CrossDeskMobile-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "CrossDeskMobile-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
200000000000000000000008 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
200000000000000000000009 /* NativeMetalVideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeMetalVideoView.swift; sourceTree = "<group>"; };
|
||||
20000000000000000000000A /* RemoteTouchInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTouchInputView.swift; sourceTree = "<group>"; };
|
||||
20000000000000000000000B /* CrossDeskMobile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CrossDeskMobile.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
20000000000000000000000C /* build_minirtc_ios.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = build_minirtc_ios.sh; sourceTree = "<group>"; };
|
||||
20000000000000000000000D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
300000000000000000000001 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
400000000000000000000001 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
400000000000000000000002 /* CrossDeskMobile */,
|
||||
400000000000000000000006 /* scripts */,
|
||||
400000000000000000000007 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
400000000000000000000002 /* CrossDeskMobile */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
200000000000000000000001 /* CrossDeskMobileApp.swift */,
|
||||
200000000000000000000002 /* RemoteSessionModel.swift */,
|
||||
200000000000000000000003 /* ContentView.swift */,
|
||||
200000000000000000000004 /* RemoteSessionView.swift */,
|
||||
400000000000000000000003 /* Bridge */,
|
||||
400000000000000000000004 /* Rendering */,
|
||||
400000000000000000000005 /* Input */,
|
||||
20000000000000000000000D /* Assets.xcassets */,
|
||||
200000000000000000000007 /* CrossDeskMobile-Bridging-Header.h */,
|
||||
200000000000000000000008 /* Info.plist */,
|
||||
);
|
||||
path = CrossDeskMobile;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
400000000000000000000003 /* Bridge */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
200000000000000000000005 /* CrossDeskRTCBridge.h */,
|
||||
200000000000000000000006 /* CrossDeskRTCBridge.mm */,
|
||||
);
|
||||
path = Bridge;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
400000000000000000000004 /* Rendering */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
200000000000000000000009 /* NativeMetalVideoView.swift */,
|
||||
);
|
||||
path = Rendering;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
400000000000000000000005 /* Input */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
20000000000000000000000A /* RemoteTouchInputView.swift */,
|
||||
);
|
||||
path = Input;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
400000000000000000000006 /* scripts */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
20000000000000000000000C /* build_minirtc_ios.sh */,
|
||||
);
|
||||
path = scripts;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
400000000000000000000007 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
20000000000000000000000B /* CrossDeskMobile.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
500000000000000000000001 /* CrossDeskMobile */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 900000000000000000000002 /* Build configuration list for PBXNativeTarget "CrossDeskMobile" */;
|
||||
buildPhases = (
|
||||
700000000000000000000001 /* Build native MiniRTC */,
|
||||
600000000000000000000001 /* Sources */,
|
||||
300000000000000000000001 /* Frameworks */,
|
||||
600000000000000000000002 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = CrossDeskMobile;
|
||||
productName = CrossDeskMobile;
|
||||
productReference = 20000000000000000000000B /* CrossDeskMobile.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
800000000000000000000001 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2660;
|
||||
LastUpgradeCheck = 2660;
|
||||
TargetAttributes = {
|
||||
500000000000000000000001 = {
|
||||
CreatedOnToolsVersion = 26.6;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 900000000000000000000001 /* Build configuration list for PBXProject "CrossDeskMobile" */;
|
||||
compatibilityVersion = "Xcode 15.0";
|
||||
developmentRegion = zh_CN;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
Base,
|
||||
en,
|
||||
zh_CN,
|
||||
);
|
||||
mainGroup = 400000000000000000000001;
|
||||
productRefGroup = 400000000000000000000007 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
500000000000000000000001 /* CrossDeskMobile */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
600000000000000000000002 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
100000000000000000000008 /* Assets.xcassets in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
700000000000000000000001 /* Build native MiniRTC */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Build native MiniRTC";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(PROJECT_DIR)/Vendor/iphoneos/$(CONFIGURATION)/libCrossDeskMiniRTC.a",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/zsh;
|
||||
shellScript = "\"${PROJECT_DIR}/scripts/build_minirtc_ios.sh\"\n";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
600000000000000000000001 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
100000000000000000000001 /* CrossDeskMobileApp.swift in Sources */,
|
||||
100000000000000000000002 /* RemoteSessionModel.swift in Sources */,
|
||||
100000000000000000000003 /* ContentView.swift in Sources */,
|
||||
100000000000000000000004 /* RemoteSessionView.swift in Sources */,
|
||||
100000000000000000000005 /* CrossDeskRTCBridge.mm in Sources */,
|
||||
100000000000000000000006 /* NativeMetalVideoView.swift in Sources */,
|
||||
100000000000000000000007 /* RemoteTouchInputView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
A00000000000000000000001 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "c++17";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
A00000000000000000000002 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "c++17";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
A00000000000000000000003 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ARCHS = arm64;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 8DNC3SZ86S;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
HEADER_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/../../deps/submodules/minirtc/src/api",
|
||||
"$(PROJECT_DIR)/../../libs/wire/include",
|
||||
);
|
||||
INFOPLIST_FILE = CrossDeskMobile/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/Vendor/iphoneos/$(CONFIGURATION)/libCrossDeskMiniRTC.a",
|
||||
"-liconv",
|
||||
"-lresolv",
|
||||
"-framework",
|
||||
VideoToolbox,
|
||||
"-framework",
|
||||
CoreMedia,
|
||||
"-framework",
|
||||
CoreVideo,
|
||||
"-framework",
|
||||
Security,
|
||||
"-framework",
|
||||
SystemConfiguration,
|
||||
"-framework",
|
||||
CoreFoundation,
|
||||
"-framework",
|
||||
Foundation,
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = cn.crossdesk.mobile;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "CrossDeskMobile/CrossDeskMobile-Bridging-Header.h";
|
||||
SWIFT_STRICT_CONCURRENCY = minimal;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
A00000000000000000000004 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ARCHS = arm64;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 8DNC3SZ86S;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
HEADER_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/../../deps/submodules/minirtc/src/api",
|
||||
"$(PROJECT_DIR)/../../libs/wire/include",
|
||||
);
|
||||
INFOPLIST_FILE = CrossDeskMobile/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/Vendor/iphoneos/$(CONFIGURATION)/libCrossDeskMiniRTC.a",
|
||||
"-liconv",
|
||||
"-lresolv",
|
||||
"-framework",
|
||||
VideoToolbox,
|
||||
"-framework",
|
||||
CoreMedia,
|
||||
"-framework",
|
||||
CoreVideo,
|
||||
"-framework",
|
||||
Security,
|
||||
"-framework",
|
||||
SystemConfiguration,
|
||||
"-framework",
|
||||
CoreFoundation,
|
||||
"-framework",
|
||||
Foundation,
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = cn.crossdesk.mobile;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "CrossDeskMobile/CrossDeskMobile-Bridging-Header.h";
|
||||
SWIFT_STRICT_CONCURRENCY = minimal;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
900000000000000000000001 /* Build configuration list for PBXProject "CrossDeskMobile" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A00000000000000000000001 /* Debug */,
|
||||
A00000000000000000000002 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
900000000000000000000002 /* Build configuration list for PBXNativeTarget "CrossDeskMobile" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A00000000000000000000003 /* Debug */,
|
||||
A00000000000000000000004 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 800000000000000000000001 /* Project object */;
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2660"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "500000000000000000000001"
|
||||
BuildableName = "CrossDeskMobile.app"
|
||||
BlueprintName = "CrossDeskMobile"
|
||||
ReferencedContainer = "container:CrossDeskMobile.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "500000000000000000000001"
|
||||
BuildableName = "CrossDeskMobile.app"
|
||||
BlueprintName = "CrossDeskMobile"
|
||||
ReferencedContainer = "container:CrossDeskMobile.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "500000000000000000000001"
|
||||
BuildableName = "CrossDeskMobile.app"
|
||||
BlueprintName = "CrossDeskMobile"
|
||||
ReferencedContainer = "container:CrossDeskMobile.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "CrossDeskAppIcon-1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 217 KiB |
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
#import <CoreVideo/CoreVideo.h>
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
typedef NS_ENUM(NSInteger, CrossDeskSignalState) {
|
||||
CrossDeskSignalStateConnecting = 0,
|
||||
CrossDeskSignalStateConnected,
|
||||
CrossDeskSignalStateFailed,
|
||||
CrossDeskSignalStateClosed,
|
||||
CrossDeskSignalStateReconnecting,
|
||||
CrossDeskSignalStateServerClosed,
|
||||
CrossDeskSignalStateTLSCertificateError,
|
||||
};
|
||||
|
||||
typedef NS_ENUM(NSInteger, CrossDeskConnectionState) {
|
||||
CrossDeskConnectionStateConnecting = 0,
|
||||
CrossDeskConnectionStateConnected,
|
||||
CrossDeskConnectionStateGathering,
|
||||
CrossDeskConnectionStateDisconnected,
|
||||
CrossDeskConnectionStateFailed,
|
||||
CrossDeskConnectionStateClosed,
|
||||
CrossDeskConnectionStateIncorrectPassword,
|
||||
CrossDeskConnectionStateNoSuchID,
|
||||
CrossDeskConnectionStateRemoteUnavailable,
|
||||
};
|
||||
|
||||
typedef NS_ENUM(NSInteger, CrossDeskPointerAction) {
|
||||
CrossDeskPointerActionMove = 0,
|
||||
CrossDeskPointerActionLeftDown,
|
||||
CrossDeskPointerActionLeftUp,
|
||||
CrossDeskPointerActionRightDown,
|
||||
CrossDeskPointerActionRightUp,
|
||||
CrossDeskPointerActionMiddleDown,
|
||||
CrossDeskPointerActionMiddleUp,
|
||||
};
|
||||
|
||||
typedef NS_ENUM(NSInteger, CrossDeskVideoAdaptationPolicy) {
|
||||
CrossDeskVideoAdaptationPolicyFrameRatePriority = 0,
|
||||
CrossDeskVideoAdaptationPolicyQualityPriority,
|
||||
CrossDeskVideoAdaptationPolicyBalanced,
|
||||
};
|
||||
|
||||
@class CrossDeskRTCBridge;
|
||||
|
||||
@protocol CrossDeskRTCBridgeDelegate <NSObject>
|
||||
@optional
|
||||
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
|
||||
didChangeSignalState:(CrossDeskSignalState)state;
|
||||
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
|
||||
didChangeConnectionState:(CrossDeskConnectionState)state
|
||||
remoteID:(NSString *)remoteID;
|
||||
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
|
||||
didProvisionIdentity:(NSString *)identity;
|
||||
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
|
||||
didReceivePixelBuffer:(CVPixelBufferRef)pixelBuffer
|
||||
width:(NSInteger)width
|
||||
height:(NSInteger)height;
|
||||
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
|
||||
didReceiveHostName:(NSString *)hostName
|
||||
displayNames:(NSArray<NSString *> *)displayNames
|
||||
displaySizes:(NSArray<NSValue *> *)displaySizes;
|
||||
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
|
||||
didReceivePresence:(NSDictionary<NSString *, NSNumber *> *)presence;
|
||||
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
|
||||
didReceiveCursorVisible:(BOOL)visible
|
||||
shape:(NSInteger)shape
|
||||
positionUpdate:(BOOL)positionUpdate
|
||||
positionValid:(BOOL)positionValid
|
||||
x:(float)x
|
||||
y:(float)y
|
||||
visualOffsetX:(float)visualOffsetX
|
||||
visualOffsetY:(float)visualOffsetY
|
||||
displayIndex:(NSInteger)displayIndex
|
||||
sequence:(uint32_t)sequence;
|
||||
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
|
||||
didReceiveAudioPCM:(NSData *)pcmData;
|
||||
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
|
||||
didReceiveClipboardText:(NSString *)text;
|
||||
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
|
||||
didUpdateFileTransfer:(NSString *)fileName
|
||||
progress:(double)progress
|
||||
sending:(BOOL)sending;
|
||||
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
|
||||
didReceiveFileAtURL:(NSURL *)fileURL;
|
||||
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
|
||||
didUpdateBitrate:(NSUInteger)bitsPerSecond
|
||||
lossRate:(float)lossRate
|
||||
usingTURN:(BOOL)usingTURN;
|
||||
@end
|
||||
|
||||
/// Objective-C++ boundary around the native MiniRTC C API.
|
||||
///
|
||||
/// All MiniRTC ownership and calls are serialized internally. Delegate methods
|
||||
/// are delivered on the main queue so SwiftUI never touches callback threads.
|
||||
@interface CrossDeskRTCBridge : NSObject
|
||||
|
||||
@property(nonatomic, weak, nullable) id<CrossDeskRTCBridgeDelegate> delegate;
|
||||
|
||||
- (void)configureWithSignalHost:(NSString *)host
|
||||
signalPort:(NSInteger)signalPort
|
||||
turnPort:(NSInteger)turnPort
|
||||
enableSRTP:(BOOL)enableSRTP;
|
||||
- (void)setHardwareAccelerationEnabled:(BOOL)enabled;
|
||||
- (void)setVideoAdaptationPolicy:(CrossDeskVideoAdaptationPolicy)policy;
|
||||
- (void)requestPresenceForRemoteIDs:(NSArray<NSString *> *)remoteIDs
|
||||
NS_SWIFT_NAME(requestPresence(remoteIDs:));
|
||||
|
||||
- (void)connectToRemoteID:(NSString *)remoteID password:(NSString *)password;
|
||||
- (void)disconnect;
|
||||
|
||||
- (void)sendPointerAtX:(float)x
|
||||
y:(float)y
|
||||
action:(CrossDeskPointerAction)action
|
||||
NS_SWIFT_NAME(sendPointer(x:y:action:));
|
||||
- (void)sendScrollX:(float)x y:(float)y deltaX:(NSInteger)deltaX
|
||||
deltaY:(NSInteger)deltaY;
|
||||
- (void)sendWindowsKeyCode:(NSUInteger)keyCode isDown:(BOOL)isDown;
|
||||
- (void)sendText:(NSString *)text;
|
||||
- (void)switchToDisplay:(NSInteger)displayIndex;
|
||||
- (void)setAudioEnabled:(BOOL)enabled;
|
||||
- (void)sendClipboardText:(NSString *)text;
|
||||
- (void)sendFileAtURL:(NSURL *)fileURL;
|
||||
- (void)sendSecureAttentionSequence;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -1,747 +0,0 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@ObservedObject var session: RemoteSessionModel
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if session.sessionVisible {
|
||||
RemoteSessionView(session: session)
|
||||
.transition(.opacity)
|
||||
} else {
|
||||
ConnectionHomeView(session: session)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: session.sessionVisible)
|
||||
.preferredColorScheme(session.sessionVisible ? .dark : .light)
|
||||
.onAppear {
|
||||
if !session.sessionVisible {
|
||||
AppOrientation.update(to: .portrait)
|
||||
}
|
||||
if scenePhase == .active {
|
||||
session.refreshRecentConnectionPresenceAfterForeground()
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { phase in
|
||||
if phase == .active {
|
||||
session.refreshRecentConnectionPresenceAfterForeground()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct KeyboardDismissTapView: UIViewRepresentable {
|
||||
let onDismiss: () -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(onDismiss: onDismiss)
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> UIView {
|
||||
let view = UIView(frame: .zero)
|
||||
view.backgroundColor = .clear
|
||||
view.isUserInteractionEnabled = false
|
||||
installRecognizer(for: view, coordinator: context.coordinator)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ view: UIView, context: Context) {
|
||||
context.coordinator.onDismiss = onDismiss
|
||||
installRecognizer(for: view, coordinator: context.coordinator)
|
||||
}
|
||||
|
||||
static func dismantleUIView(_ view: UIView, coordinator: Coordinator) {
|
||||
coordinator.uninstall()
|
||||
}
|
||||
|
||||
private func installRecognizer(for view: UIView, coordinator: Coordinator) {
|
||||
DispatchQueue.main.async { [weak view, weak coordinator] in
|
||||
guard let window = view?.window else { return }
|
||||
coordinator?.install(in: window)
|
||||
}
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, UIGestureRecognizerDelegate {
|
||||
var onDismiss: () -> Void
|
||||
private weak var window: UIWindow?
|
||||
private var recognizer: UITapGestureRecognizer?
|
||||
|
||||
init(onDismiss: @escaping () -> Void) {
|
||||
self.onDismiss = onDismiss
|
||||
}
|
||||
|
||||
func install(in window: UIWindow) {
|
||||
guard self.window !== window || recognizer == nil else { return }
|
||||
uninstall()
|
||||
|
||||
let recognizer = UITapGestureRecognizer(target: self,
|
||||
action: #selector(didTapOutsideInput))
|
||||
recognizer.cancelsTouchesInView = false
|
||||
recognizer.delegate = self
|
||||
window.addGestureRecognizer(recognizer)
|
||||
self.window = window
|
||||
self.recognizer = recognizer
|
||||
}
|
||||
|
||||
func uninstall() {
|
||||
if let recognizer {
|
||||
window?.removeGestureRecognizer(recognizer)
|
||||
}
|
||||
recognizer = nil
|
||||
window = nil
|
||||
}
|
||||
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
|
||||
shouldReceive touch: UITouch) -> Bool {
|
||||
var touchedView = touch.view
|
||||
while let view = touchedView {
|
||||
if view is UITextField || view is UITextView {
|
||||
return false
|
||||
}
|
||||
touchedView = view.superview
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@objc private func didTapOutsideInput() {
|
||||
onDismiss()
|
||||
window?.endEditing(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ConnectionHomeView: View {
|
||||
@ObservedObject var session: RemoteSessionModel
|
||||
@FocusState private var remoteIDFocused: Bool
|
||||
@State private var passwordPromptVisible = false
|
||||
@State private var settingsVisible = false
|
||||
@State private var promptRemoteID = ""
|
||||
@State private var promptPassword = ""
|
||||
@State private var promptRememberPassword = false
|
||||
|
||||
private let connectionAccent = Color(red: 0.18, green: 0.48, blue: 0.86)
|
||||
private let connectionAccentEnd = Color(red: 0.12, green: 0.38, blue: 0.78)
|
||||
|
||||
private var trimmedRemoteID: String {
|
||||
session.remoteID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private var groupedRemoteID: Binding<String> {
|
||||
Binding(
|
||||
get: { formatRemoteID(session.remoteID) },
|
||||
set: { value in
|
||||
session.remoteID = String(value.filter { $0.isNumber }.prefix(9))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var signalIsConnected: Bool {
|
||||
session.signalStatus == "已连接服务器"
|
||||
}
|
||||
|
||||
private var signalHasError: Bool {
|
||||
session.signalStatus.contains("失败") ||
|
||||
session.signalStatus.contains("无效") ||
|
||||
session.signalStatus.contains("关闭") ||
|
||||
session.signalStatus.contains("未知")
|
||||
}
|
||||
|
||||
private var signalTint: Color {
|
||||
if signalIsConnected { return .green }
|
||||
if signalHasError { return .red }
|
||||
return .orange
|
||||
}
|
||||
|
||||
private var signalSymbol: String {
|
||||
if signalIsConnected { return "checkmark.circle.fill" }
|
||||
if signalHasError { return "exclamationmark.triangle.fill" }
|
||||
return "arrow.triangle.2.circlepath"
|
||||
}
|
||||
|
||||
private var recentConnectionColumns: [GridItem] {
|
||||
[
|
||||
GridItem(.flexible(), spacing: 12, alignment: .top),
|
||||
GridItem(.flexible(), spacing: 12, alignment: .top)
|
||||
]
|
||||
}
|
||||
|
||||
private var orderedRecentConnections: [RecentConnection] {
|
||||
session.recentConnections.enumerated()
|
||||
.sorted { lhs, rhs in
|
||||
let lhsOnline = session.isRecentConnectionOnline(lhs.element)
|
||||
let rhsOnline = session.isRecentConnectionOnline(rhs.element)
|
||||
if lhsOnline != rhsOnline { return lhsOnline }
|
||||
return lhs.offset < rhs.offset
|
||||
}
|
||||
.map(\.element)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(.systemGroupedBackground).ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
remoteConnectionPanel
|
||||
.padding(.top, 24)
|
||||
recentConnectionsPanel
|
||||
.padding(.top, 20)
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
if session.isConnecting {
|
||||
connectionProgressOverlay
|
||||
}
|
||||
|
||||
}
|
||||
.background {
|
||||
KeyboardDismissTapView {
|
||||
remoteIDFocused = false
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.sheet(isPresented: $passwordPromptVisible) {
|
||||
PasswordPromptView(remoteID: promptRemoteID,
|
||||
password: $promptPassword,
|
||||
rememberPassword: $promptRememberPassword) {
|
||||
passwordPromptVisible = false
|
||||
session.remoteID = promptRemoteID
|
||||
session.connect(password: promptPassword,
|
||||
rememberPassword: promptRememberPassword)
|
||||
}
|
||||
.presentationDetents([.height(340)])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.sheet(isPresented: $settingsVisible) {
|
||||
ServerSettingsView(session: session)
|
||||
.presentationDetents([.fraction(0.80)])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.alert("设备离线", isPresented: Binding(
|
||||
get: { session.deviceOfflineAlertVisible },
|
||||
set: { visible in
|
||||
if !visible { session.dismissDeviceOfflineAlert() }
|
||||
}
|
||||
)) {
|
||||
Button("确定") {
|
||||
session.dismissDeviceOfflineAlert()
|
||||
}
|
||||
}
|
||||
.onChange(of: session.remoteID) { value in
|
||||
let formatted = String(value.filter { $0.isNumber }.prefix(9))
|
||||
if formatted != value {
|
||||
session.remoteID = formatted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 8) {
|
||||
Label {
|
||||
Text(session.signalStatus)
|
||||
.font(.caption.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
} icon: {
|
||||
Image(systemName: signalSymbol)
|
||||
.font(.caption.weight(.bold))
|
||||
}
|
||||
.foregroundStyle(signalTint)
|
||||
.padding(.horizontal, 11)
|
||||
.frame(height: 34)
|
||||
.background(signalTint.opacity(0.11), in: Capsule())
|
||||
.overlay {
|
||||
Capsule()
|
||||
.stroke(signalTint.opacity(0.22), lineWidth: 1)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
settingsVisible = true
|
||||
} label: {
|
||||
Image(systemName: "gearshape")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.frame(width: 40, height: 40)
|
||||
.background(Color(.secondarySystemGroupedBackground),
|
||||
in: RoundedRectangle(cornerRadius: 10,
|
||||
style: .continuous))
|
||||
}
|
||||
.foregroundStyle(.primary)
|
||||
.accessibilityLabel("设置")
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
private var remoteConnectionPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
Text("远程桌面")
|
||||
.font(.title3.weight(.bold))
|
||||
Spacer()
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
TextField("对端 ID", text: groupedRemoteID)
|
||||
.keyboardType(.numberPad)
|
||||
.textContentType(.username)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.font(.system(.title3, design: .rounded).weight(.semibold))
|
||||
.monospacedDigit()
|
||||
.focused($remoteIDFocused)
|
||||
.padding(.horizontal, 15)
|
||||
.frame(height: 54)
|
||||
.background(Color(.systemGroupedBackground),
|
||||
in: RoundedRectangle(cornerRadius: 12,
|
||||
style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.stroke(remoteIDFocused
|
||||
? connectionAccent
|
||||
: Color(.separator).opacity(0.4),
|
||||
lineWidth: remoteIDFocused ? 2 : 1)
|
||||
}
|
||||
.shadow(color: remoteIDFocused
|
||||
? connectionAccent.opacity(0.12) : .clear,
|
||||
radius: 8, y: 2)
|
||||
|
||||
Button {
|
||||
presentPasswordPrompt(for: trimmedRemoteID)
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Text("连接")
|
||||
Image(systemName: "arrow.right")
|
||||
}
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.frame(width: 96, height: 54)
|
||||
.foregroundStyle(.white)
|
||||
.background(LinearGradient(colors: [
|
||||
connectionAccent,
|
||||
connectionAccentEnd
|
||||
], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12,
|
||||
style: .continuous))
|
||||
.shadow(color: connectionAccent.opacity(0.24),
|
||||
radius: 8, y: 4)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(trimmedRemoteID.isEmpty || session.isConnecting)
|
||||
.opacity(trimmedRemoteID.isEmpty || session.isConnecting ? 0.45 : 1)
|
||||
.accessibilityLabel("连接")
|
||||
}
|
||||
}
|
||||
.padding(18)
|
||||
.background(Color(.secondarySystemGroupedBackground),
|
||||
in: RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.stroke(Color(.separator).opacity(0.28), lineWidth: 1)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.055), radius: 14, y: 5)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var recentConnectionsPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("最近连接")
|
||||
.font(.title3.bold())
|
||||
Spacer()
|
||||
if !session.recentConnections.isEmpty {
|
||||
Text("\(session.recentConnections.count) 台设备")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if session.recentConnections.isEmpty {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "clock.arrow.circlepath")
|
||||
.font(.system(size: 30, weight: .medium))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text("还没有连接记录")
|
||||
.font(.headline)
|
||||
Text("成功连接后,这里会显示远端桌面的缩略图。")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollView(.vertical) {
|
||||
LazyVGrid(columns: recentConnectionColumns, spacing: 12) {
|
||||
ForEach(orderedRecentConnections) { connection in
|
||||
RecentConnectionCard(
|
||||
connection: connection,
|
||||
thumbnail: session.thumbnailImage(for: connection),
|
||||
online: session.isRecentConnectionOnline(connection),
|
||||
connect: {
|
||||
connectRecent(connection)
|
||||
},
|
||||
delete: {
|
||||
session.removeRecentConnection(connection)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.background(Color(.secondarySystemGroupedBackground),
|
||||
in: RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.stroke(Color(.separator).opacity(0.35), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionProgressOverlay: some View {
|
||||
ZStack {
|
||||
Color.clear
|
||||
.ignoresSafeArea()
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { }
|
||||
|
||||
VStack(spacing: 0) {
|
||||
VStack(spacing: 14) {
|
||||
ProgressView()
|
||||
.controlSize(.large)
|
||||
.tint(connectionAccent)
|
||||
|
||||
VStack(spacing: 6) {
|
||||
Text("正在连接")
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Text(session.connectionStatus)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2)
|
||||
.frame(minHeight: 36)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 26)
|
||||
.padding(.top, 24)
|
||||
.padding(.bottom, 20)
|
||||
|
||||
Divider()
|
||||
|
||||
Button(role: .cancel, action: session.disconnect) {
|
||||
Text("取消连接")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(Color.red)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 46)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.frame(width: 292)
|
||||
.background(Color.white,
|
||||
in: RoundedRectangle(cornerRadius: 24,
|
||||
style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.stroke(Color(.separator).opacity(0.24), lineWidth: 0.8)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.22), radius: 28, y: 10)
|
||||
}
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.96)))
|
||||
}
|
||||
|
||||
private func presentPasswordPrompt(for identifier: String) {
|
||||
let trimmed = identifier.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
remoteIDFocused = true
|
||||
return
|
||||
}
|
||||
remoteIDFocused = false
|
||||
promptRemoteID = trimmed
|
||||
promptPassword = session.savedPassword(for: trimmed)
|
||||
promptRememberPassword = session.remembersPassword(for: trimmed)
|
||||
passwordPromptVisible = true
|
||||
}
|
||||
|
||||
private func formatRemoteID(_ value: String) -> String {
|
||||
let digits = Array(value.filter { $0.isNumber }.prefix(9))
|
||||
return stride(from: 0, to: digits.count, by: 3)
|
||||
.map { start in
|
||||
String(digits[start..<min(start + 3, digits.count)])
|
||||
}
|
||||
.joined(separator: " ")
|
||||
}
|
||||
|
||||
private func connectRecent(_ connection: RecentConnection) {
|
||||
session.remoteID = connection.remoteID
|
||||
remoteIDFocused = false
|
||||
|
||||
if connection.remembersPassword,
|
||||
let savedPassword = session.savedCredential(for: connection.remoteID) {
|
||||
session.connect(password: savedPassword, rememberPassword: true)
|
||||
} else {
|
||||
presentPasswordPrompt(for: connection.remoteID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct RecentConnectionCard: View {
|
||||
let connection: RecentConnection
|
||||
let thumbnail: UIImage?
|
||||
let online: Bool
|
||||
let connect: () -> Void
|
||||
let delete: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: connect) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ZStack {
|
||||
if let thumbnail {
|
||||
Image(uiImage: thumbnail)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else {
|
||||
LinearGradient(colors: [
|
||||
Color(red: 0.18, green: 0.48, blue: 0.86),
|
||||
Color(red: 0.38, green: 0.68, blue: 0.96)
|
||||
], startPoint: .topLeading, endPoint: .bottomTrailing)
|
||||
Image(systemName: "display")
|
||||
.font(.system(size: 34, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.aspectRatio(16 / 9, contentMode: .fit)
|
||||
.clipped()
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(connection.displayName)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 5) {
|
||||
Text("ID \(connection.remoteID)")
|
||||
.font(.caption2.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
if connection.remembersPassword {
|
||||
Image(systemName: "key.fill")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityLabel("已保存密码")
|
||||
}
|
||||
Text(online ? "在线" : "离线")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(online ? Color.green : Color.secondary)
|
||||
.accessibilityLabel(online ? "在线" : "离线")
|
||||
}
|
||||
}
|
||||
.padding(9)
|
||||
}
|
||||
.background(Color(.secondarySystemGroupedBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.stroke(Color(.separator).opacity(0.35), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
Button(action: connect) {
|
||||
Label("连接", systemImage: "arrow.right.circle")
|
||||
}
|
||||
Button(role: .destructive, action: delete) {
|
||||
Label("删除记录", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private struct PasswordPromptView: View {
|
||||
let remoteID: String
|
||||
@Binding var password: String
|
||||
@Binding var rememberPassword: Bool
|
||||
let connect: () -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@FocusState private var passwordFocused: Bool
|
||||
@State private var passwordVisible = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("连接远程桌面")
|
||||
.font(.title2.bold())
|
||||
Text("对端 ID \(remoteID)")
|
||||
.font(.subheadline.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.accessibilityLabel("关闭")
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Group {
|
||||
if passwordVisible {
|
||||
TextField("访问密码", text: $password)
|
||||
} else {
|
||||
SecureField("访问密码", text: $password)
|
||||
}
|
||||
}
|
||||
.keyboardType(.numberPad)
|
||||
.textContentType(.password)
|
||||
.focused($passwordFocused)
|
||||
|
||||
Button {
|
||||
passwordVisible.toggle()
|
||||
} label: {
|
||||
Image(systemName: passwordVisible ? "eye.slash" : "eye")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.accessibilityLabel(passwordVisible ? "隐藏密码" : "显示密码")
|
||||
}
|
||||
.padding(.horizontal, 13)
|
||||
.frame(height: 50)
|
||||
.background(Color(.secondarySystemGroupedBackground),
|
||||
in: RoundedRectangle(cornerRadius: 10,
|
||||
style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.stroke(Color(.separator).opacity(0.5), lineWidth: 1)
|
||||
}
|
||||
|
||||
Toggle(isOn: $rememberPassword) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("保存密码")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("密码将安全保存在本机 Keychain 中")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Button(action: connect) {
|
||||
Label("连接", systemImage: "arrow.right")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 48)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.buttonBorderShape(.roundedRectangle(radius: 10))
|
||||
}
|
||||
.padding(22)
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||
passwordFocused = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ServerSettingsView: View {
|
||||
@ObservedObject var session: RemoteSessionModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("鼠标控制") {
|
||||
Picker("控制模式", selection: $session.mouseControlMode) {
|
||||
ForEach(MouseControlMode.allCases) { mode in
|
||||
Text(mode.title).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
Text(session.mouseControlMode.detail)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Section("视频编解码") {
|
||||
Picker("处理方式", selection: $session.videoCodecMode) {
|
||||
ForEach(VideoCodecMode.allCases) { mode in
|
||||
Text(mode.title).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
Text(session.videoCodecMode.detail)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text("修改后从下一次连接开始生效。")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
Section("画面偏好") {
|
||||
Picker("偏好模式", selection: $session.videoAdaptationPolicy) {
|
||||
ForEach(VideoAdaptationPolicy.allCases) { policy in
|
||||
Text(policy.title).tag(policy)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
Text(session.videoAdaptationPolicy.detail)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text("修改后从下一次连接开始生效。")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
Section("服务器") {
|
||||
TextField("信令服务器", text: $session.signalHost)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
TextField("信令端口", text: $session.signalPort)
|
||||
.keyboardType(.numberPad)
|
||||
TextField("STUN/TURN 端口", text: $session.turnPort)
|
||||
.keyboardType(.numberPad)
|
||||
}
|
||||
Section {
|
||||
Toggle("启用 SRTP", isOn: $session.enableSRTP)
|
||||
} header: {
|
||||
Text("传输")
|
||||
} footer: {
|
||||
Text(session.localIdentity.isEmpty
|
||||
? "正在获取本机 ID…"
|
||||
: "本机 ID \(session.localIdentity)")
|
||||
.font(.caption2.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.top, 16)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
.navigationTitle("设置")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("应用") {
|
||||
session.configureBridge()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("取消") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
#import "Bridge/CrossDeskRTCBridge.h"
|
||||
@@ -1,43 +0,0 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
final class CrossDeskAppDelegate: NSObject, UIApplicationDelegate {
|
||||
static var supportedOrientations: UIInterfaceOrientationMask = .portrait
|
||||
|
||||
func application(_ application: UIApplication,
|
||||
supportedInterfaceOrientationsFor window: UIWindow?)
|
||||
-> UIInterfaceOrientationMask {
|
||||
Self.supportedOrientations
|
||||
}
|
||||
}
|
||||
|
||||
enum AppOrientation {
|
||||
static func update(to orientations: UIInterfaceOrientationMask) {
|
||||
DispatchQueue.main.async {
|
||||
CrossDeskAppDelegate.supportedOrientations = orientations
|
||||
|
||||
for case let scene as UIWindowScene in UIApplication.shared.connectedScenes {
|
||||
scene.windows.forEach {
|
||||
$0.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
|
||||
}
|
||||
scene.requestGeometryUpdate(
|
||||
.iOS(interfaceOrientations: orientations)
|
||||
) { error in
|
||||
NSLog("CrossDesk orientation update failed: %@", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
struct CrossDeskMobileApp: App {
|
||||
@UIApplicationDelegateAdaptor(CrossDeskAppDelegate.self) private var appDelegate
|
||||
@StateObject private var session = RemoteSessionModel()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView(session: session)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>CrossDesk</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>zh_CN</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>2</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>CrossDesk 使用局域网发现并建立原生 P2P 远程控制连接。</string>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,502 +0,0 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
enum RemoteVideoGeometry {
|
||||
private static func pixelAligned(_ rect: CGRect) -> CGRect {
|
||||
let scale = max(UIScreen.main.scale, 1)
|
||||
let minX = (rect.minX * scale).rounded() / scale
|
||||
let minY = (rect.minY * scale).rounded() / scale
|
||||
let maxX = (rect.maxX * scale).rounded() / scale
|
||||
let maxY = (rect.maxY * scale).rounded() / scale
|
||||
return CGRect(x: minX, y: minY,
|
||||
width: max(0, maxX - minX),
|
||||
height: max(0, maxY - minY))
|
||||
}
|
||||
|
||||
static func aspectFitRect(containerSize: CGSize,
|
||||
videoSize: CGSize) -> CGRect? {
|
||||
guard containerSize.width > 0, containerSize.height > 0,
|
||||
videoSize.width > 0, videoSize.height > 0 else { return nil }
|
||||
let videoAspect = videoSize.width / videoSize.height
|
||||
let containerAspect = containerSize.width / containerSize.height
|
||||
if videoAspect > containerAspect {
|
||||
let height = containerSize.width / videoAspect
|
||||
return pixelAligned(
|
||||
CGRect(x: 0, y: (containerSize.height - height) / 2,
|
||||
width: containerSize.width, height: height)
|
||||
)
|
||||
}
|
||||
let width = containerSize.height * videoAspect
|
||||
return pixelAligned(
|
||||
CGRect(x: (containerSize.width - width) / 2, y: 0,
|
||||
width: width, height: containerSize.height)
|
||||
)
|
||||
}
|
||||
|
||||
static func transformedRect(containerSize: CGSize,
|
||||
videoSize: CGSize,
|
||||
scale: CGFloat,
|
||||
offset: CGSize) -> CGRect? {
|
||||
guard let baseRect = aspectFitRect(containerSize: containerSize,
|
||||
videoSize: videoSize) else {
|
||||
return nil
|
||||
}
|
||||
// SwiftUI scales the video around the center of its own pixel-aligned
|
||||
// frame. That center can differ fractionally from the container center
|
||||
// after edge alignment, and the difference is amplified at 10x.
|
||||
let center = CGPoint(x: baseRect.midX, y: baseRect.midY)
|
||||
return CGRect(
|
||||
x: center.x + (baseRect.minX - center.x) * scale + offset.width,
|
||||
y: center.y + (baseRect.minY - center.y) * scale + offset.height,
|
||||
width: baseRect.width * scale,
|
||||
height: baseRect.height * scale
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct RemoteTouchInputView: UIViewRepresentable {
|
||||
let videoSize: CGSize
|
||||
let controlMode: MouseControlMode
|
||||
let remoteCursorPosition: CGPoint?
|
||||
let viewportScale: CGFloat
|
||||
let viewportOffset: CGSize
|
||||
let viewportChanged: (CGFloat, CGSize) -> Void
|
||||
let move: (Float, Float) -> Void
|
||||
let leftDown: (Float, Float) -> Void
|
||||
let leftUp: (Float, Float) -> Void
|
||||
let rightClick: (Float, Float) -> Void
|
||||
let scroll: (Float, Float, Int, Int) -> Void
|
||||
|
||||
func makeUIView(context: Context) -> RemoteTouchSurface {
|
||||
RemoteTouchSurface()
|
||||
}
|
||||
|
||||
func updateUIView(_ view: RemoteTouchSurface, context: Context) {
|
||||
view.videoSize = videoSize
|
||||
view.controlMode = controlMode
|
||||
view.setViewport(scale: viewportScale, offset: viewportOffset)
|
||||
view.onViewportChanged = viewportChanged
|
||||
view.onMove = move
|
||||
view.onLeftDown = leftDown
|
||||
view.onLeftUp = leftUp
|
||||
view.onRightClick = rightClick
|
||||
view.onScroll = scroll
|
||||
view.synchronizeRemoteCursor(remoteCursorPosition)
|
||||
}
|
||||
}
|
||||
|
||||
final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
|
||||
var videoSize = CGSize.zero {
|
||||
didSet { setNeedsLayout() }
|
||||
}
|
||||
var controlMode: MouseControlMode = .absolute {
|
||||
didSet {
|
||||
guard oldValue != controlMode else { return }
|
||||
relativeCursorPoint = lastRemoteCursorPoint ?? (0.5, 0.5)
|
||||
relativePanActive = false
|
||||
heldDragPoint = nil
|
||||
heldDragLastLocation = nil
|
||||
hoverLastLocation = nil
|
||||
}
|
||||
}
|
||||
var onMove: ((Float, Float) -> Void)?
|
||||
var onLeftDown: ((Float, Float) -> Void)?
|
||||
var onLeftUp: ((Float, Float) -> Void)?
|
||||
var onRightClick: ((Float, Float) -> Void)?
|
||||
var onScroll: ((Float, Float, Int, Int) -> Void)?
|
||||
var onViewportChanged: ((CGFloat, CGSize) -> Void)?
|
||||
|
||||
private var relativeCursorPoint: (Float, Float) = (0.5, 0.5)
|
||||
private var lastRemoteCursorPoint: (Float, Float)?
|
||||
private var relativePanActive = false
|
||||
private var heldDragPoint: (Float, Float)?
|
||||
private var heldDragLastLocation: CGPoint?
|
||||
private var hoverLastLocation: CGPoint?
|
||||
private var viewportScale: CGFloat = 1
|
||||
private var viewportOffset = CGSize.zero
|
||||
private var pinchActive = false
|
||||
private var viewportPanActive = false
|
||||
private let relativePointerSpeed: CGFloat = 1.35
|
||||
private let maximumViewportScale: CGFloat = 10
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
isMultipleTouchEnabled = true
|
||||
|
||||
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
|
||||
tap.numberOfTouchesRequired = 1
|
||||
addGestureRecognizer(tap)
|
||||
|
||||
let rightTap = UITapGestureRecognizer(target: self, action: #selector(handleRightTap(_:)))
|
||||
rightTap.numberOfTouchesRequired = 2
|
||||
tap.require(toFail: rightTap)
|
||||
addGestureRecognizer(rightTap)
|
||||
|
||||
let drag = UIPanGestureRecognizer(target: self, action: #selector(handleDrag(_:)))
|
||||
drag.minimumNumberOfTouches = 1
|
||||
drag.maximumNumberOfTouches = 1
|
||||
drag.delegate = self
|
||||
addGestureRecognizer(drag)
|
||||
|
||||
let heldDrag = UILongPressGestureRecognizer(
|
||||
target: self,
|
||||
action: #selector(handleHeldDrag(_:))
|
||||
)
|
||||
heldDrag.minimumPressDuration = 0.35
|
||||
heldDrag.allowableMovement = 14
|
||||
heldDrag.numberOfTouchesRequired = 1
|
||||
heldDrag.delegate = self
|
||||
addGestureRecognizer(heldDrag)
|
||||
|
||||
let pinch = UIPinchGestureRecognizer(target: self,
|
||||
action: #selector(handlePinch(_:)))
|
||||
pinch.delegate = self
|
||||
addGestureRecognizer(pinch)
|
||||
|
||||
let wheel = UIPanGestureRecognizer(target: self, action: #selector(handleWheel(_:)))
|
||||
wheel.minimumNumberOfTouches = 2
|
||||
wheel.maximumNumberOfTouches = 2
|
||||
wheel.delegate = self
|
||||
addGestureRecognizer(wheel)
|
||||
|
||||
let hover = UIHoverGestureRecognizer(target: self,
|
||||
action: #selector(handleHover(_:)))
|
||||
addGestureRecognizer(hover)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
let constrained = constrainedViewportOffset(viewportOffset,
|
||||
scale: viewportScale)
|
||||
guard constrained != viewportOffset else { return }
|
||||
viewportOffset = constrained
|
||||
publishViewport()
|
||||
}
|
||||
|
||||
func setViewport(scale: CGFloat, offset: CGSize) {
|
||||
let nextScale = min(max(scale, 1), maximumViewportScale)
|
||||
viewportScale = nextScale
|
||||
viewportOffset = constrainedViewportOffset(offset, scale: nextScale)
|
||||
}
|
||||
|
||||
func synchronizeRemoteCursor(_ position: CGPoint?) {
|
||||
guard let position else {
|
||||
lastRemoteCursorPoint = nil
|
||||
return
|
||||
}
|
||||
let point = (
|
||||
Float(min(max(position.x, 0), 1)),
|
||||
Float(min(max(position.y, 0), 1))
|
||||
)
|
||||
lastRemoteCursorPoint = point
|
||||
applyRemoteCursorIfIdle()
|
||||
}
|
||||
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
|
||||
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
let recognizers = [gestureRecognizer, otherGestureRecognizer]
|
||||
let hasPinch = recognizers.contains { $0 is UIPinchGestureRecognizer }
|
||||
let hasTwoFingerPan = recognizers.contains { recognizer in
|
||||
guard let pan = recognizer as? UIPanGestureRecognizer else {
|
||||
return false
|
||||
}
|
||||
return pan.minimumNumberOfTouches == 2
|
||||
}
|
||||
return hasPinch && hasTwoFingerPan
|
||||
}
|
||||
|
||||
@objc private func handleTap(_ recognizer: UITapGestureRecognizer) {
|
||||
let location = recognizer.location(in: self)
|
||||
guard recognizer.state == .ended,
|
||||
let point = pointerPoint(for: location) else { return }
|
||||
#if DEBUG
|
||||
NSLog("CrossDesk tap location=%@ normalized=(%.5f, %.5f) " +
|
||||
"render=%@ scale=%.4f offset=%@ mode=%@",
|
||||
String(describing: location), point.0, point.1,
|
||||
renderRect().map { String(describing: $0) } ?? "nil",
|
||||
viewportScale, String(describing: viewportOffset),
|
||||
controlMode == .relative ? "relative" : "absolute")
|
||||
#endif
|
||||
onMove?(point.0, point.1)
|
||||
onLeftDown?(point.0, point.1)
|
||||
onLeftUp?(point.0, point.1)
|
||||
}
|
||||
|
||||
@objc private func handleRightTap(_ recognizer: UITapGestureRecognizer) {
|
||||
guard recognizer.state == .ended,
|
||||
let point = pointerPoint(for: recognizer.location(in: self)) else { return }
|
||||
onMove?(point.0, point.1)
|
||||
onRightClick?(point.0, point.1)
|
||||
}
|
||||
|
||||
@objc private func handleDrag(_ recognizer: UIPanGestureRecognizer) {
|
||||
if controlMode == .relative {
|
||||
handleRelativeDrag(recognizer)
|
||||
return
|
||||
}
|
||||
|
||||
switch recognizer.state {
|
||||
case .began, .changed:
|
||||
guard let point = normalized(recognizer.location(in: self)) else { return }
|
||||
onMove?(point.0, point.1)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleHeldDrag(_ recognizer: UILongPressGestureRecognizer) {
|
||||
if controlMode == .relative {
|
||||
handleRelativeHeldDrag(recognizer)
|
||||
return
|
||||
}
|
||||
|
||||
let point = normalized(recognizer.location(in: self)) ?? heldDragPoint
|
||||
switch recognizer.state {
|
||||
case .began:
|
||||
guard let point else { return }
|
||||
heldDragPoint = point
|
||||
onMove?(point.0, point.1)
|
||||
onLeftDown?(point.0, point.1)
|
||||
case .changed:
|
||||
guard let point else { return }
|
||||
heldDragPoint = point
|
||||
onMove?(point.0, point.1)
|
||||
case .ended, .cancelled, .failed:
|
||||
if let point {
|
||||
onLeftUp?(point.0, point.1)
|
||||
}
|
||||
heldDragPoint = nil
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handlePinch(_ recognizer: UIPinchGestureRecognizer) {
|
||||
let focus = recognizer.location(in: self)
|
||||
switch recognizer.state {
|
||||
case .began:
|
||||
pinchActive = renderRect()?.contains(focus) == true
|
||||
recognizer.scale = 1
|
||||
case .changed:
|
||||
guard pinchActive else { return }
|
||||
let oldScale = viewportScale
|
||||
let newScale = min(max(oldScale * recognizer.scale, 1),
|
||||
maximumViewportScale)
|
||||
recognizer.scale = 1
|
||||
guard abs(newScale - oldScale) > 0.0001 else { return }
|
||||
|
||||
let center = CGPoint(x: bounds.midX, y: bounds.midY)
|
||||
let ratio = newScale / oldScale
|
||||
let proposedOffset = CGSize(
|
||||
width: focus.x - center.x -
|
||||
(focus.x - center.x - viewportOffset.width) * ratio,
|
||||
height: focus.y - center.y -
|
||||
(focus.y - center.y - viewportOffset.height) * ratio
|
||||
)
|
||||
viewportScale = newScale
|
||||
viewportOffset = constrainedViewportOffset(proposedOffset,
|
||||
scale: newScale)
|
||||
publishViewport()
|
||||
case .ended, .cancelled, .failed:
|
||||
pinchActive = false
|
||||
if viewportScale <= 1.001 {
|
||||
viewportScale = 1
|
||||
viewportOffset = .zero
|
||||
publishViewport()
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleWheel(_ recognizer: UIPanGestureRecognizer) {
|
||||
if viewportScale > 1.001 || pinchActive {
|
||||
switch recognizer.state {
|
||||
case .began:
|
||||
viewportPanActive = renderRect()?.contains(
|
||||
recognizer.location(in: self)
|
||||
) == true
|
||||
recognizer.setTranslation(.zero, in: self)
|
||||
case .changed:
|
||||
if !viewportPanActive {
|
||||
viewportPanActive = renderRect()?.contains(
|
||||
recognizer.location(in: self)
|
||||
) == true
|
||||
}
|
||||
guard viewportPanActive else { return }
|
||||
let translation = recognizer.translation(in: self)
|
||||
recognizer.setTranslation(.zero, in: self)
|
||||
let proposedOffset = CGSize(
|
||||
width: viewportOffset.width + translation.x,
|
||||
height: viewportOffset.height + translation.y
|
||||
)
|
||||
viewportOffset = constrainedViewportOffset(proposedOffset,
|
||||
scale: viewportScale)
|
||||
publishViewport()
|
||||
default:
|
||||
viewportPanActive = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let point = pointerPoint(for: recognizer.location(in: self)) else { return }
|
||||
let translation = recognizer.translation(in: self)
|
||||
let deltaX = Int(-translation.x / 12)
|
||||
let deltaY = Int(-translation.y / 12)
|
||||
if deltaX != 0 || deltaY != 0 {
|
||||
onScroll?(point.0, point.1, deltaX, deltaY)
|
||||
recognizer.setTranslation(.zero, in: self)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleHover(_ recognizer: UIHoverGestureRecognizer) {
|
||||
let location = recognizer.location(in: self)
|
||||
if controlMode == .relative {
|
||||
switch recognizer.state {
|
||||
case .began:
|
||||
guard renderRect()?.contains(location) == true else { return }
|
||||
hoverLastLocation = location
|
||||
onMove?(relativeCursorPoint.0, relativeCursorPoint.1)
|
||||
case .changed:
|
||||
guard let previous = hoverLastLocation else { return }
|
||||
hoverLastLocation = location
|
||||
if let point = updateRelativeCursor(
|
||||
by: CGPoint(x: location.x - previous.x,
|
||||
y: location.y - previous.y)
|
||||
) {
|
||||
onMove?(point.0, point.1)
|
||||
}
|
||||
default:
|
||||
hoverLastLocation = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard recognizer.state == .began || recognizer.state == .changed,
|
||||
let point = normalized(location) else { return }
|
||||
onMove?(point.0, point.1)
|
||||
}
|
||||
|
||||
private func handleRelativeDrag(_ recognizer: UIPanGestureRecognizer) {
|
||||
switch recognizer.state {
|
||||
case .began:
|
||||
let location = recognizer.location(in: self)
|
||||
relativePanActive = renderRect()?.contains(location) == true
|
||||
recognizer.setTranslation(.zero, in: self)
|
||||
if relativePanActive {
|
||||
onMove?(relativeCursorPoint.0, relativeCursorPoint.1)
|
||||
}
|
||||
case .changed:
|
||||
guard relativePanActive else { return }
|
||||
let translation = recognizer.translation(in: self)
|
||||
recognizer.setTranslation(.zero, in: self)
|
||||
if let point = updateRelativeCursor(by: translation) {
|
||||
onMove?(point.0, point.1)
|
||||
}
|
||||
default:
|
||||
relativePanActive = false
|
||||
applyRemoteCursorIfIdle()
|
||||
}
|
||||
}
|
||||
|
||||
private func handleRelativeHeldDrag(_ recognizer: UILongPressGestureRecognizer) {
|
||||
let location = recognizer.location(in: self)
|
||||
switch recognizer.state {
|
||||
case .began:
|
||||
guard renderRect()?.contains(location) == true else { return }
|
||||
heldDragLastLocation = location
|
||||
heldDragPoint = relativeCursorPoint
|
||||
onMove?(relativeCursorPoint.0, relativeCursorPoint.1)
|
||||
onLeftDown?(relativeCursorPoint.0, relativeCursorPoint.1)
|
||||
case .changed:
|
||||
guard let previous = heldDragLastLocation else { return }
|
||||
heldDragLastLocation = location
|
||||
if let point = updateRelativeCursor(
|
||||
by: CGPoint(x: location.x - previous.x,
|
||||
y: location.y - previous.y)
|
||||
) {
|
||||
heldDragPoint = point
|
||||
onMove?(point.0, point.1)
|
||||
}
|
||||
case .ended, .cancelled, .failed:
|
||||
if let point = heldDragPoint {
|
||||
onLeftUp?(point.0, point.1)
|
||||
}
|
||||
heldDragPoint = nil
|
||||
heldDragLastLocation = nil
|
||||
applyRemoteCursorIfIdle()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func pointerPoint(for location: CGPoint) -> (Float, Float)? {
|
||||
if controlMode == .relative {
|
||||
guard renderRect()?.contains(location) == true else { return nil }
|
||||
return relativeCursorPoint
|
||||
}
|
||||
return normalized(location)
|
||||
}
|
||||
|
||||
private func updateRelativeCursor(by translation: CGPoint) -> (Float, Float)? {
|
||||
guard let renderRect = renderRect(),
|
||||
renderRect.width > 0, renderRect.height > 0 else { return nil }
|
||||
let nextX = CGFloat(relativeCursorPoint.0) +
|
||||
translation.x / renderRect.width * relativePointerSpeed
|
||||
let nextY = CGFloat(relativeCursorPoint.1) +
|
||||
translation.y / renderRect.height * relativePointerSpeed
|
||||
relativeCursorPoint = (
|
||||
Float(min(max(nextX, 0), 1)),
|
||||
Float(min(max(nextY, 0), 1))
|
||||
)
|
||||
return relativeCursorPoint
|
||||
}
|
||||
|
||||
private func applyRemoteCursorIfIdle() {
|
||||
guard controlMode == .relative,
|
||||
!relativePanActive,
|
||||
heldDragPoint == nil,
|
||||
let point = lastRemoteCursorPoint else { return }
|
||||
relativeCursorPoint = point
|
||||
}
|
||||
|
||||
private func normalized(_ point: CGPoint) -> (Float, Float)? {
|
||||
guard let renderRect = renderRect(), renderRect.contains(point) else { return nil }
|
||||
return (Float((point.x - renderRect.minX) / renderRect.width),
|
||||
Float((point.y - renderRect.minY) / renderRect.height))
|
||||
}
|
||||
|
||||
private func renderRect() -> CGRect? {
|
||||
RemoteVideoGeometry.transformedRect(
|
||||
containerSize: bounds.size,
|
||||
videoSize: videoSize,
|
||||
scale: viewportScale,
|
||||
offset: viewportOffset
|
||||
)
|
||||
}
|
||||
|
||||
private func baseRenderRect() -> CGRect? {
|
||||
RemoteVideoGeometry.aspectFitRect(containerSize: bounds.size,
|
||||
videoSize: videoSize)
|
||||
}
|
||||
|
||||
private func constrainedViewportOffset(_ offset: CGSize,
|
||||
scale: CGFloat) -> CGSize {
|
||||
guard let baseRect = baseRenderRect() else { return offset }
|
||||
let maximumX = max(0, baseRect.width * (scale - 1) / 2)
|
||||
let maximumY = max(0, baseRect.height * (scale - 1) / 2)
|
||||
return CGSize(
|
||||
width: min(max(offset.width, -maximumX), maximumX),
|
||||
height: min(max(offset.height, -maximumY), maximumY)
|
||||
)
|
||||
}
|
||||
|
||||
private func publishViewport() {
|
||||
onViewportChanged?(viewportScale, viewportOffset)
|
||||
}
|
||||
}
|
||||
@@ -1,928 +0,0 @@
|
||||
import AVFoundation
|
||||
import CoreImage
|
||||
import CoreVideo
|
||||
import Foundation
|
||||
import Security
|
||||
import UIKit
|
||||
|
||||
enum MouseControlMode: String, CaseIterable, Identifiable {
|
||||
case relative
|
||||
case absolute
|
||||
|
||||
private static let defaultsKey = "crossdesk.mobile.mouse-control-mode"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .absolute: return "绝对位置"
|
||||
case .relative: return "相对位置"
|
||||
}
|
||||
}
|
||||
|
||||
var detail: String {
|
||||
switch self {
|
||||
case .absolute:
|
||||
return "触摸位置直接对应远端屏幕位置,适合快速定位。"
|
||||
case .relative:
|
||||
return "像触控板一样滑动光标,点击时操作当前光标位置。"
|
||||
}
|
||||
}
|
||||
|
||||
static var saved: MouseControlMode {
|
||||
guard let rawValue = UserDefaults.standard.string(forKey: defaultsKey),
|
||||
let mode = MouseControlMode(rawValue: rawValue) else {
|
||||
return .relative
|
||||
}
|
||||
return mode
|
||||
}
|
||||
|
||||
func save() {
|
||||
UserDefaults.standard.set(rawValue, forKey: Self.defaultsKey)
|
||||
}
|
||||
}
|
||||
|
||||
enum VideoCodecMode: String, CaseIterable, Identifiable {
|
||||
case hardware
|
||||
case software
|
||||
|
||||
private static let defaultsKey = "crossdesk.mobile.video-codec-mode"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .hardware: return "硬件"
|
||||
case .software: return "软件"
|
||||
}
|
||||
}
|
||||
|
||||
var detail: String {
|
||||
switch self {
|
||||
case .hardware:
|
||||
return "使用 VideoToolbox,延迟和耗电更低,推荐日常使用。"
|
||||
case .software:
|
||||
return "使用 OpenH264 软件编解码,适合兼容性测试。"
|
||||
}
|
||||
}
|
||||
|
||||
static var saved: VideoCodecMode {
|
||||
guard let rawValue = UserDefaults.standard.string(forKey: defaultsKey),
|
||||
let mode = VideoCodecMode(rawValue: rawValue) else {
|
||||
return .hardware
|
||||
}
|
||||
return mode
|
||||
}
|
||||
|
||||
func save() {
|
||||
UserDefaults.standard.set(rawValue, forKey: Self.defaultsKey)
|
||||
}
|
||||
}
|
||||
|
||||
enum VideoAdaptationPolicy: String, CaseIterable, Identifiable {
|
||||
case frameRatePriority
|
||||
case qualityPriority
|
||||
case balanced
|
||||
|
||||
private static let defaultsKey = "crossdesk.mobile.video-adaptation-policy"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .frameRatePriority: return "帧率优先"
|
||||
case .qualityPriority: return "画质优先"
|
||||
case .balanced: return "平衡"
|
||||
}
|
||||
}
|
||||
|
||||
var detail: String {
|
||||
switch self {
|
||||
case .frameRatePriority:
|
||||
return "优先保持画面流畅,带宽或编码压力较大时会更积极地降低分辨率。"
|
||||
case .qualityPriority:
|
||||
return "始终保持较高分辨率,带宽不足时通过降低帧率来保证画面清晰度。"
|
||||
case .balanced:
|
||||
return "兼顾清晰度和流畅度,只在持续压力下逐步降低分辨率。"
|
||||
}
|
||||
}
|
||||
|
||||
var bridgeValue: CrossDeskVideoAdaptationPolicy {
|
||||
switch self {
|
||||
case .frameRatePriority: return .frameRatePriority
|
||||
case .qualityPriority: return .qualityPriority
|
||||
case .balanced: return .balanced
|
||||
}
|
||||
}
|
||||
|
||||
static var saved: VideoAdaptationPolicy {
|
||||
guard let rawValue = UserDefaults.standard.string(forKey: defaultsKey),
|
||||
let policy = VideoAdaptationPolicy(rawValue: rawValue) else {
|
||||
return .qualityPriority
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
func save() {
|
||||
UserDefaults.standard.set(rawValue, forKey: Self.defaultsKey)
|
||||
}
|
||||
}
|
||||
|
||||
private struct RemoteVideoFrame {
|
||||
let pixelBuffer: CVPixelBuffer
|
||||
let encodedSize: CGSize
|
||||
}
|
||||
|
||||
struct RecentConnection: Codable, Identifiable, Equatable {
|
||||
let remoteID: String
|
||||
var displayName: String
|
||||
var lastConnectedAt: Date
|
||||
var remembersPassword: Bool
|
||||
var thumbnailFileName: String?
|
||||
|
||||
var id: String { remoteID }
|
||||
}
|
||||
|
||||
private enum RecentConnectionStore {
|
||||
private static let defaultsKey = "crossdesk.mobile.recent-connections.v1"
|
||||
private static let thumbnailQueue =
|
||||
DispatchQueue(label: "cn.crossdesk.mobile.thumbnails", qos: .utility)
|
||||
private static let context = CIContext(options: [.cacheIntermediates: false])
|
||||
|
||||
static func load() -> [RecentConnection] {
|
||||
guard let data = UserDefaults.standard.data(forKey: defaultsKey),
|
||||
let connections = try? JSONDecoder().decode([RecentConnection].self,
|
||||
from: data) else {
|
||||
return []
|
||||
}
|
||||
return connections.sorted { $0.lastConnectedAt > $1.lastConnectedAt }
|
||||
}
|
||||
|
||||
static func save(_ connections: [RecentConnection]) {
|
||||
guard let data = try? JSONEncoder().encode(connections) else { return }
|
||||
UserDefaults.standard.set(data, forKey: defaultsKey)
|
||||
}
|
||||
|
||||
static func thumbnailURL(fileName: String) -> URL? {
|
||||
guard let directory = thumbnailDirectory() else { return nil }
|
||||
return directory.appendingPathComponent(fileName, isDirectory: false)
|
||||
}
|
||||
|
||||
static func captureThumbnail(from pixelBuffer: CVPixelBuffer,
|
||||
remoteID: String,
|
||||
completion: @escaping (String?) -> Void) {
|
||||
thumbnailQueue.async {
|
||||
let fileName: String? = autoreleasepool {
|
||||
let image = CIImage(cvPixelBuffer: pixelBuffer)
|
||||
let targetSize = CGSize(width: 640, height: 360)
|
||||
guard image.extent.width > 0, image.extent.height > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Scale in Core Image before materializing a CGImage. Creating
|
||||
// a full 4K bitmap and then drawing it into a thumbnail can add
|
||||
// tens of megabytes to the first-frame memory peak.
|
||||
let scale = max(targetSize.width / image.extent.width,
|
||||
targetSize.height / image.extent.height)
|
||||
let scaled = image.transformed(by: CGAffineTransform(
|
||||
scaleX: scale,
|
||||
y: scale
|
||||
))
|
||||
let cropRect = CGRect(
|
||||
x: scaled.extent.midX - targetSize.width / 2,
|
||||
y: scaled.extent.midY - targetSize.height / 2,
|
||||
width: targetSize.width,
|
||||
height: targetSize.height
|
||||
).integral
|
||||
guard let thumbnailImage = context.createCGImage(
|
||||
scaled.cropped(to: cropRect),
|
||||
from: cropRect
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let safeID = remoteID.filter {
|
||||
$0.isLetter || $0.isNumber || $0 == "-"
|
||||
}
|
||||
let name = "\(safeID.isEmpty ? "remote" : safeID).jpg"
|
||||
guard let url = thumbnailURL(fileName: name),
|
||||
let data = UIImage(cgImage: thumbnailImage)
|
||||
.jpegData(compressionQuality: 0.78) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
try data.write(to: url, options: .atomic)
|
||||
return name
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
DispatchQueue.main.async { completion(fileName) }
|
||||
}
|
||||
}
|
||||
|
||||
static func removeThumbnail(fileName: String?) {
|
||||
guard let fileName, let url = thumbnailURL(fileName: fileName) else { return }
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
|
||||
private static func thumbnailDirectory() -> URL? {
|
||||
guard let base = FileManager.default.urls(for: .applicationSupportDirectory,
|
||||
in: .userDomainMask).first else {
|
||||
return nil
|
||||
}
|
||||
let directory = base.appendingPathComponent("RecentConnectionThumbnails",
|
||||
isDirectory: true)
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: directory,
|
||||
withIntermediateDirectories: true)
|
||||
return directory
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum ConnectionCredentialStore {
|
||||
private static let service = "cn.crossdesk.mobile.saved-password"
|
||||
|
||||
static func password(for remoteID: String) -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: remoteID,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne
|
||||
]
|
||||
var result: CFTypeRef?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
||||
let data = result as? Data else {
|
||||
return nil
|
||||
}
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
static func save(password: String, for remoteID: String) {
|
||||
removePassword(for: remoteID)
|
||||
let attributes: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: remoteID,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||
kSecValueData as String: Data(password.utf8)
|
||||
]
|
||||
SecItemAdd(attributes as CFDictionary, nil)
|
||||
}
|
||||
|
||||
static func removePassword(for remoteID: String) {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: remoteID
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
|
||||
private final class RemoteAudioPlayer {
|
||||
private let engine = AVAudioEngine()
|
||||
private let player = AVAudioPlayerNode()
|
||||
private let queue = DispatchQueue(label: "cn.crossdesk.mobile.audio")
|
||||
private let format = AVAudioFormat(commonFormat: .pcmFormatInt16,
|
||||
sampleRate: 48_000,
|
||||
channels: 1,
|
||||
interleaved: false)!
|
||||
private var enabled = true
|
||||
private var queuedBuffers = 0
|
||||
|
||||
init() {
|
||||
engine.attach(player)
|
||||
engine.connect(player, to: engine.mainMixerNode, format: format)
|
||||
}
|
||||
|
||||
func setEnabled(_ value: Bool) {
|
||||
queue.async {
|
||||
self.enabled = value
|
||||
if value {
|
||||
self.startIfNeeded()
|
||||
} else {
|
||||
self.player.stop()
|
||||
self.engine.stop()
|
||||
self.queuedBuffers = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func enqueue(_ data: Data) {
|
||||
guard !data.isEmpty, data.count.isMultiple(of: MemoryLayout<Int16>.size) else { return }
|
||||
queue.async {
|
||||
guard self.enabled, self.queuedBuffers < 80 else { return }
|
||||
self.startIfNeeded()
|
||||
guard self.engine.isRunning,
|
||||
let buffer = AVAudioPCMBuffer(pcmFormat: self.format,
|
||||
frameCapacity: AVAudioFrameCount(data.count / 2)),
|
||||
let samples = buffer.int16ChannelData?[0] else { return }
|
||||
buffer.frameLength = buffer.frameCapacity
|
||||
data.withUnsafeBytes { bytes in
|
||||
guard let source = bytes.baseAddress else { return }
|
||||
memcpy(samples, source, data.count)
|
||||
}
|
||||
self.queuedBuffers += 1
|
||||
self.player.scheduleBuffer(buffer) {
|
||||
self.queue.async {
|
||||
self.queuedBuffers = max(0, self.queuedBuffers - 1)
|
||||
}
|
||||
}
|
||||
if !self.player.isPlaying {
|
||||
self.player.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startIfNeeded() {
|
||||
guard enabled, !engine.isRunning else { return }
|
||||
do {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(.playback, mode: .moviePlayback,
|
||||
options: [.mixWithOthers])
|
||||
try session.setActive(true)
|
||||
try engine.start()
|
||||
player.play()
|
||||
} catch {
|
||||
// The next PCM packet retries activation after route changes.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDelegate {
|
||||
@Published var signalHost = "api.crossdesk.cn"
|
||||
@Published var signalPort = "9099"
|
||||
@Published var turnPort = "3478"
|
||||
@Published var enableSRTP = false
|
||||
@Published var mouseControlMode = MouseControlMode.saved {
|
||||
didSet { mouseControlMode.save() }
|
||||
}
|
||||
@Published var videoCodecMode = VideoCodecMode.saved {
|
||||
didSet { videoCodecMode.save() }
|
||||
}
|
||||
@Published var videoAdaptationPolicy = VideoAdaptationPolicy.saved {
|
||||
didSet { videoAdaptationPolicy.save() }
|
||||
}
|
||||
@Published var remoteID = ""
|
||||
@Published var password = ""
|
||||
@Published private(set) var signalStatus = "正在连接信令服务"
|
||||
@Published private(set) var connectionStatus = "未连接"
|
||||
@Published private(set) var localIdentity = ""
|
||||
@Published private(set) var isConnecting = false
|
||||
@Published private(set) var isConnected = false
|
||||
@Published private(set) var sessionVisible = false
|
||||
@Published private var videoFrame: RemoteVideoFrame?
|
||||
@Published private(set) var remoteCursorVisible = true
|
||||
@Published private(set) var remoteCursorShape = 0
|
||||
@Published private(set) var remoteCursorPosition: CGPoint?
|
||||
@Published private(set) var remoteCursorVisualOffset = CGPoint.zero
|
||||
@Published private(set) var hasRemoteCursorState = false
|
||||
@Published private(set) var bitrate: UInt = 0
|
||||
@Published private(set) var lossRate: Float = 0
|
||||
@Published private(set) var usingTURN = false
|
||||
@Published private(set) var displays: [String] = ["显示器 1"]
|
||||
@Published private(set) var displaySizes: [CGSize] = []
|
||||
@Published var selectedDisplay = 0
|
||||
@Published var audioEnabled = true
|
||||
@Published private(set) var clipboardStatus = ""
|
||||
@Published private(set) var transferStatus = ""
|
||||
@Published private(set) var transferProgress = 0.0
|
||||
@Published private(set) var receivedFileURL: URL?
|
||||
@Published private(set) var frameCount: UInt64 = 0
|
||||
@Published private(set) var recentConnections = RecentConnectionStore.load()
|
||||
@Published private(set) var recentConnectionPresence: [String: Bool] = [:]
|
||||
@Published private(set) var deviceOfflineAlertVisible = false
|
||||
|
||||
let bridge = CrossDeskRTCBridge()
|
||||
private let audioPlayer = RemoteAudioPlayer()
|
||||
private var activeRemoteID = ""
|
||||
private var pendingRememberPassword = false
|
||||
private var connectionRecorded = false
|
||||
private var shouldCaptureThumbnail = false
|
||||
private var activeDisplayName = ""
|
||||
private var remoteCursorSequence: UInt32?
|
||||
private var pendingPresenceRemoteID: String?
|
||||
private var presenceProbeGeneration: UInt64 = 0
|
||||
|
||||
var pixelBuffer: CVPixelBuffer? { videoFrame?.pixelBuffer }
|
||||
var frameSize: CGSize { videoFrame?.encodedSize ?? .zero }
|
||||
var displayGeometrySize: CGSize {
|
||||
guard displaySizes.indices.contains(selectedDisplay) else {
|
||||
return frameSize
|
||||
}
|
||||
let size = displaySizes[selectedDisplay]
|
||||
return size.width > 0 && size.height > 0 ? size : frameSize
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
bridge.delegate = self
|
||||
configureBridge()
|
||||
}
|
||||
|
||||
func configureBridge() {
|
||||
guard let signal = Int(signalPort), let turn = Int(turnPort),
|
||||
!signalHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
signalStatus = "服务器配置无效"
|
||||
return
|
||||
}
|
||||
bridge.setHardwareAccelerationEnabled(videoCodecMode == .hardware)
|
||||
bridge.setVideoAdaptationPolicy(videoAdaptationPolicy.bridgeValue)
|
||||
bridge.configure(withSignalHost: signalHost,
|
||||
signalPort: signal,
|
||||
turnPort: turn,
|
||||
enableSRTP: enableSRTP)
|
||||
}
|
||||
|
||||
func connect(password: String, rememberPassword: Bool) {
|
||||
self.password = password
|
||||
pendingRememberPassword = rememberPassword
|
||||
connect()
|
||||
}
|
||||
|
||||
func connect() {
|
||||
let identifier = remoteID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !identifier.isEmpty else {
|
||||
connectionStatus = "请输入远程设备 ID"
|
||||
return
|
||||
}
|
||||
remoteID = identifier
|
||||
configureBridge()
|
||||
if recentConnectionPresence[identifier] == true {
|
||||
beginRemoteConnection(identifier)
|
||||
} else {
|
||||
beginPresenceProbe(identifier)
|
||||
}
|
||||
}
|
||||
|
||||
private func beginRemoteConnection(_ identifier: String) {
|
||||
cancelPresenceProbe()
|
||||
activeRemoteID = identifier
|
||||
activeDisplayName = ""
|
||||
displaySizes = []
|
||||
connectionRecorded = false
|
||||
shouldCaptureThumbnail = true
|
||||
isConnecting = true
|
||||
isConnected = false
|
||||
sessionVisible = false
|
||||
resetRemoteCursorState()
|
||||
connectionStatus = "正在准备连接…"
|
||||
bridge.connect(toRemoteID: identifier, password: password)
|
||||
}
|
||||
|
||||
private func beginPresenceProbe(_ identifier: String) {
|
||||
guard signalStatus == "已连接服务器" else {
|
||||
showDeviceOffline()
|
||||
return
|
||||
}
|
||||
|
||||
presenceProbeGeneration &+= 1
|
||||
let generation = presenceProbeGeneration
|
||||
pendingPresenceRemoteID = identifier
|
||||
isConnecting = true
|
||||
isConnected = false
|
||||
sessionVisible = false
|
||||
connectionStatus = "正在确认设备状态…"
|
||||
bridge.requestPresence(remoteIDs: [identifier])
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [weak self] in
|
||||
guard let self,
|
||||
self.presenceProbeGeneration == generation,
|
||||
self.pendingPresenceRemoteID == identifier else {
|
||||
return
|
||||
}
|
||||
self.cancelPresenceProbe()
|
||||
self.refreshRecentConnectionPresence()
|
||||
self.showDeviceOffline()
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelPresenceProbe() {
|
||||
presenceProbeGeneration &+= 1
|
||||
pendingPresenceRemoteID = nil
|
||||
}
|
||||
|
||||
private func showDeviceOffline() {
|
||||
isConnecting = false
|
||||
isConnected = false
|
||||
sessionVisible = false
|
||||
connectionStatus = "设备离线"
|
||||
deviceOfflineAlertVisible = true
|
||||
}
|
||||
|
||||
func dismissDeviceOfflineAlert() {
|
||||
deviceOfflineAlertVisible = false
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
let wasCheckingPresence = pendingPresenceRemoteID != nil
|
||||
cancelPresenceProbe()
|
||||
if wasCheckingPresence {
|
||||
refreshRecentConnectionPresence()
|
||||
}
|
||||
bridge.disconnect()
|
||||
isConnecting = false
|
||||
isConnected = false
|
||||
sessionVisible = false
|
||||
videoFrame = nil
|
||||
resetRemoteCursorState()
|
||||
frameCount = 0
|
||||
bitrate = 0
|
||||
displays = ["显示器 1"]
|
||||
displaySizes = []
|
||||
selectedDisplay = 0
|
||||
connectionStatus = "未连接"
|
||||
audioPlayer.setEnabled(false)
|
||||
AppOrientation.update(to: .portrait)
|
||||
}
|
||||
|
||||
func retry() {
|
||||
bridge.disconnect()
|
||||
connect()
|
||||
}
|
||||
|
||||
func savedPassword(for remoteID: String) -> String {
|
||||
ConnectionCredentialStore.password(for: remoteID) ?? ""
|
||||
}
|
||||
|
||||
func savedCredential(for remoteID: String) -> String? {
|
||||
ConnectionCredentialStore.password(for: remoteID)
|
||||
}
|
||||
|
||||
func remembersPassword(for remoteID: String) -> Bool {
|
||||
recentConnections.first(where: { $0.remoteID == remoteID })?
|
||||
.remembersPassword == true
|
||||
}
|
||||
|
||||
func thumbnailImage(for connection: RecentConnection) -> UIImage? {
|
||||
guard let fileName = connection.thumbnailFileName,
|
||||
let url = RecentConnectionStore.thumbnailURL(fileName: fileName) else {
|
||||
return nil
|
||||
}
|
||||
return UIImage(contentsOfFile: url.path)
|
||||
}
|
||||
|
||||
func isRecentConnectionOnline(_ connection: RecentConnection) -> Bool {
|
||||
recentConnectionPresence[connection.remoteID] == true
|
||||
}
|
||||
|
||||
private func refreshRecentConnectionPresence() {
|
||||
bridge.requestPresence(remoteIDs: recentConnections.map(\.remoteID))
|
||||
}
|
||||
|
||||
func refreshRecentConnectionPresenceAfterForeground() {
|
||||
// iOS can suspend the process while it is in the background, so presence
|
||||
// updates received during that time are missed. Treat cached values as
|
||||
// unknown (and therefore offline, like the desktop client) until the
|
||||
// signaling server returns a fresh snapshot.
|
||||
recentConnectionPresence = [:]
|
||||
if let pendingPresenceRemoteID {
|
||||
bridge.requestPresence(remoteIDs: [pendingPresenceRemoteID])
|
||||
} else {
|
||||
refreshRecentConnectionPresence()
|
||||
}
|
||||
}
|
||||
|
||||
func removeRecentConnection(_ connection: RecentConnection) {
|
||||
recentConnections.removeAll { $0.remoteID == connection.remoteID }
|
||||
recentConnectionPresence.removeValue(forKey: connection.remoteID)
|
||||
RecentConnectionStore.save(recentConnections)
|
||||
RecentConnectionStore.removeThumbnail(fileName: connection.thumbnailFileName)
|
||||
ConnectionCredentialStore.removePassword(for: connection.remoteID)
|
||||
refreshRecentConnectionPresence()
|
||||
}
|
||||
|
||||
private func recordSuccessfulConnectionIfNeeded() {
|
||||
guard !connectionRecorded, !activeRemoteID.isEmpty else { return }
|
||||
connectionRecorded = true
|
||||
|
||||
if pendingRememberPassword {
|
||||
ConnectionCredentialStore.save(password: password, for: activeRemoteID)
|
||||
} else {
|
||||
ConnectionCredentialStore.removePassword(for: activeRemoteID)
|
||||
}
|
||||
|
||||
let previous = recentConnections.first { $0.remoteID == activeRemoteID }
|
||||
let title = activeDisplayName.isEmpty
|
||||
? (previous?.displayName ?? activeRemoteID)
|
||||
: activeDisplayName
|
||||
let connection = RecentConnection(
|
||||
remoteID: activeRemoteID,
|
||||
displayName: title,
|
||||
lastConnectedAt: Date(),
|
||||
remembersPassword: pendingRememberPassword,
|
||||
thumbnailFileName: previous?.thumbnailFileName
|
||||
)
|
||||
recentConnections.removeAll { $0.remoteID == activeRemoteID }
|
||||
recentConnections.insert(connection, at: 0)
|
||||
recentConnectionPresence[activeRemoteID] = true
|
||||
RecentConnectionStore.save(recentConnections)
|
||||
refreshRecentConnectionPresence()
|
||||
}
|
||||
|
||||
private func updateActiveConnectionName(_ name: String) {
|
||||
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, !activeRemoteID.isEmpty else { return }
|
||||
activeDisplayName = trimmed
|
||||
guard let index = recentConnections.firstIndex(where: {
|
||||
$0.remoteID == activeRemoteID
|
||||
}) else { return }
|
||||
recentConnections[index].displayName = trimmed
|
||||
RecentConnectionStore.save(recentConnections)
|
||||
}
|
||||
|
||||
private func captureRecentThumbnailIfNeeded(_ pixelBuffer: CVPixelBuffer) {
|
||||
guard shouldCaptureThumbnail, !activeRemoteID.isEmpty else { return }
|
||||
shouldCaptureThumbnail = false
|
||||
let identifier = activeRemoteID
|
||||
RecentConnectionStore.captureThumbnail(from: pixelBuffer,
|
||||
remoteID: identifier) { [weak self] fileName in
|
||||
guard let self, let fileName,
|
||||
let index = self.recentConnections.firstIndex(where: {
|
||||
$0.remoteID == identifier
|
||||
}) else { return }
|
||||
self.recentConnections[index].thumbnailFileName = fileName
|
||||
RecentConnectionStore.save(self.recentConnections)
|
||||
}
|
||||
}
|
||||
|
||||
func selectDisplay(_ index: Int) {
|
||||
guard displays.indices.contains(index) else { return }
|
||||
selectedDisplay = index
|
||||
videoFrame = nil
|
||||
resetRemoteCursorState()
|
||||
frameCount = 0
|
||||
bridge.switch(toDisplay: index)
|
||||
}
|
||||
|
||||
private func resetRemoteCursorState() {
|
||||
remoteCursorVisible = true
|
||||
remoteCursorShape = 0
|
||||
remoteCursorPosition = nil
|
||||
remoteCursorVisualOffset = .zero
|
||||
hasRemoteCursorState = false
|
||||
remoteCursorSequence = nil
|
||||
}
|
||||
|
||||
func toggleAudio() {
|
||||
audioEnabled.toggle()
|
||||
audioPlayer.setEnabled(audioEnabled)
|
||||
bridge.setAudioEnabled(audioEnabled)
|
||||
}
|
||||
|
||||
func sendClipboard() {
|
||||
guard let text = UIPasteboard.general.string, !text.isEmpty else {
|
||||
clipboardStatus = "剪贴板中没有文本"
|
||||
return
|
||||
}
|
||||
guard text.lengthOfBytes(using: .utf8) <= 128 * 1024 else {
|
||||
clipboardStatus = "剪贴板文本超过 128 KiB"
|
||||
return
|
||||
}
|
||||
bridge.sendClipboardText(text)
|
||||
clipboardStatus = "已发送本机剪贴板"
|
||||
}
|
||||
|
||||
func sendFile(_ url: URL) {
|
||||
transferStatus = "正在发送 \(url.lastPathComponent)"
|
||||
transferProgress = 0
|
||||
bridge.sendFile(at: url)
|
||||
}
|
||||
|
||||
func sendKeyStroke(_ keyCode: UInt) {
|
||||
bridge.sendWindowsKeyCode(keyCode, isDown: true)
|
||||
bridge.sendWindowsKeyCode(keyCode, isDown: false)
|
||||
}
|
||||
|
||||
func sendKeyState(_ keyCode: UInt, isDown: Bool) {
|
||||
bridge.sendWindowsKeyCode(keyCode, isDown: isDown)
|
||||
}
|
||||
|
||||
func rtcBridge(_ bridge: CrossDeskRTCBridge,
|
||||
didChange state: CrossDeskSignalState) {
|
||||
switch state.rawValue {
|
||||
case 0: signalStatus = "正在连接信令服务"
|
||||
case 1: signalStatus = "已连接服务器"
|
||||
case 2: signalStatus = "信令连接失败"
|
||||
case 3: signalStatus = "信令连接已关闭"
|
||||
case 4: signalStatus = "信令服务重连中"
|
||||
case 5: signalStatus = "信令服务器已关闭连接"
|
||||
case 6: signalStatus = "TLS 证书校验失败"
|
||||
default: signalStatus = "未知信令状态"
|
||||
}
|
||||
if state.rawValue == 1 {
|
||||
if let pendingPresenceRemoteID {
|
||||
bridge.requestPresence(remoteIDs: [pendingPresenceRemoteID])
|
||||
} else {
|
||||
refreshRecentConnectionPresence()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func rtcBridge(_ bridge: CrossDeskRTCBridge,
|
||||
didChange state: CrossDeskConnectionState,
|
||||
remoteID: String) {
|
||||
isConnected = state.rawValue == 1
|
||||
isConnecting = [0, 2].contains(state.rawValue)
|
||||
switch state.rawValue {
|
||||
case 0: connectionStatus = "正在建立远程连接"
|
||||
case 1:
|
||||
connectionStatus = "已连接"
|
||||
if !remoteID.isEmpty {
|
||||
recentConnectionPresence[remoteID] = true
|
||||
}
|
||||
sessionVisible = true
|
||||
recordSuccessfulConnectionIfNeeded()
|
||||
audioPlayer.setEnabled(audioEnabled)
|
||||
bridge.setAudioEnabled(audioEnabled)
|
||||
AppOrientation.update(to: .allButUpsideDown)
|
||||
case 2: connectionStatus = "正在收集 ICE 候选"
|
||||
case 3: connectionStatus = "网络连接已中断"
|
||||
case 4:
|
||||
connectionStatus = "P2P/TURN 建链失败"
|
||||
sessionVisible = false
|
||||
AppOrientation.update(to: .portrait)
|
||||
case 5:
|
||||
connectionStatus = "未连接"
|
||||
sessionVisible = false
|
||||
AppOrientation.update(to: .portrait)
|
||||
case 6:
|
||||
connectionStatus = "访问密码错误"
|
||||
sessionVisible = false
|
||||
AppOrientation.update(to: .portrait)
|
||||
case 7:
|
||||
connectionStatus = "远程设备 ID 不存在"
|
||||
if !remoteID.isEmpty {
|
||||
recentConnectionPresence[remoteID] = false
|
||||
}
|
||||
sessionVisible = false
|
||||
AppOrientation.update(to: .portrait)
|
||||
case 8:
|
||||
connectionStatus = "远程设备当前不可用"
|
||||
if !remoteID.isEmpty {
|
||||
recentConnectionPresence[remoteID] = false
|
||||
}
|
||||
sessionVisible = false
|
||||
AppOrientation.update(to: .portrait)
|
||||
default: connectionStatus = "未知连接状态"
|
||||
}
|
||||
}
|
||||
|
||||
func rtcBridge(_ bridge: CrossDeskRTCBridge, didProvisionIdentity identity: String) {
|
||||
localIdentity = identity.split(separator: "@").first.map(String.init) ?? identity
|
||||
if let pendingPresenceRemoteID {
|
||||
bridge.requestPresence(remoteIDs: [pendingPresenceRemoteID])
|
||||
} else {
|
||||
refreshRecentConnectionPresence()
|
||||
}
|
||||
}
|
||||
|
||||
func rtcBridge(_ bridge: CrossDeskRTCBridge,
|
||||
didReceivePresence presence: [String: NSNumber]) {
|
||||
var updated = recentConnectionPresence
|
||||
for (remoteID, online) in presence {
|
||||
updated[remoteID] = online.boolValue
|
||||
}
|
||||
recentConnectionPresence = updated
|
||||
|
||||
guard let pendingRemoteID = pendingPresenceRemoteID,
|
||||
let online = presence[pendingRemoteID]?.boolValue else {
|
||||
return
|
||||
}
|
||||
cancelPresenceProbe()
|
||||
refreshRecentConnectionPresence()
|
||||
if online {
|
||||
beginRemoteConnection(pendingRemoteID)
|
||||
} else {
|
||||
showDeviceOffline()
|
||||
}
|
||||
}
|
||||
|
||||
func rtcBridge(_ bridge: CrossDeskRTCBridge,
|
||||
didReceive pixelBuffer: CVPixelBuffer,
|
||||
width: Int,
|
||||
height: Int) {
|
||||
// Buffer and encoded dimensions must be one observable value. Adaptive
|
||||
// resolution changes must never expose a new frame with the previous
|
||||
// frame's geometry to SwiftUI.
|
||||
videoFrame = RemoteVideoFrame(
|
||||
pixelBuffer: pixelBuffer,
|
||||
encodedSize: CGSize(width: width, height: height)
|
||||
)
|
||||
frameCount &+= 1
|
||||
captureRecentThumbnailIfNeeded(pixelBuffer)
|
||||
}
|
||||
|
||||
func rtcBridge(_ bridge: CrossDeskRTCBridge,
|
||||
didReceiveHostName hostName: String,
|
||||
displayNames remoteDisplayNames: [String],
|
||||
displaySizes remoteDisplaySizes: [NSValue]) {
|
||||
updateActiveConnectionName(hostName)
|
||||
let names = remoteDisplayNames.enumerated().map { index, displayName in
|
||||
let name = displayName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return name.isEmpty ? "显示器 \(index + 1)" : name
|
||||
}
|
||||
if !names.isEmpty {
|
||||
displays = names
|
||||
displaySizes = remoteDisplaySizes.map(\.cgSizeValue)
|
||||
selectedDisplay = min(selectedDisplay, names.count - 1)
|
||||
bridge.switch(toDisplay: selectedDisplay)
|
||||
}
|
||||
}
|
||||
|
||||
func rtcBridge(_ bridge: CrossDeskRTCBridge,
|
||||
didReceiveCursorVisible visible: Bool,
|
||||
shape: Int,
|
||||
positionUpdate: Bool,
|
||||
positionValid: Bool,
|
||||
x: Float,
|
||||
y: Float,
|
||||
visualOffsetX: Float,
|
||||
visualOffsetY: Float,
|
||||
display displayIndex: Int,
|
||||
sequence: UInt32) {
|
||||
if let previous = remoteCursorSequence,
|
||||
sequence != 0,
|
||||
Int32(bitPattern: sequence &- previous) <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
remoteCursorSequence = sequence
|
||||
remoteCursorVisible = visible
|
||||
remoteCursorShape = min(max(shape, 0), 28)
|
||||
remoteCursorVisualOffset = CGPoint(x: CGFloat(visualOffsetX),
|
||||
y: CGFloat(visualOffsetY))
|
||||
if positionUpdate {
|
||||
if positionValid, displayIndex == selectedDisplay {
|
||||
remoteCursorPosition = CGPoint(
|
||||
x: CGFloat(min(max(x, 0), 1)),
|
||||
y: CGFloat(min(max(y, 0), 1))
|
||||
)
|
||||
} else {
|
||||
remoteCursorPosition = nil
|
||||
}
|
||||
}
|
||||
hasRemoteCursorState = true
|
||||
}
|
||||
|
||||
func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceiveAudioPCM pcmData: Data) {
|
||||
audioPlayer.enqueue(pcmData)
|
||||
}
|
||||
|
||||
func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceiveClipboardText text: String) {
|
||||
UIPasteboard.general.string = text
|
||||
clipboardStatus = "已接收远端剪贴板"
|
||||
}
|
||||
|
||||
func rtcBridge(_ bridge: CrossDeskRTCBridge,
|
||||
didUpdateFileTransfer fileName: String,
|
||||
progress: Double,
|
||||
sending: Bool) {
|
||||
if progress < 0 {
|
||||
transferStatus = "\(fileName) 传输失败"
|
||||
transferProgress = 0
|
||||
return
|
||||
}
|
||||
transferProgress = progress
|
||||
let percent = Int((progress * 100).rounded())
|
||||
transferStatus = "\(sending ? "发送" : "接收") \(fileName) · \(percent)%"
|
||||
}
|
||||
|
||||
func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceiveFileAt fileURL: URL) {
|
||||
receivedFileURL = fileURL
|
||||
transferStatus = "已接收 \(fileURL.lastPathComponent)"
|
||||
transferProgress = 1
|
||||
}
|
||||
|
||||
func rtcBridge(_ bridge: CrossDeskRTCBridge,
|
||||
didUpdateBitrate bitsPerSecond: UInt,
|
||||
lossRate: Float,
|
||||
usingTURN: Bool) {
|
||||
bitrate = bitsPerSecond
|
||||
self.lossRate = lossRate
|
||||
self.usingTURN = usingTURN
|
||||
}
|
||||
|
||||
var formattedBitrate: String {
|
||||
if bitrate >= 1_000_000 {
|
||||
return String(format: "%.1f Mbps", Double(bitrate) / 1_000_000)
|
||||
}
|
||||
if bitrate >= 1_000 {
|
||||
return String(format: "%.0f Kbps", Double(bitrate) / 1_000)
|
||||
}
|
||||
return "\(bitrate) bps"
|
||||
}
|
||||
|
||||
var videoStatus: String {
|
||||
if frameCount > 0 {
|
||||
return "视频 \(Int(frameSize.width))×\(Int(frameSize.height)) · \(frameCount) 帧"
|
||||
}
|
||||
return isConnected ? "正在取回远程画面…" : "等待连接"
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import AVFoundation
|
||||
import CoreMedia
|
||||
import CoreVideo
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// Presents decoded NV12 frames through iOS's native video display path.
|
||||
///
|
||||
/// `AVSampleBufferDisplayLayer` handles YUV conversion and drawable scheduling
|
||||
/// itself. This avoids the Core Image -> MTKView path, which can remain black
|
||||
/// on a physical device even though VideoToolbox is producing valid frames.
|
||||
struct NativeVideoView: UIViewRepresentable {
|
||||
let pixelBuffer: CVPixelBuffer?
|
||||
|
||||
func makeUIView(context: Context) -> SampleBufferVideoView {
|
||||
SampleBufferVideoView(frame: .zero)
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: SampleBufferVideoView, context: Context) {
|
||||
uiView.display(pixelBuffer)
|
||||
}
|
||||
}
|
||||
|
||||
final class SampleBufferVideoView: UIView {
|
||||
override class var layerClass: AnyClass {
|
||||
AVSampleBufferDisplayLayer.self
|
||||
}
|
||||
|
||||
private var videoLayer: AVSampleBufferDisplayLayer {
|
||||
layer as! AVSampleBufferDisplayLayer
|
||||
}
|
||||
|
||||
private var lastPixelBuffer: CVPixelBuffer?
|
||||
private var submittedFrames: UInt64 = 0
|
||||
private var droppedFrames: UInt64 = 0
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
configureLayer()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
configureLayer()
|
||||
}
|
||||
|
||||
private func configureLayer() {
|
||||
backgroundColor = .black
|
||||
isOpaque = true
|
||||
videoLayer.backgroundColor = UIColor.black.cgColor
|
||||
// The SwiftUI host gives this view the exact aspect-fit video rect.
|
||||
// Filling that rect avoids a second, independently rounded aspect-fit
|
||||
// calculation inside AVSampleBufferDisplayLayer. That rounding becomes
|
||||
// visibly amplified when the remote desktop is zoomed up to 10x.
|
||||
videoLayer.videoGravity = .resize
|
||||
}
|
||||
|
||||
func display(_ pixelBuffer: CVPixelBuffer?) {
|
||||
guard let pixelBuffer else {
|
||||
lastPixelBuffer = nil
|
||||
submittedFrames = 0
|
||||
droppedFrames = 0
|
||||
videoLayer.flushAndRemoveImage()
|
||||
return
|
||||
}
|
||||
|
||||
// SwiftUI can refresh for unrelated status fields. Do not enqueue the
|
||||
// same retained CVPixelBuffer more than once.
|
||||
guard lastPixelBuffer !== pixelBuffer else { return }
|
||||
lastPixelBuffer = pixelBuffer
|
||||
|
||||
if videoLayer.status == .failed {
|
||||
NSLog("CrossDesk video layer failed: %@",
|
||||
videoLayer.error?.localizedDescription ?? "unknown error")
|
||||
videoLayer.flush()
|
||||
}
|
||||
|
||||
// Never let AVSampleBufferDisplayLayer turn temporary rendering
|
||||
// pressure into seconds of latency. Its queued buffers are already
|
||||
// stale by the time it stops accepting more data, so discard them and
|
||||
// present the newest decoded IOSurface instead.
|
||||
if !videoLayer.isReadyForMoreMediaData {
|
||||
droppedFrames &+= 1
|
||||
videoLayer.flush()
|
||||
if droppedFrames == 1 || droppedFrames.isMultiple(of: 300) {
|
||||
NSLog("CrossDesk dropped stale display frames: %llu",
|
||||
droppedFrames)
|
||||
}
|
||||
}
|
||||
guard videoLayer.isReadyForMoreMediaData else { return }
|
||||
|
||||
var formatDescription: CMVideoFormatDescription?
|
||||
guard CMVideoFormatDescriptionCreateForImageBuffer(
|
||||
allocator: kCFAllocatorDefault,
|
||||
imageBuffer: pixelBuffer,
|
||||
formatDescriptionOut: &formatDescription
|
||||
) == noErr, let formatDescription else {
|
||||
NSLog("CrossDesk could not create a video format description")
|
||||
return
|
||||
}
|
||||
|
||||
var timing = CMSampleTimingInfo(
|
||||
duration: .invalid,
|
||||
presentationTimeStamp: .invalid,
|
||||
decodeTimeStamp: .invalid
|
||||
)
|
||||
var sampleBuffer: CMSampleBuffer?
|
||||
guard CMSampleBufferCreateReadyWithImageBuffer(
|
||||
allocator: kCFAllocatorDefault,
|
||||
imageBuffer: pixelBuffer,
|
||||
formatDescription: formatDescription,
|
||||
sampleTiming: &timing,
|
||||
sampleBufferOut: &sampleBuffer
|
||||
) == noErr, let sampleBuffer else {
|
||||
NSLog("CrossDesk could not create a video sample buffer")
|
||||
return
|
||||
}
|
||||
|
||||
if let attachments = CMSampleBufferGetSampleAttachmentsArray(
|
||||
sampleBuffer,
|
||||
createIfNecessary: true
|
||||
), CFArrayGetCount(attachments) > 0 {
|
||||
let attachment = unsafeBitCast(
|
||||
CFArrayGetValueAtIndex(attachments, 0),
|
||||
to: CFMutableDictionary.self
|
||||
)
|
||||
CFDictionarySetValue(
|
||||
attachment,
|
||||
Unmanaged.passUnretained(kCMSampleAttachmentKey_DisplayImmediately).toOpaque(),
|
||||
Unmanaged.passUnretained(kCFBooleanTrue).toOpaque()
|
||||
)
|
||||
}
|
||||
|
||||
videoLayer.enqueue(sampleBuffer)
|
||||
submittedFrames &+= 1
|
||||
if submittedFrames == 1 || submittedFrames.isMultiple(of: 300) {
|
||||
NSLog("CrossDesk presented video frame %llu (%zu x %zu)",
|
||||
submittedFrames,
|
||||
CVPixelBufferGetWidth(pixelBuffer),
|
||||
CVPixelBufferGetHeight(pixelBuffer))
|
||||
}
|
||||
if submittedFrames == 1 {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
|
||||
guard let self else { return }
|
||||
NSLog("CrossDesk video layer status=%ld error=%@",
|
||||
self.videoLayer.status.rawValue,
|
||||
self.videoLayer.error?.localizedDescription ?? "none")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
# CrossDesk Mobile (native iOS)
|
||||
|
||||
This target is a native MiniRTC controller. It uses the same WebSocket
|
||||
signaling, libnice ICE, SRTP/RTP and data-stream protocol as the desktop app.
|
||||
There is no `WKWebView` or browser runtime.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Xcode 16 or newer
|
||||
- Xmake available on `PATH`
|
||||
- A physical arm64 iPhone or iPad running iOS 16 or newer
|
||||
|
||||
## Build
|
||||
|
||||
Open `CrossDeskMobile.xcodeproj`, select the `CrossDeskMobile` scheme and a
|
||||
physical device, then build. The first build phase compiles MiniRTC and the
|
||||
CrossDesk wire library, then merges their iPhoneOS static dependencies into
|
||||
a local archive under `Vendor/`.
|
||||
|
||||
You can also verify the target without code signing:
|
||||
|
||||
```sh
|
||||
xcodebuild -project apps/ios/CrossDeskMobile.xcodeproj \
|
||||
-scheme CrossDeskMobile \
|
||||
-configuration Debug \
|
||||
-destination 'generic/platform=iOS' \
|
||||
CODE_SIGNING_ALLOWED=NO build
|
||||
```
|
||||
|
||||
The first connection to a signaling server provisions and stores an identity
|
||||
for that server. Remote control sessions then log in as `C-<identity>` and use
|
||||
the desktop-compatible `DisplayN`, `control_audio`, `mouse`, `keyboard`,
|
||||
`control_data`, `clipboard`, `file`, and `file_feedback` streams.
|
||||
|
||||
## Video codecs
|
||||
|
||||
Every iOS build includes VideoToolbox, OpenH264, dav1d, libaom, and SVT-AV1. H.264 uses
|
||||
VideoToolbox by default; the in-app **Settings > Video Codec** selector switches
|
||||
H.264 software processing to OpenH264. AV1 encoding uses SVT-AV1 and AV1 decoding
|
||||
uses dav1d; VideoToolbox AV1 hardware decoding is currently unsupported.
|
||||
|
||||
## Physical-device test checklist
|
||||
|
||||
Run the app from Xcode on a physical device and connect to a current desktop
|
||||
build. Test the features in this order so a media problem is not confused with
|
||||
a data-channel problem:
|
||||
|
||||
1. **Video:** after the connection turns green, the waiting panel should be
|
||||
replaced by the remote desktop. The status bar reports the decoded size and
|
||||
frame count.
|
||||
2. **Audio:** play continuous sound on the remote computer, then toggle the
|
||||
speaker button. Audio is Opus-decoded by MiniRTC and played as 48 kHz mono
|
||||
16-bit PCM through `AVAudioEngine`.
|
||||
3. **Clipboard:** copy a short text value on the phone and choose **Send local
|
||||
clipboard**. Copy a different value on the desktop and confirm that it is
|
||||
written to the iOS pasteboard. Text is limited to 128 KiB.
|
||||
4. **Displays:** open the display menu, switch every listed monitor, and check
|
||||
that the frame counter restarts and the selected monitor appears after a
|
||||
new key frame.
|
||||
5. **Files:** use the folder button to pick a small file, then send a file back
|
||||
from the desktop. Progress is ACK-driven. Received files are stored in the
|
||||
app's `Documents/Received` directory and can be exported with the share
|
||||
button or Finder's Files tab.
|
||||
|
||||
For black-screen diagnosis, keep Xcode's device console open and filter for
|
||||
`CrossDesk`. A working pipeline prints both `VideoToolbox decoded frame` from
|
||||
MiniRTC and `CrossDesk received decoded frame` from the iOS bridge. If only the
|
||||
first message appears, verify that the selected stream is named `Display1`,
|
||||
`Display2`, and so on. If neither appears, update and rebuild the desktop side
|
||||
and request a key frame.
|
||||
@@ -1,152 +0,0 @@
|
||||
#!/bin/zsh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="${0:A:h}"
|
||||
IOS_DIR="${SCRIPT_DIR:h}"
|
||||
REPO_DIR="${IOS_DIR:h:h}"
|
||||
MINIRTC_DIR="${REPO_DIR}/deps/submodules/minirtc"
|
||||
CONFIG_NAME="${CONFIGURATION:-Debug}"
|
||||
MODE="${CONFIG_NAME:l}"
|
||||
|
||||
# Keep iOS dependencies isolated from desktop Xmake packages. Besides avoiding
|
||||
# cross-project cache collisions, this guarantees every archive is compiled
|
||||
# with the iOS 16 deployment target instead of the active SDK version.
|
||||
export XMAKE_PKG_INSTALLDIR="${IOS_DIR}/.xmake/packages"
|
||||
|
||||
if [[ "${MODE}" != "debug" && "${MODE}" != "release" ]]; then
|
||||
MODE="release"
|
||||
fi
|
||||
|
||||
ARCH_NAME="${CURRENT_ARCH:-arm64}"
|
||||
if [[ "${ARCH_NAME}" == "undefined_arch" ]]; then
|
||||
ARCH_NAME="arm64"
|
||||
fi
|
||||
if [[ "${ARCH_NAME}" != "arm64" ]]; then
|
||||
print -u2 "CrossDesk Mobile currently supports physical iOS arm64 builds only."
|
||||
exit 64
|
||||
fi
|
||||
|
||||
XMAKE_BIN="${XMAKE_BIN:-}"
|
||||
if [[ -z "${XMAKE_BIN}" ]]; then
|
||||
XMAKE_BIN="$(command -v xmake 2>/dev/null || true)"
|
||||
fi
|
||||
if [[ -z "${XMAKE_BIN}" ]]; then
|
||||
for candidate in /opt/homebrew/bin/xmake /usr/local/bin/xmake "${HOME}/.local/bin/xmake"; do
|
||||
if [[ -x "${candidate}" ]]; then
|
||||
XMAKE_BIN="${candidate}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [[ -z "${XMAKE_BIN}" || ! -x "${XMAKE_BIN}" ]]; then
|
||||
print -u2 "xmake is required. Install it from https://xmake.io first."
|
||||
exit 69
|
||||
fi
|
||||
|
||||
# Xcode build phases export the iOS SDK, compiler and linker settings into the
|
||||
# script environment. Letting Xmake inherit those variables also targets its
|
||||
# build-machine tools (for example NASM and Meson helpers) at iOS, so they
|
||||
# cannot run on the macOS build host. Give Xmake a clean host environment and
|
||||
# pass the iOS target exclusively through its command-line configuration.
|
||||
XMAKE_DEVELOPER_DIR="${DEVELOPER_DIR:-$(xcode-select -p)}"
|
||||
XMAKE_TOOLCHAIN_BIN="${XMAKE_DEVELOPER_DIR}/Toolchains/XcodeDefault.xctoolchain/usr/bin"
|
||||
run_xmake() {
|
||||
/usr/bin/env -i \
|
||||
HOME="${HOME}" \
|
||||
PATH="${XMAKE_TOOLCHAIN_BIN}:${PATH}" \
|
||||
TMPDIR="${TMPDIR:-/tmp}" \
|
||||
USER="${USER:-}" \
|
||||
LOGNAME="${LOGNAME:-${USER:-}}" \
|
||||
LANG="${LANG:-en_US.UTF-8}" \
|
||||
TERM="${TERM:-dumb}" \
|
||||
NO_COLOR="${NO_COLOR:-}" \
|
||||
DEVELOPER_DIR="${XMAKE_DEVELOPER_DIR}" \
|
||||
XMAKE_PKG_INSTALLDIR="${XMAKE_PKG_INSTALLDIR}" \
|
||||
CROSSDESK_SOURCE_DIR="${REPO_DIR}" \
|
||||
"${XMAKE_BIN}" "$@"
|
||||
}
|
||||
|
||||
OUTPUT_DIR="${IOS_DIR}/Vendor/iphoneos/${CONFIG_NAME}"
|
||||
OUTPUT_LIBRARY="${OUTPUT_DIR}/libCrossDeskMiniRTC.a"
|
||||
MINIRTC_BUILD_DIR="${IOS_DIR}/.xmake/minirtc-build"
|
||||
MINIRTC_LIBRARY="${MINIRTC_BUILD_DIR}/iphoneos/arm64/${MODE}/libminirtc.a"
|
||||
WIRE_BUILD_DIR="${IOS_DIR}/.xmake/wire-build"
|
||||
WIRE_WORK_DIR="${IOS_DIR}/.xmake/wire-work"
|
||||
WIRE_MANIFEST_FILE="${WIRE_WORK_DIR}/xmake.lua"
|
||||
WIRE_LIBRARY="${WIRE_BUILD_DIR}/iphoneos/arm64/${MODE}/libcrossdesk_wire.a"
|
||||
|
||||
mkdir -p "${OUTPUT_DIR}" "${WIRE_WORK_DIR}"
|
||||
cp "${REPO_DIR}/xmake.lua" "${WIRE_MANIFEST_FILE}"
|
||||
|
||||
# Xmake stores the configured build directory relative to the process working
|
||||
# directory. Xcode does not guarantee that directory for build phases, so keep
|
||||
# configuration, compilation and inspection anchored to the MiniRTC project.
|
||||
(
|
||||
cd "${MINIRTC_DIR}"
|
||||
run_xmake f -P "${MINIRTC_DIR}" -c -o "${MINIRTC_BUILD_DIR}" \
|
||||
-p iphoneos -a arm64 -m "${MODE}" \
|
||||
--as="${XMAKE_TOOLCHAIN_BIN}/clang" \
|
||||
--target_minver=16.0 --USE_CUDA=false -y
|
||||
run_xmake b -P "${MINIRTC_DIR}" minirtc
|
||||
)
|
||||
|
||||
if [[ ! -f "${MINIRTC_LIBRARY}" ]]; then
|
||||
print -u2 "MiniRTC archive was not produced at ${MINIRTC_LIBRARY}."
|
||||
exit 66
|
||||
fi
|
||||
|
||||
# Build the platform-independent wire archive from the root project's target.
|
||||
# The copied manifest only isolates Xmake's generated configuration; there is
|
||||
# no second project definition to maintain.
|
||||
(
|
||||
cd "${WIRE_WORK_DIR}"
|
||||
run_xmake f -P "${WIRE_WORK_DIR}" -c -o "${WIRE_BUILD_DIR}" \
|
||||
-p iphoneos -a arm64 -m "${MODE}" \
|
||||
--target_minver=16.0 -y
|
||||
run_xmake b -P "${WIRE_WORK_DIR}" crossdesk_wire
|
||||
)
|
||||
|
||||
if [[ ! -f "${WIRE_LIBRARY}" ]]; then
|
||||
print -u2 "CrossDesk wire archive was not produced at ${WIRE_LIBRARY}."
|
||||
exit 66
|
||||
fi
|
||||
|
||||
# Xmake owns the package hashes, so query its resolved target instead of
|
||||
# embedding machine-specific ~/.xmake paths in the Xcode project.
|
||||
TARGET_INFO="$(cd "${MINIRTC_DIR}" && TERM=dumb NO_COLOR=1 \
|
||||
run_xmake show -P "${MINIRTC_DIR}" -t minirtc)"
|
||||
CLEAN_INFO="$(print -r -- "${TARGET_INFO}" | sed $'s/\033\\[[0-9;]*[[:alpha:]]//g')"
|
||||
LINK_DIRS=("${(@f)$(print -r -- "${CLEAN_INFO}" | sed -nE 's|.*-> (/.*)/lib -> package.*|\1/lib|p' | sort -u)}")
|
||||
|
||||
REQUIRED_LINKS=(
|
||||
nice glib-2.0 gobject-2.0 gmodule-2.0 gio-2.0 gthread-2.0 intl
|
||||
ffi pcre2-8 pcre2-posix z ssl crypto srtp2 openfec opus yuv kcp
|
||||
datachannel usrsctp openh264 dav1d aom SvtAv1Enc
|
||||
)
|
||||
DEPENDENCY_ARCHIVES=()
|
||||
|
||||
for link_name in "${REQUIRED_LINKS[@]}"; do
|
||||
archive_path=""
|
||||
for link_dir in "${LINK_DIRS[@]}"; do
|
||||
candidate="${link_dir}/lib${link_name}.a"
|
||||
if [[ -f "${candidate}" ]]; then
|
||||
archive_path="${candidate}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ -z "${archive_path}" ]]; then
|
||||
print -u2 "Unable to resolve static dependency lib${link_name}.a from Xmake."
|
||||
exit 66
|
||||
fi
|
||||
DEPENDENCY_ARCHIVES+=("${archive_path}")
|
||||
done
|
||||
|
||||
TEMP_LIBRARY="${OUTPUT_LIBRARY}.tmp"
|
||||
rm -f "${TEMP_LIBRARY}"
|
||||
/usr/bin/libtool -static -o "${TEMP_LIBRARY}" \
|
||||
"${MINIRTC_LIBRARY}" "${DEPENDENCY_ARCHIVES[@]}" \
|
||||
"${WIRE_LIBRARY}"
|
||||
mv -f "${TEMP_LIBRARY}" "${OUTPUT_LIBRARY}"
|
||||
|
||||
print "Created ${OUTPUT_LIBRARY}"
|
||||
@@ -144,11 +144,11 @@ RUN case "${TARGETARCH}" in \
|
||||
WORKDIR /tmp/crossdesk-dependencies
|
||||
|
||||
COPY xmake.lua ./xmake.lua
|
||||
COPY apps/desktop/xmake ./apps/desktop/xmake
|
||||
COPY deps/thirdparty ./deps/thirdparty
|
||||
COPY deps/submodules/xmake.lua ./deps/submodules/xmake.lua
|
||||
COPY deps/submodules/minirtc/xmake.lua ./deps/submodules/minirtc/xmake.lua
|
||||
COPY deps/submodules/minirtc/thirdparty ./deps/submodules/minirtc/thirdparty
|
||||
COPY xmake ./xmake
|
||||
COPY thirdparty ./thirdparty
|
||||
COPY submodules/xmake.lua ./submodules/xmake.lua
|
||||
COPY submodules/minirtc/xmake.lua ./submodules/minirtc/xmake.lua
|
||||
COPY submodules/minirtc/thirdparty ./submodules/minirtc/thirdparty
|
||||
|
||||
# Configure the real project manifests so the image cache stays aligned with
|
||||
# CrossDesk's exact dependency graph. Source files are intentionally excluded
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# GUI 目录结构与职责说明
|
||||
|
||||
本文档说明 `apps/desktop/src/gui` 的目录结构、核心类型、依赖方向和代码归属规则,用于避免 `Render`、`GuiApplication` 或 `GuiRuntime` 再次演变成职责混杂的大类。
|
||||
本文档说明 `src/gui` 的目录结构、核心类型、依赖方向和代码归属规则,用于避免 `Render`、`GuiApplication` 或 `GuiRuntime` 再次演变成职责混杂的大类。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
apps/desktop/src/gui/
|
||||
src/gui/
|
||||
├── render.h # 对外稳定入口 Render
|
||||
├── render.cpp # 创建 GuiApplication 并转发 Run
|
||||
├── application/ # SDL/ImGui 应用外壳
|
||||
@@ -19,6 +19,8 @@ apps/desktop/src/gui/
|
||||
├── runtime/ # 非界面的 GUI 运行时
|
||||
│ ├── gui_runtime.h/.cpp # 运行时协调接口与公共实现
|
||||
│ ├── connection_runtime.cpp # 连接、在线探测、超时及会话清理
|
||||
│ ├── windows_service_runtime.cpp # Windows 服务和安全桌面集成
|
||||
│ ├── mac_permission_runtime.mm # macOS 权限检查和系统设置调用
|
||||
│ ├── gui_state.h # ApplicationState 与 RuntimeState 组合点
|
||||
│ ├── runtime_state.h # 配置、连接、平台和通信状态
|
||||
│ ├── remote_session.h # 单个远端会话 RemoteSession
|
||||
@@ -37,11 +39,11 @@ apps/desktop/src/gui/
|
||||
│ ├── panels/ # 主窗口内嵌面板
|
||||
│ ├── toolbars/ # 标题栏、状态栏和控制栏
|
||||
│ └── windows/ # 独立窗口和模态对话框
|
||||
├── platform/ # 原生桌面平台适配
|
||||
│ └── tray/ # Windows、macOS 和 Linux 托盘
|
||||
└── assets/ # 字体、图标、布局和本地化资源
|
||||
```
|
||||
|
||||
平台私有 GUI 实现统一位于 `apps/desktop/src/platform/<os>/gui/`,包括托盘、macOS Metal 渲染与权限、Windows 服务运行时等;Windows 与 Linux 共用的 OpenGL 渲染器位于 `apps/desktop/src/platform/common/gui/`。
|
||||
|
||||
## 分层关系
|
||||
|
||||
```text
|
||||
@@ -67,8 +69,6 @@ Render
|
||||
|
||||
底层功能模块不应反向依赖具体面板、工具栏或窗口。
|
||||
|
||||
桌面端与 iOS Bridge 统一依赖静态库 `crossdesk_wire`。线上契约头位于 `libs/wire/include/`,包含控制消息、鼠标指针类型、显示流 ID、数据流名称和文件传输线格式。操作系统输入注入、剪贴板、文件读写以及界面状态仍由各自应用实现,`libs/wire/` 不得依赖桌面端或操作系统专用头文件。
|
||||
|
||||
## 核心类型
|
||||
|
||||
### Render
|
||||
@@ -196,13 +196,12 @@ SDL quit / tray exit
|
||||
| SDL 初始化、事件循环、窗口生命周期 | `application/` |
|
||||
| 连接、会话、Peer 回调和平台运行时 | `runtime/` |
|
||||
| 视频、音频、鼠标、键盘设备生命周期 | `features/devices/` |
|
||||
| 键盘状态与桌面输入处理 | `features/input/` |
|
||||
| 线上控制消息和线格式 | `libs/wire/` |
|
||||
| 键盘协议和按键状态 | `features/input/` |
|
||||
| 剪贴板同步 | `features/clipboard/` |
|
||||
| 文件传输 | `features/file_transfer/` |
|
||||
| 配置持久化 | `features/settings/` |
|
||||
| 主窗口内嵌区域 | `views/panels/` |
|
||||
| 标题栏、状态栏、控制栏 | `views/toolbars/` |
|
||||
| 独立窗口和对话框 | `views/windows/` |
|
||||
| 系统托盘 | `apps/desktop/src/platform/<os>/gui/tray/` |
|
||||
| 系统托盘 | `platform/tray/` |
|
||||
| 字体、图标、布局和本地化数据 | `assets/` |
|
||||
|
||||
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 746 B After Width: | Height: | Size: 746 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |