From 7595ab8bd8adf573d65ff7ee08eb26d0a78bc906 Mon Sep 17 00:00:00 2001 From: dijunkun Date: Mon, 31 Aug 2026 02:14:27 +0800 Subject: [PATCH] [feat] add native iOS client support --- .github/workflows/build-ios.yml | 161 ++ .gitignore | 7 +- docs/gui-architecture.md | 5 +- ios/CrossDeskMobile.xcodeproj/project.pbxproj | 388 ++++ .../xcschemes/CrossDeskMobile.xcscheme | 76 + .../AppIcon.appiconset/Contents.json | 14 + .../CrossDeskAppIcon-1024.png | Bin 0 -> 222154 bytes .../Assets.xcassets/Contents.json | 6 + .../Bridge/CrossDeskRTCBridge.h | 121 ++ .../Bridge/CrossDeskRTCBridge.mm | 1577 +++++++++++++++++ ios/CrossDeskMobile/ContentView.swift | 731 ++++++++ .../CrossDeskMobile-Bridging-Header.h | 1 + ios/CrossDeskMobile/CrossDeskMobileApp.swift | 43 + ios/CrossDeskMobile/Info.plist | 53 + .../Input/RemoteTouchInputView.swift | 502 ++++++ ios/CrossDeskMobile/RemoteSessionModel.swift | 875 +++++++++ ios/CrossDeskMobile/RemoteSessionView.swift | 1357 ++++++++++++++ .../Rendering/NativeMetalVideoView.swift | 151 ++ ios/README.md | 70 + ios/scripts/build_minirtc_ios.sh | 147 ++ scripts/macosx/pkg_arm64.sh | 11 +- scripts/macosx/pkg_x64.sh | 11 +- src/common/stream_names.h | 22 + src/device_controller/device_controller.h | 292 +-- .../mouse/linux/mouse_controller.h | 1 + .../mouse/mac/mouse_controller.cpp | 50 +- .../mouse/mac/mouse_controller.h | 3 + .../mouse/windows/mouse_controller.h | 3 +- src/device_controller/remote_action.cpp | 300 ++++ src/gui/application/gui_application.cpp | 155 +- src/gui/application/gui_application.h | 18 +- src/gui/application/sdl_events.cpp | 9 + .../devices/session_device_manager.cpp | 2 +- .../file_transfer/file_transfer_manager.cpp | 11 +- src/gui/runtime/connection_runtime.cpp | 4 + src/gui/runtime/cursor_position.h | 66 + src/gui/runtime/cursor_state_provider.cpp | 14 +- src/gui/runtime/cursor_state_provider.h | 11 +- src/gui/runtime/cursor_state_provider_mac.mm | 28 +- src/gui/runtime/gui_runtime.cpp | 3 + src/gui/runtime/peer_data_callbacks.cpp | 25 +- src/gui/runtime/remote_session.h | 23 +- src/gui/runtime/runtime_state.h | 26 +- src/tools/file_transfer.cpp | 71 +- src/tools/file_transfer.h | 33 +- src/tools/file_transfer_protocol.cpp | 81 + src/tools/file_transfer_protocol.h | 67 + submodules/minirtc | 2 +- tests/protocol_test.cpp | 179 ++ xmake/targets.lua | 17 +- 50 files changed, 7391 insertions(+), 432 deletions(-) create mode 100644 .github/workflows/build-ios.yml create mode 100644 ios/CrossDeskMobile.xcodeproj/project.pbxproj create mode 100644 ios/CrossDeskMobile.xcodeproj/xcshareddata/xcschemes/CrossDeskMobile.xcscheme create mode 100644 ios/CrossDeskMobile/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 ios/CrossDeskMobile/Assets.xcassets/AppIcon.appiconset/CrossDeskAppIcon-1024.png create mode 100644 ios/CrossDeskMobile/Assets.xcassets/Contents.json create mode 100644 ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h create mode 100644 ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm create mode 100644 ios/CrossDeskMobile/ContentView.swift create mode 100644 ios/CrossDeskMobile/CrossDeskMobile-Bridging-Header.h create mode 100644 ios/CrossDeskMobile/CrossDeskMobileApp.swift create mode 100644 ios/CrossDeskMobile/Info.plist create mode 100644 ios/CrossDeskMobile/Input/RemoteTouchInputView.swift create mode 100644 ios/CrossDeskMobile/RemoteSessionModel.swift create mode 100644 ios/CrossDeskMobile/RemoteSessionView.swift create mode 100644 ios/CrossDeskMobile/Rendering/NativeMetalVideoView.swift create mode 100644 ios/README.md create mode 100755 ios/scripts/build_minirtc_ios.sh create mode 100644 src/common/stream_names.h create mode 100644 src/device_controller/remote_action.cpp create mode 100644 src/gui/runtime/cursor_position.h create mode 100644 src/tools/file_transfer_protocol.cpp create mode 100644 src/tools/file_transfer_protocol.h create mode 100644 tests/protocol_test.cpp diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml new file mode 100644 index 0000000..6660e28 --- /dev/null +++ b/.github/workflows/build-ios.yml @@ -0,0 +1,161 @@ +name: Build iOS + +on: + push: + branches: + - "**" + - "!ci/linux-build-image" + tags: + - "*" + workflow_dispatch: + inputs: + patch: + description: "Hotfix patch number, for example 1 or 2. Use 0 for a normal build." + required: false + default: "0" + +permissions: + contents: read + +env: + IOS_DEPLOYMENT_TARGET: "16.0" + EXPECTED_IOS_SDK: "26.5" + +jobs: + build-ios: + name: Build iOS (arm64) + runs-on: macos-26 + timeout-minutes: 120 + + steps: + - name: Extract version number + shell: bash + run: | + VERSION_REF="${GITHUB_REF##*/}" + VERSION_BASE="${VERSION_REF#v}" + BUILD_DATE=$(TZ=Asia/Shanghai date +%Y%m%d) + PATCH_NUMBER="${{ github.event.inputs.patch }}" + + if [[ ! "${PATCH_NUMBER}" =~ ^[0-9]+$ ]]; then + PATCH_NUMBER=0 + fi + + if [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)-([0-9]{8})$ ]]; then + VERSION_BASE="${BASH_REMATCH[1]}" + PATCH_NUMBER="${BASH_REMATCH[3]}" + BUILD_DATE="${BASH_REMATCH[4]}" + elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})-([0-9]+)$ ]]; then + VERSION_BASE="${BASH_REMATCH[1]}" + BUILD_DATE="${BASH_REMATCH[3]}" + PATCH_NUMBER="${BASH_REMATCH[4]}" + elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]{8})$ ]]; then + VERSION_BASE="${BASH_REMATCH[1]}" + BUILD_DATE="${BASH_REMATCH[3]}" + elif [[ "${VERSION_BASE}" =~ ^([0-9]+(\.[0-9]+){1,3})-([0-9]+)$ && "${PATCH_NUMBER}" == "0" ]]; then + VERSION_BASE="${BASH_REMATCH[1]}" + PATCH_NUMBER="${BASH_REMATCH[3]}" + fi + + if [[ "${PATCH_NUMBER}" != "0" ]]; then + VERSION_NUM="v${VERSION_BASE}-${PATCH_NUMBER}-${BUILD_DATE}" + else + VERSION_NUM="v${VERSION_BASE}-${BUILD_DATE}" + fi + + echo "VERSION_NUM=${VERSION_NUM}" >> "${GITHUB_ENV}" + echo "VERSION_NUM=${VERSION_NUM}" + + - name: Select Xcode 26.6 + shell: bash + run: | + sudo xcode-select -s /Applications/Xcode_26.6.app/Contents/Developer + xcodebuild -version + SDK_VERSION="$(xcrun --sdk iphoneos --show-sdk-version)" + if [[ "${SDK_VERSION}" != "${EXPECTED_IOS_SDK}" ]]; then + echo "Unexpected iOS SDK: expected ${EXPECTED_IOS_SDK}, got ${SDK_VERSION}" >&2 + exit 1 + fi + + - name: Checkout code + uses: actions/checkout@v5 + + - name: Initialize submodules + run: git submodule update --init --recursive + + - name: Cache xmake dependencies + uses: actions/cache@v5 + with: + path: ios/.xmake/packages + key: "${{ runner.os }}-xmake-deps-ios-arm64-xcode26.6-min16-${{ github.run_id }}" + restore-keys: | + ${{ runner.os }}-xmake-deps-ios-arm64-xcode26.6-min16- + + - name: Install xmake + run: brew install xmake + + - name: Update xmake repositories + run: xmake repo -u + + - name: Build CrossDesk Mobile + shell: bash + run: | + DERIVED_DATA="${RUNNER_TEMP}/CrossDeskMobileDerivedData" + echo "DERIVED_DATA=${DERIVED_DATA}" >> "${GITHUB_ENV}" + xcodebuild \ + -project ios/CrossDeskMobile.xcodeproj \ + -scheme CrossDeskMobile \ + -configuration Release \ + -destination 'generic/platform=iOS' \ + -derivedDataPath "${DERIVED_DATA}" \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + COMPILER_INDEX_STORE_ENABLE=NO \ + IPHONEOS_DEPLOYMENT_TARGET="${IOS_DEPLOYMENT_TARGET}" \ + build + + - name: Verify CrossDesk Mobile + shell: bash + run: | + APP_PATH="${DERIVED_DATA}/Build/Products/Release-iphoneos/CrossDeskMobile.app" + EXECUTABLE_PATH="${APP_PATH}/CrossDeskMobile" + INFO_PLIST="${APP_PATH}/Info.plist" + + if [[ ! -x "${EXECUTABLE_PATH}" ]]; then + echo "CrossDeskMobile executable is missing from ${APP_PATH}" >&2 + exit 1 + fi + + xcrun lipo "${EXECUTABLE_PATH}" -verify_arch arm64 + MINIMUM_OS=$(/usr/libexec/PlistBuddy -c 'Print :MinimumOSVersion' "${INFO_PLIST}") + if [[ "${MINIMUM_OS}" != "${IOS_DEPLOYMENT_TARGET}" ]]; then + echo "Unexpected minimum iOS version: expected ${IOS_DEPLOYMENT_TARGET}, got ${MINIMUM_OS}" >&2 + exit 1 + fi + + xcrun lipo -info "${EXECUTABLE_PATH}" + echo "Minimum iOS version: ${MINIMUM_OS}" + + - name: Package CrossDesk Mobile + shell: bash + run: | + APP_PATH="${DERIVED_DATA}/Build/Products/Release-iphoneos/CrossDeskMobile.app" + PACKAGE_DIR="${RUNNER_TEMP}/crossdesk-ios-arm64-unsigned-${VERSION_NUM}" + PACKAGE_FILE="${GITHUB_WORKSPACE}/crossdesk-ios-arm64-unsigned-${VERSION_NUM}.zip" + + mkdir -p "${PACKAGE_DIR}" + cp -R "${APP_PATH}" "${PACKAGE_DIR}/" + + DSYM_PATH="${DERIVED_DATA}/Build/Products/Release-iphoneos/CrossDeskMobile.app.dSYM" + if [[ -d "${DSYM_PATH}" ]]; then + cp -R "${DSYM_PATH}" "${PACKAGE_DIR}/" + fi + + ditto -c -k --sequesterRsrc --keepParent "${PACKAGE_DIR}" "${PACKAGE_FILE}" + echo "PACKAGE_FILE=${PACKAGE_FILE}" >> "${GITHUB_ENV}" + + - name: Upload build artifact + uses: actions/upload-artifact@v6 + with: + name: crossdesk-ios-arm64-unsigned-${{ env.VERSION_NUM }} + path: ${{ env.PACKAGE_FILE }} + if-no-files-found: error diff --git a/.gitignore b/.gitignore index b7c01b6..fd2b3af 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,14 @@ # Xmake cache .xmake/ build/ +ios/Vendor/ +ios/DerivedData/ +ios/.xmake/ +xcuserdata/ +*.xcuserstate # MacOS Cache .DS_Store # VSCode cache -.vscode \ No newline at end of file +.vscode diff --git a/docs/gui-architecture.md b/docs/gui-architecture.md index caa1b66..ed647d3 100644 --- a/docs/gui-architecture.md +++ b/docs/gui-architecture.md @@ -69,6 +69,8 @@ Render 底层功能模块不应反向依赖具体面板、工具栏或窗口。 +桌面端与 iOS Bridge 复用 `src/` 中的协议定义:`device_controller/` 保存控制消息,`common/` 保存数据流名称和鼠标指针类型,`tools/` 保存文件传输线格式。操作系统输入注入、剪贴板、文件读写以及界面状态仍由各自应用实现。 + ## 核心类型 ### Render @@ -196,7 +198,8 @@ SDL quit / tray exit | SDL 初始化、事件循环、窗口生命周期 | `application/` | | 连接、会话、Peer 回调和平台运行时 | `runtime/` | | 视频、音频、鼠标、键盘设备生命周期 | `features/devices/` | -| 键盘协议和按键状态 | `features/input/` | +| 键盘状态与桌面输入处理 | `features/input/` | +| 控制消息和线格式 | `device_controller/`、`common/`、`tools/` | | 剪贴板同步 | `features/clipboard/` | | 文件传输 | `features/file_transfer/` | | 配置持久化 | `features/settings/` | diff --git a/ios/CrossDeskMobile.xcodeproj/project.pbxproj b/ios/CrossDeskMobile.xcodeproj/project.pbxproj new file mode 100644 index 0000000..beaa325 --- /dev/null +++ b/ios/CrossDeskMobile.xcodeproj/project.pbxproj @@ -0,0 +1,388 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 60; + objects = { + +/* Begin PBXBuildFile section */ + 100000000000000000000001 /* CrossDeskMobileApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000001 /* CrossDeskMobileApp.swift */; }; + 100000000000000000000002 /* RemoteSessionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000002 /* RemoteSessionModel.swift */; }; + 100000000000000000000003 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000003 /* ContentView.swift */; }; + 100000000000000000000004 /* RemoteSessionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000004 /* RemoteSessionView.swift */; }; + 100000000000000000000005 /* CrossDeskRTCBridge.mm in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000006 /* CrossDeskRTCBridge.mm */; }; + 100000000000000000000006 /* NativeMetalVideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 200000000000000000000009 /* NativeMetalVideoView.swift */; }; + 100000000000000000000007 /* RemoteTouchInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000000A /* RemoteTouchInputView.swift */; }; + 100000000000000000000008 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 20000000000000000000000D /* Assets.xcassets */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 200000000000000000000001 /* CrossDeskMobileApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossDeskMobileApp.swift; sourceTree = ""; }; + 200000000000000000000002 /* RemoteSessionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSessionModel.swift; sourceTree = ""; }; + 200000000000000000000003 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 200000000000000000000004 /* RemoteSessionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSessionView.swift; sourceTree = ""; }; + 200000000000000000000005 /* CrossDeskRTCBridge.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CrossDeskRTCBridge.h; sourceTree = ""; }; + 200000000000000000000006 /* CrossDeskRTCBridge.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = CrossDeskRTCBridge.mm; sourceTree = ""; }; + 200000000000000000000007 /* CrossDeskMobile-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "CrossDeskMobile-Bridging-Header.h"; sourceTree = ""; }; + 200000000000000000000008 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 200000000000000000000009 /* NativeMetalVideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeMetalVideoView.swift; sourceTree = ""; }; + 20000000000000000000000A /* RemoteTouchInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTouchInputView.swift; sourceTree = ""; }; + 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 = ""; }; + 20000000000000000000000D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; +/* 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 = ""; + }; + 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 = ""; + }; + 400000000000000000000003 /* Bridge */ = { + isa = PBXGroup; + children = ( + 200000000000000000000005 /* CrossDeskRTCBridge.h */, + 200000000000000000000006 /* CrossDeskRTCBridge.mm */, + ); + path = Bridge; + sourceTree = ""; + }; + 400000000000000000000004 /* Rendering */ = { + isa = PBXGroup; + children = ( + 200000000000000000000009 /* NativeMetalVideoView.swift */, + ); + path = Rendering; + sourceTree = ""; + }; + 400000000000000000000005 /* Input */ = { + isa = PBXGroup; + children = ( + 20000000000000000000000A /* RemoteTouchInputView.swift */, + ); + path = Input; + sourceTree = ""; + }; + 400000000000000000000006 /* scripts */ = { + isa = PBXGroup; + children = ( + 20000000000000000000000C /* build_minirtc_ios.sh */, + ); + path = scripts; + sourceTree = ""; + }; + 400000000000000000000007 /* Products */ = { + isa = PBXGroup; + children = ( + 20000000000000000000000B /* CrossDeskMobile.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 500000000000000000000001 /* CrossDeskMobile */ = { + isa = PBXNativeTarget; + buildConfigurationList = 900000000000000000000002 /* Build configuration list for PBXNativeTarget "CrossDeskMobile" */; + buildPhases = ( + 700000000000000000000001 /* Build native MiniRTC */, + 600000000000000000000001 /* Sources */, + 300000000000000000000001 /* Frameworks */, + 600000000000000000000002 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = CrossDeskMobile; + productName = CrossDeskMobile; + productReference = 20000000000000000000000B /* CrossDeskMobile.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 800000000000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2660; + LastUpgradeCheck = 2660; + TargetAttributes = { + 500000000000000000000001 = { + CreatedOnToolsVersion = 26.6; + }; + }; + }; + buildConfigurationList = 900000000000000000000001 /* Build configuration list for PBXProject "CrossDeskMobile" */; + compatibilityVersion = "Xcode 15.0"; + developmentRegion = zh_CN; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + zh_CN, + ); + mainGroup = 400000000000000000000001; + productRefGroup = 400000000000000000000007 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 500000000000000000000001 /* CrossDeskMobile */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 600000000000000000000002 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 100000000000000000000008 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 700000000000000000000001 /* Build native MiniRTC */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Build native MiniRTC"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(PROJECT_DIR)/Vendor/iphoneos/$(CONFIGURATION)/libCrossDeskMiniRTC.a", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/zsh; + shellScript = "\"${PROJECT_DIR}/scripts/build_minirtc_ios.sh\"\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 600000000000000000000001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 100000000000000000000001 /* CrossDeskMobileApp.swift in Sources */, + 100000000000000000000002 /* RemoteSessionModel.swift in Sources */, + 100000000000000000000003 /* ContentView.swift in Sources */, + 100000000000000000000004 /* RemoteSessionView.swift in Sources */, + 100000000000000000000005 /* CrossDeskRTCBridge.mm in Sources */, + 100000000000000000000006 /* NativeMetalVideoView.swift in Sources */, + 100000000000000000000007 /* RemoteTouchInputView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + A00000000000000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "c++17"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + A00000000000000000000002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "c++17"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + GCC_C_LANGUAGE_STANDARD = gnu17; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + A00000000000000000000003 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = arm64; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 8DNC3SZ86S; + ENABLE_BITCODE = NO; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = NO; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/../submodules/minirtc/src/api", + "$(PROJECT_DIR)/../src/device_controller", + "$(PROJECT_DIR)/../src/common", + "$(PROJECT_DIR)/../src/tools", + ); + INFOPLIST_FILE = CrossDeskMobile/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "$(PROJECT_DIR)/Vendor/iphoneos/$(CONFIGURATION)/libCrossDeskMiniRTC.a", + "-liconv", + "-lresolv", + "-framework", + VideoToolbox, + "-framework", + CoreMedia, + "-framework", + CoreVideo, + "-framework", + Security, + "-framework", + SystemConfiguration, + "-framework", + CoreFoundation, + "-framework", + Foundation, + ); + PRODUCT_BUNDLE_IDENTIFIER = cn.crossdesk.mobile; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_OBJC_BRIDGING_HEADER = "CrossDeskMobile/CrossDeskMobile-Bridging-Header.h"; + SWIFT_STRICT_CONCURRENCY = minimal; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + A00000000000000000000004 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = arm64; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 8DNC3SZ86S; + ENABLE_BITCODE = NO; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = NO; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/../submodules/minirtc/src/api", + "$(PROJECT_DIR)/../src/device_controller", + "$(PROJECT_DIR)/../src/common", + "$(PROJECT_DIR)/../src/tools", + ); + INFOPLIST_FILE = CrossDeskMobile/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "$(PROJECT_DIR)/Vendor/iphoneos/$(CONFIGURATION)/libCrossDeskMiniRTC.a", + "-liconv", + "-lresolv", + "-framework", + VideoToolbox, + "-framework", + CoreMedia, + "-framework", + CoreVideo, + "-framework", + Security, + "-framework", + SystemConfiguration, + "-framework", + CoreFoundation, + "-framework", + Foundation, + ); + PRODUCT_BUNDLE_IDENTIFIER = cn.crossdesk.mobile; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_OBJC_BRIDGING_HEADER = "CrossDeskMobile/CrossDeskMobile-Bridging-Header.h"; + SWIFT_STRICT_CONCURRENCY = minimal; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 900000000000000000000001 /* Build configuration list for PBXProject "CrossDeskMobile" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A00000000000000000000001 /* Debug */, + A00000000000000000000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 900000000000000000000002 /* Build configuration list for PBXNativeTarget "CrossDeskMobile" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A00000000000000000000003 /* Debug */, + A00000000000000000000004 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 800000000000000000000001 /* Project object */; +} diff --git a/ios/CrossDeskMobile.xcodeproj/xcshareddata/xcschemes/CrossDeskMobile.xcscheme b/ios/CrossDeskMobile.xcodeproj/xcshareddata/xcschemes/CrossDeskMobile.xcscheme new file mode 100644 index 0000000..121cd44 --- /dev/null +++ b/ios/CrossDeskMobile.xcodeproj/xcshareddata/xcschemes/CrossDeskMobile.xcscheme @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/CrossDeskMobile/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/CrossDeskMobile/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d81fcf6 --- /dev/null +++ b/ios/CrossDeskMobile/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "CrossDeskAppIcon-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/CrossDeskMobile/Assets.xcassets/AppIcon.appiconset/CrossDeskAppIcon-1024.png b/ios/CrossDeskMobile/Assets.xcassets/AppIcon.appiconset/CrossDeskAppIcon-1024.png new file mode 100644 index 0000000000000000000000000000000000000000..a0d5a5491e303ad009ebbdec75b4168e9d06fb6f GIT binary patch literal 222154 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

^fNg!y5s$?8g>G;7H*nGX_CEfgdoE!+ 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%0AW7l32_pOg|t-#ZgR9 z_8p7Aj|>8z#PUb>7{wPaLm-bev{nC`nTb3zIZNoj`S8+YoRtatLD<&{%rk48ZMwZY z+kFkKsLH~-SFc$9nk~AA9wdQZ(L0t-S;q+e67s#$B%%Qf^Dd9aJoqPSCh#67BU@i(jMv4TNHA|vMR*mD&!h?sufC>Zc*%P z1PBxgz()8LmT1lL!z6^M2do;E{~w<*7q88XPRvpB$7I9=xhgbP)W!Lxb{w=e!F-{p zyY?+uwpKUjtZV?L&&!f*nw7K-Kv1zLX~7t%4M#<#lAriK_MXzg?S0?<`sqD828a@s zQYBht(*xu@_5r?wKW7D2I7&gn+UQmMEg(=ppn$-Jf`Ea=p4tX9sN!eOa}MKvyDeJAyz zG}xE!u~ZI72c0mWMI|&LLDGuq?AQTl2y%9J$Kh)TR@@Y|D#NkGu$I3H8Oi;=z+o8_1pe6A9~t=J zl>{bOMIdaYyh@j^-MTS)duBee#q>}NB-1#SY8kV;VX=Mm5GXtVkN7vx%Fv)@Khy`} zm7AGaGSICBW6s_(=Wj(f#^=Id8%|7oZB`VO=S@^fI)W5(cNbd&G~cz$b$P`uo!!Da zm?2yXfUJ}_$7P8yel#MKp%`Kmm9l!pEDV&Jdxpv<_jP~oYlo}W!lA05DhOv|{`nnB zp(Azhb zl?&1?^5y9x)+_c0gBxI#?j(v(4okFGzH+lXfrjO_ecZEuoqyUW8%rD=*eQ`XDOiz%|iI&Ul z%E={BIRoT{`{WJVNB30VYMjQ5)$`Kn zT~8ev==aI6K-Q;7kOhj`DZnQ3cE9(Ms1;D~zfhkeFL$;HHQ$rWu{o}aRRbk33Z zd%@=3F1A|&0+GoY#;XV$DDmM1Fn&6bH`R;8uLM5TRD43~RB~5Q!c7io%W74Fc3O_4 zur{i&Ab`SmGP-pHrE0*b-AT`@d<}beYl~?x;hQIpo zjSIJ04Sc^{I1*?U(~2APv&!89rWCc(pyFF+`{#YCt#Ex?`d8Pq6LH@SPqI# z5FxfyV^e+e+}L8hdHVE`eSNI)v;+rH;S#I@=u-A3(1Fj$eYOJp9{n675YZpc4t^-; zGrNTX@Q8mHmVkU{ZdrMVo?1#6)zAVKQ}h1$cx-BJp++^Wwz1!O(;o3dZ``ksQyUHC zrvdN+%VnD|k#qkB>0EDV{>h^|UVLKDk)7N61}Ys*f<6QBMHaIlf>K~Mli<~hbBTrk-*pkpICbhvJ03Q2h$Byoozj`L<>aBKv@blL` z$V_)oDYqhNwqR!9Td~PxmF@~|!lHKrtQ!0rH_0jELPPP_=(xsZ?kVl4@xBP3z>|{ufT~f9&93PX+nJB$O>E zR=|URX$5_WZ5m{9wIw#vy4*R*a0&*2vb(q-?b+jEvn3%w4pJ0nejo(c)@5FN>cGt1 zCtm%{xhuD4ysUe%QS0z8*x%#XEV--|KBy}{m!$8yMpTcO#Ph;;|=UcrUJ=JqBx|OWvNteG)x&G zRwkulJO~0Rw$Y--2EIT-Ys(9Ztyf<8&C5@pFxx7FWL=;Dz>lqq1~}%@&V%k%oL?6R z6yndifcYU`LeP2%?gtkX=R<%c^{8RYY-+CEF}J6qgdQdcu=zWM%KQ*e>Mb=MQ4=+G zODgMT;!+k>N(=o#b8m0v7?ddul!ZmRoO`w?7XO<#=iBv^LhiEuR6t z_I<1bw`ADaGGHnu@@WW^0vpmJ-7v}a?Wq3#H=i0F>^^^W@|E9Q@D}2|Tu<#I? zf^w2H*|oAh*g}}F!Ude|AO62A>pO3Nw0HbWZ3O%DmltOW2&^gupkaO}({!sD4|E03 zoZ6ja-mbn=QO0f)?i~PuxFJGU! zc;(JO?~$}B!C#Sqz#G3j6Q*<_YJkFSi}A=HP*B@P26}xl+|K6Eu^yJBD3~A$z}<$q zaIHSyD7VN*!W5At3fSsUiS@JoMz#X9Xp4rbAyNB3@snnkSsdxEoj%le<^Xa3P$sg0 zUGSp2wAelj0$?UEZKJ5g(-p_=lWm|s>^{YNTL1z$%@CM;lT`eur`$ZTf8bj$oc#W` zzw*qfy~90T#k9Hu4;KJ=O;}Ok?quN;y_m=SWB3=HQ#U^O=Jc`>m4yB_er*956;s$G z5O8D+oa_n=@u0i=%vT@Z^_9o=jr0Xw0kLef1A~KAG`saR&rWMn?PZeD-?*3>*%b`jP8wOFJ%TSi#`?m-Z+AR-?T z^Mj%esr?*JZwnXqZ_B>%%)XbNIYonVWD&0Kcqx9=dpZ-J@u z{;z-S$ud1iM6c}vu~dfX=c`5YQr!mqFV@Q={gr7B2EVn47{GwRze?2{+<&mEyE;32 zcVcR~m3U#L62q01g9n@5O!&F}pasKIYLWvaDsga7B6cBd)*5?ujo>Hj>B1o|+_#9& zdH|eaA?yE~9s7+gf{j#Q49aYyC@K_y4S)>drbty<2?Wf}C+4Fu^XqfXOE(Ars`!2u zQoUZ$LF5Wp584KJ8u{wUkzIqm^!mkwX%mtF zXUmfGA`0$-Kc+~urPnr>TH>CJIEa7CpAebn5{JMm&M#k4>}?$gh)@X$;2xy&vD&PV z3xO&PberQx4j(zNk6d67Wnp0+2Y|__0oZNDeFz*YE^g}pLL}jmvI!(AApK(-v^7IF z4H6X!z~)En7#BQbtwhmJ5F3Pzvu*tZu=&-lu9^A9T69mzR&fr@3%^v)VLBkW``^i#G)3T{m)A^C! zg%kUBeD%!19o>MD8ifHBKOL=?2vP(fJ{Xw!J6LfI{T&hC*jn=}X4_01=_Cm%)>9a{ z(zNqiZ&_Thi6MY=uL5#`(+y$dpg?M3A|-|gN@pJ1)74YHHP!s@KYR7UrCWCwkpYy4 z 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

jmfC8aXl=3&xz8D@^b^2re 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

vs}^2^9{e<3A|KIpxr`_V31-;Fz#3nJB<6 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

By0!&Q#siQ;v4rr7Bds{ zB-uAF@lM)6#vQ?q8@w}ud7N3<#Gc&Wp2jrel-&}93vX$tOy%jj50S>OR(WP@>AD6z zAY2~I|1N5{lmA6}gR2EoDa;%ViQgLS@)n7H?eWstuFJ$_(f`#cjW5<<>a6>qcW>D=A2cmq&2Vm;ELAyFTj|UQvgoXejm#>q+mAUk>G``v1^DY6n0LnTyjzmy zolYpU9a-op!K2aA@ux87*s8bU_fifsW}omC? +#import + +NS_ASSUME_NONNULL_BEGIN + +typedef NS_ENUM(NSInteger, CrossDeskSignalState) { + CrossDeskSignalStateConnecting = 0, + CrossDeskSignalStateConnected, + CrossDeskSignalStateFailed, + CrossDeskSignalStateClosed, + CrossDeskSignalStateReconnecting, + CrossDeskSignalStateServerClosed, + CrossDeskSignalStateTLSCertificateError, +}; + +typedef NS_ENUM(NSInteger, CrossDeskConnectionState) { + CrossDeskConnectionStateConnecting = 0, + CrossDeskConnectionStateConnected, + CrossDeskConnectionStateGathering, + CrossDeskConnectionStateDisconnected, + CrossDeskConnectionStateFailed, + CrossDeskConnectionStateClosed, + CrossDeskConnectionStateIncorrectPassword, + CrossDeskConnectionStateNoSuchID, + CrossDeskConnectionStateRemoteUnavailable, +}; + +typedef NS_ENUM(NSInteger, CrossDeskPointerAction) { + CrossDeskPointerActionMove = 0, + CrossDeskPointerActionLeftDown, + CrossDeskPointerActionLeftUp, + CrossDeskPointerActionRightDown, + CrossDeskPointerActionRightUp, + CrossDeskPointerActionMiddleDown, + CrossDeskPointerActionMiddleUp, +}; + +@class CrossDeskRTCBridge; + +@protocol CrossDeskRTCBridgeDelegate +@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 *)displayNames + displaySizes:(NSArray *)displaySizes; +- (void)rtcBridge:(CrossDeskRTCBridge *)bridge + didReceivePresence:(NSDictionary *)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 delegate; + +- (void)configureWithSignalHost:(NSString *)host + signalPort:(NSInteger)signalPort + turnPort:(NSInteger)turnPort + enableSRTP:(BOOL)enableSRTP; +- (void)setHardwareAccelerationEnabled:(BOOL)enabled; +- (void)requestPresenceForRemoteIDs:(NSArray *)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 diff --git a/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm b/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm new file mode 100644 index 0000000..1604ee9 --- /dev/null +++ b/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm @@ -0,0 +1,1577 @@ +#import "CrossDeskRTCBridge.h" + +#import +#import + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "device_controller.h" +#include "file_transfer_protocol.h" +#include "stream_names.h" +#include "minirtc.h" + +namespace { + +using crossdesk::ControlType; +using crossdesk::FileTransferAck; +using crossdesk::KeyFlag; +using crossdesk::MouseFlag; +using crossdesk::RemoteAction; +using crossdesk::ServiceCommandFlag; +using crossdesk::kFileAckMagic; +using crossdesk::protocol::kAudioStream; +using crossdesk::protocol::kClipboardStream; +using crossdesk::protocol::kControlStream; +using crossdesk::protocol::kDataStream; +using crossdesk::protocol::kFileChunkSize; +using crossdesk::protocol::kFileFeedbackStream; +using crossdesk::protocol::kFileStream; +using crossdesk::protocol::kKeyboardStream; +using crossdesk::protocol::kMaxClipboardBytes; +using crossdesk::protocol::kMouseStream; + +struct IncomingFile { + std::string name; + std::string path; + uint64_t total_size = 0; + uint64_t received = 0; + FILE *handle = nullptr; +}; + +struct OutgoingFile { + std::string name; + uint64_t total_size = 0; +}; + +enum class PeerRole { Identity, Controller }; + +struct CallbackContext { + __weak CrossDeskRTCBridge *owner = nil; + PeerRole role = PeerRole::Identity; + uint64_t generation = 0; +}; + +struct RTCState { + PeerPtr *identity_peer = nullptr; + PeerPtr *controller_peer = nullptr; + CallbackContext identity_context; + CallbackContext controller_context; + std::string signal_host; + int signal_port = 0; + int turn_port = 0; + bool enable_srtp = false; + bool hardware_acceleration = true; + bool identity_ready = false; + bool identity_recovery_attempted = false; + std::string identity_with_password; + std::string identity_base; + std::string controller_login; + std::string pending_remote_id; + std::string pending_password; + std::string transmission_id; + std::string log_path; + std::unordered_map incoming_files; + std::unordered_map outgoing_files; +}; + +std::atomic g_next_file_id{1}; +char kRTCQueueSpecificKey; + +bool IsReusableIdentity(const std::string &identity) { + const size_t separator = identity.find('@'); + return separator != std::string::npos && separator > 0 && + separator + 1 < identity.size(); +} + +void CopyCString(char *destination, size_t capacity, + const std::string &source) { + if (!destination || capacity == 0) return; + std::strncpy(destination, source.c_str(), capacity - 1); + destination[capacity - 1] = '\0'; +} + +std::string BaseIdentity(const std::string &identity) { + const size_t separator = identity.find('@'); + return separator == std::string::npos ? identity + : identity.substr(0, separator); +} + +std::string MouseJSON(float x, float y, int wheel, int flag) { + RemoteAction action{}; + action.type = ControlType::mouse; + action.m = {std::clamp(x, 0.0f, 1.0f), std::clamp(y, 0.0f, 1.0f), + wheel, static_cast(flag)}; + return action.to_json(); +} + +std::string KeyJSON(NSUInteger key_code, bool is_down) { + RemoteAction action{}; + action.type = ControlType::keyboard; + action.k = {static_cast(key_code), 0, false, + is_down ? KeyFlag::key_down : KeyFlag::key_up}; + return action.to_json(); +} + +struct KeyMapping { + NSUInteger key_code = 0; + bool shift = false; + bool valid = false; +}; + +KeyMapping MapASCII(unsigned char character) { + if (character >= 'a' && character <= 'z') { + return {static_cast(character - 'a' + 'A'), false, true}; + } + if (character >= 'A' && character <= 'Z') { + return {static_cast(character), true, true}; + } + if (character >= '0' && character <= '9') { + return {static_cast(character), false, true}; + } + switch (character) { + case ' ': return {0x20, false, true}; + case '\n': return {0x0D, false, true}; + case '\t': return {0x09, false, true}; + case '-': return {0xBD, false, true}; + case '_': return {0xBD, true, true}; + case '=': return {0xBB, false, true}; + case '+': return {0xBB, true, true}; + case '[': return {0xDB, false, true}; + case '{': return {0xDB, true, true}; + case ']': return {0xDD, false, true}; + case '}': return {0xDD, true, true}; + case '\\': return {0xDC, false, true}; + case '|': return {0xDC, true, true}; + case ';': return {0xBA, false, true}; + case ':': return {0xBA, true, true}; + case '\'': return {0xDE, false, true}; + case '"': return {0xDE, true, true}; + case ',': return {0xBC, false, true}; + case '<': return {0xBC, true, true}; + case '.': return {0xBE, false, true}; + case '>': return {0xBE, true, true}; + case '/': return {0xBF, false, true}; + case '?': return {0xBF, true, true}; + case '`': return {0xC0, false, true}; + case '~': return {0xC0, true, true}; + case '!': return {'1', true, true}; + case '@': return {'2', true, true}; + case '#': return {'3', true, true}; + case '$': return {'4', true, true}; + case '%': return {'5', true, true}; + case '^': return {'6', true, true}; + case '&': return {'7', true, true}; + case '*': return {'8', true, true}; + case '(': return {'9', true, true}; + case ')': return {'0', true, true}; + default: return {}; + } +} + +void DispatchMain(dispatch_block_t block) { + if ([NSThread isMainThread]) { + block(); + } else { + dispatch_async(dispatch_get_main_queue(), block); + } +} + +} // namespace + +@interface CrossDeskRTCBridge () +- (void)handleSignalState:(CrossDeskSignalState)state role:(PeerRole)role; +- (void)handleSignalMessage:(const char *)message + size:(size_t)size + role:(PeerRole)role; +- (void)handleProvisionedIdentity:(const std::string &)identity; +- (void)handleConnectionState:(CrossDeskConnectionState)state + remoteID:(const std::string &)remoteID + generation:(uint64_t)generation; +- (BOOL)isControllerGenerationActive:(uint64_t)generation; +- (BOOL)isControllerGenerationCurrent:(uint64_t)generation; +- (void)handleVideoFrame:(const XVideoFrame *)frame + sourceID:(const char *)sourceID + sourceIDSize:(size_t)sourceIDSize; +- (void)enqueueVideoPixelBuffer:(CVPixelBufferRef)pixelBuffer + width:(NSInteger)width + height:(NSInteger)height; +- (void)resetVideoDelivery; +- (void)handleAudio:(const char *)data size:(size_t)size; +- (void)handleData:(const char *)data + size:(size_t)size + sourceID:(const char *)sourceID + sourceIDSize:(size_t)sourceIDSize; +- (void)handleStats:(const XNetTrafficStats *)stats mode:(TraversalMode)mode; +- (void)sendMessage:(const std::string &)message + reliable:(BOOL)reliable + stream:(const char *)stream; +- (void)createIdentityPeer; +- (void)createControllerPeer; +- (void)destroyIdentityPeer; +- (void)destroyControllerPeer; +- (void)requestKeyFrame; +@end + +namespace { + +void OnVideoFrame(const XVideoFrame *frame, const char *, size_t, + const char *source_id, size_t source_id_size, + void *user_data) { + auto *context = static_cast(user_data); + CrossDeskRTCBridge *owner = context ? context->owner : nil; + if (owner && context->role == PeerRole::Controller && frame && + [owner isControllerGenerationActive:context->generation]) { + [owner handleVideoFrame:frame + sourceID:source_id + sourceIDSize:source_id_size]; + } +} + +void OnAudioBuffer(const char *data, size_t size, const char *, size_t, + const char *, size_t, void *user_data) { + auto *context = static_cast(user_data); + CrossDeskRTCBridge *owner = context ? context->owner : nil; + if (owner && context->role == PeerRole::Controller && data && size > 0 && + [owner isControllerGenerationActive:context->generation]) { + [owner handleAudio:data size:size]; + } +} + +void OnDataBuffer(const char *data, size_t size, const char *, size_t, + const char *source_id, size_t source_id_size, + void *user_data) { + auto *context = static_cast(user_data); + CrossDeskRTCBridge *owner = context ? context->owner : nil; + if (owner && context->role == PeerRole::Controller && + [owner isControllerGenerationActive:context->generation]) { + [owner handleData:data + size:size + sourceID:source_id + sourceIDSize:source_id_size]; + } +} + +void OnSignalState(SignalStatus status, const char *, size_t, void *user_data) { + auto *context = static_cast(user_data); + CrossDeskRTCBridge *owner = context ? context->owner : nil; + if (owner && + (context->role == PeerRole::Identity || + [owner isControllerGenerationActive:context->generation])) { + [owner handleSignalState:static_cast(status) + role:context->role]; + } +} + +void OnSignalMessage(const char *message, size_t size, void *user_data) { + auto *context = static_cast(user_data); + CrossDeskRTCBridge *owner = context ? context->owner : nil; + if (owner && message && size > 0) { + [owner handleSignalMessage:message size:size role:context->role]; + } +} + +void OnConnectionState(ConnectionStatus status, const char *remote_id, + size_t remote_id_size, void *user_data) { + auto *context = static_cast(user_data); + CrossDeskRTCBridge *owner = context ? context->owner : nil; + if (!owner || context->role != PeerRole::Controller || + ![owner isControllerGenerationActive:context->generation]) { + return; + } + const std::string identifier(remote_id ? remote_id : "", remote_id_size); + [owner handleConnectionState:static_cast(status) + remoteID:identifier + generation:context->generation]; +} + +void OnNetworkStats(const char *peer_id, size_t peer_id_size, + TraversalMode mode, const XNetTrafficStats *stats, + const char *, size_t, void *user_data) { + auto *context = static_cast(user_data); + CrossDeskRTCBridge *owner = context ? context->owner : nil; + if (!owner) return; + if (context->role == PeerRole::Identity && mode == TraversalMode::UnknownMode && + peer_id && peer_id_size > 0) { + [owner handleProvisionedIdentity:std::string(peer_id, peer_id_size)]; + } else if (context->role == PeerRole::Controller && stats && + [owner isControllerGenerationActive:context->generation]) { + [owner handleStats:stats mode:mode]; + } +} + +Params MakeParams(const RTCState &state, const std::string &user_id, + CallbackContext *context) { + Params params{}; + params.use_cfg_file = false; + CopyCString(params.signal_server_ip, sizeof(params.signal_server_ip), + state.signal_host); + params.signal_server_port = state.signal_port; + CopyCString(params.stun_server_ip, sizeof(params.stun_server_ip), + state.signal_host); + params.stun_server_port = state.turn_port; + CopyCString(params.turn_server_ip, sizeof(params.turn_server_ip), + state.signal_host); + params.turn_server_port = state.turn_port; + params.turn_server_username[0] = '\0'; + params.turn_server_password[0] = '\0'; + CopyCString(params.log_path, sizeof(params.log_path), state.log_path); + params.hardware_acceleration = state.hardware_acceleration; + params.native_video_output = true; + params.av1_encoding = false; + params.turn_mode = TurnMode::TurnAutoUdpTcp; + params.enable_srtp = state.enable_srtp; + params.video_content_type = VideoContentType::ScreenContent; + params.video_quality = VideoQuality::QualityHigh; + params.video_frame_rate = 60; + params.video_degradation_preference = + VideoDegradationPreference::MaintainFrameRate; + params.on_receive_video_buffer = nullptr; + params.on_receive_audio_buffer = OnAudioBuffer; + params.on_receive_data_buffer = OnDataBuffer; + params.on_receive_video_frame = OnVideoFrame; + params.on_signal_status = OnSignalState; + params.on_signal_message = OnSignalMessage; + params.on_connection_status = OnConnectionState; + params.on_net_status_report = OnNetworkStats; + params.user_id = user_id.c_str(); + params.user_data = context; + return params; +} + +} // namespace + +@implementation CrossDeskRTCBridge { + std::unique_ptr _state; + dispatch_queue_t _rtcQueue; + CVPixelBufferPoolRef _videoPool; + size_t _videoPoolWidth; + size_t _videoPoolHeight; + std::mutex _videoPoolMutex; + std::mutex _pendingVideoMutex; + CVPixelBufferRef _pendingVideoBuffer; + NSInteger _pendingVideoWidth; + NSInteger _pendingVideoHeight; + bool _videoDeliveryScheduled; + uint64_t _videoDeliveryGeneration; + std::mutex _fileMutex; + std::atomic _selectedDisplay; + std::atomic _receivedVideoFrames; + std::atomic _controllerGenerationCounter; + std::atomic _activeControllerGeneration; +} + +- (instancetype)init { + self = [super init]; + if (self) { + _state = std::make_unique(); + _state->identity_context.owner = self; + _state->identity_context.role = PeerRole::Identity; + _state->controller_context.owner = self; + _state->controller_context.role = PeerRole::Controller; + _rtcQueue = dispatch_queue_create("cn.crossdesk.mobile.minirtc", + DISPATCH_QUEUE_SERIAL); + dispatch_queue_set_specific(_rtcQueue, &kRTCQueueSpecificKey, + _state.get(), nullptr); + _videoPool = nullptr; + _videoPoolWidth = 0; + _videoPoolHeight = 0; + _pendingVideoBuffer = nullptr; + _pendingVideoWidth = 0; + _pendingVideoHeight = 0; + _videoDeliveryScheduled = false; + _videoDeliveryGeneration = 0; + _selectedDisplay.store(0); + _receivedVideoFrames.store(0); + _controllerGenerationCounter.store(0); + _activeControllerGeneration.store(0); + } + return self; +} + +- (void)dealloc { + _activeControllerGeneration.store(0); + _state->identity_context.owner = nil; + _state->controller_context.owner = nil; + if (dispatch_get_specific(&kRTCQueueSpecificKey) == _state.get()) { + [self destroyControllerPeer]; + [self destroyIdentityPeer]; + } else { + dispatch_sync(_rtcQueue, ^{ + [self destroyControllerPeer]; + [self destroyIdentityPeer]; + }); + } + [self resetVideoDelivery]; + std::lock_guard lock(_videoPoolMutex); + if (_videoPool) { + CVPixelBufferPoolRelease(_videoPool); + _videoPool = nullptr; + } +} + +- (void)configureWithSignalHost:(NSString *)host + signalPort:(NSInteger)signalPort + turnPort:(NSInteger)turnPort + enableSRTP:(BOOL)enableSRTP { + NSString *trimmed = [host stringByTrimmingCharactersInSet: + NSCharacterSet.whitespaceAndNewlineCharacterSet]; + const char *host_c_string = trimmed.UTF8String; + const std::string host_value = host_c_string ? host_c_string : ""; + if (host_value.empty() || signalPort <= 0 || turnPort <= 0) return; + + NSString *cache = NSSearchPathForDirectoriesInDomains( + NSCachesDirectory, NSUserDomainMask, YES) + .firstObject; + NSString *logDirectory = [cache stringByAppendingPathComponent: + @"CrossDeskMobile/MiniRTC"]; + [[NSFileManager defaultManager] createDirectoryAtPath:logDirectory + withIntermediateDirectories:YES + attributes:nil + error:nil]; + const char *log_c_string = logDirectory.UTF8String; + const std::string log_path = log_c_string ? log_c_string : "logs"; + + dispatch_async(_rtcQueue, ^{ + const bool unchanged = self->_state->signal_host == host_value && + self->_state->signal_port == signalPort && + self->_state->turn_port == turnPort && + self->_state->enable_srtp == enableSRTP && + self->_state->identity_peer != nullptr; + if (unchanged) return; + + [self destroyControllerPeer]; + [self destroyIdentityPeer]; + self->_state->signal_host = host_value; + self->_state->signal_port = static_cast(signalPort); + self->_state->turn_port = static_cast(turnPort); + self->_state->enable_srtp = enableSRTP; + self->_state->log_path = log_path; + self->_state->identity_ready = false; + self->_state->identity_recovery_attempted = false; + self->_state->identity_with_password.clear(); + self->_state->identity_base.clear(); + + NSString *key = [NSString stringWithFormat:@"CrossDeskIdentity.%@.%ld", + trimmed, (long)signalPort]; + NSString *cached = [[NSUserDefaults standardUserDefaults] + stringForKey:key]; + if (cached.length > 0) { + const std::string cached_identity = cached.UTF8String ?: ""; + if (IsReusableIdentity(cached_identity)) { + self->_state->identity_with_password = cached_identity; + self->_state->identity_base = BaseIdentity(cached_identity); + DispatchMain(^{ + id delegate = self.delegate; + if ([delegate respondsToSelector: + @selector(rtcBridge:didProvisionIdentity:)]) { + [delegate rtcBridge:self didProvisionIdentity:cached]; + } + }); + } else { + // A bare ID cannot authenticate after its first signaling session. + // Older iOS builds stored it anyway, causing every later launch to be + // rejected as "Incorrect password". Drop it and request a fresh + // server-issued ID/password pair. + [[NSUserDefaults standardUserDefaults] removeObjectForKey:key]; + } + } + [self createIdentityPeer]; + }); +} + +- (void)setHardwareAccelerationEnabled:(BOOL)enabled { + dispatch_async(_rtcQueue, ^{ + self->_state->hardware_acceleration = enabled; + }); +} + +- (void)requestPresenceForRemoteIDs:(NSArray *)remoteIDs { + NSArray *owned_ids = [remoteIDs copy]; + dispatch_async(_rtcQueue, ^{ + if (!self->_state->identity_peer || !self->_state->identity_ready || + self->_state->identity_base.empty()) { + return; + } + + NSMutableArray *devices = + [NSMutableArray arrayWithCapacity:owned_ids.count]; + for (NSString *remote_id in owned_ids) { + if (![remote_id isKindOfClass:NSString.class]) continue; + NSString *trimmed = [remote_id + stringByTrimmingCharactersInSet: + NSCharacterSet.whitespaceAndNewlineCharacterSet]; + if (trimmed.length > 0) [devices addObject:trimmed]; + } + NSString *user_id = [NSString + stringWithUTF8String:self->_state->identity_base.c_str()]; + NSDictionary *request = @{ + @"type" : @"recent_connections_presence", + @"user_id" : user_id ?: @"", + @"devices" : devices, + }; + NSData *payload = [NSJSONSerialization dataWithJSONObject:request + options:0 + error:nil]; + if (!payload) return; + SendSignalMessage(self->_state->identity_peer, + static_cast(payload.bytes), + payload.length); + }); +} + +- (void)connectToRemoteID:(NSString *)remoteID password:(NSString *)password { + NSString *cleanID = [remoteID + stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; + NSString *cleanPassword = [password + stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; + const char *identifier_c_string = cleanID.UTF8String; + const char *password_c_string = cleanPassword.UTF8String; + const std::string identifier = + identifier_c_string ? identifier_c_string : ""; + const std::string password_value = + password_c_string ? password_c_string : ""; + if (identifier.empty()) return; + + dispatch_async(_rtcQueue, ^{ + self->_state->pending_remote_id = identifier; + self->_state->pending_password = password_value; + [self destroyControllerPeer]; + if (self->_state->identity_ready && + !self->_state->identity_base.empty()) { + [self createControllerPeer]; + } + }); +} + +- (void)disconnect { + dispatch_async(_rtcQueue, ^{ + self->_state->pending_remote_id.clear(); + self->_state->pending_password.clear(); + [self destroyControllerPeer]; + }); +} + +- (void)requestKeyFrame { + dispatch_async(_rtcQueue, ^{ + if (self->_state->controller_peer) { + const std::string stream = + "Display" + std::to_string(self->_selectedDisplay.load() + 1); + RequestVideoKeyFrame(self->_state->controller_peer, stream.c_str()); + } + }); +} + +- (void)sendPointerAtX:(float)x + y:(float)y + action:(CrossDeskPointerAction)action { + const std::string message = MouseJSON(x, y, 0, static_cast(action)); + // Movement is latency-sensitive and can be superseded by newer positions. + // Button transitions must not be lost or reordered, otherwise a tap can be + // applied at a stale pointer position or leave a button logically pressed. + // MiniRTC reliability is configured per stream, so transitions use the + // existing reliable control stream while movement stays on the mouse stream. + const BOOL is_movement = action == CrossDeskPointerActionMove; +#if DEBUG + if (!is_movement) { + NSLog(@"CrossDesk pointer action=%ld normalized=(%.5f, %.5f)", + static_cast(action), x, y); + } +#endif + [self sendMessage:message + reliable:!is_movement + stream:is_movement ? kMouseStream : kControlStream]; +} + +- (void)sendScrollX:(float)x + y:(float)y + deltaX:(NSInteger)deltaX + deltaY:(NSInteger)deltaY { + const bool vertical = std::abs(deltaY) >= std::abs(deltaX); + const NSInteger raw_delta = vertical ? deltaY : deltaX; + if (raw_delta == 0) return; + const int flag = vertical ? 7 : 8; + const int wheel = raw_delta > 0 ? 1 : -1; + const std::string message = MouseJSON(x, y, wheel, flag); + [self sendMessage:message reliable:NO stream:kMouseStream]; +} + +- (void)sendWindowsKeyCode:(NSUInteger)keyCode isDown:(BOOL)isDown { + [self sendMessage:KeyJSON(keyCode, isDown) + reliable:YES + stream:kKeyboardStream]; +} + +- (void)sendText:(NSString *)text { + NSData *utf8 = [text dataUsingEncoding:NSUTF8StringEncoding]; + if (!utf8.length) return; + const std::string bytes(static_cast(utf8.bytes), utf8.length); + dispatch_async(_rtcQueue, ^{ + if (!self->_state->controller_peer) return; + for (unsigned char character : bytes) { + KeyMapping mapping = MapASCII(character); + if (!mapping.valid) continue; + if (mapping.shift) { + const std::string shift_down = KeyJSON(0x10, true); + SendReliableDataFrame(self->_state->controller_peer, + shift_down.data(), shift_down.size(), + kKeyboardStream); + } + const std::string key_down = KeyJSON(mapping.key_code, true); + const std::string key_up = KeyJSON(mapping.key_code, false); + SendReliableDataFrame(self->_state->controller_peer, key_down.data(), + key_down.size(), kKeyboardStream); + SendReliableDataFrame(self->_state->controller_peer, key_up.data(), + key_up.size(), kKeyboardStream); + if (mapping.shift) { + const std::string shift_up = KeyJSON(0x10, false); + SendReliableDataFrame(self->_state->controller_peer, shift_up.data(), + shift_up.size(), kKeyboardStream); + } + } + }); +} + +- (void)switchToDisplay:(NSInteger)displayIndex { + if (displayIndex < 0 || displayIndex >= 8) return; + [self resetVideoDelivery]; + _selectedDisplay.store(static_cast(displayIndex)); + _receivedVideoFrames.store(0); + RemoteAction action{}; + action.type = ControlType::display_id; + action.d = static_cast(displayIndex); + [self sendMessage:action.to_json() reliable:YES stream:kControlStream]; + [self requestKeyFrame]; +} + +- (void)setAudioEnabled:(BOOL)enabled { + RemoteAction action{}; + action.type = ControlType::audio_capture; + action.a = enabled; + [self sendMessage:action.to_json() reliable:YES stream:kControlStream]; +} + +- (void)sendClipboardText:(NSString *)text { + NSData *utf8 = [text dataUsingEncoding:NSUTF8StringEncoding]; + if (!utf8.length || utf8.length > kMaxClipboardBytes) return; + const std::string bytes(static_cast(utf8.bytes), utf8.length); + [self sendMessage:bytes reliable:YES stream:kClipboardStream]; +} + +- (void)sendFileAtURL:(NSURL *)fileURL { + if (!fileURL.isFileURL) return; + NSURL *url = [fileURL copy]; + // Acquire the document picker's sandbox extension before its completion + // callback returns; the actual reads happen on the serialized RTC queue. + const BOOL scoped = [url startAccessingSecurityScopedResource]; + dispatch_async(_rtcQueue, ^{ + if (!self->_state->controller_peer) { + if (scoped) [url stopAccessingSecurityScopedResource]; + return; + } + const char *path_bytes = url.path.fileSystemRepresentation; + FILE *input = path_bytes ? std::fopen(path_bytes, "rb") : nullptr; + if (!input) { + if (scoped) [url stopAccessingSecurityScopedResource]; + return; + } + + if (fseeko(input, 0, SEEK_END) != 0) { + std::fclose(input); + if (scoped) [url stopAccessingSecurityScopedResource]; + return; + } + const off_t end = ftello(input); + rewind(input); + if (end < 0) { + std::fclose(input); + if (scoped) [url stopAccessingSecurityScopedResource]; + return; + } + + NSString *last_component = url.lastPathComponent.length > 0 + ? url.lastPathComponent + : @"file"; + NSData *name_data = [last_component dataUsingEncoding:NSUTF8StringEncoding]; + if (name_data.length == 0 || + name_data.length > std::numeric_limits::max()) { + std::fclose(input); + if (scoped) [url stopAccessingSecurityScopedResource]; + return; + } + + const uint32_t file_id = g_next_file_id.fetch_add(1); + const uint64_t total_size = static_cast(end); + { + std::lock_guard lock(self->_fileMutex); + self->_state->outgoing_files[file_id] = { + std::string(last_component.UTF8String ?: "file"), total_size}; + } + DispatchMain(^{ + id delegate = self.delegate; + if ([delegate respondsToSelector: + @selector(rtcBridge:didUpdateFileTransfer:progress:sending:)]) { + [delegate rtcBridge:self + didUpdateFileTransfer:last_component + progress:0 + sending:YES]; + } + }); + + std::vector payload(kFileChunkSize); + uint64_t offset = 0; + bool first = true; + int send_result = 0; + do { + const size_t to_read = static_cast( + std::min(kFileChunkSize, total_size - offset)); + const size_t bytes_read = to_read > 0 + ? std::fread(payload.data(), 1, to_read, input) + : 0; + if (to_read > 0 && bytes_read == 0) { + send_result = -1; + break; + } + const bool last = offset + bytes_read >= total_size; + const std::string file_name(last_component.UTF8String ?: "file"); + const std::string* file_name_pointer = first ? &file_name : nullptr; + std::vector chunk = crossdesk::protocol::EncodeFileChunk( + file_id, offset, total_size, payload.data(), + static_cast(bytes_read), file_name_pointer, first, last); + if (chunk.empty()) { + send_result = -1; + break; + } + send_result = SendReliableDataFrame(self->_state->controller_peer, + chunk.data(), chunk.size(), + kFileStream); + offset += bytes_read; + first = false; + if (send_result != 0) break; + } while (offset < total_size); + + std::fclose(input); + if (scoped) [url stopAccessingSecurityScopedResource]; + if (send_result != 0) { + { + std::lock_guard lock(self->_fileMutex); + self->_state->outgoing_files.erase(file_id); + } + DispatchMain(^{ + id delegate = self.delegate; + if ([delegate respondsToSelector: + @selector(rtcBridge:didUpdateFileTransfer:progress:sending:)]) { + [delegate rtcBridge:self + didUpdateFileTransfer:last_component + progress:-1 + sending:YES]; + } + }); + } + }); +} + +- (void)sendSecureAttentionSequence { + RemoteAction action{}; + action.type = ControlType::service_command; + action.c.flag = ServiceCommandFlag::send_sas; + [self sendMessage:action.to_json() reliable:YES stream:kControlStream]; +} + +- (void)sendMessage:(const std::string &)message + reliable:(BOOL)reliable + stream:(const char *)stream { + // This method always hops to the RTC queue. Own both values before the + // caller's stack frame disappears; several callers pass local/temporary + // std::strings. + const std::string owned_message(message); + const std::string owned_stream(stream ? stream : ""); + dispatch_async(_rtcQueue, ^{ + if (!self->_state->controller_peer) return; + if (reliable) { + SendReliableDataFrame(self->_state->controller_peer, + owned_message.data(), owned_message.size(), + owned_stream.c_str()); + } else { + SendDataFrame(self->_state->controller_peer, owned_message.data(), + owned_message.size(), owned_stream.c_str()); + } + }); +} + +- (void)createIdentityPeer { + if (_state->identity_peer || _state->signal_host.empty()) return; + Params params = MakeParams(*_state, _state->identity_with_password, + &_state->identity_context); + _state->identity_peer = CreatePeer(¶ms); + if (!_state->identity_peer || Init(_state->identity_peer) != 0) { + [self destroyIdentityPeer]; + [self handleSignalState:CrossDeskSignalStateFailed + role:PeerRole::Identity]; + } +} + +- (void)createControllerPeer { + if (_state->controller_peer || _state->pending_remote_id.empty() || + _state->identity_base.empty()) { + return; + } + const uint64_t generation = _controllerGenerationCounter.fetch_add(1) + 1; + _state->controller_context.generation = generation; + _activeControllerGeneration.store(generation); + _state->controller_login = "C-" + _state->identity_base; + Params params = MakeParams(*_state, _state->controller_login, + &_state->controller_context); + _state->controller_peer = CreatePeer(¶ms); + if (!_state->controller_peer) { + _activeControllerGeneration.store(0); + [self handleConnectionState:CrossDeskConnectionStateFailed + remoteID:_state->pending_remote_id + generation:generation]; + return; + } + + // This native iOS peer is controller-only and does not publish a local + // display. Remote display receivers are created from the host's SDP. + AddAudioStream(_state->controller_peer, kAudioStream); + AddDataStream(_state->controller_peer, kDataStream, false); + AddDataStream(_state->controller_peer, kMouseStream, false); + AddDataStream(_state->controller_peer, kKeyboardStream, true); + AddDataStream(_state->controller_peer, kControlStream, true); + AddDataStream(_state->controller_peer, kFileStream, true); + AddDataStream(_state->controller_peer, kFileFeedbackStream, true); + AddDataStream(_state->controller_peer, kClipboardStream, true); + + if (Init(_state->controller_peer) != 0) { + [self destroyControllerPeer]; + [self handleConnectionState:CrossDeskConnectionStateFailed + remoteID:_state->pending_remote_id + generation:generation]; + return; + } + const std::string join_id = _state->pending_remote_id + "@" + + _state->pending_password; + JoinConnection(_state->controller_peer, join_id.c_str()); + + // Joining authenticates with "remote-id@password", while leaving is routed + // by the remote peer ID alone (the same contract used by the desktop app). + _state->transmission_id = _state->pending_remote_id; +} + +- (void)destroyIdentityPeer { + if (_state->identity_peer) { + DestroyPeer(&_state->identity_peer); + } + _state->identity_ready = false; +} + +- (void)destroyControllerPeer { + _activeControllerGeneration.store(0); + if (_state->controller_peer) { + if (!_state->transmission_id.empty()) { + LeaveConnection(_state->controller_peer, + _state->transmission_id.c_str()); + } + DestroyPeer(&_state->controller_peer); + } + _state->controller_login.clear(); + _state->transmission_id.clear(); + { + std::lock_guard lock(_fileMutex); + for (auto &entry : _state->incoming_files) { + if (entry.second.handle) std::fclose(entry.second.handle); + } + _state->incoming_files.clear(); + _state->outgoing_files.clear(); + } + _selectedDisplay.store(0); + _receivedVideoFrames.store(0); + [self resetVideoDelivery]; +} + +- (void)handleSignalState:(CrossDeskSignalState)state role:(PeerRole)role { + dispatch_async(_rtcQueue, ^{ + if (role == PeerRole::Identity) { + self->_state->identity_ready = state == CrossDeskSignalStateConnected; + if (state == CrossDeskSignalStateFailed && + !self->_state->identity_with_password.empty() && + !self->_state->identity_recovery_attempted) { + // The saved credential can become invalid after a server-side reset or + // an interrupted password change. Retry once with an empty identity so + // the signaling server provisions a new credential. + self->_state->identity_recovery_attempted = true; + NSString *host = [NSString stringWithUTF8String: + self->_state->signal_host.c_str()]; + NSString *key = [NSString stringWithFormat:@"CrossDeskIdentity.%@.%d", + host, + self->_state->signal_port]; + [[NSUserDefaults standardUserDefaults] removeObjectForKey:key]; + [self destroyIdentityPeer]; + self->_state->identity_with_password.clear(); + self->_state->identity_base.clear(); + [self createIdentityPeer]; + } + if (self->_state->identity_ready && + !self->_state->identity_base.empty() && + !self->_state->pending_remote_id.empty() && + !self->_state->controller_peer) { + [self createControllerPeer]; + } + } + // The home-page signal indicator represents the long-lived identity peer. + // A controller peer is created and destroyed for each session; forwarding + // its shutdown would incorrectly make the server look disconnected. + if (role == PeerRole::Identity) { + DispatchMain(^{ + id delegate = self.delegate; + if ([delegate respondsToSelector: + @selector(rtcBridge:didChangeSignalState:)]) { + [delegate rtcBridge:self didChangeSignalState:state]; + } + }); + } + }); +} + +- (void)handleSignalMessage:(const char *)message + size:(size_t)size + role:(PeerRole)role { + constexpr size_t kMaxPresenceMessageSize = 256 * 1024; + if (role != PeerRole::Identity || !message || size == 0 || + size > kMaxPresenceMessageSize) { + return; + } + + NSData *payload = [NSData dataWithBytes:message length:size]; + id decoded = [NSJSONSerialization JSONObjectWithData:payload + options:0 + error:nil]; + if (![decoded isKindOfClass:NSDictionary.class]) return; + NSDictionary *object = static_cast(decoded); + NSString *type = object[@"type"]; + if (![type isKindOfClass:NSString.class]) return; + + NSMutableDictionary *presence = + [NSMutableDictionary dictionary]; + if ([type isEqualToString:@"presence"]) { + NSArray *devices = object[@"devices"]; + if (![devices isKindOfClass:NSArray.class]) return; + for (id value in devices) { + if (![value isKindOfClass:NSDictionary.class]) continue; + NSDictionary *device = static_cast(value); + NSString *remote_id = device[@"id"]; + NSNumber *online = device[@"online"]; + if ([remote_id isKindOfClass:NSString.class] && remote_id.length > 0 && + [online isKindOfClass:NSNumber.class]) { + presence[remote_id] = @([online boolValue]); + } + } + } else if ([type isEqualToString:@"presence_update"]) { + NSString *remote_id = object[@"id"]; + NSNumber *online = object[@"online"]; + if ([remote_id isKindOfClass:NSString.class] && remote_id.length > 0 && + [online isKindOfClass:NSNumber.class]) { + presence[remote_id] = @([online boolValue]); + } + } else { + return; + } + if (presence.count == 0) return; + + NSDictionary *result = [presence copy]; + DispatchMain(^{ + id delegate = self.delegate; + if ([delegate respondsToSelector: + @selector(rtcBridge:didReceivePresence:)]) { + [delegate rtcBridge:self didReceivePresence:result]; + } + }); +} + +- (void)handleProvisionedIdentity:(const std::string &)identity { + if (identity.empty()) return; + const std::string identity_copy = identity; + dispatch_async(_rtcQueue, ^{ + self->_state->identity_with_password = identity_copy; + self->_state->identity_base = BaseIdentity(identity_copy); + NSString *host = [NSString stringWithUTF8String: + self->_state->signal_host.c_str()]; + NSString *key = [NSString stringWithFormat:@"CrossDeskIdentity.%@.%d", + host, self->_state->signal_port]; + NSString *value = [NSString stringWithUTF8String:identity_copy.c_str()]; + if (IsReusableIdentity(identity_copy)) { + [[NSUserDefaults standardUserDefaults] setObject:value forKey:key]; + } else { + // Keep a passwordless identity only for this process. It is valid for + // the current login but is not safe to reuse on the next app launch. + [[NSUserDefaults standardUserDefaults] removeObjectForKey:key]; + } + DispatchMain(^{ + id delegate = self.delegate; + if ([delegate respondsToSelector:@selector(rtcBridge:didProvisionIdentity:)]) { + [delegate rtcBridge:self didProvisionIdentity:value]; + } + }); + }); +} + +- (void)handleConnectionState:(CrossDeskConnectionState)state + remoteID:(const std::string &)remoteID + generation:(uint64_t)generation { + if (![self isControllerGenerationCurrent:generation]) return; + const std::string remote_copy = remoteID; + if (state == CrossDeskConnectionStateConnected) { + dispatch_async(_rtcQueue, ^{ + if (![self isControllerGenerationActive:generation] || + !self->_state->controller_peer) { + return; + } + constexpr char client_info[] = + "{\"type\":\"client_info\",\"version\":\"ios-native\"," + "\"platform\":\"ios\"}"; + SendSignalMessage(self->_state->controller_peer, client_info, + sizeof(client_info) - 1); + const std::string controller_name = + self->_state->identity_base.empty() + ? "iPhone" + : "iPhone-" + self->_state->identity_base; + RemoteAction action{}; + action.type = ControlType::host_infomation; + CopyCString(action.i.host_name, sizeof(action.i.host_name), + controller_name); + action.i.host_name_size = std::strlen(action.i.host_name); + action.i.display_list = nullptr; + action.i.display_num = 0; + action.i.left = nullptr; + action.i.top = nullptr; + action.i.right = nullptr; + action.i.bottom = nullptr; + const std::string host_info = action.to_json(); + SendReliableDataFrame(self->_state->controller_peer, host_info.data(), + host_info.size(), kControlStream); + const std::string stream = + "Display" + std::to_string(self->_selectedDisplay.load() + 1); + RequestVideoKeyFrame(self->_state->controller_peer, stream.c_str()); + }); + } + DispatchMain(^{ + if (![self isControllerGenerationCurrent:generation]) return; + if (state == CrossDeskConnectionStateConnected && + ![self isControllerGenerationActive:generation]) { + return; + } + NSString *identifier = [NSString stringWithUTF8String:remote_copy.c_str()]; + id delegate = self.delegate; + if ([delegate respondsToSelector: + @selector(rtcBridge:didChangeConnectionState:remoteID:)]) { + [delegate rtcBridge:self didChangeConnectionState:state remoteID:identifier]; + } + }); +} + +- (BOOL)isControllerGenerationActive:(uint64_t)generation { + return generation != 0 && + _activeControllerGeneration.load() == generation; +} + +- (BOOL)isControllerGenerationCurrent:(uint64_t)generation { + if (generation == 0 || + _controllerGenerationCounter.load() != generation) { + return NO; + } + const uint64_t active = _activeControllerGeneration.load(); + return active == 0 || active == generation; +} + +- (void)handleVideoFrame:(const XVideoFrame *)frame + sourceID:(const char *)sourceID + sourceIDSize:(size_t)sourceIDSize { + if (!frame || frame->width == 0 || frame->height == 0) return; + if (sourceID && sourceIDSize > 0) { + const std::string source(sourceID, sourceIDSize); + const std::string expected = + "Display" + std::to_string(_selectedDisplay.load() + 1); + if (source != expected) return; + } + const bool has_native_pixel_buffer = + frame->native_handle && + frame->native_handle_type == XVideoFrameNativeHandleCVPixelBuffer; + if (has_native_pixel_buffer) { + CVPixelBufferRef pixel_buffer = + static_cast(frame->native_handle); + [self enqueueVideoPixelBuffer:pixel_buffer + width:frame->width + height:frame->height]; + return; + } + + if (!frame->data) return; + const size_t y_size = static_cast(frame->width) * frame->height; + const size_t required_size = y_size + y_size / 2; + if (frame->size < required_size) return; + + CVPixelBufferRef pixel_buffer = nullptr; + CVReturn result = kCVReturnError; + { + std::lock_guard lock(_videoPoolMutex); + if (!_videoPool || _videoPoolWidth != frame->width || + _videoPoolHeight != frame->height) { + if (_videoPool) CVPixelBufferPoolRelease(_videoPool); + _videoPool = nullptr; + _videoPoolWidth = frame->width; + _videoPoolHeight = frame->height; + NSDictionary *poolAttributes = @{ + (NSString *)kCVPixelBufferPoolMinimumBufferCountKey : @3, + }; + NSDictionary *pixelAttributes = @{ + (NSString *)kCVPixelBufferWidthKey : @(frame->width), + (NSString *)kCVPixelBufferHeightKey : @(frame->height), + (NSString *)kCVPixelBufferPixelFormatTypeKey : + @(kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange), + (NSString *)kCVPixelBufferIOSurfacePropertiesKey : @{}, + (NSString *)kCVPixelBufferMetalCompatibilityKey : @YES, + }; + CVPixelBufferPoolCreate(kCFAllocatorDefault, + (__bridge CFDictionaryRef)poolAttributes, + (__bridge CFDictionaryRef)pixelAttributes, + &_videoPool); + } + if (_videoPool) { + result = CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, + _videoPool, &pixel_buffer); + } + } + if (result != kCVReturnSuccess || !pixel_buffer) return; + + CVPixelBufferLockBaseAddress(pixel_buffer, 0); + const uint8_t *source_y = reinterpret_cast(frame->data); + const uint8_t *source_uv = source_y + y_size; + uint8_t *destination_y = static_cast( + CVPixelBufferGetBaseAddressOfPlane(pixel_buffer, 0)); + uint8_t *destination_uv = static_cast( + CVPixelBufferGetBaseAddressOfPlane(pixel_buffer, 1)); + const size_t y_stride = CVPixelBufferGetBytesPerRowOfPlane(pixel_buffer, 0); + const size_t uv_stride = CVPixelBufferGetBytesPerRowOfPlane(pixel_buffer, 1); + for (size_t row = 0; row < frame->height; ++row) { + std::memcpy(destination_y + row * y_stride, + source_y + row * frame->width, frame->width); + } + for (size_t row = 0; row < frame->height / 2; ++row) { + std::memcpy(destination_uv + row * uv_stride, + source_uv + row * frame->width, frame->width); + } + CVPixelBufferUnlockBaseAddress(pixel_buffer, 0); + CVBufferSetAttachment(pixel_buffer, kCVImageBufferYCbCrMatrixKey, + kCVImageBufferYCbCrMatrix_ITU_R_601_4, + kCVAttachmentMode_ShouldPropagate); + + const NSInteger frame_width = frame->width; + const NSInteger frame_height = frame->height; + if (_receivedVideoFrames.load() == 0) { + uint8_t minimum_luma = std::numeric_limits::max(); + uint8_t maximum_luma = std::numeric_limits::min(); + uint64_t luma_total = 0; + size_t luma_samples = 0; + const size_t row_step = std::max(1, frame->height / 96); + const size_t column_step = std::max(1, frame->width / 96); + for (size_t row = 0; row < frame->height; row += row_step) { + for (size_t column = 0; column < frame->width; column += column_step) { + const uint8_t luma = source_y[row * frame->width + column]; + minimum_luma = std::min(minimum_luma, luma); + maximum_luma = std::max(maximum_luma, luma); + luma_total += luma; + ++luma_samples; + } + } + const double average_luma = luma_samples + ? static_cast(luma_total) / + static_cast(luma_samples) + : 0.0; + NSLog(@"CrossDesk first-frame luma min=%u max=%u average=%.1f", + minimum_luma, maximum_luma, average_luma); + } + + [self enqueueVideoPixelBuffer:pixel_buffer + width:frame_width + height:frame_height]; + CVPixelBufferRelease(pixel_buffer); +} + +- (void)enqueueVideoPixelBuffer:(CVPixelBufferRef)pixelBuffer + width:(NSInteger)width + height:(NSInteger)height { + if (!pixelBuffer) return; + + CVPixelBufferRetain(pixelBuffer); + bool should_schedule = false; + uint64_t generation = 0; + { + std::lock_guard lock(_pendingVideoMutex); + if (_pendingVideoBuffer) { + CVPixelBufferRelease(_pendingVideoBuffer); + } + _pendingVideoBuffer = pixelBuffer; + _pendingVideoWidth = width; + _pendingVideoHeight = height; + generation = _videoDeliveryGeneration; + if (!_videoDeliveryScheduled) { + _videoDeliveryScheduled = true; + should_schedule = true; + } + } + + if (!should_schedule) return; + dispatch_async(dispatch_get_main_queue(), ^{ + CVPixelBufferRef latest_buffer = nullptr; + NSInteger latest_width = 0; + NSInteger latest_height = 0; + { + std::lock_guard lock(self->_pendingVideoMutex); + if (generation != self->_videoDeliveryGeneration) return; + latest_buffer = self->_pendingVideoBuffer; + latest_width = self->_pendingVideoWidth; + latest_height = self->_pendingVideoHeight; + self->_pendingVideoBuffer = nullptr; + self->_pendingVideoWidth = 0; + self->_pendingVideoHeight = 0; + self->_videoDeliveryScheduled = false; + } + + if (!latest_buffer) return; + const uint64_t frame_count = ++self->_receivedVideoFrames; + if (frame_count == 1 || frame_count % 300 == 0) { + NSLog(@"CrossDesk delivered latest frame %llu (%ld x %ld)", + static_cast(frame_count), (long)latest_width, + (long)latest_height); + } + id delegate = self.delegate; + if ([delegate respondsToSelector: + @selector(rtcBridge:didReceivePixelBuffer:width:height:)]) { + [delegate rtcBridge:self + didReceivePixelBuffer:latest_buffer + width:latest_width + height:latest_height]; + } + CVPixelBufferRelease(latest_buffer); + }); +} + +- (void)resetVideoDelivery { + CVPixelBufferRef pending_buffer = nullptr; + { + std::lock_guard lock(_pendingVideoMutex); + ++_videoDeliveryGeneration; + pending_buffer = _pendingVideoBuffer; + _pendingVideoBuffer = nullptr; + _pendingVideoWidth = 0; + _pendingVideoHeight = 0; + _videoDeliveryScheduled = false; + } + if (pending_buffer) CVPixelBufferRelease(pending_buffer); +} + +- (void)handleAudio:(const char *)data size:(size_t)size { + if (!data || size < sizeof(int16_t) || size % sizeof(int16_t) != 0) return; + NSData *pcm = [NSData dataWithBytes:data length:size]; + DispatchMain(^{ + id delegate = self.delegate; + if ([delegate respondsToSelector: + @selector(rtcBridge:didReceiveAudioPCM:)]) { + [delegate rtcBridge:self didReceiveAudioPCM:pcm]; + } + }); +} + +- (void)handleData:(const char *)data + size:(size_t)size + sourceID:(const char *)sourceID + sourceIDSize:(size_t)sourceIDSize { + if (!data || size == 0 || !sourceID) return; + const std::string source(sourceID, sourceIDSize); + if (source == kControlStream) { + RemoteAction action{}; + if (!action.from_json(std::string(data, size)) || + action.type != ControlType::host_infomation) { + return; + } + NSString *host_name = [[NSString alloc] + initWithBytes:action.i.host_name + length:action.i.host_name_size + encoding:NSUTF8StringEncoding]; + NSMutableArray *display_names = + [NSMutableArray arrayWithCapacity:action.i.display_num]; + NSMutableArray *display_sizes = + [NSMutableArray arrayWithCapacity:action.i.display_num]; + for (std::size_t index = 0; index < action.i.display_num; ++index) { + NSString *name = action.i.display_list && action.i.display_list[index] + ? [NSString stringWithUTF8String:action.i.display_list[index]] + : nil; + [display_names addObject:name ?: @""]; + const int width = action.i.left && action.i.right + ? std::max(action.i.right[index] - action.i.left[index], 0) + : 0; + const int height = action.i.top && action.i.bottom + ? std::max(action.i.bottom[index] - action.i.top[index], 0) + : 0; + [display_sizes addObject:[NSValue valueWithCGSize:CGSizeMake(width, + height)]]; + } + crossdesk::FreeRemoteAction(action); + DispatchMain(^{ + id delegate = self.delegate; + if ([delegate respondsToSelector: + @selector(rtcBridge:didReceiveHostName:displayNames: + displaySizes:)]) { + [delegate rtcBridge:self + didReceiveHostName:host_name ?: @"" + displayNames:display_names + displaySizes:display_sizes]; + } + }); + return; + } + + if (source == kMouseStream) { + RemoteAction action{}; + if (!action.from_json(std::string(data, size)) || + action.type != ControlType::cursor_state) { + return; + } + const BOOL visible = action.cs.visible; + const NSInteger shape = static_cast(action.cs.shape); + const BOOL position_update = action.cs.position_update; + const BOOL position_valid = action.cs.position_valid; + const float position_x = action.cs.x; + const float position_y = action.cs.y; + const float visual_offset_x = action.cs.visual_offset_x; + const float visual_offset_y = action.cs.visual_offset_y; + const NSInteger display_index = action.cs.display_id; + const uint32_t sequence = action.cs.seq; + DispatchMain(^{ + id delegate = self.delegate; + if ([delegate respondsToSelector: + @selector(rtcBridge:didReceiveCursorVisible:shape: + positionUpdate:positionValid:x:y: + visualOffsetX:visualOffsetY:displayIndex: + sequence:)]) { + [delegate rtcBridge:self + didReceiveCursorVisible:visible + shape:shape + positionUpdate:position_update + positionValid:position_valid + x:position_x + y:position_y + visualOffsetX:visual_offset_x + visualOffsetY:visual_offset_y + displayIndex:display_index + sequence:sequence]; + } + }); + return; + } + + if (source == kClipboardStream) { + if (size > kMaxClipboardBytes || std::memchr(data, '\0', size)) return; + NSString *text = [[NSString alloc] initWithBytes:data + length:size + encoding:NSUTF8StringEncoding]; + if (!text) return; + DispatchMain(^{ + id delegate = self.delegate; + if ([delegate respondsToSelector: + @selector(rtcBridge:didReceiveClipboardText:)]) { + [delegate rtcBridge:self didReceiveClipboardText:text]; + } + }); + return; + } + + if (source == kFileFeedbackStream) { + FileTransferAck ack{}; + if (!crossdesk::protocol::DecodeFileTransferAck(data, size, &ack)) return; + + NSString *name = nil; + double progress = 0; + { + std::lock_guard lock(_fileMutex); + auto it = _state->outgoing_files.find(ack.file_id); + if (it == _state->outgoing_files.end()) return; + name = [NSString stringWithUTF8String:it->second.name.c_str()]; + progress = ack.total_size == 0 + ? 1 + : std::clamp(static_cast(ack.acked_offset) / + static_cast(ack.total_size), + 0.0, 1.0); + if ((ack.flags & 0x03) != 0) { + if ((ack.flags & 0x02) != 0) progress = -1; + _state->outgoing_files.erase(it); + } + } + DispatchMain(^{ + id delegate = self.delegate; + if ([delegate respondsToSelector: + @selector(rtcBridge:didUpdateFileTransfer:progress:sending:)]) { + [delegate rtcBridge:self + didUpdateFileTransfer:name ?: @"file" + progress:progress + sending:YES]; + } + }); + return; + } + + if (source != kFileStream) return; + crossdesk::protocol::FileChunkView chunk; + if (!crossdesk::protocol::DecodeFileChunk(data, size, &chunk)) return; + const crossdesk::FileChunkHeader& header = chunk.header; + + NSURL *completed_url = nil; + NSString *progress_name = nil; + double progress = 0; + FileTransferAck ack{}; + ack.magic = kFileAckMagic; + ack.file_id = header.file_id; + ack.acked_offset = header.offset; + ack.total_size = header.total_size; + + { + std::lock_guard lock(_fileMutex); + auto it = _state->incoming_files.find(header.file_id); + if (it == _state->incoming_files.end()) { + if ((header.flags & 0x01) == 0) return; + NSString *raw_name = !chunk.file_name.empty() + ? [[NSString alloc] initWithBytes:chunk.file_name.data() + length:chunk.file_name.size() + encoding:NSUTF8StringEncoding] + : nil; + NSString *safe_name = raw_name.lastPathComponent; + safe_name = [safe_name stringByReplacingOccurrencesOfString:@"\\" + withString:@"_"]; + safe_name = [safe_name stringByReplacingOccurrencesOfString:@":" + withString:@"_"]; + if (safe_name.length == 0 || [safe_name isEqualToString:@"."] || + [safe_name isEqualToString:@".."]) { + safe_name = [NSString stringWithFormat:@"received_%u", header.file_id]; + } + + NSURL *documents = [[NSFileManager defaultManager] + URLsForDirectory:NSDocumentDirectory + inDomains:NSUserDomainMask].firstObject; + NSURL *directory = [documents URLByAppendingPathComponent:@"Received" + isDirectory:YES]; + [[NSFileManager defaultManager] createDirectoryAtURL:directory + withIntermediateDirectories:YES + attributes:nil + error:nil]; + NSURL *target = [directory URLByAppendingPathComponent:safe_name]; + if ([[NSFileManager defaultManager] fileExistsAtPath:target.path]) { + NSString *stem = safe_name.stringByDeletingPathExtension; + NSString *extension = safe_name.pathExtension; + NSString *unique = extension.length > 0 + ? [NSString stringWithFormat:@"%@_%u.%@", stem, header.file_id, + extension] + : [NSString stringWithFormat:@"%@_%u", stem, header.file_id]; + target = [directory URLByAppendingPathComponent:unique]; + safe_name = unique; + } + + FILE *output = std::fopen(target.path.fileSystemRepresentation, "wb+"); + if (!output) return; + IncomingFile incoming; + incoming.name = safe_name.UTF8String ?: "file"; + incoming.path = target.path.fileSystemRepresentation ?: ""; + incoming.total_size = header.total_size; + incoming.handle = output; + _state->incoming_files.emplace(header.file_id, std::move(incoming)); + it = _state->incoming_files.find(header.file_id); + } + + IncomingFile &incoming = it->second; + bool write_ok = incoming.total_size == header.total_size && + fseeko(incoming.handle, static_cast(header.offset), + SEEK_SET) == 0; + if (write_ok && chunk.payload_size > 0) { + write_ok = std::fwrite(chunk.payload, 1, chunk.payload_size, + incoming.handle) == chunk.payload_size; + } + if (!write_ok) { + ack.flags |= 0x02; + std::fclose(incoming.handle); + _state->incoming_files.erase(it); + } else { + ack.acked_offset = header.offset + chunk.payload_size; + incoming.received = std::max(incoming.received, ack.acked_offset); + progress_name = [NSString stringWithUTF8String:incoming.name.c_str()]; + progress = incoming.total_size == 0 + ? 1 + : std::clamp(static_cast(incoming.received) / + static_cast(incoming.total_size), + 0.0, 1.0); + const bool completed = (header.flags & 0x02) != 0 || + incoming.received >= incoming.total_size; + if (completed) { + ack.flags |= 0x01; + std::fflush(incoming.handle); + std::fclose(incoming.handle); + completed_url = [NSURL fileURLWithPath: + [NSString stringWithUTF8String:incoming.path.c_str()]]; + _state->incoming_files.erase(it); + } + } + } + + dispatch_async(_rtcQueue, ^{ + if (self->_state->controller_peer) { + const auto encoded_ack = + crossdesk::protocol::EncodeFileTransferAck(ack); + SendReliableDataFrame(self->_state->controller_peer, + encoded_ack.data(), encoded_ack.size(), + kFileFeedbackStream); + } + }); + if (progress_name) { + DispatchMain(^{ + id delegate = self.delegate; + if ([delegate respondsToSelector: + @selector(rtcBridge:didUpdateFileTransfer:progress:sending:)]) { + [delegate rtcBridge:self + didUpdateFileTransfer:progress_name + progress:progress + sending:NO]; + } + if (completed_url && + [delegate respondsToSelector: + @selector(rtcBridge:didReceiveFileAtURL:)]) { + [delegate rtcBridge:self didReceiveFileAtURL:completed_url]; + } + }); + } +} + +- (void)handleStats:(const XNetTrafficStats *)stats mode:(TraversalMode)mode { + if (!stats) return; + const NSUInteger bitrate = stats->total_inbound_stats.bitrate; + const float loss = stats->video_inbound_stats.loss_rate; + const BOOL using_turn = mode == TraversalMode::Relay; + DispatchMain(^{ + id delegate = self.delegate; + if ([delegate respondsToSelector: + @selector(rtcBridge:didUpdateBitrate:lossRate:usingTURN:)]) { + [delegate rtcBridge:self + didUpdateBitrate:bitrate + lossRate:loss + usingTURN:using_turn]; + } + }); +} + +@end diff --git a/ios/CrossDeskMobile/ContentView.swift b/ios/CrossDeskMobile/ContentView.swift new file mode 100644 index 0000000..bea5e37 --- /dev/null +++ b/ios/CrossDeskMobile/ContentView.swift @@ -0,0 +1,731 @@ +import SwiftUI +import UIKit + +struct ContentView: View { + @Environment(\.scenePhase) private var scenePhase + @ObservedObject var session: RemoteSessionModel + + var body: some View { + Group { + if session.sessionVisible { + RemoteSessionView(session: session) + .transition(.opacity) + } else { + ConnectionHomeView(session: session) + .transition(.opacity) + } + } + .animation(.easeInOut(duration: 0.2), value: session.sessionVisible) + .preferredColorScheme(session.sessionVisible ? .dark : .light) + .onAppear { + if !session.sessionVisible { + AppOrientation.update(to: .portrait) + } + if scenePhase == .active { + session.refreshRecentConnectionPresenceAfterForeground() + } + } + .onChange(of: scenePhase) { phase in + if phase == .active { + session.refreshRecentConnectionPresenceAfterForeground() + } + } + } +} + +private struct KeyboardDismissTapView: UIViewRepresentable { + let onDismiss: () -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(onDismiss: onDismiss) + } + + func makeUIView(context: Context) -> UIView { + let view = UIView(frame: .zero) + view.backgroundColor = .clear + view.isUserInteractionEnabled = false + installRecognizer(for: view, coordinator: context.coordinator) + return view + } + + func updateUIView(_ view: UIView, context: Context) { + context.coordinator.onDismiss = onDismiss + installRecognizer(for: view, coordinator: context.coordinator) + } + + static func dismantleUIView(_ view: UIView, coordinator: Coordinator) { + coordinator.uninstall() + } + + private func installRecognizer(for view: UIView, coordinator: Coordinator) { + DispatchQueue.main.async { [weak view, weak coordinator] in + guard let window = view?.window else { return } + coordinator?.install(in: window) + } + } + + final class Coordinator: NSObject, UIGestureRecognizerDelegate { + var onDismiss: () -> Void + private weak var window: UIWindow? + private var recognizer: UITapGestureRecognizer? + + init(onDismiss: @escaping () -> Void) { + self.onDismiss = onDismiss + } + + func install(in window: UIWindow) { + guard self.window !== window || recognizer == nil else { return } + uninstall() + + let recognizer = UITapGestureRecognizer(target: self, + action: #selector(didTapOutsideInput)) + recognizer.cancelsTouchesInView = false + recognizer.delegate = self + window.addGestureRecognizer(recognizer) + self.window = window + self.recognizer = recognizer + } + + func uninstall() { + if let recognizer { + window?.removeGestureRecognizer(recognizer) + } + recognizer = nil + window = nil + } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, + shouldReceive touch: UITouch) -> Bool { + var touchedView = touch.view + while let view = touchedView { + if view is UITextField || view is UITextView { + return false + } + touchedView = view.superview + } + return true + } + + @objc private func didTapOutsideInput() { + onDismiss() + window?.endEditing(true) + } + } +} + +private struct ConnectionHomeView: View { + @ObservedObject var session: RemoteSessionModel + @FocusState private var remoteIDFocused: Bool + @State private var passwordPromptVisible = false + @State private var settingsVisible = false + @State private var promptRemoteID = "" + @State private var promptPassword = "" + @State private var promptRememberPassword = false + + private let connectionAccent = Color(red: 0.18, green: 0.48, blue: 0.86) + private let connectionAccentEnd = Color(red: 0.12, green: 0.38, blue: 0.78) + + private var trimmedRemoteID: String { + session.remoteID.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var groupedRemoteID: Binding { + 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.. Void + let delete: () -> Void + + var body: some View { + Button(action: connect) { + VStack(alignment: .leading, spacing: 0) { + ZStack { + if let thumbnail { + Image(uiImage: thumbnail) + .resizable() + .scaledToFill() + } else { + LinearGradient(colors: [ + Color(red: 0.18, green: 0.48, blue: 0.86), + Color(red: 0.38, green: 0.68, blue: 0.96) + ], startPoint: .topLeading, endPoint: .bottomTrailing) + Image(systemName: "display") + .font(.system(size: 34, weight: .medium)) + .foregroundStyle(.white.opacity(0.9)) + } + } + .frame(maxWidth: .infinity) + .aspectRatio(16 / 9, contentMode: .fit) + .clipped() + + VStack(alignment: .leading, spacing: 3) { + Text(connection.displayName) + .font(.caption.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + HStack(spacing: 5) { + Text("ID \(connection.remoteID)") + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + .lineLimit(1) + Spacer(minLength: 4) + if connection.remembersPassword { + Image(systemName: "key.fill") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.secondary) + .accessibilityLabel("已保存密码") + } + Text(online ? "在线" : "离线") + .font(.caption2.weight(.semibold)) + .foregroundStyle(online ? Color.green : Color.secondary) + .accessibilityLabel(online ? "在线" : "离线") + } + } + .padding(9) + } + .background(Color(.secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Color(.separator).opacity(0.35), lineWidth: 1) + } + } + .buttonStyle(.plain) + .contextMenu { + Button(action: connect) { + Label("连接", systemImage: "arrow.right.circle") + } + Button(role: .destructive, action: delete) { + Label("删除记录", systemImage: "trash") + } + } + } + +} + +private struct PasswordPromptView: View { + let remoteID: String + @Binding var password: String + @Binding var rememberPassword: Bool + let connect: () -> Void + + @Environment(\.dismiss) private var dismiss + @FocusState private var passwordFocused: Bool + @State private var passwordVisible = false + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("连接远程桌面") + .font(.title2.bold()) + Text("对端 ID \(remoteID)") + .font(.subheadline.monospacedDigit()) + .foregroundStyle(.secondary) + } + Spacer() + Button { + dismiss() + } label: { + Image(systemName: "xmark.circle.fill") + .font(.title2) + .foregroundStyle(.secondary) + } + .accessibilityLabel("关闭") + } + + HStack(spacing: 10) { + Group { + if passwordVisible { + TextField("访问密码", text: $password) + } else { + SecureField("访问密码", text: $password) + } + } + .keyboardType(.numberPad) + .textContentType(.password) + .focused($passwordFocused) + + Button { + passwordVisible.toggle() + } label: { + Image(systemName: passwordVisible ? "eye.slash" : "eye") + .foregroundStyle(.secondary) + } + .accessibilityLabel(passwordVisible ? "隐藏密码" : "显示密码") + } + .padding(.horizontal, 13) + .frame(height: 50) + .background(Color(.secondarySystemGroupedBackground), + in: RoundedRectangle(cornerRadius: 10, + style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(Color(.separator).opacity(0.5), lineWidth: 1) + } + + Toggle(isOn: $rememberPassword) { + VStack(alignment: .leading, spacing: 2) { + Text("保存密码") + .font(.subheadline.weight(.semibold)) + Text("密码将安全保存在本机 Keychain 中") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Button(action: connect) { + Label("连接", systemImage: "arrow.right") + .font(.headline) + .frame(maxWidth: .infinity) + .frame(height: 48) + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.roundedRectangle(radius: 10)) + } + .padding(22) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { + passwordFocused = true + } + } + } +} + +private struct ServerSettingsView: View { + @ObservedObject var session: RemoteSessionModel + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + Form { + Section("鼠标控制") { + Picker("控制模式", selection: $session.mouseControlMode) { + ForEach(MouseControlMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .pickerStyle(.segmented) + + Text(session.mouseControlMode.detail) + .font(.footnote) + .foregroundStyle(.secondary) + } + Section("视频编解码") { + Picker("处理方式", selection: $session.videoCodecMode) { + ForEach(VideoCodecMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .pickerStyle(.segmented) + + Text(session.videoCodecMode.detail) + .font(.footnote) + .foregroundStyle(.secondary) + + Text("修改后从下一次连接开始生效。") + .font(.caption) + .foregroundStyle(.tertiary) + } + Section("服务器") { + TextField("信令服务器", text: $session.signalHost) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + TextField("信令端口", text: $session.signalPort) + .keyboardType(.numberPad) + TextField("STUN/TURN 端口", text: $session.turnPort) + .keyboardType(.numberPad) + } + Section { + Toggle("启用 SRTP", isOn: $session.enableSRTP) + } header: { + Text("传输") + } footer: { + Text(session.localIdentity.isEmpty + ? "正在获取本机 ID…" + : "本机 ID \(session.localIdentity)") + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 16) + .textSelection(.enabled) + } + } + .navigationTitle("设置") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("应用") { + session.configureBridge() + dismiss() + } + } + ToolbarItem(placement: .cancellationAction) { + Button("取消") { dismiss() } + } + } + } + } +} diff --git a/ios/CrossDeskMobile/CrossDeskMobile-Bridging-Header.h b/ios/CrossDeskMobile/CrossDeskMobile-Bridging-Header.h new file mode 100644 index 0000000..af0c570 --- /dev/null +++ b/ios/CrossDeskMobile/CrossDeskMobile-Bridging-Header.h @@ -0,0 +1 @@ +#import "Bridge/CrossDeskRTCBridge.h" diff --git a/ios/CrossDeskMobile/CrossDeskMobileApp.swift b/ios/CrossDeskMobile/CrossDeskMobileApp.swift new file mode 100644 index 0000000..11c16fb --- /dev/null +++ b/ios/CrossDeskMobile/CrossDeskMobileApp.swift @@ -0,0 +1,43 @@ +import SwiftUI +import UIKit + +final class CrossDeskAppDelegate: NSObject, UIApplicationDelegate { + static var supportedOrientations: UIInterfaceOrientationMask = .portrait + + func application(_ application: UIApplication, + supportedInterfaceOrientationsFor window: UIWindow?) + -> UIInterfaceOrientationMask { + Self.supportedOrientations + } +} + +enum AppOrientation { + static func update(to orientations: UIInterfaceOrientationMask) { + DispatchQueue.main.async { + CrossDeskAppDelegate.supportedOrientations = orientations + + for case let scene as UIWindowScene in UIApplication.shared.connectedScenes { + scene.windows.forEach { + $0.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations() + } + scene.requestGeometryUpdate( + .iOS(interfaceOrientations: orientations) + ) { error in + NSLog("CrossDesk orientation update failed: %@", error.localizedDescription) + } + } + } + } +} + +@main +struct CrossDeskMobileApp: App { + @UIApplicationDelegateAdaptor(CrossDeskAppDelegate.self) private var appDelegate + @StateObject private var session = RemoteSessionModel() + + var body: some Scene { + WindowGroup { + ContentView(session: session) + } + } +} diff --git a/ios/CrossDeskMobile/Info.plist b/ios/CrossDeskMobile/Info.plist new file mode 100644 index 0000000..3e76d4b --- /dev/null +++ b/ios/CrossDeskMobile/Info.plist @@ -0,0 +1,53 @@ + + + + + CFBundleDisplayName + CrossDesk + CFBundleDevelopmentRegion + zh_CN + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 2 + LSRequiresIPhoneOS + + NSLocalNetworkUsageDescription + CrossDesk 使用局域网发现并建立原生 P2P 远程控制连接。 + UIApplicationSupportsIndirectInputEvents + + UIFileSharingEnabled + + LSSupportsOpeningDocumentsInPlace + + UILaunchScreen + + UIRequiredDeviceCapabilities + + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/ios/CrossDeskMobile/Input/RemoteTouchInputView.swift b/ios/CrossDeskMobile/Input/RemoteTouchInputView.swift new file mode 100644 index 0000000..23c4b73 --- /dev/null +++ b/ios/CrossDeskMobile/Input/RemoteTouchInputView.swift @@ -0,0 +1,502 @@ +import SwiftUI +import UIKit + +enum RemoteVideoGeometry { + private static func pixelAligned(_ rect: CGRect) -> CGRect { + let scale = max(UIScreen.main.scale, 1) + let minX = (rect.minX * scale).rounded() / scale + let minY = (rect.minY * scale).rounded() / scale + let maxX = (rect.maxX * scale).rounded() / scale + let maxY = (rect.maxY * scale).rounded() / scale + return CGRect(x: minX, y: minY, + width: max(0, maxX - minX), + height: max(0, maxY - minY)) + } + + static func aspectFitRect(containerSize: CGSize, + videoSize: CGSize) -> CGRect? { + guard containerSize.width > 0, containerSize.height > 0, + videoSize.width > 0, videoSize.height > 0 else { return nil } + let videoAspect = videoSize.width / videoSize.height + let containerAspect = containerSize.width / containerSize.height + if videoAspect > containerAspect { + let height = containerSize.width / videoAspect + return pixelAligned( + CGRect(x: 0, y: (containerSize.height - height) / 2, + width: containerSize.width, height: height) + ) + } + let width = containerSize.height * videoAspect + return pixelAligned( + CGRect(x: (containerSize.width - width) / 2, y: 0, + width: width, height: containerSize.height) + ) + } + + static func transformedRect(containerSize: CGSize, + videoSize: CGSize, + scale: CGFloat, + offset: CGSize) -> CGRect? { + guard let baseRect = aspectFitRect(containerSize: containerSize, + videoSize: videoSize) else { + return nil + } + // SwiftUI scales the video around the center of its own pixel-aligned + // frame. That center can differ fractionally from the container center + // after edge alignment, and the difference is amplified at 10x. + let center = CGPoint(x: baseRect.midX, y: baseRect.midY) + return CGRect( + x: center.x + (baseRect.minX - center.x) * scale + offset.width, + y: center.y + (baseRect.minY - center.y) * scale + offset.height, + width: baseRect.width * scale, + height: baseRect.height * scale + ) + } +} + +struct RemoteTouchInputView: UIViewRepresentable { + let videoSize: CGSize + let controlMode: MouseControlMode + let remoteCursorPosition: CGPoint? + let viewportScale: CGFloat + let viewportOffset: CGSize + let viewportChanged: (CGFloat, CGSize) -> Void + let move: (Float, Float) -> Void + let leftDown: (Float, Float) -> Void + let leftUp: (Float, Float) -> Void + let rightClick: (Float, Float) -> Void + let scroll: (Float, Float, Int, Int) -> Void + + func makeUIView(context: Context) -> RemoteTouchSurface { + RemoteTouchSurface() + } + + func updateUIView(_ view: RemoteTouchSurface, context: Context) { + view.videoSize = videoSize + view.controlMode = controlMode + view.setViewport(scale: viewportScale, offset: viewportOffset) + view.onViewportChanged = viewportChanged + view.onMove = move + view.onLeftDown = leftDown + view.onLeftUp = leftUp + view.onRightClick = rightClick + view.onScroll = scroll + view.synchronizeRemoteCursor(remoteCursorPosition) + } +} + +final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate { + var videoSize = CGSize.zero { + didSet { setNeedsLayout() } + } + var controlMode: MouseControlMode = .absolute { + didSet { + guard oldValue != controlMode else { return } + relativeCursorPoint = lastRemoteCursorPoint ?? (0.5, 0.5) + relativePanActive = false + heldDragPoint = nil + heldDragLastLocation = nil + hoverLastLocation = nil + } + } + var onMove: ((Float, Float) -> Void)? + var onLeftDown: ((Float, Float) -> Void)? + var onLeftUp: ((Float, Float) -> Void)? + var onRightClick: ((Float, Float) -> Void)? + var onScroll: ((Float, Float, Int, Int) -> Void)? + var onViewportChanged: ((CGFloat, CGSize) -> Void)? + + private var relativeCursorPoint: (Float, Float) = (0.5, 0.5) + private var lastRemoteCursorPoint: (Float, Float)? + private var relativePanActive = false + private var heldDragPoint: (Float, Float)? + private var heldDragLastLocation: CGPoint? + private var hoverLastLocation: CGPoint? + private var viewportScale: CGFloat = 1 + private var viewportOffset = CGSize.zero + private var pinchActive = false + private var viewportPanActive = false + private let relativePointerSpeed: CGFloat = 1.35 + private let maximumViewportScale: CGFloat = 10 + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + isMultipleTouchEnabled = true + + let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) + tap.numberOfTouchesRequired = 1 + addGestureRecognizer(tap) + + let rightTap = UITapGestureRecognizer(target: self, action: #selector(handleRightTap(_:))) + rightTap.numberOfTouchesRequired = 2 + tap.require(toFail: rightTap) + addGestureRecognizer(rightTap) + + let drag = UIPanGestureRecognizer(target: self, action: #selector(handleDrag(_:))) + drag.minimumNumberOfTouches = 1 + drag.maximumNumberOfTouches = 1 + drag.delegate = self + addGestureRecognizer(drag) + + let heldDrag = UILongPressGestureRecognizer( + target: self, + action: #selector(handleHeldDrag(_:)) + ) + heldDrag.minimumPressDuration = 0.35 + heldDrag.allowableMovement = 14 + heldDrag.numberOfTouchesRequired = 1 + heldDrag.delegate = self + addGestureRecognizer(heldDrag) + + let pinch = UIPinchGestureRecognizer(target: self, + action: #selector(handlePinch(_:))) + pinch.delegate = self + addGestureRecognizer(pinch) + + let wheel = UIPanGestureRecognizer(target: self, action: #selector(handleWheel(_:))) + wheel.minimumNumberOfTouches = 2 + wheel.maximumNumberOfTouches = 2 + wheel.delegate = self + addGestureRecognizer(wheel) + + let hover = UIHoverGestureRecognizer(target: self, + action: #selector(handleHover(_:))) + addGestureRecognizer(hover) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + let constrained = constrainedViewportOffset(viewportOffset, + scale: viewportScale) + guard constrained != viewportOffset else { return } + viewportOffset = constrained + publishViewport() + } + + func setViewport(scale: CGFloat, offset: CGSize) { + let nextScale = min(max(scale, 1), maximumViewportScale) + viewportScale = nextScale + viewportOffset = constrainedViewportOffset(offset, scale: nextScale) + } + + func synchronizeRemoteCursor(_ position: CGPoint?) { + guard let position else { + lastRemoteCursorPoint = nil + return + } + let point = ( + Float(min(max(position.x, 0), 1)), + Float(min(max(position.y, 0), 1)) + ) + lastRemoteCursorPoint = point + applyRemoteCursorIfIdle() + } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + let recognizers = [gestureRecognizer, otherGestureRecognizer] + let hasPinch = recognizers.contains { $0 is UIPinchGestureRecognizer } + let hasTwoFingerPan = recognizers.contains { recognizer in + guard let pan = recognizer as? UIPanGestureRecognizer else { + return false + } + return pan.minimumNumberOfTouches == 2 + } + return hasPinch && hasTwoFingerPan + } + + @objc private func handleTap(_ recognizer: UITapGestureRecognizer) { + let location = recognizer.location(in: self) + guard recognizer.state == .ended, + let point = pointerPoint(for: location) else { return } +#if DEBUG + NSLog("CrossDesk tap location=%@ normalized=(%.5f, %.5f) " + + "render=%@ scale=%.4f offset=%@ mode=%@", + String(describing: location), point.0, point.1, + renderRect().map { String(describing: $0) } ?? "nil", + viewportScale, String(describing: viewportOffset), + controlMode == .relative ? "relative" : "absolute") +#endif + onMove?(point.0, point.1) + onLeftDown?(point.0, point.1) + onLeftUp?(point.0, point.1) + } + + @objc private func handleRightTap(_ recognizer: UITapGestureRecognizer) { + guard recognizer.state == .ended, + let point = pointerPoint(for: recognizer.location(in: self)) else { return } + onMove?(point.0, point.1) + onRightClick?(point.0, point.1) + } + + @objc private func handleDrag(_ recognizer: UIPanGestureRecognizer) { + if controlMode == .relative { + handleRelativeDrag(recognizer) + return + } + + switch recognizer.state { + case .began, .changed: + guard let point = normalized(recognizer.location(in: self)) else { return } + onMove?(point.0, point.1) + default: + break + } + } + + @objc private func handleHeldDrag(_ recognizer: UILongPressGestureRecognizer) { + if controlMode == .relative { + handleRelativeHeldDrag(recognizer) + return + } + + let point = normalized(recognizer.location(in: self)) ?? heldDragPoint + switch recognizer.state { + case .began: + guard let point else { return } + heldDragPoint = point + onMove?(point.0, point.1) + onLeftDown?(point.0, point.1) + case .changed: + guard let point else { return } + heldDragPoint = point + onMove?(point.0, point.1) + case .ended, .cancelled, .failed: + if let point { + onLeftUp?(point.0, point.1) + } + heldDragPoint = nil + default: + break + } + } + + @objc private func handlePinch(_ recognizer: UIPinchGestureRecognizer) { + let focus = recognizer.location(in: self) + switch recognizer.state { + case .began: + pinchActive = renderRect()?.contains(focus) == true + recognizer.scale = 1 + case .changed: + guard pinchActive else { return } + let oldScale = viewportScale + let newScale = min(max(oldScale * recognizer.scale, 1), + maximumViewportScale) + recognizer.scale = 1 + guard abs(newScale - oldScale) > 0.0001 else { return } + + let center = CGPoint(x: bounds.midX, y: bounds.midY) + let ratio = newScale / oldScale + let proposedOffset = CGSize( + width: focus.x - center.x - + (focus.x - center.x - viewportOffset.width) * ratio, + height: focus.y - center.y - + (focus.y - center.y - viewportOffset.height) * ratio + ) + viewportScale = newScale + viewportOffset = constrainedViewportOffset(proposedOffset, + scale: newScale) + publishViewport() + case .ended, .cancelled, .failed: + pinchActive = false + if viewportScale <= 1.001 { + viewportScale = 1 + viewportOffset = .zero + publishViewport() + } + default: + break + } + } + + @objc private func handleWheel(_ recognizer: UIPanGestureRecognizer) { + if viewportScale > 1.001 || pinchActive { + switch recognizer.state { + case .began: + viewportPanActive = renderRect()?.contains( + recognizer.location(in: self) + ) == true + recognizer.setTranslation(.zero, in: self) + case .changed: + if !viewportPanActive { + viewportPanActive = renderRect()?.contains( + recognizer.location(in: self) + ) == true + } + guard viewportPanActive else { return } + let translation = recognizer.translation(in: self) + recognizer.setTranslation(.zero, in: self) + let proposedOffset = CGSize( + width: viewportOffset.width + translation.x, + height: viewportOffset.height + translation.y + ) + viewportOffset = constrainedViewportOffset(proposedOffset, + scale: viewportScale) + publishViewport() + default: + viewportPanActive = false + } + return + } + + guard let point = pointerPoint(for: recognizer.location(in: self)) else { return } + let translation = recognizer.translation(in: self) + let deltaX = Int(-translation.x / 12) + let deltaY = Int(-translation.y / 12) + if deltaX != 0 || deltaY != 0 { + onScroll?(point.0, point.1, deltaX, deltaY) + recognizer.setTranslation(.zero, in: self) + } + } + + @objc private func handleHover(_ recognizer: UIHoverGestureRecognizer) { + let location = recognizer.location(in: self) + if controlMode == .relative { + switch recognizer.state { + case .began: + guard renderRect()?.contains(location) == true else { return } + hoverLastLocation = location + onMove?(relativeCursorPoint.0, relativeCursorPoint.1) + case .changed: + guard let previous = hoverLastLocation else { return } + hoverLastLocation = location + if let point = updateRelativeCursor( + by: CGPoint(x: location.x - previous.x, + y: location.y - previous.y) + ) { + onMove?(point.0, point.1) + } + default: + hoverLastLocation = nil + } + return + } + + guard recognizer.state == .began || recognizer.state == .changed, + let point = normalized(location) else { return } + onMove?(point.0, point.1) + } + + private func handleRelativeDrag(_ recognizer: UIPanGestureRecognizer) { + switch recognizer.state { + case .began: + let location = recognizer.location(in: self) + relativePanActive = renderRect()?.contains(location) == true + recognizer.setTranslation(.zero, in: self) + if relativePanActive { + onMove?(relativeCursorPoint.0, relativeCursorPoint.1) + } + case .changed: + guard relativePanActive else { return } + let translation = recognizer.translation(in: self) + recognizer.setTranslation(.zero, in: self) + if let point = updateRelativeCursor(by: translation) { + onMove?(point.0, point.1) + } + default: + relativePanActive = false + applyRemoteCursorIfIdle() + } + } + + private func handleRelativeHeldDrag(_ recognizer: UILongPressGestureRecognizer) { + let location = recognizer.location(in: self) + switch recognizer.state { + case .began: + guard renderRect()?.contains(location) == true else { return } + heldDragLastLocation = location + heldDragPoint = relativeCursorPoint + onMove?(relativeCursorPoint.0, relativeCursorPoint.1) + onLeftDown?(relativeCursorPoint.0, relativeCursorPoint.1) + case .changed: + guard let previous = heldDragLastLocation else { return } + heldDragLastLocation = location + if let point = updateRelativeCursor( + by: CGPoint(x: location.x - previous.x, + y: location.y - previous.y) + ) { + heldDragPoint = point + onMove?(point.0, point.1) + } + case .ended, .cancelled, .failed: + if let point = heldDragPoint { + onLeftUp?(point.0, point.1) + } + heldDragPoint = nil + heldDragLastLocation = nil + applyRemoteCursorIfIdle() + default: + break + } + } + + private func pointerPoint(for location: CGPoint) -> (Float, Float)? { + if controlMode == .relative { + guard renderRect()?.contains(location) == true else { return nil } + return relativeCursorPoint + } + return normalized(location) + } + + private func updateRelativeCursor(by translation: CGPoint) -> (Float, Float)? { + guard let renderRect = renderRect(), + renderRect.width > 0, renderRect.height > 0 else { return nil } + let nextX = CGFloat(relativeCursorPoint.0) + + translation.x / renderRect.width * relativePointerSpeed + let nextY = CGFloat(relativeCursorPoint.1) + + translation.y / renderRect.height * relativePointerSpeed + relativeCursorPoint = ( + Float(min(max(nextX, 0), 1)), + Float(min(max(nextY, 0), 1)) + ) + return relativeCursorPoint + } + + private func applyRemoteCursorIfIdle() { + guard controlMode == .relative, + !relativePanActive, + heldDragPoint == nil, + let point = lastRemoteCursorPoint else { return } + relativeCursorPoint = point + } + + private func normalized(_ point: CGPoint) -> (Float, Float)? { + guard let renderRect = renderRect(), renderRect.contains(point) else { return nil } + return (Float((point.x - renderRect.minX) / renderRect.width), + Float((point.y - renderRect.minY) / renderRect.height)) + } + + private func renderRect() -> CGRect? { + RemoteVideoGeometry.transformedRect( + containerSize: bounds.size, + videoSize: videoSize, + scale: viewportScale, + offset: viewportOffset + ) + } + + private func baseRenderRect() -> CGRect? { + RemoteVideoGeometry.aspectFitRect(containerSize: bounds.size, + videoSize: videoSize) + } + + private func constrainedViewportOffset(_ offset: CGSize, + scale: CGFloat) -> CGSize { + guard let baseRect = baseRenderRect() else { return offset } + let maximumX = max(0, baseRect.width * (scale - 1) / 2) + let maximumY = max(0, baseRect.height * (scale - 1) / 2) + return CGSize( + width: min(max(offset.width, -maximumX), maximumX), + height: min(max(offset.height, -maximumY), maximumY) + ) + } + + private func publishViewport() { + onViewportChanged?(viewportScale, viewportOffset) + } +} diff --git a/ios/CrossDeskMobile/RemoteSessionModel.swift b/ios/CrossDeskMobile/RemoteSessionModel.swift new file mode 100644 index 0000000..35e6b5b --- /dev/null +++ b/ios/CrossDeskMobile/RemoteSessionModel.swift @@ -0,0 +1,875 @@ +import AVFoundation +import CoreImage +import CoreVideo +import Foundation +import Security +import UIKit + +enum MouseControlMode: String, CaseIterable, Identifiable { + case relative + case absolute + + private static let defaultsKey = "crossdesk.mobile.mouse-control-mode" + + var id: String { rawValue } + + var title: String { + switch self { + case .absolute: return "绝对位置" + case .relative: return "相对位置" + } + } + + var detail: String { + switch self { + case .absolute: + return "触摸位置直接对应远端屏幕位置,适合快速定位。" + case .relative: + return "像触控板一样滑动光标,点击时操作当前光标位置。" + } + } + + static var saved: MouseControlMode { + guard let rawValue = UserDefaults.standard.string(forKey: defaultsKey), + let mode = MouseControlMode(rawValue: rawValue) else { + return .relative + } + return mode + } + + func save() { + UserDefaults.standard.set(rawValue, forKey: Self.defaultsKey) + } +} + +enum VideoCodecMode: String, CaseIterable, Identifiable { + case hardware + case software + + private static let defaultsKey = "crossdesk.mobile.video-codec-mode" + + var id: String { rawValue } + + var title: String { + switch self { + case .hardware: return "硬件" + case .software: return "软件" + } + } + + var detail: String { + switch self { + case .hardware: + return "使用 VideoToolbox,延迟和耗电更低,推荐日常使用。" + case .software: + return "使用 OpenH264 软件编解码,适合兼容性测试。" + } + } + + static var saved: VideoCodecMode { + guard let rawValue = UserDefaults.standard.string(forKey: defaultsKey), + let mode = VideoCodecMode(rawValue: rawValue) else { + return .hardware + } + return mode + } + + func save() { + UserDefaults.standard.set(rawValue, forKey: Self.defaultsKey) + } +} + +private struct RemoteVideoFrame { + let pixelBuffer: CVPixelBuffer + let encodedSize: CGSize +} + +struct RecentConnection: Codable, Identifiable, Equatable { + let remoteID: String + var displayName: String + var lastConnectedAt: Date + var remembersPassword: Bool + var thumbnailFileName: String? + + var id: String { remoteID } +} + +private enum RecentConnectionStore { + private static let defaultsKey = "crossdesk.mobile.recent-connections.v1" + private static let thumbnailQueue = + DispatchQueue(label: "cn.crossdesk.mobile.thumbnails", qos: .utility) + private static let context = CIContext(options: [.cacheIntermediates: false]) + + static func load() -> [RecentConnection] { + guard let data = UserDefaults.standard.data(forKey: defaultsKey), + let connections = try? JSONDecoder().decode([RecentConnection].self, + from: data) else { + return [] + } + return connections.sorted { $0.lastConnectedAt > $1.lastConnectedAt } + } + + static func save(_ connections: [RecentConnection]) { + guard let data = try? JSONEncoder().encode(connections) else { return } + UserDefaults.standard.set(data, forKey: defaultsKey) + } + + static func thumbnailURL(fileName: String) -> URL? { + guard let directory = thumbnailDirectory() else { return nil } + return directory.appendingPathComponent(fileName, isDirectory: false) + } + + static func captureThumbnail(from pixelBuffer: CVPixelBuffer, + remoteID: String, + completion: @escaping (String?) -> Void) { + thumbnailQueue.async { + let fileName: String? = autoreleasepool { + let image = CIImage(cvPixelBuffer: pixelBuffer) + let targetSize = CGSize(width: 640, height: 360) + guard image.extent.width > 0, image.extent.height > 0 else { + return nil + } + + // Scale in Core Image before materializing a CGImage. Creating + // a full 4K bitmap and then drawing it into a thumbnail can add + // tens of megabytes to the first-frame memory peak. + let scale = max(targetSize.width / image.extent.width, + targetSize.height / image.extent.height) + let scaled = image.transformed(by: CGAffineTransform( + scaleX: scale, + y: scale + )) + let cropRect = CGRect( + x: scaled.extent.midX - targetSize.width / 2, + y: scaled.extent.midY - targetSize.height / 2, + width: targetSize.width, + height: targetSize.height + ).integral + guard let thumbnailImage = context.createCGImage( + scaled.cropped(to: cropRect), + from: cropRect + ) else { + return nil + } + + let safeID = remoteID.filter { + $0.isLetter || $0.isNumber || $0 == "-" + } + let name = "\(safeID.isEmpty ? "remote" : safeID).jpg" + guard let url = thumbnailURL(fileName: name), + let data = UIImage(cgImage: thumbnailImage) + .jpegData(compressionQuality: 0.78) else { + return nil + } + + do { + try data.write(to: url, options: .atomic) + return name + } catch { + return nil + } + } + DispatchQueue.main.async { completion(fileName) } + } + } + + static func removeThumbnail(fileName: String?) { + guard let fileName, let url = thumbnailURL(fileName: fileName) else { return } + try? FileManager.default.removeItem(at: url) + } + + private static func thumbnailDirectory() -> URL? { + guard let base = FileManager.default.urls(for: .applicationSupportDirectory, + in: .userDomainMask).first else { + return nil + } + let directory = base.appendingPathComponent("RecentConnectionThumbnails", + isDirectory: true) + do { + try FileManager.default.createDirectory(at: directory, + withIntermediateDirectories: true) + return directory + } catch { + return nil + } + } +} + +private enum ConnectionCredentialStore { + private static let service = "cn.crossdesk.mobile.saved-password" + + static func password(for remoteID: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: remoteID, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var result: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let data = result as? Data else { + return nil + } + return String(data: data, encoding: .utf8) + } + + static func save(password: String, for remoteID: String) { + removePassword(for: remoteID) + let attributes: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: remoteID, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + kSecValueData as String: Data(password.utf8) + ] + SecItemAdd(attributes as CFDictionary, nil) + } + + static func removePassword(for remoteID: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: remoteID + ] + SecItemDelete(query as CFDictionary) + } +} + +private final class RemoteAudioPlayer { + private let engine = AVAudioEngine() + private let player = AVAudioPlayerNode() + private let queue = DispatchQueue(label: "cn.crossdesk.mobile.audio") + private let format = AVAudioFormat(commonFormat: .pcmFormatInt16, + sampleRate: 48_000, + channels: 1, + interleaved: false)! + private var enabled = true + private var queuedBuffers = 0 + + init() { + engine.attach(player) + engine.connect(player, to: engine.mainMixerNode, format: format) + } + + func setEnabled(_ value: Bool) { + queue.async { + self.enabled = value + if value { + self.startIfNeeded() + } else { + self.player.stop() + self.engine.stop() + self.queuedBuffers = 0 + } + } + } + + func enqueue(_ data: Data) { + guard !data.isEmpty, data.count.isMultiple(of: MemoryLayout.size) else { return } + queue.async { + guard self.enabled, self.queuedBuffers < 80 else { return } + self.startIfNeeded() + guard self.engine.isRunning, + let buffer = AVAudioPCMBuffer(pcmFormat: self.format, + frameCapacity: AVAudioFrameCount(data.count / 2)), + let samples = buffer.int16ChannelData?[0] else { return } + buffer.frameLength = buffer.frameCapacity + data.withUnsafeBytes { bytes in + guard let source = bytes.baseAddress else { return } + memcpy(samples, source, data.count) + } + self.queuedBuffers += 1 + self.player.scheduleBuffer(buffer) { + self.queue.async { + self.queuedBuffers = max(0, self.queuedBuffers - 1) + } + } + if !self.player.isPlaying { + self.player.play() + } + } + } + + private func startIfNeeded() { + guard enabled, !engine.isRunning else { return } + do { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playback, mode: .moviePlayback, + options: [.mixWithOthers]) + try session.setActive(true) + try engine.start() + player.play() + } catch { + // The next PCM packet retries activation after route changes. + } + } +} + +final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDelegate { + @Published var signalHost = "api.crossdesk.cn" + @Published var signalPort = "9099" + @Published var turnPort = "3478" + @Published var enableSRTP = false + @Published var mouseControlMode = MouseControlMode.saved { + didSet { mouseControlMode.save() } + } + @Published var videoCodecMode = VideoCodecMode.saved { + didSet { videoCodecMode.save() } + } + @Published var remoteID = "" + @Published var password = "" + @Published private(set) var signalStatus = "正在连接信令服务" + @Published private(set) var connectionStatus = "未连接" + @Published private(set) var localIdentity = "" + @Published private(set) var isConnecting = false + @Published private(set) var isConnected = false + @Published private(set) var sessionVisible = false + @Published private var videoFrame: RemoteVideoFrame? + @Published private(set) var remoteCursorVisible = true + @Published private(set) var remoteCursorShape = 0 + @Published private(set) var remoteCursorPosition: CGPoint? + @Published private(set) var remoteCursorVisualOffset = CGPoint.zero + @Published private(set) var hasRemoteCursorState = false + @Published private(set) var bitrate: UInt = 0 + @Published private(set) var lossRate: Float = 0 + @Published private(set) var usingTURN = false + @Published private(set) var displays: [String] = ["显示器 1"] + @Published private(set) var displaySizes: [CGSize] = [] + @Published var selectedDisplay = 0 + @Published var audioEnabled = true + @Published private(set) var clipboardStatus = "" + @Published private(set) var transferStatus = "" + @Published private(set) var transferProgress = 0.0 + @Published private(set) var receivedFileURL: URL? + @Published private(set) var frameCount: UInt64 = 0 + @Published private(set) var recentConnections = RecentConnectionStore.load() + @Published private(set) var recentConnectionPresence: [String: Bool] = [:] + @Published private(set) var deviceOfflineAlertVisible = false + + let bridge = CrossDeskRTCBridge() + private let audioPlayer = RemoteAudioPlayer() + private var activeRemoteID = "" + private var pendingRememberPassword = false + private var connectionRecorded = false + private var shouldCaptureThumbnail = false + private var activeDisplayName = "" + private var remoteCursorSequence: UInt32? + private var pendingPresenceRemoteID: String? + private var presenceProbeGeneration: UInt64 = 0 + + var pixelBuffer: CVPixelBuffer? { videoFrame?.pixelBuffer } + var frameSize: CGSize { videoFrame?.encodedSize ?? .zero } + var displayGeometrySize: CGSize { + guard displaySizes.indices.contains(selectedDisplay) else { + return frameSize + } + let size = displaySizes[selectedDisplay] + return size.width > 0 && size.height > 0 ? size : frameSize + } + + override init() { + super.init() + bridge.delegate = self + configureBridge() + } + + func configureBridge() { + guard let signal = Int(signalPort), let turn = Int(turnPort), + !signalHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + signalStatus = "服务器配置无效" + return + } + bridge.setHardwareAccelerationEnabled(videoCodecMode == .hardware) + bridge.configure(withSignalHost: signalHost, + signalPort: signal, + turnPort: turn, + enableSRTP: enableSRTP) + } + + func connect(password: String, rememberPassword: Bool) { + self.password = password + pendingRememberPassword = rememberPassword + connect() + } + + func connect() { + let identifier = remoteID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !identifier.isEmpty else { + connectionStatus = "请输入远程设备 ID" + return + } + remoteID = identifier + configureBridge() + if recentConnectionPresence[identifier] == true { + beginRemoteConnection(identifier) + } else { + beginPresenceProbe(identifier) + } + } + + private func beginRemoteConnection(_ identifier: String) { + cancelPresenceProbe() + activeRemoteID = identifier + activeDisplayName = "" + displaySizes = [] + connectionRecorded = false + shouldCaptureThumbnail = true + isConnecting = true + isConnected = false + sessionVisible = false + resetRemoteCursorState() + connectionStatus = "正在准备连接…" + bridge.connect(toRemoteID: identifier, password: password) + } + + private func beginPresenceProbe(_ identifier: String) { + guard signalStatus == "已连接服务器" else { + showDeviceOffline() + return + } + + presenceProbeGeneration &+= 1 + let generation = presenceProbeGeneration + pendingPresenceRemoteID = identifier + isConnecting = true + isConnected = false + sessionVisible = false + connectionStatus = "正在确认设备状态…" + bridge.requestPresence(remoteIDs: [identifier]) + + DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [weak self] in + guard let self, + self.presenceProbeGeneration == generation, + self.pendingPresenceRemoteID == identifier else { + return + } + self.cancelPresenceProbe() + self.refreshRecentConnectionPresence() + self.showDeviceOffline() + } + } + + private func cancelPresenceProbe() { + presenceProbeGeneration &+= 1 + pendingPresenceRemoteID = nil + } + + private func showDeviceOffline() { + isConnecting = false + isConnected = false + sessionVisible = false + connectionStatus = "设备离线" + deviceOfflineAlertVisible = true + } + + func dismissDeviceOfflineAlert() { + deviceOfflineAlertVisible = false + } + + func disconnect() { + let wasCheckingPresence = pendingPresenceRemoteID != nil + cancelPresenceProbe() + if wasCheckingPresence { + refreshRecentConnectionPresence() + } + bridge.disconnect() + isConnecting = false + isConnected = false + sessionVisible = false + videoFrame = nil + resetRemoteCursorState() + frameCount = 0 + bitrate = 0 + displays = ["显示器 1"] + displaySizes = [] + selectedDisplay = 0 + connectionStatus = "未连接" + audioPlayer.setEnabled(false) + AppOrientation.update(to: .portrait) + } + + func retry() { + bridge.disconnect() + connect() + } + + func savedPassword(for remoteID: String) -> String { + ConnectionCredentialStore.password(for: remoteID) ?? "" + } + + func savedCredential(for remoteID: String) -> String? { + ConnectionCredentialStore.password(for: remoteID) + } + + func remembersPassword(for remoteID: String) -> Bool { + recentConnections.first(where: { $0.remoteID == remoteID })? + .remembersPassword == true + } + + func thumbnailImage(for connection: RecentConnection) -> UIImage? { + guard let fileName = connection.thumbnailFileName, + let url = RecentConnectionStore.thumbnailURL(fileName: fileName) else { + return nil + } + return UIImage(contentsOfFile: url.path) + } + + func isRecentConnectionOnline(_ connection: RecentConnection) -> Bool { + recentConnectionPresence[connection.remoteID] == true + } + + private func refreshRecentConnectionPresence() { + bridge.requestPresence(remoteIDs: recentConnections.map(\.remoteID)) + } + + func refreshRecentConnectionPresenceAfterForeground() { + // iOS can suspend the process while it is in the background, so presence + // updates received during that time are missed. Treat cached values as + // unknown (and therefore offline, like the desktop client) until the + // signaling server returns a fresh snapshot. + recentConnectionPresence = [:] + if let pendingPresenceRemoteID { + bridge.requestPresence(remoteIDs: [pendingPresenceRemoteID]) + } else { + refreshRecentConnectionPresence() + } + } + + func removeRecentConnection(_ connection: RecentConnection) { + recentConnections.removeAll { $0.remoteID == connection.remoteID } + recentConnectionPresence.removeValue(forKey: connection.remoteID) + RecentConnectionStore.save(recentConnections) + RecentConnectionStore.removeThumbnail(fileName: connection.thumbnailFileName) + ConnectionCredentialStore.removePassword(for: connection.remoteID) + refreshRecentConnectionPresence() + } + + private func recordSuccessfulConnectionIfNeeded() { + guard !connectionRecorded, !activeRemoteID.isEmpty else { return } + connectionRecorded = true + + if pendingRememberPassword { + ConnectionCredentialStore.save(password: password, for: activeRemoteID) + } else { + ConnectionCredentialStore.removePassword(for: activeRemoteID) + } + + let previous = recentConnections.first { $0.remoteID == activeRemoteID } + let title = activeDisplayName.isEmpty + ? (previous?.displayName ?? activeRemoteID) + : activeDisplayName + let connection = RecentConnection( + remoteID: activeRemoteID, + displayName: title, + lastConnectedAt: Date(), + remembersPassword: pendingRememberPassword, + thumbnailFileName: previous?.thumbnailFileName + ) + recentConnections.removeAll { $0.remoteID == activeRemoteID } + recentConnections.insert(connection, at: 0) + recentConnectionPresence[activeRemoteID] = true + RecentConnectionStore.save(recentConnections) + refreshRecentConnectionPresence() + } + + private func updateActiveConnectionName(_ name: String) { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, !activeRemoteID.isEmpty else { return } + activeDisplayName = trimmed + guard let index = recentConnections.firstIndex(where: { + $0.remoteID == activeRemoteID + }) else { return } + recentConnections[index].displayName = trimmed + RecentConnectionStore.save(recentConnections) + } + + private func captureRecentThumbnailIfNeeded(_ pixelBuffer: CVPixelBuffer) { + guard shouldCaptureThumbnail, !activeRemoteID.isEmpty else { return } + shouldCaptureThumbnail = false + let identifier = activeRemoteID + RecentConnectionStore.captureThumbnail(from: pixelBuffer, + remoteID: identifier) { [weak self] fileName in + guard let self, let fileName, + let index = self.recentConnections.firstIndex(where: { + $0.remoteID == identifier + }) else { return } + self.recentConnections[index].thumbnailFileName = fileName + RecentConnectionStore.save(self.recentConnections) + } + } + + func selectDisplay(_ index: Int) { + guard displays.indices.contains(index) else { return } + selectedDisplay = index + videoFrame = nil + resetRemoteCursorState() + frameCount = 0 + bridge.switch(toDisplay: index) + } + + private func resetRemoteCursorState() { + remoteCursorVisible = true + remoteCursorShape = 0 + remoteCursorPosition = nil + remoteCursorVisualOffset = .zero + hasRemoteCursorState = false + remoteCursorSequence = nil + } + + func toggleAudio() { + audioEnabled.toggle() + audioPlayer.setEnabled(audioEnabled) + bridge.setAudioEnabled(audioEnabled) + } + + func sendClipboard() { + guard let text = UIPasteboard.general.string, !text.isEmpty else { + clipboardStatus = "剪贴板中没有文本" + return + } + guard text.lengthOfBytes(using: .utf8) <= 128 * 1024 else { + clipboardStatus = "剪贴板文本超过 128 KiB" + return + } + bridge.sendClipboardText(text) + clipboardStatus = "已发送本机剪贴板" + } + + func sendFile(_ url: URL) { + transferStatus = "正在发送 \(url.lastPathComponent)" + transferProgress = 0 + bridge.sendFile(at: url) + } + + func sendKeyStroke(_ keyCode: UInt) { + bridge.sendWindowsKeyCode(keyCode, isDown: true) + bridge.sendWindowsKeyCode(keyCode, isDown: false) + } + + func sendKeyState(_ keyCode: UInt, isDown: Bool) { + bridge.sendWindowsKeyCode(keyCode, isDown: isDown) + } + + func rtcBridge(_ bridge: CrossDeskRTCBridge, + didChange state: CrossDeskSignalState) { + switch state.rawValue { + case 0: signalStatus = "正在连接信令服务" + case 1: signalStatus = "已连接服务器" + case 2: signalStatus = "信令连接失败" + case 3: signalStatus = "信令连接已关闭" + case 4: signalStatus = "信令服务重连中" + case 5: signalStatus = "信令服务器已关闭连接" + case 6: signalStatus = "TLS 证书校验失败" + default: signalStatus = "未知信令状态" + } + if state.rawValue == 1 { + if let pendingPresenceRemoteID { + bridge.requestPresence(remoteIDs: [pendingPresenceRemoteID]) + } else { + refreshRecentConnectionPresence() + } + } + } + + func rtcBridge(_ bridge: CrossDeskRTCBridge, + didChange state: CrossDeskConnectionState, + remoteID: String) { + isConnected = state.rawValue == 1 + isConnecting = [0, 2].contains(state.rawValue) + switch state.rawValue { + case 0: connectionStatus = "正在建立远程连接" + case 1: + connectionStatus = "已连接" + if !remoteID.isEmpty { + recentConnectionPresence[remoteID] = true + } + sessionVisible = true + recordSuccessfulConnectionIfNeeded() + audioPlayer.setEnabled(audioEnabled) + bridge.setAudioEnabled(audioEnabled) + AppOrientation.update(to: .allButUpsideDown) + case 2: connectionStatus = "正在收集 ICE 候选" + case 3: connectionStatus = "网络连接已中断" + case 4: + connectionStatus = "P2P/TURN 建链失败" + sessionVisible = false + AppOrientation.update(to: .portrait) + case 5: + connectionStatus = "未连接" + sessionVisible = false + AppOrientation.update(to: .portrait) + case 6: + connectionStatus = "访问密码错误" + sessionVisible = false + AppOrientation.update(to: .portrait) + case 7: + connectionStatus = "远程设备 ID 不存在" + if !remoteID.isEmpty { + recentConnectionPresence[remoteID] = false + } + sessionVisible = false + AppOrientation.update(to: .portrait) + case 8: + connectionStatus = "远程设备当前不可用" + if !remoteID.isEmpty { + recentConnectionPresence[remoteID] = false + } + sessionVisible = false + AppOrientation.update(to: .portrait) + default: connectionStatus = "未知连接状态" + } + } + + func rtcBridge(_ bridge: CrossDeskRTCBridge, didProvisionIdentity identity: String) { + localIdentity = identity.split(separator: "@").first.map(String.init) ?? identity + if let pendingPresenceRemoteID { + bridge.requestPresence(remoteIDs: [pendingPresenceRemoteID]) + } else { + refreshRecentConnectionPresence() + } + } + + func rtcBridge(_ bridge: CrossDeskRTCBridge, + didReceivePresence presence: [String: NSNumber]) { + var updated = recentConnectionPresence + for (remoteID, online) in presence { + updated[remoteID] = online.boolValue + } + recentConnectionPresence = updated + + guard let pendingRemoteID = pendingPresenceRemoteID, + let online = presence[pendingRemoteID]?.boolValue else { + return + } + cancelPresenceProbe() + refreshRecentConnectionPresence() + if online { + beginRemoteConnection(pendingRemoteID) + } else { + showDeviceOffline() + } + } + + func rtcBridge(_ bridge: CrossDeskRTCBridge, + didReceive pixelBuffer: CVPixelBuffer, + width: Int, + height: Int) { + // Buffer and encoded dimensions must be one observable value. Adaptive + // resolution changes must never expose a new frame with the previous + // frame's geometry to SwiftUI. + videoFrame = RemoteVideoFrame( + pixelBuffer: pixelBuffer, + encodedSize: CGSize(width: width, height: height) + ) + frameCount &+= 1 + captureRecentThumbnailIfNeeded(pixelBuffer) + } + + func rtcBridge(_ bridge: CrossDeskRTCBridge, + didReceiveHostName hostName: String, + displayNames remoteDisplayNames: [String], + displaySizes remoteDisplaySizes: [NSValue]) { + updateActiveConnectionName(hostName) + let names = remoteDisplayNames.enumerated().map { index, displayName in + let name = displayName.trimmingCharacters(in: .whitespacesAndNewlines) + return name.isEmpty ? "显示器 \(index + 1)" : name + } + if !names.isEmpty { + displays = names + displaySizes = remoteDisplaySizes.map(\.cgSizeValue) + selectedDisplay = min(selectedDisplay, names.count - 1) + bridge.switch(toDisplay: selectedDisplay) + } + } + + func rtcBridge(_ bridge: CrossDeskRTCBridge, + didReceiveCursorVisible visible: Bool, + shape: Int, + positionUpdate: Bool, + positionValid: Bool, + x: Float, + y: Float, + visualOffsetX: Float, + visualOffsetY: Float, + display displayIndex: Int, + sequence: UInt32) { + if let previous = remoteCursorSequence, + sequence != 0, + Int32(bitPattern: sequence &- previous) <= 0 { + return + } + + remoteCursorSequence = sequence + remoteCursorVisible = visible + remoteCursorShape = min(max(shape, 0), 28) + remoteCursorVisualOffset = CGPoint(x: CGFloat(visualOffsetX), + y: CGFloat(visualOffsetY)) + if positionUpdate { + if positionValid, displayIndex == selectedDisplay { + remoteCursorPosition = CGPoint( + x: CGFloat(min(max(x, 0), 1)), + y: CGFloat(min(max(y, 0), 1)) + ) + } else { + remoteCursorPosition = nil + } + } + hasRemoteCursorState = true + } + + func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceiveAudioPCM pcmData: Data) { + audioPlayer.enqueue(pcmData) + } + + func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceiveClipboardText text: String) { + UIPasteboard.general.string = text + clipboardStatus = "已接收远端剪贴板" + } + + func rtcBridge(_ bridge: CrossDeskRTCBridge, + didUpdateFileTransfer fileName: String, + progress: Double, + sending: Bool) { + if progress < 0 { + transferStatus = "\(fileName) 传输失败" + transferProgress = 0 + return + } + transferProgress = progress + let percent = Int((progress * 100).rounded()) + transferStatus = "\(sending ? "发送" : "接收") \(fileName) · \(percent)%" + } + + func rtcBridge(_ bridge: CrossDeskRTCBridge, didReceiveFileAt fileURL: URL) { + receivedFileURL = fileURL + transferStatus = "已接收 \(fileURL.lastPathComponent)" + transferProgress = 1 + } + + func rtcBridge(_ bridge: CrossDeskRTCBridge, + didUpdateBitrate bitsPerSecond: UInt, + lossRate: Float, + usingTURN: Bool) { + bitrate = bitsPerSecond + self.lossRate = lossRate + self.usingTURN = usingTURN + } + + var formattedBitrate: String { + if bitrate >= 1_000_000 { + return String(format: "%.1f Mbps", Double(bitrate) / 1_000_000) + } + if bitrate >= 1_000 { + return String(format: "%.0f Kbps", Double(bitrate) / 1_000) + } + return "\(bitrate) bps" + } + + var videoStatus: String { + if frameCount > 0 { + return "视频 \(Int(frameSize.width))×\(Int(frameSize.height)) · \(frameCount) 帧" + } + return isConnected ? "正在取回远程画面…" : "等待连接" + } +} diff --git a/ios/CrossDeskMobile/RemoteSessionView.swift b/ios/CrossDeskMobile/RemoteSessionView.swift new file mode 100644 index 0000000..6b9db94 --- /dev/null +++ b/ios/CrossDeskMobile/RemoteSessionView.swift @@ -0,0 +1,1357 @@ +import SwiftUI +import UIKit +import UniformTypeIdentifiers + +private enum RemoteKeyboardPalette { + static let background = Color(red: 0.82, green: 0.84, blue: 0.87) + static let characterKey = Color.white + static let specialKey = Color(red: 0.68, green: 0.71, blue: 0.75) + static let accentKey = Color(red: 0.04, green: 0.48, blue: 1.00) + static let keyText = Color.black.opacity(0.88) +} + +private enum RemoteKeyboardMetrics { + static let rowHeight: CGFloat = 31 + static let keySpacing: CGFloat = 3 + static let rowSpacing: CGFloat = 3 + static let cornerRadius: CGFloat = 5 + static let panelPadding: CGFloat = 5 + static let dragHandleHeight: CGFloat = 10 +} + +private enum RemoteKeyboardMode: String { + case system + case computer +} + +private struct RemoteViewportState: Equatable { + var scale: CGFloat = 1 + var offset = CGSize.zero +} + +struct RemoteSessionView: View { + @ObservedObject var session: RemoteSessionModel + @State private var keyboardInputVisible = false + @State private var keyboardMode: RemoteKeyboardMode = .system + @State private var viewport = RemoteViewportState() + @State private var showingFileImporter = false + @State private var disconnectConfirmationVisible = false + @State private var cursorPosition: CGPoint? + @State private var statusMenuVisible = false + @State private var statusOrbCenter: CGPoint? + @State private var keyboardPanelCenter: CGPoint? + @GestureState private var statusOrbDragTranslation = CGSize.zero + @GestureState private var keyboardDragTranslation = CGSize.zero + + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + GeometryReader { proxy in + ZStack(alignment: .topLeading) { + if let videoRect = RemoteVideoGeometry.aspectFitRect( + containerSize: proxy.size, + videoSize: session.displayGeometrySize + ) { + ZStack(alignment: .topLeading) { + NativeVideoView(pixelBuffer: session.pixelBuffer) + + if let cursorPosition, + session.pixelBuffer != nil { + RemoteCursorOverlay( + normalizedPosition: cursorPosition, + normalizedVisualOffset: + session.remoteCursorVisualOffset, + contentSize: videoRect.size, + viewportScale: viewport.scale, + shape: visibleCursorShape + ) + .allowsHitTesting(false) + } + } + .frame(width: videoRect.width, + height: videoRect.height) + .position(x: videoRect.midX, y: videoRect.midY) + .scaleEffect(viewport.scale, anchor: .center) + .offset(viewport.offset) + } + + RemoteTouchInputView( + videoSize: session.displayGeometrySize, + controlMode: session.mouseControlMode, + remoteCursorPosition: session.remoteCursorPosition, + viewportScale: viewport.scale, + viewportOffset: viewport.offset, + viewportChanged: { scale, offset in + // Scale and offset describe one affine transform. + // Publish them atomically so all layers observe the + // same viewport. + viewport = RemoteViewportState(scale: scale, + offset: offset) + }, + move: { x, y in + cursorPosition = CGPoint(x: CGFloat(x), y: CGFloat(y)) + session.bridge.sendPointer(x: x, y: y, action: .move) + }, + leftDown: { x, y in + session.bridge.sendPointer(x: x, y: y, action: .leftDown) + }, + leftUp: { x, y in + session.bridge.sendPointer(x: x, y: y, action: .leftUp) + }, + rightClick: { x, y in + session.bridge.sendPointer(x: x, y: y, action: .rightDown) + session.bridge.sendPointer(x: x, y: y, action: .rightUp) + }, + scroll: { x, y, dx, dy in + cursorPosition = CGPoint(x: CGFloat(x), y: CGFloat(y)) + session.bridge.sendScrollX(x, y: y, + deltaX: dx, deltaY: dy) + } + ) + .frame(width: proxy.size.width, height: proxy.size.height) + .allowsHitTesting(session.pixelBuffer != nil) + + } + .frame(width: proxy.size.width, height: proxy.size.height) + } + .ignoresSafeArea() + .transaction { transaction in + transaction.animation = nil + transaction.disablesAnimations = true + } + + if session.pixelBuffer == nil { + VStack(spacing: 12) { + ProgressView() + .tint(.white) + Text(session.videoStatus) + .font(.callout.monospacedDigit()) + .foregroundStyle(.white) + } + .padding(20) + .background(.black.opacity(0.55), in: RoundedRectangle(cornerRadius: 14)) + .allowsHitTesting(true) + } + + if keyboardInputVisible { + draggableKeyboard + .zIndex(20) + } + + floatingStatusControls + .zIndex(30) + + if disconnectConfirmationVisible { + disconnectConfirmationOverlay + .zIndex(50) + } + } + .onDisappear { + keyboardInputVisible = false + viewport = RemoteViewportState() + cursorPosition = nil + statusMenuVisible = false + statusOrbCenter = nil + keyboardPanelCenter = nil + } + .onChange(of: session.selectedDisplay) { _ in + cursorPosition = nil + viewport = RemoteViewportState() + } + .onChange(of: session.mouseControlMode) { _ in + cursorPosition = session.remoteCursorPosition + } + .onChange(of: session.remoteCursorPosition) { position in + guard let position else { return } + cursorPosition = position + } + .fileImporter(isPresented: $showingFileImporter, + allowedContentTypes: [.item], + allowsMultipleSelection: false) { result in + if case let .success(urls) = result, let url = urls.first { + session.sendFile(url) + } + } + } + + private var disconnectConfirmationOverlay: some View { + GeometryReader { proxy in + ZStack { + Color.black.opacity(0.28) + .ignoresSafeArea() + .contentShape(Rectangle()) + .onTapGesture { + withAnimation(.easeOut(duration: 0.16)) { + disconnectConfirmationVisible = false + } + } + + VStack(spacing: 0) { + VStack(spacing: 4) { + Text("断开远程连接?") + .font(.headline) + .foregroundStyle(Color.black.opacity(0.9)) + + Text("断开后将返回首页") + .font(.subheadline) + .foregroundStyle(Color.black.opacity(0.55)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + + Divider() + + Button { + disconnectConfirmationVisible = false + session.disconnect() + } label: { + Text("断开连接") + .font(.body.weight(.semibold)) + .foregroundStyle(Color.red) + .frame(maxWidth: .infinity) + .frame(height: 45) + } + + Divider() + + Button { + withAnimation(.easeOut(duration: 0.16)) { + disconnectConfirmationVisible = false + } + } label: { + Text("取消") + .font(.body.weight(.semibold)) + .foregroundStyle(Color.blue) + .frame(maxWidth: .infinity) + .frame(height: 45) + } + } + .background( + Color.white, + in: RoundedRectangle(cornerRadius: 16, + style: .continuous) + ) + .clipShape(RoundedRectangle(cornerRadius: 16, + style: .continuous)) + .shadow(color: .black.opacity(0.2), radius: 18, y: 7) + .frame(width: min(250, max(220, proxy.size.width - 32))) + .position(x: proxy.size.width / 2, + y: proxy.size.height / 2) + .transition(.scale(scale: 0.96).combined(with: .opacity)) + } + .frame(width: proxy.size.width, height: proxy.size.height) + } + .ignoresSafeArea() + } + + private var floatingStatusControls: some View { + GeometryReader { proxy in + let containerSize = proxy.size + let restingOrbCenter = boundedOrbCenter(in: containerSize) + let orbCenter = constrainedOrbCenter( + CGPoint(x: restingOrbCenter.x + statusOrbDragTranslation.width, + y: restingOrbCenter.y + statusOrbDragTranslation.height), + in: containerSize + ) + let panelSize = CGSize( + width: min(340, max(280, containerSize.width - 160)), + height: min(250, max(220, containerSize.height - 48)) + ) + let panelCenter = floatingPanelCenter( + orbCenter: orbCenter, + panelSize: panelSize, + containerSize: containerSize + ) + + ZStack { + if statusMenuVisible { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + withAnimation(.easeOut(duration: 0.14)) { + statusMenuVisible = false + } + } + + FloatingSessionMenu( + session: session, + showKeyboard: { + statusMenuVisible = false + keyboardInputVisible.toggle() + }, + chooseFile: { + statusMenuVisible = false + showingFileImporter = true + }, + close: { + withAnimation(.easeOut(duration: 0.14)) { + statusMenuVisible = false + } + }, + disconnect: { + statusMenuVisible = false + keyboardInputVisible = false + withAnimation(.easeOut(duration: 0.18)) { + disconnectConfirmationVisible = true + } + } + ) + .frame(width: panelSize.width, height: panelSize.height) + .position(panelCenter) + .transition(.scale(scale: 0.94, anchor: .topTrailing) + .combined(with: .opacity)) + } + + CrossDeskStatusOrb(isExpanded: statusMenuVisible) + .frame(width: 54, height: 54) + .contentShape(Circle()) + .position(orbCenter) + .onTapGesture { + withAnimation(.spring(response: 0.24, + dampingFraction: 0.84)) { + statusMenuVisible.toggle() + } + } + .gesture( + DragGesture(minimumDistance: 1) + .updating($statusOrbDragTranslation) { value, state, _ in + state = value.translation + } + .onChanged { _ in + if statusMenuVisible { + statusMenuVisible = false + } + } + .onEnded { value in + statusOrbCenter = constrainedOrbCenter( + CGPoint(x: restingOrbCenter.x + value.translation.width, + y: restingOrbCenter.y + value.translation.height), + in: containerSize + ) + } + ) + } + } + .ignoresSafeArea() + } + + private var draggableKeyboard: some View { + GeometryReader { proxy in + let containerSize = proxy.size + let panelSize = keyboardPanelSize(in: containerSize) + let restingCenter = constrainedKeyboardCenter( + keyboardPanelCenter ?? defaultKeyboardCenter( + in: containerSize, + panelSize: panelSize + ), + in: containerSize, + panelSize: panelSize + ) + let visibleCenter = constrainedKeyboardCenter( + CGPoint( + x: restingCenter.x + keyboardDragTranslation.width, + y: restingCenter.y + keyboardDragTranslation.height + ), + in: containerSize, + panelSize: panelSize + ) + + remoteKeyboardPanel(containerSize: containerSize, + panelSize: panelSize) + .frame(width: panelSize.width, height: panelSize.height) + .position(restingCenter) + .offset(x: visibleCenter.x - restingCenter.x, + y: visibleCenter.y - restingCenter.y) + .transaction { transaction in + transaction.animation = nil + } + } + } + + private func remoteKeyboardPanel(containerSize: CGSize, + panelSize: CGSize) -> some View { + VStack(spacing: RemoteKeyboardMetrics.rowSpacing) { + Capsule() + .fill(Color.black.opacity(0.28)) + .frame(width: 38, height: 4) + .frame(maxWidth: .infinity) + .frame(height: RemoteKeyboardMetrics.dragHandleHeight) + .contentShape(Rectangle()) + .gesture(keyboardDragGesture(containerSize: containerSize, + panelSize: panelSize)) + .accessibilityLabel("拖动键盘") + + Group { + if keyboardMode == .system { + RemoteSystemKeyboard( + session: session, + switchKeyboard: { keyboardMode = .computer }, + dismiss: { keyboardInputVisible = false } + ) + } else { + RemoteComputerKeyboard( + session: session, + switchKeyboard: { keyboardMode = .system }, + dismiss: { keyboardInputVisible = false } + ) + } + } + } + .padding(RemoteKeyboardMetrics.panelPadding) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(RemoteKeyboardPalette.background, + in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .shadow(color: .black.opacity(0.2), radius: 10, y: 4) + } + + private func keyboardPanelSize(in containerSize: CGSize) -> CGSize { + let rowCount: CGFloat = keyboardMode == .system ? 4 : 6 + let keyboardHeight = rowCount * RemoteKeyboardMetrics.rowHeight + + (rowCount - 1) * RemoteKeyboardMetrics.rowSpacing + let height = keyboardHeight + + RemoteKeyboardMetrics.dragHandleHeight + + RemoteKeyboardMetrics.rowSpacing + + RemoteKeyboardMetrics.panelPadding * 2 + return CGSize(width: min(780, max(0, containerSize.width - 12)), + height: height) + } + + private func defaultKeyboardCenter(in containerSize: CGSize, + panelSize: CGSize) -> CGPoint { + CGPoint(x: containerSize.width / 2, + y: containerSize.height - panelSize.height / 2 - 6) + } + + private func constrainedKeyboardCenter(_ point: CGPoint, + in containerSize: CGSize, + panelSize: CGSize) -> CGPoint { + let margin: CGFloat = 6 + let minimumX = panelSize.width / 2 + margin + let maximumX = max(minimumX, + containerSize.width - panelSize.width / 2 - margin) + let minimumY = panelSize.height / 2 + margin + let maximumY = max(minimumY, + containerSize.height - panelSize.height / 2 - margin) + return CGPoint(x: min(max(point.x, minimumX), maximumX), + y: min(max(point.y, minimumY), maximumY)) + } + + private func keyboardDragGesture(containerSize: CGSize, + panelSize: CGSize) -> some Gesture { + DragGesture(minimumDistance: 1, coordinateSpace: .global) + .updating($keyboardDragTranslation) { value, state, _ in + state = value.translation + } + .onEnded { value in + let restingCenter = constrainedKeyboardCenter( + keyboardPanelCenter ?? defaultKeyboardCenter( + in: containerSize, + panelSize: panelSize + ), + in: containerSize, + panelSize: panelSize + ) + keyboardPanelCenter = constrainedKeyboardCenter( + CGPoint(x: restingCenter.x + value.translation.width, + y: restingCenter.y + value.translation.height), + in: containerSize, + panelSize: panelSize + ) + } + } + + private func boundedOrbCenter(in size: CGSize) -> CGPoint { + let initial = statusOrbCenter ?? CGPoint(x: size.width - 39, y: 44) + return constrainedOrbCenter(initial, in: size) + } + + private func constrainedOrbCenter(_ point: CGPoint, in size: CGSize) -> CGPoint { + let horizontalMargin: CGFloat = 39 + let verticalMargin: CGFloat = 39 + return CGPoint( + x: min(max(point.x, horizontalMargin), + max(horizontalMargin, size.width - horizontalMargin)), + y: min(max(point.y, verticalMargin), + max(verticalMargin, size.height - verticalMargin)) + ) + } + + private func floatingPanelCenter(orbCenter: CGPoint, + panelSize: CGSize, + containerSize: CGSize) -> CGPoint { + let gap: CGFloat = 12 + let orbRadius: CGFloat = 27 + let horizontalPadding: CGFloat = 8 + let verticalPadding: CGFloat = 8 + let preferredX = orbCenter.x > containerSize.width / 2 + ? orbCenter.x - orbRadius - gap - panelSize.width / 2 + : orbCenter.x + orbRadius + gap + panelSize.width / 2 + return CGPoint( + x: min(max(preferredX, panelSize.width / 2 + horizontalPadding), + containerSize.width - panelSize.width / 2 - horizontalPadding), + y: min(max(orbCenter.y, panelSize.height / 2 + verticalPadding), + containerSize.height - panelSize.height / 2 - verticalPadding) + ) + } + + private var visibleCursorShape: Int { + guard session.hasRemoteCursorState, + session.remoteCursorVisible, + session.remoteCursorShape != 1 else { + // The desktop hides its native cursor from the captured frame and + // can therefore report a hidden cursor while remote input is + // active. Keep the iOS-side touch cursor visible in that state. + return 0 + } + return session.remoteCursorShape + } + +} + +private enum RemoteSystemKeyboardPage { + case letters + case numbers + case symbols +} + +private enum RemoteSystemKeyAction { + case text(String) + case shift + case backspace + case returnKey + case page(RemoteSystemKeyboardPage) + case switchKeyboard + case dismiss +} + +private enum RemoteSystemKeyStyle { + case character + case special + case accent +} + +private struct RemoteKeyboardKeyCap: View { + let title: String? + let symbol: String? + let style: RemoteSystemKeyStyle + let active: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Group { + if let symbol { + Image(systemName: symbol) + .font(.system(size: 14, weight: .semibold)) + } else { + Text(title ?? "") + .font(.system(size: labelFontSize, + weight: .medium, + design: .rounded)) + .multilineTextAlignment(.center) + .lineLimit(2) + .minimumScaleFactor(0.62) + } + } + .foregroundStyle(usesAccentColor + ? Color.white : RemoteKeyboardPalette.keyText) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(backgroundColor, + in: RoundedRectangle( + cornerRadius: RemoteKeyboardMetrics.cornerRadius, + style: .continuous + )) + .shadow(color: .black.opacity(0.14), radius: 0.4, y: 0.8) + } + .buttonStyle(RemoteKeyboardPressStyle()) + } + + private var labelFontSize: CGFloat { + guard let title else { return 14 } + if title.contains("\n") { return 9 } + switch title.count { + case 0...1: return 14 + case 2...3: return 11.5 + default: return 10 + } + } + + private var usesAccentColor: Bool { + if active { return true } + if case .accent = style { return true } + return false + } + + private var backgroundColor: Color { + if usesAccentColor { return RemoteKeyboardPalette.accentKey } + switch style { + case .character: return RemoteKeyboardPalette.characterKey + case .special: return RemoteKeyboardPalette.specialKey + case .accent: return RemoteKeyboardPalette.accentKey + } + } +} + +private struct RemoteKeyboardPressStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .brightness(configuration.isPressed ? -0.08 : 0) + .scaleEffect(configuration.isPressed ? 0.97 : 1) + } +} + +private struct RemoteSystemKey { + let title: String? + let symbol: String? + let width: CGFloat + let style: RemoteSystemKeyStyle + let action: RemoteSystemKeyAction + + init(_ title: String, + width: CGFloat = 1, + style: RemoteSystemKeyStyle = .character, + action: RemoteSystemKeyAction) { + self.title = title + self.symbol = nil + self.width = width + self.style = style + self.action = action + } + + init(symbol: String, + width: CGFloat = 1, + style: RemoteSystemKeyStyle = .special, + action: RemoteSystemKeyAction) { + self.title = nil + self.symbol = symbol + self.width = width + self.style = style + self.action = action + } + + var isShift: Bool { + if case .shift = action { return true } + return false + } + + var isLetter: Bool { + guard case let .text(value) = action else { return false } + return value.rangeOfCharacter(from: .letters) != nil + } +} + +private struct RemoteSystemKeyboard: View { + @ObservedObject var session: RemoteSessionModel + let switchKeyboard: () -> Void + let dismiss: () -> Void + + @State private var page: RemoteSystemKeyboardPage = .letters + @State private var shiftEnabled = false + + var body: some View { + VStack(spacing: RemoteKeyboardMetrics.rowSpacing) { + ForEach(Array(rows.enumerated()), id: \.offset) { _, row in + RemoteSystemKeyboardRow( + keys: row, + shiftEnabled: shiftEnabled, + action: handleKey + ) + } + } + } + + private var rows: [[RemoteSystemKey]] { + switch page { + case .letters: + return letterRows + case .numbers: + return numberRows + case .symbols: + return symbolRows + } + } + + private var letterRows: [[RemoteSystemKey]] { + [ + textKeys("qwertyuiop"), + textKeys("asdfghjkl"), + [RemoteSystemKey(symbol: "shift", + width: 1.55, + action: .shift)] + + textKeys("zxcvbnm") + + [RemoteSystemKey(symbol: "delete.left", + width: 1.55, + action: .backspace)], + bottomRow(pageTitle: "123", targetPage: .numbers) + ] + } + + private var numberRows: [[RemoteSystemKey]] { + [ + valueKeys(["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"]), + valueKeys(["-", "/", ":", ";", "(", ")", "$", "&", "@", "\""]), + [RemoteSystemKey("#+=", width: 1.55, style: .special, + action: .page(.symbols))] + + valueKeys([".", ",", "?", "!", "'"]) + + [RemoteSystemKey(symbol: "delete.left", width: 1.55, + action: .backspace)], + bottomRow(pageTitle: "ABC", targetPage: .letters) + ] + } + + private var symbolRows: [[RemoteSystemKey]] { + [ + valueKeys(["[", "]", "{", "}", "#", "%", "^", "*", "+", "="]), + valueKeys(["_", "\\", "|", "~", "<", ">", "$", "&", "@", "`"]), + [RemoteSystemKey("123", width: 1.55, style: .special, + action: .page(.numbers))] + + valueKeys([".", ",", "?", "!", "'"]) + + [RemoteSystemKey(symbol: "delete.left", width: 1.55, + action: .backspace)], + bottomRow(pageTitle: "ABC", targetPage: .letters) + ] + } + + private func textKeys(_ letters: String) -> [RemoteSystemKey] { + letters.map { character in + let value = String(character) + return RemoteSystemKey(value, action: .text(value)) + } + } + + private func valueKeys(_ values: [String]) -> [RemoteSystemKey] { + values.map { RemoteSystemKey($0, action: .text($0)) } + } + + private func bottomRow(pageTitle: String, + targetPage: RemoteSystemKeyboardPage) + -> [RemoteSystemKey] { + [ + RemoteSystemKey(pageTitle, width: 1.45, style: .special, + action: .page(targetPage)), + RemoteSystemKey(symbol: "arrow.left.arrow.right", width: 1.2, + action: .switchKeyboard), + RemoteSystemKey("空格", width: 5, action: .text(" ")), + RemoteSystemKey("回车", width: 1.55, style: .accent, + action: .returnKey), + RemoteSystemKey(symbol: "keyboard.chevron.compact.down", width: 1.2, + action: .dismiss) + ] + } + + private func handleKey(_ key: RemoteSystemKey) { + switch key.action { + case let .text(value): + let isLetter = value.rangeOfCharacter(from: .letters) != nil + session.bridge.sendText(shiftEnabled && isLetter + ? value.uppercased() : value) + case .shift: + withTransaction(Transaction(animation: nil)) { + shiftEnabled.toggle() + } + case .backspace: + session.sendKeyStroke(0x08) + case .returnKey: + session.sendKeyStroke(0x0D) + case let .page(newPage): + page = newPage + case .switchKeyboard: + switchKeyboard() + case .dismiss: + dismiss() + } + } +} + +private struct RemoteSystemKeyboardRow: View { + let keys: [RemoteSystemKey] + let shiftEnabled: Bool + let action: (RemoteSystemKey) -> Void + + var body: some View { + GeometryReader { proxy in + let spacing = RemoteKeyboardMetrics.keySpacing + let totalWidth = keys.reduce(CGFloat.zero) { $0 + $1.width } + let keySpace = max(0, proxy.size.width - + CGFloat(max(0, keys.count - 1)) * spacing) + + HStack(spacing: spacing) { + ForEach(Array(keys.enumerated()), id: \.offset) { _, key in + let isActive = key.isShift && shiftEnabled + let visibleTitle = shiftEnabled && key.isLetter + ? key.title?.uppercased() : key.title + let visibleSymbol = isActive && key.isShift + ? "shift.fill" : key.symbol + RemoteKeyboardKeyCap( + title: visibleTitle, + symbol: visibleSymbol, + style: key.style, + active: isActive + ) { + action(key) + } + .id("\(visibleTitle ?? visibleSymbol ?? "key")-\(isActive)") + .frame(width: keySpace * key.width / totalWidth) + .accessibilityLabel(visibleTitle ?? "键盘操作") + } + } + } + .frame(height: RemoteKeyboardMetrics.rowHeight) + } +} + +private enum RemoteComputerKeyAction { + case stroke(UInt) + case modifier(UInt) + case switchKeyboard + case dismiss +} + +private struct RemoteComputerKey { + let title: String? + let symbol: String? + let width: CGFloat + let style: RemoteSystemKeyStyle + let action: RemoteComputerKeyAction + + init(_ title: String, + _ keyCode: UInt, + width: CGFloat = 1, + isModifier: Bool = false, + style: RemoteSystemKeyStyle? = nil) { + self.title = title + self.symbol = nil + self.width = width + self.style = style ?? (isModifier ? .special : .character) + self.action = isModifier ? .modifier(keyCode) : .stroke(keyCode) + } + + init(_ title: String, + width: CGFloat = 1, + style: RemoteSystemKeyStyle = .special, + action: RemoteComputerKeyAction) { + self.title = title + self.symbol = nil + self.width = width + self.style = style + self.action = action + } + + init(symbol: String, + _ keyCode: UInt, + width: CGFloat = 1, + isModifier: Bool = false, + style: RemoteSystemKeyStyle? = nil) { + self.title = nil + self.symbol = symbol + self.width = width + self.style = style ?? (isModifier ? .special : .character) + self.action = isModifier ? .modifier(keyCode) : .stroke(keyCode) + } + + init(symbol: String, + width: CGFloat = 1, + style: RemoteSystemKeyStyle = .special, + action: RemoteComputerKeyAction) { + self.title = nil + self.symbol = symbol + self.width = width + self.style = style + self.action = action + } + + var modifierCode: UInt? { + if case let .modifier(keyCode) = action { return keyCode } + return nil + } +} + +private struct RemoteComputerKeyboard: View { + @ObservedObject var session: RemoteSessionModel + let switchKeyboard: () -> Void + let dismiss: () -> Void + @State private var activeModifiers: Set = [] + + private static let rows: [[RemoteComputerKey]] = [ + [ + .init("Esc", 0x1B, width: 1.2), + .init("F1", 0x70), .init("F2", 0x71), .init("F3", 0x72), + .init("F4", 0x73), .init("F5", 0x74), .init("F6", 0x75), + .init("F7", 0x76), .init("F8", 0x77), .init("F9", 0x78), + .init("F10", 0x79), .init("F11", 0x7A), .init("F12", 0x7B), + .init("Del", 0x2E, width: 1.2) + ], + [ + .init("~\n`", 0xC0), + .init("!\n1", 0x31), .init("@\n2", 0x32), + .init("#\n3", 0x33), .init("$\n4", 0x34), + .init("%\n5", 0x35), .init("^\n6", 0x36), + .init("&\n7", 0x37), .init("*\n8", 0x38), + .init("(\n9", 0x39), .init(")\n0", 0x30), + .init("_\n-", 0xBD), .init("+\n=", 0xBB), + .init(symbol: "delete.left", 0x08, width: 1.65, + style: .special) + ], + [ + .init("Tab", 0x09, width: 1.45, style: .special), + .init("Q", 0x51), .init("W", 0x57), .init("E", 0x45), + .init("R", 0x52), .init("T", 0x54), .init("Y", 0x59), + .init("U", 0x55), .init("I", 0x49), .init("O", 0x4F), + .init("P", 0x50), .init("{\n[", 0xDB), + .init("}\n]", 0xDD), .init("|\n\\", 0xDC, width: 1.25) + ], + [ + .init("Caps", 0x14, width: 1.7, style: .special), + .init("A", 0x41), .init("S", 0x53), .init("D", 0x44), + .init("F", 0x46), .init("G", 0x47), .init("H", 0x48), + .init("J", 0x4A), .init("K", 0x4B), .init("L", 0x4C), + .init(":\n;", 0xBA), .init("\"\n'", 0xDE), + .init("回车", 0x0D, width: 1.9, style: .accent) + ], + [ + .init(symbol: "shift", 0x10, width: 2.15, isModifier: true), + .init("Z", 0x5A), .init("X", 0x58), .init("C", 0x43), + .init("V", 0x56), .init("B", 0x42), .init("N", 0x4E), + .init("M", 0x4D), .init("<\n,", 0xBC), + .init(">\n.", 0xBE), .init("?\n/", 0xBF), + .init(symbol: "shift", 0x10, width: 2.15, isModifier: true) + ], + [ + .init("Ctrl", 0x11, width: 1.35, isModifier: true), + .init("Win", 0x5B, width: 1.25, isModifier: true), + .init("Alt", 0x12, width: 1.25, isModifier: true), + .init("空格", 0x20, width: 3.8), + .init("Alt", 0x12, width: 1.25, isModifier: true), + .init("Ctrl", 0x11, width: 1.35, isModifier: true), + .init("←", 0x25, style: .special), + .init("↑", 0x26, style: .special), + .init("↓", 0x28, style: .special), + .init("→", 0x27, style: .special), + .init(symbol: "arrow.left.arrow.right", width: 1.35, + action: .switchKeyboard), + .init(symbol: "keyboard.chevron.compact.down", width: 1.35, + action: .dismiss) + ] + ] + + var body: some View { + VStack(spacing: RemoteKeyboardMetrics.rowSpacing) { + ForEach(Array(Self.rows.enumerated()), id: \.offset) { _, row in + RemoteComputerKeyboardRow( + keys: row, + activeModifiers: activeModifiers, + action: handleKey + ) + } + } + .onDisappear(perform: releaseModifiers) + } + + private func handleKey(_ key: RemoteComputerKey) { + switch key.action { + case let .modifier(keyCode): + if activeModifiers.contains(keyCode) { + session.sendKeyState(keyCode, isDown: false) + activeModifiers.remove(keyCode) + } else { + session.sendKeyState(keyCode, isDown: true) + activeModifiers.insert(keyCode) + } + case let .stroke(keyCode): + session.sendKeyStroke(keyCode) + case .switchKeyboard: + releaseModifiers() + switchKeyboard() + case .dismiss: + releaseModifiers() + dismiss() + } + } + + private func releaseModifiers() { + for keyCode in activeModifiers { + session.sendKeyState(keyCode, isDown: false) + } + activeModifiers.removeAll() + } +} + +private struct RemoteComputerKeyboardRow: View { + let keys: [RemoteComputerKey] + let activeModifiers: Set + let action: (RemoteComputerKey) -> Void + + var body: some View { + GeometryReader { proxy in + let spacing = RemoteKeyboardMetrics.keySpacing + let totalWidth = keys.reduce(CGFloat.zero) { $0 + $1.width } + let keySpace = max(0, proxy.size.width - + CGFloat(max(0, keys.count - 1)) * spacing) + + HStack(spacing: spacing) { + ForEach(Array(keys.enumerated()), id: \.offset) { _, key in + let isActive = key.modifierCode.map(activeModifiers.contains) ?? false + let visibleSymbol = isActive && key.symbol == "shift" + ? "shift.fill" : key.symbol + RemoteKeyboardKeyCap( + title: key.title, + symbol: visibleSymbol, + style: key.style, + active: isActive + ) { + action(key) + } + .frame(width: keySpace * key.width / totalWidth) + .accessibilityLabel((key.title ?? "键盘操作") + .replacingOccurrences(of: "\n", with: " ")) + } + } + } + .frame(height: RemoteKeyboardMetrics.rowHeight) + } +} + +private struct CrossDeskStatusOrb: View { + let isExpanded: Bool + + private static let appIcon: UIImage? = { + let primaryIcon = (Bundle.main.infoDictionary?["CFBundleIcons"] + as? [String: Any])?["CFBundlePrimaryIcon"] as? [String: Any] + let iconName = primaryIcon?["CFBundleIconName"] as? String + let iconFiles = primaryIcon?["CFBundleIconFiles"] as? [String] + return iconName.flatMap(UIImage.init(named:)) + ?? iconFiles?.last.flatMap(UIImage.init(named:)) + ?? UIImage(named: "AppIcon") + }() + + var body: some View { + ZStack(alignment: .bottomTrailing) { + Circle() + .fill(Color(.secondarySystemBackground).opacity(0.96)) + + if let icon = Self.appIcon { + Image(uiImage: icon) + .resizable() + .scaledToFill() + .clipShape(Circle()) + .padding(4) + } else { + Image(systemName: "desktopcomputer") + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(.blue) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .overlay { + Circle() + .stroke(Color.white.opacity(isExpanded ? 0.95 : 0.65), + lineWidth: isExpanded ? 2 : 1) + } + .compositingGroup() + .accessibilityLabel(isExpanded ? "收起远程控制菜单" : "展开远程控制菜单") + } +} + +private struct FloatingSessionMenu: View { + @ObservedObject var session: RemoteSessionModel + let showKeyboard: () -> Void + let chooseFile: () -> Void + let close: () -> Void + let disconnect: () -> Void + + private let columns = Array(repeating: GridItem(.flexible(), spacing: 7), + count: 3) + + var body: some View { + VStack(spacing: 9) { + HStack(spacing: 8) { + Circle() + .fill(session.isConnected ? Color.green : Color.orange) + .frame(width: 9, height: 9) + VStack(alignment: .leading, spacing: 1) { + Text(session.connectionStatus) + .font(.subheadline.weight(.semibold)) + Text(statusDetail) + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + } + Spacer() + Button(action: close) { + Image(systemName: "xmark.circle.fill") + .font(.title3) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + + LazyVGrid(columns: columns, spacing: 7) { + FloatingControlButton(title: "键盘", symbol: "keyboard", + action: showKeyboard) + + Menu { + ForEach(Array(session.displays.enumerated()), id: \.offset) { index, name in + Button { + session.selectDisplay(index) + } label: { + if index == session.selectedDisplay { + Label(name, systemImage: "checkmark") + } else { + Text(name) + } + } + } + } label: { + FloatingControlLabel(title: "显示器", symbol: "display") + } + .buttonStyle(.plain) + + FloatingControlButton( + title: session.audioEnabled ? "声音" : "静音", + symbol: session.audioEnabled ? "speaker.wave.2" : "speaker.slash", + action: session.toggleAudio + ) + FloatingControlButton( + title: session.mouseControlMode == .relative ? "相对鼠标" : "绝对鼠标", + symbol: "computermouse", + action: { + session.mouseControlMode = session.mouseControlMode == .relative + ? .absolute : .relative + } + ) + FloatingControlButton(title: "发送文件", + symbol: "folder.badge.plus", + action: chooseFile) + FloatingControlButton(title: "Ctrl+Alt+Del", + symbol: "lock.trianglebadge.exclamationmark", + action: session.bridge.sendSecureAttentionSequence) + } + + if !session.transferStatus.isEmpty || !session.clipboardStatus.isEmpty { + HStack(spacing: 8) { + Text(!session.transferStatus.isEmpty + ? session.transferStatus : session.clipboardStatus) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + Spacer(minLength: 0) + if session.transferProgress > 0 && session.transferProgress < 1 { + ProgressView(value: session.transferProgress) + .frame(width: 60) + } + if let file = session.receivedFileURL { + ShareLink(item: file) { + Image(systemName: "square.and.arrow.up") + } + } + } + } + + Button(role: .destructive, action: disconnect) { + Text("断开连接") + .font(.subheadline.weight(.semibold)) + .frame(maxWidth: .infinity) + .frame(height: 34) + } + .buttonStyle(.borderedProminent) + .tint(.red) + } + .padding(12) + .background(Color.white, + in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(Color(.separator).opacity(0.24), lineWidth: 1) + } + .shadow(color: .black.opacity(0.34), radius: 18, y: 7) + .environment(\.colorScheme, .light) + } + + private var statusDetail: String { + var components = [session.usingTURN ? "TURN" : "P2P", + session.formattedBitrate] + if session.lossRate > 0 { + components.append(String(format: "丢包 %.1f%%", session.lossRate * 100)) + } + return components.joined(separator: " · ") + } +} + +private struct FloatingControlButton: View { + let title: String + let symbol: String + let action: () -> Void + + var body: some View { + Button(action: action) { + FloatingControlLabel(title: title, symbol: symbol) + } + .buttonStyle(.plain) + } +} + +private struct FloatingControlLabel: View { + let title: String + let symbol: String + + var body: some View { + VStack(spacing: 4) { + Image(systemName: symbol) + .font(.system(size: 16, weight: .semibold)) + Text(title) + .font(.system(size: 9.5, weight: .medium)) + .lineLimit(1) + .minimumScaleFactor(0.72) + } + .foregroundStyle(.primary) + .frame(maxWidth: .infinity) + .frame(height: 52) + .background(Color.primary.opacity(0.07), + in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + } +} + +private struct RemoteCursorOverlay: View { + let normalizedPosition: CGPoint + let normalizedVisualOffset: CGPoint + let contentSize: CGSize + let viewportScale: CGFloat + let shape: Int + + var body: some View { + let scale = max(viewportScale, 0.0001) + let x = min(max(normalizedPosition.x + normalizedVisualOffset.x, 0), 1) * + contentSize.width + let y = min(max(normalizedPosition.y + normalizedVisualOffset.y, 0), 1) * + contentSize.height + let offset = RemoteCursorGlyph.hotspotOffset(for: shape) + + ZStack(alignment: .topLeading) { + RemoteCursorGlyph(shape: shape) + // The cursor lives inside the exact same transformed view as + // the decoded frame, so its anchor cannot drift from video + // because of an independently reconstructed transform. Undo + // the outer zoom for the glyph itself and pre-divide the + // hotspot offset so the cursor remains a constant screen size. + .scaleEffect(1 / scale, anchor: .center) + .position(x: x + offset.x / scale, + y: y + offset.y / scale) + } + .frame(width: contentSize.width, height: contentSize.height, + alignment: .topLeading) + } +} + +private struct RemoteCursorGlyph: View { + let shape: Int + + var body: some View { + Group { + if shape == 0 { + ZStack { + RemoteCursorArrowShape() + .stroke(Color.black, lineWidth: 1.75) + RemoteCursorArrowShape() + .fill(Color.white) + } + .frame(width: 12, height: 15) + } else if shape == 7 { + ZStack { + RemoteIBeamShape() + .stroke(Color.black, lineWidth: 2.5) + RemoteIBeamShape() + .stroke(Color.white, lineWidth: 1) + } + .frame(width: 12, height: 15) + } else { + Image(systemName: availableSymbolName) + .font(.system(size: 10.5, weight: .black)) + .symbolRenderingMode(.monochrome) + .foregroundStyle(.white) + .shadow(color: .black, radius: 0.75) + } + } + .frame(width: 15, height: 15) + .accessibilityHidden(true) + } + + static func hotspotOffset(for shape: Int) -> CGPoint { + switch shape { + case 0: + // The arrow path's visual tip is near (2.5, 0.6) inside its + // 15-point container, rather than at the container origin. + return CGPoint(x: 5, y: 7) + case 3: + return CGPoint(x: 4.5, y: 4.5) + default: + return .zero + } + } + + private var availableSymbolName: String { + let desired: String + switch shape { + case 2: desired = "questionmark.circle.fill" + case 3: desired = "hand.point.up.left.fill" + case 4: desired = "arrow.triangle.2.circlepath" + case 5: desired = "hourglass" + case 6: desired = "scope" + case 8: desired = "arrow.turn.up.right" + case 9: desired = "plus.square.on.square" + case 10: desired = "arrow.up.and.down.and.arrow.left.and.right" + case 11, 12: desired = "nosign" + case 13, 14: desired = "hand.raised.fill" + case 15, 25: desired = "arrow.left.and.right" + case 16, 26: desired = "arrow.up.and.down" + case 17: desired = "arrow.up" + case 18: desired = "arrow.right" + case 19: desired = "arrow.down" + case 20: desired = "arrow.left" + case 21: desired = "arrow.up.right" + case 22: desired = "arrow.up.left" + case 23: desired = "arrow.down.right" + case 24: desired = "arrow.down.left" + case 27: desired = "arrow.up.right.and.arrow.down.left" + case 28: desired = "arrow.up.left.and.arrow.down.right" + default: desired = "arrow.up.left" + } + return UIImage(systemName: desired) == nil ? "arrow.up.left" : desired + } +} + +private struct RemoteCursorArrowShape: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: rect.minX + rect.width * 0.08, + y: rect.minY + rect.height * 0.04)) + path.addLine(to: CGPoint(x: rect.minX + rect.width * 0.08, + y: rect.minY + rect.height * 0.78)) + path.addLine(to: CGPoint(x: rect.minX + rect.width * 0.34, + y: rect.minY + rect.height * 0.60)) + path.addLine(to: CGPoint(x: rect.minX + rect.width * 0.55, + y: rect.minY + rect.height * 0.94)) + path.addLine(to: CGPoint(x: rect.minX + rect.width * 0.73, + y: rect.minY + rect.height * 0.84)) + path.addLine(to: CGPoint(x: rect.minX + rect.width * 0.52, + y: rect.minY + rect.height * 0.51)) + path.addLine(to: CGPoint(x: rect.minX + rect.width * 0.88, + y: rect.minY + rect.height * 0.49)) + path.closeSubpath() + return path + } +} + +private struct RemoteIBeamShape: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + let centerX = rect.midX + let top = rect.minY + 2 + let bottom = rect.maxY - 2 + path.move(to: CGPoint(x: centerX, y: top)) + path.addLine(to: CGPoint(x: centerX, y: bottom)) + path.move(to: CGPoint(x: rect.minX + 5, y: top)) + path.addLine(to: CGPoint(x: rect.maxX - 5, y: top)) + path.move(to: CGPoint(x: rect.minX + 5, y: bottom)) + path.addLine(to: CGPoint(x: rect.maxX - 5, y: bottom)) + return path + } +} diff --git a/ios/CrossDeskMobile/Rendering/NativeMetalVideoView.swift b/ios/CrossDeskMobile/Rendering/NativeMetalVideoView.swift new file mode 100644 index 0000000..9fcfdab --- /dev/null +++ b/ios/CrossDeskMobile/Rendering/NativeMetalVideoView.swift @@ -0,0 +1,151 @@ +import AVFoundation +import CoreMedia +import CoreVideo +import SwiftUI +import UIKit + +/// Presents decoded NV12 frames through iOS's native video display path. +/// +/// `AVSampleBufferDisplayLayer` handles YUV conversion and drawable scheduling +/// itself. This avoids the Core Image -> MTKView path, which can remain black +/// on a physical device even though VideoToolbox is producing valid frames. +struct NativeVideoView: UIViewRepresentable { + let pixelBuffer: CVPixelBuffer? + + func makeUIView(context: Context) -> SampleBufferVideoView { + SampleBufferVideoView(frame: .zero) + } + + func updateUIView(_ uiView: SampleBufferVideoView, context: Context) { + uiView.display(pixelBuffer) + } +} + +final class SampleBufferVideoView: UIView { + override class var layerClass: AnyClass { + AVSampleBufferDisplayLayer.self + } + + private var videoLayer: AVSampleBufferDisplayLayer { + layer as! AVSampleBufferDisplayLayer + } + + private var lastPixelBuffer: CVPixelBuffer? + private var submittedFrames: UInt64 = 0 + private var droppedFrames: UInt64 = 0 + + override init(frame: CGRect) { + super.init(frame: frame) + configureLayer() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configureLayer() + } + + private func configureLayer() { + backgroundColor = .black + isOpaque = true + videoLayer.backgroundColor = UIColor.black.cgColor + // The SwiftUI host gives this view the exact aspect-fit video rect. + // Filling that rect avoids a second, independently rounded aspect-fit + // calculation inside AVSampleBufferDisplayLayer. That rounding becomes + // visibly amplified when the remote desktop is zoomed up to 10x. + videoLayer.videoGravity = .resize + } + + func display(_ pixelBuffer: CVPixelBuffer?) { + guard let pixelBuffer else { + lastPixelBuffer = nil + submittedFrames = 0 + droppedFrames = 0 + videoLayer.flushAndRemoveImage() + return + } + + // SwiftUI can refresh for unrelated status fields. Do not enqueue the + // same retained CVPixelBuffer more than once. + guard lastPixelBuffer !== pixelBuffer else { return } + lastPixelBuffer = pixelBuffer + + if videoLayer.status == .failed { + NSLog("CrossDesk video layer failed: %@", + videoLayer.error?.localizedDescription ?? "unknown error") + videoLayer.flush() + } + + // Never let AVSampleBufferDisplayLayer turn temporary rendering + // pressure into seconds of latency. Its queued buffers are already + // stale by the time it stops accepting more data, so discard them and + // present the newest decoded IOSurface instead. + if !videoLayer.isReadyForMoreMediaData { + droppedFrames &+= 1 + videoLayer.flush() + if droppedFrames == 1 || droppedFrames.isMultiple(of: 300) { + NSLog("CrossDesk dropped stale display frames: %llu", + droppedFrames) + } + } + guard videoLayer.isReadyForMoreMediaData else { return } + + var formatDescription: CMVideoFormatDescription? + guard CMVideoFormatDescriptionCreateForImageBuffer( + allocator: kCFAllocatorDefault, + imageBuffer: pixelBuffer, + formatDescriptionOut: &formatDescription + ) == noErr, let formatDescription else { + NSLog("CrossDesk could not create a video format description") + return + } + + var timing = CMSampleTimingInfo( + duration: .invalid, + presentationTimeStamp: .invalid, + decodeTimeStamp: .invalid + ) + var sampleBuffer: CMSampleBuffer? + guard CMSampleBufferCreateReadyWithImageBuffer( + allocator: kCFAllocatorDefault, + imageBuffer: pixelBuffer, + formatDescription: formatDescription, + sampleTiming: &timing, + sampleBufferOut: &sampleBuffer + ) == noErr, let sampleBuffer else { + NSLog("CrossDesk could not create a video sample buffer") + return + } + + if let attachments = CMSampleBufferGetSampleAttachmentsArray( + sampleBuffer, + createIfNecessary: true + ), CFArrayGetCount(attachments) > 0 { + let attachment = unsafeBitCast( + CFArrayGetValueAtIndex(attachments, 0), + to: CFMutableDictionary.self + ) + CFDictionarySetValue( + attachment, + Unmanaged.passUnretained(kCMSampleAttachmentKey_DisplayImmediately).toOpaque(), + Unmanaged.passUnretained(kCFBooleanTrue).toOpaque() + ) + } + + videoLayer.enqueue(sampleBuffer) + submittedFrames &+= 1 + if submittedFrames == 1 || submittedFrames.isMultiple(of: 300) { + NSLog("CrossDesk presented video frame %llu (%zu x %zu)", + submittedFrames, + CVPixelBufferGetWidth(pixelBuffer), + CVPixelBufferGetHeight(pixelBuffer)) + } + if submittedFrames == 1 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + guard let self else { return } + NSLog("CrossDesk video layer status=%ld error=%@", + self.videoLayer.status.rawValue, + self.videoLayer.error?.localizedDescription ?? "none") + } + } + } +} diff --git a/ios/README.md b/ios/README.md new file mode 100644 index 0000000..9c7589b --- /dev/null +++ b/ios/README.md @@ -0,0 +1,70 @@ +# CrossDesk Mobile (native iOS) + +This target is a native MiniRTC controller. It uses the same WebSocket +signaling, libnice ICE, SRTP/RTP and data-stream protocol as the desktop app. +There is no `WKWebView` or browser runtime. + +## Requirements + +- Xcode 16 or newer +- Xmake available on `PATH` +- A physical arm64 iPhone or iPad running iOS 16 or newer + +## Build + +Open `CrossDeskMobile.xcodeproj`, select the `CrossDeskMobile` scheme and a +physical device, then build. The first build phase compiles MiniRTC and the +shared CrossDesk protocol, then merges their iPhoneOS static dependencies into +a local archive under `Vendor/`. + +You can also verify the target without code signing: + +```sh +xcodebuild -project ios/CrossDeskMobile.xcodeproj \ + -scheme CrossDeskMobile \ + -configuration Debug \ + -destination 'generic/platform=iOS' \ + CODE_SIGNING_ALLOWED=NO build +``` + +The first connection to a signaling server provisions and stores an identity +for that server. Remote control sessions then log in as `C-` 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. diff --git a/ios/scripts/build_minirtc_ios.sh b/ios/scripts/build_minirtc_ios.sh new file mode 100755 index 0000000..275ef51 --- /dev/null +++ b/ios/scripts/build_minirtc_ios.sh @@ -0,0 +1,147 @@ +#!/bin/zsh + +set -euo pipefail + +SCRIPT_DIR="${0:A:h}" +IOS_DIR="${SCRIPT_DIR:h}" +REPO_DIR="${IOS_DIR:h}" +MINIRTC_DIR="${REPO_DIR}/submodules/minirtc" +CONFIG_NAME="${CONFIGURATION:-Debug}" +MODE="${CONFIG_NAME:l}" + +# Keep iOS dependencies isolated from desktop Xmake packages. Besides avoiding +# cross-project cache collisions, this guarantees every archive is compiled +# with the iOS 16 deployment target instead of the active SDK version. +export XMAKE_PKG_INSTALLDIR="${IOS_DIR}/.xmake/packages" + +if [[ "${MODE}" != "debug" && "${MODE}" != "release" ]]; then + MODE="release" +fi + +ARCH_NAME="${CURRENT_ARCH:-arm64}" +if [[ "${ARCH_NAME}" == "undefined_arch" ]]; then + ARCH_NAME="arm64" +fi +if [[ "${ARCH_NAME}" != "arm64" ]]; then + print -u2 "CrossDesk Mobile currently supports physical iOS arm64 builds only." + exit 64 +fi + +XMAKE_BIN="${XMAKE_BIN:-}" +if [[ -z "${XMAKE_BIN}" ]]; then + XMAKE_BIN="$(command -v xmake 2>/dev/null || true)" +fi +if [[ -z "${XMAKE_BIN}" ]]; then + for candidate in /opt/homebrew/bin/xmake /usr/local/bin/xmake "${HOME}/.local/bin/xmake"; do + if [[ -x "${candidate}" ]]; then + XMAKE_BIN="${candidate}" + break + fi + done +fi +if [[ -z "${XMAKE_BIN}" || ! -x "${XMAKE_BIN}" ]]; then + print -u2 "xmake is required. Install it from https://xmake.io first." + exit 69 +fi + +OUTPUT_DIR="${IOS_DIR}/Vendor/iphoneos/${CONFIG_NAME}" +OUTPUT_LIBRARY="${OUTPUT_DIR}/libCrossDeskMiniRTC.a" +MINIRTC_BUILD_DIR="${IOS_DIR}/.xmake/minirtc-build" +MINIRTC_LIBRARY="${MINIRTC_BUILD_DIR}/iphoneos/arm64/${MODE}/libminirtc.a" +CROSSDESK_SOURCE_DIR="${REPO_DIR}/src" +CROSSDESK_BUILD_DIR="${IOS_DIR}/.xmake/crossdesk-build/iphoneos/arm64/${MODE}" + +mkdir -p "${OUTPUT_DIR}" + +# Xmake stores the configured build directory relative to the process working +# directory. Xcode does not guarantee that directory for build phases, so keep +# configuration, compilation and inspection anchored to the MiniRTC project. +( + cd "${MINIRTC_DIR}" + "${XMAKE_BIN}" f -P "${MINIRTC_DIR}" -c -o "${MINIRTC_BUILD_DIR}" \ + -p iphoneos -a arm64 -m "${MODE}" \ + --target_minver=16.0 --USE_CUDA=false -y + "${XMAKE_BIN}" b -P "${MINIRTC_DIR}" minirtc +) + +if [[ ! -f "${MINIRTC_LIBRARY}" ]]; then + print -u2 "MiniRTC archive was not produced at ${MINIRTC_LIBRARY}." + exit 66 +fi + +# Xmake owns the package hashes, so query its resolved target instead of +# embedding machine-specific ~/.xmake paths in the Xcode project. +TARGET_INFO="$(cd "${MINIRTC_DIR}" && TERM=dumb NO_COLOR=1 \ + "${XMAKE_BIN}" show -P "${MINIRTC_DIR}" -t minirtc)" +CLEAN_INFO="$(print -r -- "${TARGET_INFO}" | sed $'s/\033\\[[0-9;]*[[:alpha:]]//g')" +LINK_DIRS=("${(@f)$(print -r -- "${CLEAN_INFO}" | sed -nE 's|.*-> (/.*)/lib -> package.*|\1/lib|p' | sort -u)}") + +REQUIRED_LINKS=( + nice glib-2.0 gobject-2.0 gmodule-2.0 gio-2.0 gthread-2.0 intl + ffi pcre2-8 pcre2-posix z ssl crypto srtp2 openfec opus yuv kcp + datachannel usrsctp openh264 dav1d aom SvtAv1Enc +) +DEPENDENCY_ARCHIVES=() + +for link_name in "${REQUIRED_LINKS[@]}"; do + archive_path="" + for link_dir in "${LINK_DIRS[@]}"; do + candidate="${link_dir}/lib${link_name}.a" + if [[ -f "${candidate}" ]]; then + archive_path="${candidate}" + break + fi + done + if [[ -z "${archive_path}" ]]; then + print -u2 "Unable to resolve static dependency lib${link_name}.a from Xmake." + exit 66 + fi + DEPENDENCY_ARCHIVES+=("${archive_path}") +done + +# Build the CrossDesk message and file-transfer codecs for the same iOS +# architecture, then merge them into the aggregate archive consumed by Xcode. +NLOHMANN_HEADER="$(find "${XMAKE_PKG_INSTALLDIR}/n/nlohmann_json" \ + -path '*/include/nlohmann/json.hpp' -print -quit 2>/dev/null || true)" +if [[ -z "${NLOHMANN_HEADER}" ]]; then + print -u2 "Unable to locate the nlohmann_json headers installed by Xmake." + exit 66 +fi +NLOHMANN_INCLUDE="${NLOHMANN_HEADER:h:h}" +IOS_SDK_PATH="$(xcrun --sdk iphoneos --show-sdk-path)" +mkdir -p "${CROSSDESK_BUILD_DIR}" + +CROSSDESK_CXXFLAGS=( + -std=c++17 -arch arm64 -isysroot "${IOS_SDK_PATH}" + -miphoneos-version-min=16.0 + -I"${CROSSDESK_SOURCE_DIR}/device_controller" + -I"${CROSSDESK_SOURCE_DIR}/common" + -I"${CROSSDESK_SOURCE_DIR}/tools" + -I"${NLOHMANN_INCLUDE}" +) +if [[ "${MODE}" == "debug" ]]; then + CROSSDESK_CXXFLAGS+=(-O0 -g) +else + CROSSDESK_CXXFLAGS+=(-O2 -DNDEBUG) +fi + +CROSSDESK_SOURCES=( + "${CROSSDESK_SOURCE_DIR}/device_controller/remote_action.cpp" + "${CROSSDESK_SOURCE_DIR}/tools/file_transfer_protocol.cpp" +) +CROSSDESK_OBJECTS=() +for source_path in "${CROSSDESK_SOURCES[@]}"; do + object_path="${CROSSDESK_BUILD_DIR}/${source_path:t:r}.o" + xcrun --sdk iphoneos clang++ "${CROSSDESK_CXXFLAGS[@]}" \ + -c "${source_path}" -o "${object_path}" + CROSSDESK_OBJECTS+=("${object_path}") +done + +TEMP_LIBRARY="${OUTPUT_LIBRARY}.tmp" +rm -f "${TEMP_LIBRARY}" +/usr/bin/libtool -static -o "${TEMP_LIBRARY}" \ + "${MINIRTC_LIBRARY}" "${DEPENDENCY_ARCHIVES[@]}" \ + "${CROSSDESK_OBJECTS[@]}" +mv -f "${TEMP_LIBRARY}" "${OUTPUT_LIBRARY}" + +print "Created ${OUTPUT_LIBRARY}" diff --git a/scripts/macosx/pkg_arm64.sh b/scripts/macosx/pkg_arm64.sh index debaf24..a4d3c23 100755 --- a/scripts/macosx/pkg_arm64.sh +++ b/scripts/macosx/pkg_arm64.sh @@ -1,16 +1,19 @@ #!/usr/bin/env bash set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" APP_NAME="crossdesk" APP_NAME_UPPER="CrossDesk" -EXECUTABLE_PATH="./build/macosx/arm64/release/crossdesk" +EXECUTABLE_PATH="${PROJECT_ROOT}/build/macosx/arm64/release/crossdesk" PLATFORM="macos" ARCH="arm64" BINARY_ARCH="arm64" IDENTIFIER="cn.crossdesk.app" -ICON_PATH="icons/macos/crossdesk.icns" +ICON_PATH="${PROJECT_ROOT}/icons/macos/crossdesk.icns" MACOS_MIN_VERSION="14.0" -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +cd "$PROJECT_ROOT" normalize_app_version() { local input="$1" @@ -103,7 +106,7 @@ find "${APP_BUNDLE}" -name '._*' -delete echo ".app created successfully." mkdir -p build_pkg_scripts -cp scripts/macosx/tcc_postinstall.sh build_pkg_scripts/postinstall +cp "${SCRIPT_DIR}/tcc_postinstall.sh" build_pkg_scripts/postinstall chmod +x build_pkg_scripts/postinstall mkdir -p build_pkg_resources diff --git a/scripts/macosx/pkg_x64.sh b/scripts/macosx/pkg_x64.sh index 79fd4a5..3f1ce79 100755 --- a/scripts/macosx/pkg_x64.sh +++ b/scripts/macosx/pkg_x64.sh @@ -1,16 +1,19 @@ #!/usr/bin/env bash set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" APP_NAME="crossdesk" APP_NAME_UPPER="CrossDesk" -EXECUTABLE_PATH="build/macosx/x86_64/release/crossdesk" +EXECUTABLE_PATH="${PROJECT_ROOT}/build/macosx/x86_64/release/crossdesk" PLATFORM="macos" ARCH="x64" BINARY_ARCH="x86_64" IDENTIFIER="cn.crossdesk.app" -ICON_PATH="icons/macos/crossdesk.icns" +ICON_PATH="${PROJECT_ROOT}/icons/macos/crossdesk.icns" MACOS_MIN_VERSION="14.0" -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +cd "$PROJECT_ROOT" normalize_app_version() { local input="$1" @@ -103,7 +106,7 @@ find "${APP_BUNDLE}" -name '._*' -delete echo ".app created successfully." mkdir -p build_pkg_scripts -cp scripts/macosx/tcc_postinstall.sh build_pkg_scripts/postinstall +cp "${SCRIPT_DIR}/tcc_postinstall.sh" build_pkg_scripts/postinstall chmod +x build_pkg_scripts/postinstall mkdir -p build_pkg_resources diff --git a/src/common/stream_names.h b/src/common/stream_names.h new file mode 100644 index 0000000..d47dceb --- /dev/null +++ b/src/common/stream_names.h @@ -0,0 +1,22 @@ +#ifndef _STREAM_NAMES_H_ +#define _STREAM_NAMES_H_ + +#include + +namespace crossdesk::protocol { + +inline constexpr char kAudioStream[] = "control_audio"; +inline constexpr char kDataStream[] = "data"; +inline constexpr char kMouseStream[] = "mouse"; +inline constexpr char kKeyboardStream[] = "keyboard"; +inline constexpr char kControlStream[] = "control_data"; +inline constexpr char kFileStream[] = "file"; +inline constexpr char kFileFeedbackStream[] = "file_feedback"; +inline constexpr char kClipboardStream[] = "clipboard"; + +inline constexpr std::size_t kFileChunkSize = 64 * 1024; +inline constexpr std::size_t kMaxClipboardBytes = 128 * 1024; + +} // namespace crossdesk::protocol + +#endif diff --git a/src/device_controller/device_controller.h b/src/device_controller/device_controller.h index 9264d41..c21b00b 100644 --- a/src/device_controller/device_controller.h +++ b/src/device_controller/device_controller.h @@ -7,20 +7,16 @@ #ifndef _DEVICE_CONTROLLER_H_ #define _DEVICE_CONTROLLER_H_ -#include - +#include #include -#include -#include #include -#include "display_info.h" #include "remote_cursor_shape.h" -using json = nlohmann::json; namespace crossdesk { -typedef enum { +enum ControlType { + invalid = -1, mouse = 0, keyboard = 1, audio_capture = 2, @@ -30,8 +26,9 @@ typedef enum { service_command = 6, keyboard_state = 7, cursor_state = 8, -} ControlType; -typedef enum { +}; + +enum MouseFlag { move = 0, left_down, left_up, @@ -40,66 +37,80 @@ typedef enum { middle_down, middle_up, wheel_vertical, - wheel_horizontal -} MouseFlag; -typedef enum { key_down = 0, key_up } KeyFlag; -typedef enum { send_sas = 0, lock_workstation } ServiceCommandFlag; -typedef struct { + wheel_horizontal, +}; + +enum KeyFlag { key_down = 0, key_up }; +enum ServiceCommandFlag { send_sas = 0, lock_workstation }; + +struct Mouse { float x; float y; int s; MouseFlag flag; -} Mouse; +}; -typedef struct { - size_t key_value; +struct Key { + std::size_t key_value; uint32_t scan_code; bool extended; KeyFlag flag; -} Key; +}; -inline constexpr size_t kMaxKeyboardStateKeys = 32; +inline constexpr std::size_t kMaxKeyboardStateKeys = 32; -typedef struct { - size_t key_value; +struct KeyboardStateKey { + std::size_t key_value; uint32_t scan_code; bool extended; -} KeyboardStateKey; +}; -typedef struct { +struct KeyboardState { uint32_t seq; - size_t pressed_count; + std::size_t pressed_count; KeyboardStateKey pressed_keys[kMaxKeyboardStateKeys]; -} KeyboardState; +}; -typedef struct { +struct CursorState { uint32_t seq; bool visible; RemoteCursorShape shape; -} CursorState; + bool position_valid; + float x; + float y; + // Normalized displacement from the input hotspot to the visible cursor + // anchor. This is presentation metadata and must not affect input mapping. + float visual_offset_x; + float visual_offset_y; + int display_id; + // Whether receivers should apply the position fields in this message. + // Shape-only updates set this to false so cursor appearance can remain + // responsive while position feedback to the input source is suppressed. + bool position_update; +}; -typedef struct { +struct HostInfo { char host_name[64]; - size_t host_name_size; + std::size_t host_name_size; char** display_list; - size_t display_num; + std::size_t display_num; int* left; int* top; int* right; int* bottom; -} HostInfo; +}; -typedef struct { +struct ServiceStatus { bool available; char interactive_stage[32]; -} ServiceStatus; +}; -typedef struct { +struct ServiceCommand { ServiceCommandFlag flag; -} ServiceCommand; +}; struct RemoteAction { - ControlType type; + ControlType type = ControlType::invalid; union { Mouse m; Key k; @@ -112,210 +123,25 @@ struct RemoteAction { ServiceCommand c; }; - // parse - std::string to_json() const { return ToJson(*this); } + std::string to_json() const; + bool from_json(const std::string& json_string); - bool from_json(const std::string& json_str) { - RemoteAction temp; - if (!FromJson(json_str, temp)) return false; - *this = temp; - return true; - } - - static std::string ToJson(const RemoteAction& a) { - json j; - j["type"] = a.type; - switch (a.type) { - case ControlType::mouse: - j["mouse"] = { - {"x", a.m.x}, {"y", a.m.y}, {"s", a.m.s}, {"flag", a.m.flag}}; - break; - case ControlType::keyboard: - j["keyboard"] = {{"key_value", a.k.key_value}, - {"scan_code", a.k.scan_code}, - {"extended", a.k.extended}, - {"flag", a.k.flag}}; - break; - case ControlType::keyboard_state: { - json keys = json::array(); - const size_t pressed_count = - a.ks.pressed_count < kMaxKeyboardStateKeys - ? a.ks.pressed_count - : kMaxKeyboardStateKeys; - for (size_t idx = 0; idx < pressed_count; ++idx) { - keys.push_back({{"key_value", a.ks.pressed_keys[idx].key_value}, - {"scan_code", a.ks.pressed_keys[idx].scan_code}, - {"extended", a.ks.pressed_keys[idx].extended}}); - } - j["keyboard_state"] = {{"seq", a.ks.seq}, {"pressed_keys", keys}}; - break; - } - case ControlType::cursor_state: - j["cursor_state"] = {{"seq", a.cs.seq}, - {"visible", a.cs.visible}, - {"shape", static_cast(a.cs.shape)}}; - break; - case ControlType::audio_capture: - j["audio_capture"] = a.a; - break; - case ControlType::display_id: - j["display_id"] = a.d; - break; - case ControlType::service_status: - j["service_status"] = {{"available", a.ss.available}, - {"interactive_stage", a.ss.interactive_stage}}; - break; - case ControlType::service_command: - j["service_command"] = {{"flag", a.c.flag}}; - break; - case ControlType::host_infomation: { - json displays = json::array(); - for (size_t idx = 0; idx < a.i.display_num; idx++) { - displays.push_back( - {{"name", a.i.display_list ? a.i.display_list[idx] : ""}, - {"left", a.i.left ? a.i.left[idx] : 0}, - {"top", a.i.top ? a.i.top[idx] : 0}, - {"right", a.i.right ? a.i.right[idx] : 0}, - {"bottom", a.i.bottom ? a.i.bottom[idx] : 0}}); - } - - j["host_info"] = {{"host_name", a.i.host_name}, - {"display_num", a.i.display_num}, - {"displays", displays}}; - break; - } - } - return j.dump(); - } - - static bool FromJson(const std::string& json_str, RemoteAction& out) { - try { - json j = json::parse(json_str); - out.type = (ControlType)j.at("type").get(); - switch (out.type) { - case ControlType::mouse: - out.m.x = j.at("mouse").at("x").get(); - out.m.y = j.at("mouse").at("y").get(); - out.m.s = j.at("mouse").at("s").get(); - out.m.flag = (MouseFlag)j.at("mouse").at("flag").get(); - break; - case ControlType::keyboard: - out.k.key_value = j.at("keyboard").at("key_value").get(); - out.k.scan_code = - j.at("keyboard").value("scan_code", static_cast(0)); - out.k.extended = j.at("keyboard").value("extended", false); - out.k.flag = (KeyFlag)j.at("keyboard").at("flag").get(); - break; - case ControlType::keyboard_state: { - const auto& keyboard_state_json = j.at("keyboard_state"); - out.ks.seq = keyboard_state_json.value("seq", 0u); - out.ks.pressed_count = 0; - - const auto keys_json = - keyboard_state_json.value("pressed_keys", json::array()); - if (!keys_json.is_array()) { - break; - } - - const size_t count = - keys_json.size() < kMaxKeyboardStateKeys - ? keys_json.size() - : kMaxKeyboardStateKeys; - for (size_t idx = 0; idx < count; ++idx) { - const auto& key_json = keys_json[idx]; - out.ks.pressed_keys[idx].key_value = - key_json.at("key_value").get(); - out.ks.pressed_keys[idx].scan_code = - key_json.value("scan_code", static_cast(0)); - out.ks.pressed_keys[idx].extended = - key_json.value("extended", false); - } - out.ks.pressed_count = count; - break; - } - case ControlType::cursor_state: { - const auto& cursor_state_json = j.at("cursor_state"); - const int shape = cursor_state_json.at("shape").get(); - if (shape < static_cast(RemoteCursorShape::default_cursor) || - shape > static_cast(RemoteCursorShape::nwse_resize)) { - return false; - } - out.cs.seq = cursor_state_json.at("seq").get(); - out.cs.visible = cursor_state_json.at("visible").get(); - out.cs.shape = static_cast(shape); - break; - } - case ControlType::audio_capture: - out.a = j.at("audio_capture").get(); - break; - case ControlType::display_id: - out.d = j.at("display_id").get(); - break; - case ControlType::service_status: { - const auto& service_status_json = j.at("service_status"); - out.ss.available = service_status_json.value("available", false); - std::string interactive_stage = - service_status_json.value("interactive_stage", std::string()); - std::strncpy(out.ss.interactive_stage, interactive_stage.c_str(), - sizeof(out.ss.interactive_stage) - 1); - out.ss.interactive_stage[sizeof(out.ss.interactive_stage) - 1] = '\0'; - break; - } - case ControlType::service_command: - out.c.flag = static_cast( - j.at("service_command").at("flag").get()); - break; - case ControlType::host_infomation: { - std::string host_name = - j.at("host_info").at("host_name").get(); - strncpy(out.i.host_name, host_name.c_str(), sizeof(out.i.host_name)); - out.i.host_name[sizeof(out.i.host_name) - 1] = '\0'; - out.i.host_name_size = host_name.size(); - - out.i.display_num = j.at("host_info").at("display_num").get(); - auto displays = j.at("host_info").at("displays"); - - out.i.display_list = - (char**)malloc(out.i.display_num * sizeof(char*)); - out.i.left = (int*)malloc(out.i.display_num * sizeof(int)); - out.i.top = (int*)malloc(out.i.display_num * sizeof(int)); - out.i.right = (int*)malloc(out.i.display_num * sizeof(int)); - out.i.bottom = (int*)malloc(out.i.display_num * sizeof(int)); - - for (size_t idx = 0; idx < out.i.display_num; idx++) { - std::string name = displays[idx].at("name").get(); - out.i.display_list[idx] = (char*)malloc(name.size() + 1); - strcpy(out.i.display_list[idx], name.c_str()); - out.i.left[idx] = displays[idx].at("left").get(); - out.i.top[idx] = displays[idx].at("top").get(); - out.i.right[idx] = displays[idx].at("right").get(); - out.i.bottom[idx] = displays[idx].at("bottom").get(); - } - break; - } - } - return true; - } catch (const std::exception& e) { - printf("Failed to parse RemoteAction JSON: %s\n", e.what()); - return false; - } - } + static std::string ToJson(const RemoteAction& action); + static bool FromJson(const std::string& json_string, RemoteAction& output); }; +// Releases the dynamically allocated display arrays held by host information. +// Other RemoteAction variants do not own memory and are left unchanged. +void FreeRemoteAction(RemoteAction& action); + // int key_code, bool is_down, uint32_t scan_code, bool extended -typedef void (*OnKeyAction)(int, bool, uint32_t, bool, void*); +using OnKeyAction = void (*)(int, bool, uint32_t, bool, void*); class DeviceController { public: - virtual ~DeviceController() {} - - public: - // virtual int Init(int screen_width, int screen_height); - // virtual int Destroy(); - // virtual int SendMouseCommand(RemoteAction remote_action); - - // virtual int Hook(); - // virtual int Unhook(); + virtual ~DeviceController() = default; }; + } // namespace crossdesk + #endif diff --git a/src/device_controller/mouse/linux/mouse_controller.h b/src/device_controller/mouse/linux/mouse_controller.h index a2fa312..0d19b92 100644 --- a/src/device_controller/mouse/linux/mouse_controller.h +++ b/src/device_controller/mouse/linux/mouse_controller.h @@ -13,6 +13,7 @@ #include #include "device_controller.h" +#include "display_info.h" struct DBusConnection; struct DBusMessageIter; diff --git a/src/device_controller/mouse/mac/mouse_controller.cpp b/src/device_controller/mouse/mac/mouse_controller.cpp index 5575604..0006c20 100644 --- a/src/device_controller/mouse/mac/mouse_controller.cpp +++ b/src/device_controller/mouse/mac/mouse_controller.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include "rd_log.h" @@ -44,6 +45,13 @@ int MouseController::Init(std::vector display_info_list) { int MouseController::Destroy() { return 0; } +void MouseController::UpdateDisplayInfoList( + const std::vector& display_info_list) { + if (!display_info_list.empty()) { + display_info_list_ = display_info_list; + } +} + int MouseController::BeginClick(ClickTracker& tracker, int x, int y) { const auto now = std::chrono::steady_clock::now(); const bool continues_previous_click = @@ -94,10 +102,26 @@ int MouseController::SendMouseCommand(RemoteAction remote_action, return -1; } - const float normalized_x = std::clamp(remote_action.m.x, 0.0f, 1.0f); - const float normalized_y = std::clamp(remote_action.m.y, 0.0f, 1.0f); - int mouse_pos_x = normalized_x * display_info.width + display_info.left; - int mouse_pos_y = normalized_y * display_info.height + display_info.top; + const double normalized_x = + std::clamp(static_cast(remote_action.m.x), 0.0, 1.0); + const double normalized_y = + std::clamp(static_cast(remote_action.m.y), 0.0, 1.0); + // Keep the coordinate continuous until it reaches Core Graphics. This + // avoids turning a one-pixel rounding error into a visible offset when the + // client zooms the remote image. The upper bound remains just inside the + // display because CG display rectangles use an exclusive right/bottom edge. + const double max_x = std::nextafter(static_cast(display_info.right), + static_cast(display_info.left)); + const double max_y = std::nextafter(static_cast(display_info.bottom), + static_cast(display_info.top)); + const double mouse_pos_x = std::clamp( + display_info.left + normalized_x * display_info.width, + static_cast(display_info.left), max_x); + const double mouse_pos_y = std::clamp( + display_info.top + normalized_y * display_info.height, + static_cast(display_info.top), max_y); + const int tracked_mouse_pos_x = static_cast(std::lround(mouse_pos_x)); + const int tracked_mouse_pos_y = static_cast(std::lround(mouse_pos_y)); CGEventRef mouse_event = nullptr; CGEventType mouse_type; @@ -109,7 +133,8 @@ int MouseController::SendMouseCommand(RemoteAction remote_action, case MouseFlag::left_down: mouse_type = kCGEventLeftMouseDown; left_dragging_ = true; - click_state = BeginClick(left_click_tracker_, mouse_pos_x, mouse_pos_y); + click_state = BeginClick(left_click_tracker_, tracked_mouse_pos_x, + tracked_mouse_pos_y); mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point, kCGMouseButtonLeft); SetClickState(mouse_event, click_state); @@ -117,7 +142,8 @@ int MouseController::SendMouseCommand(RemoteAction remote_action, case MouseFlag::left_up: mouse_type = kCGEventLeftMouseUp; left_dragging_ = false; - click_state = EndClick(left_click_tracker_, mouse_pos_x, mouse_pos_y); + click_state = EndClick(left_click_tracker_, tracked_mouse_pos_x, + tracked_mouse_pos_y); mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point, kCGMouseButtonLeft); SetClickState(mouse_event, click_state); @@ -125,7 +151,8 @@ int MouseController::SendMouseCommand(RemoteAction remote_action, case MouseFlag::right_down: mouse_type = kCGEventRightMouseDown; right_dragging_ = true; - click_state = BeginClick(right_click_tracker_, mouse_pos_x, mouse_pos_y); + click_state = BeginClick(right_click_tracker_, tracked_mouse_pos_x, + tracked_mouse_pos_y); mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point, kCGMouseButtonRight); SetClickState(mouse_event, click_state); @@ -133,21 +160,24 @@ int MouseController::SendMouseCommand(RemoteAction remote_action, case MouseFlag::right_up: mouse_type = kCGEventRightMouseUp; right_dragging_ = false; - click_state = EndClick(right_click_tracker_, mouse_pos_x, mouse_pos_y); + click_state = EndClick(right_click_tracker_, tracked_mouse_pos_x, + tracked_mouse_pos_y); mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point, kCGMouseButtonRight); SetClickState(mouse_event, click_state); break; case MouseFlag::middle_down: mouse_type = kCGEventOtherMouseDown; - click_state = BeginClick(middle_click_tracker_, mouse_pos_x, mouse_pos_y); + click_state = BeginClick(middle_click_tracker_, tracked_mouse_pos_x, + tracked_mouse_pos_y); mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point, kCGMouseButtonCenter); SetClickState(mouse_event, click_state); break; case MouseFlag::middle_up: mouse_type = kCGEventOtherMouseUp; - click_state = EndClick(middle_click_tracker_, mouse_pos_x, mouse_pos_y); + click_state = EndClick(middle_click_tracker_, tracked_mouse_pos_x, + tracked_mouse_pos_y); mouse_event = CGEventCreateMouseEvent(NULL, mouse_type, mouse_point, kCGMouseButtonCenter); SetClickState(mouse_event, click_state); diff --git a/src/device_controller/mouse/mac/mouse_controller.h b/src/device_controller/mouse/mac/mouse_controller.h index 42f3240..ba1b7dc 100644 --- a/src/device_controller/mouse/mac/mouse_controller.h +++ b/src/device_controller/mouse/mac/mouse_controller.h @@ -11,6 +11,7 @@ #include #include "device_controller.h" +#include "display_info.h" namespace crossdesk { @@ -23,6 +24,8 @@ class MouseController : public DeviceController { virtual int Init(std::vector display_info_list); virtual int Destroy(); virtual int SendMouseCommand(RemoteAction remote_action, int display_index); + void UpdateDisplayInfoList( + const std::vector& display_info_list); private: struct ClickTracker { diff --git a/src/device_controller/mouse/windows/mouse_controller.h b/src/device_controller/mouse/windows/mouse_controller.h index 27562f7..8cc57a5 100644 --- a/src/device_controller/mouse/windows/mouse_controller.h +++ b/src/device_controller/mouse/windows/mouse_controller.h @@ -10,6 +10,7 @@ #include #include "device_controller.h" +#include "display_info.h" namespace crossdesk { @@ -27,4 +28,4 @@ class MouseController : public DeviceController { std::vector display_info_list_; }; } // namespace crossdesk -#endif \ No newline at end of file +#endif diff --git a/src/device_controller/remote_action.cpp b/src/device_controller/remote_action.cpp new file mode 100644 index 0000000..bb293c2 --- /dev/null +++ b/src/device_controller/remote_action.cpp @@ -0,0 +1,300 @@ +#include "device_controller.h" + +#include +#include +#include +#include +#include + +#include + +namespace crossdesk { +namespace { + +using json = nlohmann::json; + +void ResetHostInfo(HostInfo& info) { + info.host_name[0] = '\0'; + info.host_name_size = 0; + info.display_list = nullptr; + info.display_num = 0; + info.left = nullptr; + info.top = nullptr; + info.right = nullptr; + info.bottom = nullptr; +} + +bool AllocateHostDisplays(HostInfo& info, std::size_t count) { + if (count == 0) return true; + + info.display_list = static_cast(std::calloc(count, sizeof(char*))); + info.left = static_cast(std::malloc(count * sizeof(int))); + info.top = static_cast(std::malloc(count * sizeof(int))); + info.right = static_cast(std::malloc(count * sizeof(int))); + info.bottom = static_cast(std::malloc(count * sizeof(int))); + return info.display_list && info.left && info.top && info.right && + info.bottom; +} + +} // namespace + +std::string RemoteAction::to_json() const { return ToJson(*this); } + +bool RemoteAction::from_json(const std::string& json_string) { + RemoteAction temporary{}; + if (!FromJson(json_string, temporary)) return false; + *this = temporary; + return true; +} + +std::string RemoteAction::ToJson(const RemoteAction& action) { + if (action.type == ControlType::invalid) return {}; + + json object; + object["type"] = action.type; + switch (action.type) { + case ControlType::mouse: + object["mouse"] = {{"x", action.m.x}, + {"y", action.m.y}, + {"s", action.m.s}, + {"flag", action.m.flag}}; + break; + case ControlType::keyboard: + object["keyboard"] = {{"key_value", action.k.key_value}, + {"scan_code", action.k.scan_code}, + {"extended", action.k.extended}, + {"flag", action.k.flag}}; + break; + case ControlType::keyboard_state: { + json keys = json::array(); + const std::size_t pressed_count = + std::min(action.ks.pressed_count, kMaxKeyboardStateKeys); + for (std::size_t index = 0; index < pressed_count; ++index) { + keys.push_back( + {{"key_value", action.ks.pressed_keys[index].key_value}, + {"scan_code", action.ks.pressed_keys[index].scan_code}, + {"extended", action.ks.pressed_keys[index].extended}}); + } + object["keyboard_state"] = + {{"seq", action.ks.seq}, {"pressed_keys", keys}}; + break; + } + case ControlType::cursor_state: + object["cursor_state"] = + {{"seq", action.cs.seq}, + {"visible", action.cs.visible}, + {"shape", static_cast(action.cs.shape)}, + {"position_valid", action.cs.position_valid}, + {"x", action.cs.x}, + {"y", action.cs.y}, + {"visual_offset_x", action.cs.visual_offset_x}, + {"visual_offset_y", action.cs.visual_offset_y}, + {"display_id", action.cs.display_id}, + {"position_update", action.cs.position_update}}; + break; + case ControlType::audio_capture: + object["audio_capture"] = action.a; + break; + case ControlType::display_id: + object["display_id"] = action.d; + break; + case ControlType::service_status: + object["service_status"] = + {{"available", action.ss.available}, + {"interactive_stage", action.ss.interactive_stage}}; + break; + case ControlType::service_command: + object["service_command"] = {{"flag", action.c.flag}}; + break; + case ControlType::host_infomation: { + json displays = json::array(); + for (std::size_t index = 0; index < action.i.display_num; ++index) { + displays.push_back( + {{"name", action.i.display_list ? action.i.display_list[index] + : ""}, + {"left", action.i.left ? action.i.left[index] : 0}, + {"top", action.i.top ? action.i.top[index] : 0}, + {"right", action.i.right ? action.i.right[index] : 0}, + {"bottom", action.i.bottom ? action.i.bottom[index] : 0}}); + } + object["host_info"] = {{"host_name", action.i.host_name}, + {"display_num", action.i.display_num}, + {"displays", displays}}; + break; + } + case ControlType::invalid: + default: + return {}; + } + return object.dump(); +} + +bool RemoteAction::FromJson(const std::string& json_string, + RemoteAction& output) { + bool owns_host_info = false; + try { + const json object = json::parse(json_string); + output.type = static_cast(object.at("type").get()); + switch (output.type) { + case ControlType::mouse: + output.m.x = object.at("mouse").at("x").get(); + output.m.y = object.at("mouse").at("y").get(); + output.m.s = object.at("mouse").at("s").get(); + output.m.flag = static_cast( + object.at("mouse").at("flag").get()); + break; + case ControlType::keyboard: + output.k.key_value = + object.at("keyboard").at("key_value").get(); + output.k.scan_code = object.at("keyboard").value("scan_code", 0u); + output.k.extended = object.at("keyboard").value("extended", false); + output.k.flag = static_cast( + object.at("keyboard").at("flag").get()); + break; + case ControlType::keyboard_state: { + const auto& keyboard_state_object = object.at("keyboard_state"); + output.ks.seq = keyboard_state_object.value("seq", 0u); + output.ks.pressed_count = 0; + const auto keys = + keyboard_state_object.value("pressed_keys", json::array()); + if (!keys.is_array()) break; + const std::size_t count = + std::min(keys.size(), kMaxKeyboardStateKeys); + for (std::size_t index = 0; index < count; ++index) { + output.ks.pressed_keys[index].key_value = + keys[index].at("key_value").get(); + output.ks.pressed_keys[index].scan_code = + keys[index].value("scan_code", 0u); + output.ks.pressed_keys[index].extended = + keys[index].value("extended", false); + } + output.ks.pressed_count = count; + break; + } + case ControlType::cursor_state: { + const auto& cursor_state_object = object.at("cursor_state"); + const int shape = cursor_state_object.at("shape").get(); + if (shape < static_cast(RemoteCursorShape::default_cursor) || + shape > static_cast(RemoteCursorShape::nwse_resize)) { + return false; + } + output.cs.seq = cursor_state_object.at("seq").get(); + output.cs.visible = cursor_state_object.at("visible").get(); + output.cs.shape = static_cast(shape); + output.cs.position_valid = + cursor_state_object.value("position_valid", false); + output.cs.x = cursor_state_object.value("x", 0.5f); + output.cs.y = cursor_state_object.value("y", 0.5f); + output.cs.visual_offset_x = + cursor_state_object.value("visual_offset_x", 0.0f); + output.cs.visual_offset_y = + cursor_state_object.value("visual_offset_y", 0.0f); + output.cs.display_id = cursor_state_object.value("display_id", -1); + // Cursor state messages predating position-only echo suppression + // always carried an authoritative position update. + output.cs.position_update = + cursor_state_object.value("position_update", true); + if (!std::isfinite(output.cs.x) || !std::isfinite(output.cs.y) || + !std::isfinite(output.cs.visual_offset_x) || + !std::isfinite(output.cs.visual_offset_y)) { + return false; + } + output.cs.x = std::clamp(output.cs.x, 0.0f, 1.0f); + output.cs.y = std::clamp(output.cs.y, 0.0f, 1.0f); + output.cs.visual_offset_x = + std::clamp(output.cs.visual_offset_x, -1.0f, 1.0f); + output.cs.visual_offset_y = + std::clamp(output.cs.visual_offset_y, -1.0f, 1.0f); + break; + } + case ControlType::audio_capture: + output.a = object.at("audio_capture").get(); + break; + case ControlType::display_id: + output.d = object.at("display_id").get(); + break; + case ControlType::service_status: { + const auto& service_status_object = object.at("service_status"); + output.ss.available = service_status_object.value("available", false); + const std::string stage = service_status_object.value( + "interactive_stage", std::string()); + std::strncpy(output.ss.interactive_stage, stage.c_str(), + sizeof(output.ss.interactive_stage) - 1); + output.ss.interactive_stage[sizeof(output.ss.interactive_stage) - 1] = + '\0'; + break; + } + case ControlType::service_command: + output.c.flag = static_cast( + object.at("service_command").at("flag").get()); + break; + case ControlType::host_infomation: { + ResetHostInfo(output.i); + owns_host_info = true; + const auto& host_info_object = object.at("host_info"); + const std::string host_name = + host_info_object.at("host_name").get(); + std::strncpy(output.i.host_name, host_name.c_str(), + sizeof(output.i.host_name) - 1); + output.i.host_name[sizeof(output.i.host_name) - 1] = '\0'; + output.i.host_name_size = std::strlen(output.i.host_name); + + const auto& displays = host_info_object.at("displays"); + if (!displays.is_array()) return false; + output.i.display_num = + host_info_object.at("display_num").get(); + if (output.i.display_num != displays.size()) return false; + if (!AllocateHostDisplays(output.i, output.i.display_num)) { + FreeRemoteAction(output); + return false; + } + + for (std::size_t index = 0; index < output.i.display_num; ++index) { + const std::string name = + displays[index].at("name").get(); + output.i.display_list[index] = + static_cast(std::malloc(name.size() + 1)); + if (!output.i.display_list[index]) { + FreeRemoteAction(output); + return false; + } + std::memcpy(output.i.display_list[index], name.c_str(), + name.size() + 1); + output.i.left[index] = displays[index].at("left").get(); + output.i.top[index] = displays[index].at("top").get(); + output.i.right[index] = displays[index].at("right").get(); + output.i.bottom[index] = displays[index].at("bottom").get(); + } + break; + } + default: + return false; + } + return true; + } catch (const std::exception& exception) { + if (owns_host_info) { + FreeRemoteAction(output); + } + std::fprintf(stderr, "Failed to parse RemoteAction JSON: %s\n", + exception.what()); + return false; + } +} + +void FreeRemoteAction(RemoteAction& action) { + if (action.type != ControlType::host_infomation) return; + + if (action.i.display_list) { + for (std::size_t index = 0; index < action.i.display_num; ++index) { + std::free(action.i.display_list[index]); + } + } + std::free(action.i.display_list); + std::free(action.i.left); + std::free(action.i.top); + std::free(action.i.right); + std::free(action.i.bottom); + ResetHostInfo(action.i); +} + +} // namespace crossdesk diff --git a/src/gui/application/gui_application.cpp b/src/gui/application/gui_application.cpp index 5090aea..5334556 100644 --- a/src/gui/application/gui_application.cpp +++ b/src/gui/application/gui_application.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include @@ -1807,60 +1808,142 @@ void GuiApplication::Tick() { } void GuiApplication::ShareLocalCursorState() { + constexpr auto kCursorEchoSuppressionInterval = 300ms; constexpr auto kCursorStateHeartbeatInterval = 500ms; + constexpr auto kCursorPositionShareInterval = 16ms; + constexpr float kCursorPositionEpsilon = 0.00005f; - bool has_connected_controller = false; + std::vector connected_controllers; { std::shared_lock lock(connection_status_mutex_); - has_connected_controller = - std::any_of(connection_status_.begin(), connection_status_.end(), - [](const auto& entry) { - return entry.second == ConnectionStatus::Connected; - }); + connected_controllers.reserve(connection_status_.size()); + for (const auto& [remote_id, status] : connection_status_) { + if (status == ConnectionStatus::Connected) { + connected_controllers.push_back(remote_id); + } + } } - if (!is_server_mode_ || !peer_ || !has_connected_controller) { - has_shared_cursor_state_ = false; - last_cursor_state_share_time_ = {}; + if (!is_server_mode_ || !peer_ || connected_controllers.empty()) { + cursor_delivery_states_.clear(); return; } CursorState sampled{}; - if (!cursor_state_provider_.Sample(&sampled)) { + if (!cursor_state_provider_.Sample(devices_.display_info_list(), + selected_display_, &sampled)) { return; } const auto now = std::chrono::steady_clock::now(); - const bool changed = - !has_shared_cursor_state_ || - sampled.visible != last_shared_cursor_state_.visible || - sampled.shape != last_shared_cursor_state_.shape; - const bool heartbeat_due = - last_cursor_state_share_time_.time_since_epoch().count() == 0 || - now - last_cursor_state_share_time_ >= kCursorStateHeartbeatInterval; - if (!changed && !heartbeat_due) { - return; + + std::unordered_map + last_input_times; + { + std::lock_guard lock(remote_pointer_input_mutex_); + for (const auto& remote_id : connected_controllers) { + const auto input_it = last_remote_pointer_input_time_.find(remote_id); + if (input_it != last_remote_pointer_input_time_.end()) { + last_input_times.emplace(remote_id, input_it->second); + } + } } + std::unordered_set connected_ids(connected_controllers.begin(), + connected_controllers.end()); + std::erase_if(cursor_delivery_states_, [&](const auto& entry) { + return connected_ids.find(entry.first) == connected_ids.end(); + }); + + struct CursorRecipient { + std::string remote_id; + bool include_position = true; + }; + std::vector recipients; + for (const auto& remote_id : connected_controllers) { + auto& delivery = cursor_delivery_states_[remote_id]; + const auto input_it = last_input_times.find(remote_id); + const bool suppress_position = + input_it != last_input_times.end() && + now - input_it->second < kCursorEchoSuppressionInterval; + delivery.feedback_pending = + delivery.feedback_pending || suppress_position; + + const CursorState& previous = delivery.last_sent; + const bool shape_changed = + !delivery.has_sent || sampled.visible != previous.visible || + sampled.shape != previous.shape || + std::abs(sampled.visual_offset_x - previous.visual_offset_x) > + kCursorPositionEpsilon || + std::abs(sampled.visual_offset_y - previous.visual_offset_y) > + kCursorPositionEpsilon; + // Preserve sub-point cursor motion. At 10x client zoom, the old roughly + // one-logical-pixel threshold became several visible phone points. + const bool position_changed = + !delivery.has_sent || + sampled.position_valid != previous.position_valid || + sampled.display_id != previous.display_id || + (sampled.position_valid && previous.position_valid && + (std::abs(sampled.x - previous.x) > kCursorPositionEpsilon || + std::abs(sampled.y - previous.y) > kCursorPositionEpsilon)); + const bool position_update_due = + position_changed && + (delivery.last_sent_time.time_since_epoch().count() == 0 || + now - delivery.last_sent_time >= kCursorPositionShareInterval); + const bool heartbeat_due = + delivery.last_sent_time.time_since_epoch().count() == 0 || + now - delivery.last_sent_time >= kCursorStateHeartbeatInterval; + if (suppress_position) { + // Cursor appearance is independent of cursor position. Continue sending + // shape changes to every controller while withholding only the sampled + // position from the connection that originated recent input. + if (shape_changed || heartbeat_due) { + recipients.push_back({remote_id, false}); + } + } else if (delivery.feedback_pending || shape_changed || + position_update_due || heartbeat_due) { + recipients.push_back({remote_id, true}); + } + } + if (recipients.empty()) return; + sampled.seq = ++cursor_state_sequence_; - RemoteAction action{}; - action.type = ControlType::cursor_state; - action.cs = sampled; - const std::string message = action.to_json(); - const int result = SendDataFrame(peer_, message.c_str(), message.size(), - mouse_label_.c_str()); - if (result != 0) { - LOG_WARN("Send cursor state failed, ret={}", result); - return; - } + for (const auto& recipient : recipients) { + auto& delivery = cursor_delivery_states_[recipient.remote_id]; + CursorState outgoing = sampled; + outgoing.position_update = recipient.include_position; + if (!recipient.include_position) { + // Keep legacy receivers at their last acknowledged position as well. + // New receivers honor position_update=false and leave their optimistic + // local position untouched. + outgoing.position_valid = + delivery.has_sent && delivery.last_sent.position_valid; + outgoing.x = delivery.has_sent ? delivery.last_sent.x : 0.5f; + outgoing.y = delivery.has_sent ? delivery.last_sent.y : 0.5f; + outgoing.display_id = delivery.has_sent + ? delivery.last_sent.display_id + : -1; + } - if (changed) { - LOG_INFO("Sent cursor state: seq={}, visible={}, shape={}", sampled.seq, - sampled.visible, static_cast(sampled.shape)); - } + RemoteAction action{}; + action.type = ControlType::cursor_state; + action.cs = outgoing; + const std::string message = action.to_json(); + const int result = SendDataFrameToPeer( + peer_, message.c_str(), message.size(), mouse_label_.c_str(), + recipient.remote_id.c_str(), recipient.remote_id.size()); + if (result != 0) { + LOG_WARN("Send cursor state to [{}] failed, ret={}", + recipient.remote_id, result); + continue; + } - last_shared_cursor_state_ = sampled; - has_shared_cursor_state_ = true; - last_cursor_state_share_time_ = now; + delivery.last_sent = outgoing; + delivery.has_sent = true; + if (recipient.include_position) { + delivery.feedback_pending = false; + } + delivery.last_sent_time = now; + } } void GuiApplication::HandlePasswordChangeResult() { diff --git a/src/gui/application/gui_application.h b/src/gui/application/gui_application.h index 737eb79..4e46427 100644 --- a/src/gui/application/gui_application.h +++ b/src/gui/application/gui_application.h @@ -1,9 +1,10 @@ -#ifndef CROSSDESK_GUI_APPLICATION_H_ -#define CROSSDESK_GUI_APPLICATION_H_ +#ifndef _GUI_APPLICATION_H_ +#define _GUI_APPLICATION_H_ #include #include #include +#include #include "runtime/cursor_state_provider.h" #include "runtime/gui_runtime.h" @@ -21,6 +22,12 @@ public: private: struct SlintUi; + struct CursorDeliveryState { + CursorState last_sent{}; + bool has_sent = false; + bool feedback_pending = false; + std::chrono::steady_clock::time_point last_sent_time{}; + }; void InitializeLogger(); void InitializeSettings(); @@ -73,10 +80,9 @@ private: std::unique_ptr ui_; CursorStateProvider cursor_state_provider_; - CursorState last_shared_cursor_state_{}; - bool has_shared_cursor_state_ = false; + std::unordered_map + cursor_delivery_states_; uint32_t cursor_state_sequence_ = 0; - std::chrono::steady_clock::time_point last_cursor_state_share_time_{}; std::chrono::steady_clock::time_point next_video_frame_time_{}; #if defined(__linux__) && !defined(__APPLE__) bool use_xwayland_gui_ = false; @@ -86,4 +92,4 @@ private: } // namespace crossdesk -#endif // CROSSDESK_GUI_APPLICATION_H_ +#endif diff --git a/src/gui/application/sdl_events.cpp b/src/gui/application/sdl_events.cpp index 4da2cb6..3fb6fd9 100644 --- a/src/gui/application/sdl_events.cpp +++ b/src/gui/application/sdl_events.cpp @@ -233,6 +233,15 @@ int GuiApplication::ProcessKeyboardEvent(const SDL_Event &event) { int GuiApplication::ProcessMouseEvent(const SDL_Event &event) { controlled_remote_id_ = ""; RemoteAction remote_action{}; + + if ((event.type == SDL_EVENT_MOUSE_BUTTON_DOWN || + event.type == SDL_EVENT_MOUSE_BUTTON_UP) && + event.button.button != SDL_BUTTON_LEFT && + event.button.button != SDL_BUTTON_RIGHT && + event.button.button != SDL_BUTTON_MIDDLE) { + return 0; + } + float cursor_x = last_mouse_event.motion.x; float cursor_y = last_mouse_event.motion.y; diff --git a/src/gui/features/devices/session_device_manager.cpp b/src/gui/features/devices/session_device_manager.cpp index a21013b..b056fc2 100644 --- a/src/gui/features/devices/session_device_manager.cpp +++ b/src/gui/features/devices/session_device_manager.cpp @@ -461,7 +461,7 @@ void SessionDeviceManager::UpdateInteractions() { owner_.mouse_controller_is_started_ = false; } -#if defined(__linux__) && !defined(__APPLE__) +#if defined(__linux__) || defined(__APPLE__) if (owner_.screen_capturer_is_started_ && screen_capturer_ && mouse_controller_) { const auto latest_display_info = screen_capturer_->GetDisplayInfoList(); diff --git a/src/gui/features/file_transfer/file_transfer_manager.cpp b/src/gui/features/file_transfer/file_transfer_manager.cpp index 24b023e..39a4a37 100644 --- a/src/gui/features/file_transfer/file_transfer_manager.cpp +++ b/src/gui/features/file_transfer/file_transfer_manager.cpp @@ -2,7 +2,6 @@ #include #include -#include #include #include #include @@ -236,15 +235,9 @@ void FileTransferManager::Unregister(uint32_t file_id, bool per_peer) { } void FileTransferManager::HandleAck(const char *data, size_t size) { - if (size < sizeof(FileTransferAck)) { - LOG_ERROR("FileTransferAck: buffer too small, size={}", size); - return; - } - FileTransferAck ack{}; - std::memcpy(&ack, data, sizeof(ack)); - if (ack.magic != kFileAckMagic) { - LOG_ERROR("FileTransferAck: invalid magic, got 0x{:08X}", ack.magic); + if (!protocol::DecodeFileTransferAck(data, size, &ack)) { + LOG_ERROR("FileTransferAck: invalid payload, size={}", size); return; } diff --git a/src/gui/runtime/connection_runtime.cpp b/src/gui/runtime/connection_runtime.cpp index ee48fff..9bf0277 100644 --- a/src/gui/runtime/connection_runtime.cpp +++ b/src/gui/runtime/connection_runtime.cpp @@ -123,6 +123,10 @@ void GuiRuntime::HandlePresenceProbeTimeout() { void GuiRuntime::HandleServerControllerDisconnected( const std::string& remote_id, const char* reason) { keyboard_.ReleaseRemotePressedKeys(remote_id, reason); + { + std::lock_guard lock(remote_pointer_input_mutex_); + last_remote_pointer_input_time_.erase(remote_id); + } bool has_connected_controller = false; bool has_web_controller = false; diff --git a/src/gui/runtime/cursor_position.h b/src/gui/runtime/cursor_position.h new file mode 100644 index 0000000..3b21a98 --- /dev/null +++ b/src/gui/runtime/cursor_position.h @@ -0,0 +1,66 @@ +#ifndef _CURSOR_POSITION_H_ +#define _CURSOR_POSITION_H_ + +#include +#include + +#include "device_controller.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& displays, int preferred_display, + CursorState* state) { + if (!state) return false; + ResetCursorPosition(state); + + auto contains = [&](int index) { + if (index < 0 || index >= static_cast(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(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(std::clamp( + (screen_x - display.left) / horizontal_extent, 0.0, 1.0)); + state->y = static_cast(std::clamp( + (screen_y - display.top) / vertical_extent, 0.0, 1.0)); + state->display_id = display_id; + return true; +} + +} // namespace crossdesk + +#endif diff --git a/src/gui/runtime/cursor_state_provider.cpp b/src/gui/runtime/cursor_state_provider.cpp index 243e984..3f2372e 100644 --- a/src/gui/runtime/cursor_state_provider.cpp +++ b/src/gui/runtime/cursor_state_provider.cpp @@ -4,6 +4,8 @@ #include +#include "runtime/cursor_position.h" + namespace crossdesk { namespace { @@ -39,7 +41,8 @@ struct CursorStateProvider::Impl {}; CursorStateProvider::CursorStateProvider() : impl_(std::make_unique()) {} CursorStateProvider::~CursorStateProvider() = default; -bool CursorStateProvider::Sample(CursorState* state) { +bool CursorStateProvider::Sample(const std::vector& displays, + int preferred_display, CursorState* state) { if (!state) return false; CURSORINFO info{}; @@ -50,6 +53,8 @@ bool CursorStateProvider::Sample(CursorState* state) { 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; } @@ -65,6 +70,7 @@ bool CursorStateProvider::Sample(CursorState* state) { #include "linux_cursor_shape.h" #include "platform.h" #include "shared_cursor_state.h" +#include "runtime/cursor_position.h" namespace crossdesk { namespace { @@ -91,8 +97,10 @@ struct CursorStateProvider::Impl { CursorStateProvider::CursorStateProvider() : impl_(std::make_unique()) {} CursorStateProvider::~CursorStateProvider() = default; -bool CursorStateProvider::Sample(CursorState* state) { +bool CursorStateProvider::Sample(const std::vector& displays, + int preferred_display, CursorState* state) { if (!state || !impl_) return false; + ResetCursorPosition(state); if (IsWaylandSession()) { SharedCursorState shared{}; @@ -115,6 +123,8 @@ bool CursorStateProvider::Sample(CursorState* state) { 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; } diff --git a/src/gui/runtime/cursor_state_provider.h b/src/gui/runtime/cursor_state_provider.h index 35600ae..b463730 100644 --- a/src/gui/runtime/cursor_state_provider.h +++ b/src/gui/runtime/cursor_state_provider.h @@ -1,9 +1,11 @@ -#ifndef CROSSDESK_GUI_CURSOR_STATE_PROVIDER_H_ -#define CROSSDESK_GUI_CURSOR_STATE_PROVIDER_H_ +#ifndef _CURSOR_STATE_PROVIDER_H_ +#define _CURSOR_STATE_PROVIDER_H_ #include +#include #include "device_controller.h" +#include "display_info.h" namespace crossdesk { @@ -17,7 +19,8 @@ class CursorStateProvider { CursorStateProvider(const CursorStateProvider&) = delete; CursorStateProvider& operator=(const CursorStateProvider&) = delete; - bool Sample(CursorState* state); + bool Sample(const std::vector& displays, + int preferred_display, CursorState* state); private: struct Impl; @@ -26,4 +29,4 @@ class CursorStateProvider { } // namespace crossdesk -#endif // CROSSDESK_GUI_CURSOR_STATE_PROVIDER_H_ +#endif diff --git a/src/gui/runtime/cursor_state_provider_mac.mm b/src/gui/runtime/cursor_state_provider_mac.mm index 0724614..a86cf2f 100644 --- a/src/gui/runtime/cursor_state_provider_mac.mm +++ b/src/gui/runtime/cursor_state_provider_mac.mm @@ -8,9 +8,17 @@ #include #include +#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; @@ -144,7 +152,8 @@ struct CursorStateProvider::Impl {}; CursorStateProvider::CursorStateProvider() : impl_(std::make_unique()) {} CursorStateProvider::~CursorStateProvider() = default; -bool CursorStateProvider::Sample(CursorState* state) { +bool CursorStateProvider::Sample(const std::vector& displays, + int preferred_display, CursorState* state) { if (!state) return false; #pragma clang diagnostic push @@ -157,6 +166,23 @@ bool CursorStateProvider::Sample(CursorState* state) { 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(displays.size())) { + const double display_height = + std::max(displays[state->display_id].height, 1); + state->visual_offset_y = static_cast( + kDefaultArrowVisualTipYOffset / display_height); + } + CFRelease(event); + } return true; } diff --git a/src/gui/runtime/gui_runtime.cpp b/src/gui/runtime/gui_runtime.cpp index 78123da..bb14a4f 100644 --- a/src/gui/runtime/gui_runtime.cpp +++ b/src/gui/runtime/gui_runtime.cpp @@ -141,6 +141,9 @@ int GuiRuntime::CreateConnectionPeer() { sizeof(params_.log_path) - 1); params_.log_path[sizeof(params_.log_path) - 1] = '\0'; params_.hardware_acceleration = config_center_->IsHardwareVideoCodec(); + // The Slint desktop renderer currently consumes packed CPU frames. Native + // renderers can opt into platform-native frame output independently. + params_.native_video_output = false; params_.av1_encoding = config_center_->GetVideoEncodeFormat() == ConfigCenter::VIDEO_ENCODE_FORMAT::AV1 ? true diff --git a/src/gui/runtime/peer_data_callbacks.cpp b/src/gui/runtime/peer_data_callbacks.cpp index fd3cb6e..9c72c14 100644 --- a/src/gui/runtime/peer_data_callbacks.cpp +++ b/src/gui/runtime/peer_data_callbacks.cpp @@ -97,6 +97,7 @@ void PeerEventHandler::OnReceiveDataBuffer( } receiver.SetOnSendAck([runtime, remote_user_id](const FileTransferAck &ack) -> int { + const auto encoded_ack = protocol::EncodeFileTransferAck(ack); bool is_server_sending = remote_user_id.rfind("C-", 0) != 0; if (is_server_sending) { auto props = @@ -104,14 +105,14 @@ void PeerEventHandler::OnReceiveDataBuffer( if (props) { PeerPtr *peer = props->peer_; return SendReliableDataFrame( - peer, reinterpret_cast(&ack), - sizeof(FileTransferAck), runtime->file_feedback_label_.c_str()); + peer, encoded_ack.data(), encoded_ack.size(), + runtime->file_feedback_label_.c_str()); } } return SendReliableDataFrame( - runtime->peer_, reinterpret_cast(&ack), - sizeof(FileTransferAck), runtime->file_feedback_label_.c_str()); + runtime->peer_, encoded_ack.data(), encoded_ack.size(), + runtime->file_feedback_label_.c_str()); }); receiver.OnData(data, size); @@ -187,7 +188,15 @@ void PeerEventHandler::OnReceiveDataBuffer( !props->remote_cursor_state_received_ || props->remote_cursor_state_.visible != remote_action.cs.visible || props->remote_cursor_state_.shape != remote_action.cs.shape; - props->remote_cursor_state_ = remote_action.cs; + CursorState merged = remote_action.cs; + if (!remote_action.cs.position_update && + props->remote_cursor_state_received_) { + merged.position_valid = props->remote_cursor_state_.position_valid; + merged.x = props->remote_cursor_state_.x; + merged.y = props->remote_cursor_state_.y; + merged.display_id = props->remote_cursor_state_.display_id; + } + props->remote_cursor_state_ = merged; props->remote_cursor_state_received_ = true; if (changed) { LOG_INFO("Received cursor state: seq={}, visible={}, shape={}", @@ -238,6 +247,12 @@ void PeerEventHandler::OnReceiveDataBuffer( } } else { // remote + if (runtime->is_server_mode_ && + remote_action.type == ControlType::mouse) { + std::lock_guard lock(runtime->remote_pointer_input_mutex_); + runtime->last_remote_pointer_input_time_[remote_id] = + std::chrono::steady_clock::now(); + } #if _WIN32 if (runtime->local_service_status_received_ && IsSecureDesktopInteractionRequired(runtime->local_interactive_stage_) && diff --git a/src/gui/runtime/remote_session.h b/src/gui/runtime/remote_session.h index cdb66b7..f86c094 100644 --- a/src/gui/runtime/remote_session.h +++ b/src/gui/runtime/remote_session.h @@ -1,5 +1,5 @@ -#ifndef CROSSDESK_GUI_REMOTE_SESSION_H_ -#define CROSSDESK_GUI_REMOTE_SESSION_H_ +#ifndef _REMOTE_SESSION_H_ +#define _REMOTE_SESSION_H_ #include #include @@ -13,6 +13,7 @@ #include #include +#include "stream_names.h" #include "device_controller.h" #include "display_info.h" #include "minirtc.h" @@ -60,14 +61,14 @@ struct FileTransferState { struct RemoteSession { Params params_; PeerPtr* peer_ = nullptr; - std::string audio_label_ = "control_audio"; - std::string data_label_ = "data"; - std::string mouse_label_ = "mouse"; - std::string keyboard_label_ = "keyboard"; - std::string file_label_ = "file"; - std::string control_data_label_ = "control_data"; - std::string file_feedback_label_ = "file_feedback"; - std::string clipboard_label_ = "clipboard"; + std::string audio_label_ = protocol::kAudioStream; + std::string data_label_ = protocol::kDataStream; + std::string mouse_label_ = protocol::kMouseStream; + std::string keyboard_label_ = protocol::kKeyboardStream; + std::string file_label_ = protocol::kFileStream; + std::string control_data_label_ = protocol::kControlStream; + std::string file_feedback_label_ = protocol::kFileFeedbackStream; + std::string clipboard_label_ = protocol::kClipboardStream; std::string local_id_; std::string remote_id_; bool exit_ = false; @@ -173,4 +174,4 @@ using RemoteSessionPtr = std::shared_ptr; } // namespace crossdesk::gui_detail -#endif // CROSSDESK_GUI_REMOTE_SESSION_H_ +#endif diff --git a/src/gui/runtime/runtime_state.h b/src/gui/runtime/runtime_state.h index ed2c3fc..1acc0d6 100644 --- a/src/gui/runtime/runtime_state.h +++ b/src/gui/runtime/runtime_state.h @@ -1,5 +1,5 @@ -#ifndef CROSSDESK_GUI_RUNTIME_STATE_H_ -#define CROSSDESK_GUI_RUNTIME_STATE_H_ +#ifndef _RUNTIME_STATE_H_ +#define _RUNTIME_STATE_H_ #include #include @@ -57,14 +57,14 @@ struct PeerState { std::string video_primary_label_ = "primary_display"; std::string video_secondary_label_ = "secondary_display"; std::string audio_label_ = "audio"; - std::string data_label_ = "data"; - std::string mouse_label_ = "mouse"; - std::string keyboard_label_ = "keyboard"; + std::string data_label_ = protocol::kDataStream; + std::string mouse_label_ = protocol::kMouseStream; + std::string keyboard_label_ = protocol::kKeyboardStream; std::string info_label_ = "info"; - std::string control_data_label_ = "control_data"; - std::string file_label_ = "file"; - std::string file_feedback_label_ = "file_feedback"; - std::string clipboard_label_ = "clipboard"; + std::string control_data_label_ = protocol::kControlStream; + std::string file_label_ = protocol::kFileStream; + std::string file_feedback_label_ = protocol::kFileFeedbackStream; + std::string clipboard_label_ = protocol::kClipboardStream; Params params_; }; @@ -180,6 +180,12 @@ struct ConnectionState { std::shared_mutex connection_status_mutex_; std::unordered_map connection_status_; std::unordered_map connection_host_names_; + // Cursor position is sampled asynchronously after remote mouse input has + // been injected. Remember the input source so the sampled position is not + // immediately echoed back to that same controller as stale feedback. + std::mutex remote_pointer_input_mutex_; + std::unordered_map + last_remote_pointer_input_time_; std::string selected_server_remote_id_; std::string selected_server_remote_hostname_; std::mutex pending_presence_probe_mutex_; @@ -202,4 +208,4 @@ struct RuntimeState : InfrastructureState, } // namespace crossdesk::gui_detail -#endif // CROSSDESK_GUI_RUNTIME_STATE_H_ +#endif diff --git a/src/tools/file_transfer.cpp b/src/tools/file_transfer.cpp index a6c1000..194972e 100644 --- a/src/tools/file_transfer.cpp +++ b/src/tools/file_transfer.cpp @@ -93,38 +93,8 @@ std::vector FileSender::BuildChunk(uint32_t file_id, uint64_t offset, uint32_t data_size, const std::string* file_name, bool is_first, bool is_last) { - FileChunkHeader header{}; - header.magic = kFileChunkMagic; - header.file_id = file_id; - header.offset = offset; - header.total_size = total_size; - header.chunk_size = data_size; - header.name_len = - (file_name && is_first) ? static_cast(file_name->size()) : 0; - header.flags = 0; - if (is_first) header.flags |= 0x01; - if (is_last) header.flags |= 0x02; - - std::size_t total_size_bytes = - sizeof(FileChunkHeader) + header.name_len + header.chunk_size; - - std::vector buffer; - buffer.resize(total_size_bytes); - - std::size_t offset_bytes = 0; - memcpy(buffer.data() + offset_bytes, &header, sizeof(FileChunkHeader)); - offset_bytes += sizeof(FileChunkHeader); - - if (header.name_len > 0 && file_name) { - memcpy(buffer.data() + offset_bytes, file_name->data(), header.name_len); - offset_bytes += header.name_len; - } - - if (header.chunk_size > 0 && data) { - memcpy(buffer.data() + offset_bytes, data, header.chunk_size); - } - - return buffer; + return protocol::EncodeFileChunk(file_id, offset, total_size, data, + data_size, file_name, is_first, is_last); } // ---------- FileReceiver ---------- @@ -167,40 +137,15 @@ std::filesystem::path FileReceiver::GetDefaultDesktopPath() { } bool FileReceiver::OnData(const char* data, size_t size) { - if (!data || size < sizeof(FileChunkHeader)) { + protocol::FileChunkView chunk; + if (!protocol::DecodeFileChunk(data, size, &chunk)) { LOG_ERROR("FileReceiver::OnData: invalid buffer"); return false; } - - FileChunkHeader header{}; - memcpy(&header, data, sizeof(FileChunkHeader)); - - if (header.magic != kFileChunkMagic) { - return false; - } - - std::size_t header_and_name = - sizeof(FileChunkHeader) + static_cast(header.name_len); - if (size < header_and_name || - size < header_and_name + static_cast(header.chunk_size)) { - LOG_ERROR("FileReceiver::OnData: buffer too small for header + payload"); - return false; - } - - const char* name_ptr = data + sizeof(FileChunkHeader); - std::string file_name; - const std::string* file_name_ptr = nullptr; - if (header.name_len > 0) { - file_name.assign(name_ptr, - name_ptr + static_cast(header.name_len)); - file_name_ptr = &file_name; - } - - const char* payload = data + header_and_name; - std::size_t payload_size = - static_cast(header.chunk_size); // may be 0 - - return HandleChunk(header, payload, payload_size, file_name_ptr); + const std::string* file_name = + chunk.file_name.empty() ? nullptr : &chunk.file_name; + return HandleChunk(chunk.header, chunk.payload, chunk.payload_size, + file_name); } bool FileReceiver::HandleChunk(const FileChunkHeader& header, diff --git a/src/tools/file_transfer.h b/src/tools/file_transfer.h index 8409a90..5cb4ae4 100644 --- a/src/tools/file_transfer.h +++ b/src/tools/file_transfer.h @@ -7,7 +7,6 @@ #ifndef _FILE_TRANSFER_H_ #define _FILE_TRANSFER_H_ -#include #include #include #include @@ -15,32 +14,11 @@ #include #include +#include "file_transfer_protocol.h" +#include "stream_names.h" + namespace crossdesk { -// Magic constants for file transfer protocol -constexpr uint32_t kFileChunkMagic = 0x4A4E544D; // 'JNTM' -constexpr uint32_t kFileAckMagic = 0x4A4E5443; // 'JNTC' - -#pragma pack(push, 1) -struct FileChunkHeader { - uint32_t magic; // magic to identify file-transfer chunks - uint32_t file_id; // unique id per file transfer - uint64_t offset; // offset in file - uint64_t total_size; // total file size - uint32_t chunk_size; // payload size in this chunk - uint16_t name_len; // filename length (bytes), only set on first chunk - uint8_t flags; // bit0: is_first, bit1: is_last, others reserved -}; - -struct FileTransferAck { - uint32_t magic; // magic to identify file-transfer ack - uint32_t file_id; // must match FileChunkHeader.file_id - uint64_t acked_offset; // received offset - uint64_t total_size; // total file size - uint32_t flags; // bit0: completed, bit1: error -}; -#pragma pack(pop) - class FileSender { public: using SendFunc = std::function; @@ -58,7 +36,8 @@ class FileSender { // `file_id` : file id to use (0 means auto-generate). // Return 0 on success, <0 on error. int SendFile(const std::filesystem::path& path, const std::string& label, - const SendFunc& send, std::size_t chunk_size = 64 * 1024, + const SendFunc& send, + std::size_t chunk_size = protocol::kFileChunkSize, uint32_t file_id = 0); // build a single encoded chunk buffer according to FileChunkHeader protocol. @@ -119,4 +98,4 @@ class FileReceiver { } // namespace crossdesk -#endif \ No newline at end of file +#endif diff --git a/src/tools/file_transfer_protocol.cpp b/src/tools/file_transfer_protocol.cpp new file mode 100644 index 0000000..8311689 --- /dev/null +++ b/src/tools/file_transfer_protocol.cpp @@ -0,0 +1,81 @@ +#include "file_transfer_protocol.h" + +#include +#include + +namespace crossdesk::protocol { + +std::vector EncodeFileChunk(uint32_t file_id, uint64_t offset, + uint64_t total_size, const char* data, + uint32_t data_size, + const std::string* file_name, bool is_first, + bool is_last) { + const std::size_t name_size = file_name && is_first ? file_name->size() : 0; + if (name_size > std::numeric_limits::max() || + offset > total_size || data_size > total_size - offset || + (data_size > 0 && data == nullptr)) { + return {}; + } + + FileChunkHeader header{}; + header.magic = kFileChunkMagic; + header.file_id = file_id; + header.offset = offset; + header.total_size = total_size; + header.chunk_size = data_size; + header.name_len = static_cast(name_size); + header.flags = (is_first ? 0x01 : 0) | (is_last ? 0x02 : 0); + + std::vector output(sizeof(header) + name_size + data_size); + std::memcpy(output.data(), &header, sizeof(header)); + std::size_t cursor = sizeof(header); + if (name_size > 0) { + std::memcpy(output.data() + cursor, file_name->data(), name_size); + cursor += name_size; + } + if (data_size > 0) { + std::memcpy(output.data() + cursor, data, data_size); + } + return output; +} + +bool DecodeFileChunk(const char* data, std::size_t size, + FileChunkView* output) { + if (!data || !output || size < sizeof(FileChunkHeader)) return false; + + FileChunkHeader header{}; + std::memcpy(&header, data, sizeof(header)); + if (header.magic != kFileChunkMagic) return false; + + const std::size_t name_size = header.name_len; + const std::size_t payload_size = header.chunk_size; + if (name_size > size - sizeof(header)) return false; + const std::size_t payload_offset = sizeof(header) + name_size; + if (payload_size > size - payload_offset || header.offset > header.total_size || + payload_size > header.total_size - header.offset) { + return false; + } + + output->header = header; + output->file_name.assign(data + sizeof(header), name_size); + output->payload = data + payload_offset; + output->payload_size = payload_size; + return true; +} + +std::array EncodeFileTransferAck( + const FileTransferAck& ack) { + std::array output{}; + std::memcpy(output.data(), &ack, sizeof(ack)); + return output; +} + +bool DecodeFileTransferAck(const char* data, std::size_t size, + FileTransferAck* output) { + if (!data || !output || size != sizeof(FileTransferAck)) return false; + std::memcpy(output, data, sizeof(*output)); + return output->magic == kFileAckMagic && + output->acked_offset <= output->total_size; +} + +} // namespace crossdesk::protocol diff --git a/src/tools/file_transfer_protocol.h b/src/tools/file_transfer_protocol.h new file mode 100644 index 0000000..1e35dde --- /dev/null +++ b/src/tools/file_transfer_protocol.h @@ -0,0 +1,67 @@ +#ifndef _FILE_TRANSFER_PROTOCOL_H_ +#define _FILE_TRANSFER_PROTOCOL_H_ + +#include +#include +#include +#include +#include + +namespace crossdesk { + +inline constexpr uint32_t kFileChunkMagic = 0x4A4E544D; // 'JNTM' +inline constexpr uint32_t kFileAckMagic = 0x4A4E5443; // 'JNTC' + +#pragma pack(push, 1) +struct FileChunkHeader { + uint32_t magic; + uint32_t file_id; + uint64_t offset; + uint64_t total_size; + uint32_t chunk_size; + uint16_t name_len; + uint8_t flags; +}; + +struct FileTransferAck { + uint32_t magic; + uint32_t file_id; + uint64_t acked_offset; + uint64_t total_size; + uint32_t flags; +}; +#pragma pack(pop) + +static_assert(sizeof(FileChunkHeader) == 31, + "FileChunkHeader wire layout must remain stable"); +static_assert(sizeof(FileTransferAck) == 28, + "FileTransferAck wire layout must remain stable"); + +namespace protocol { + +struct FileChunkView { + FileChunkHeader header{}; + std::string file_name; + const char* payload = nullptr; + std::size_t payload_size = 0; +}; + +std::vector EncodeFileChunk(uint32_t file_id, uint64_t offset, + uint64_t total_size, const char* data, + uint32_t data_size, + const std::string* file_name, bool is_first, + bool is_last); + +bool DecodeFileChunk(const char* data, std::size_t size, + FileChunkView* output); + +std::array EncodeFileTransferAck( + const FileTransferAck& ack); + +bool DecodeFileTransferAck(const char* data, std::size_t size, + FileTransferAck* output); + +} // namespace protocol +} // namespace crossdesk + +#endif diff --git a/submodules/minirtc b/submodules/minirtc index 3aa5fa5..85bc11a 160000 --- a/submodules/minirtc +++ b/submodules/minirtc @@ -1 +1 @@ -Subproject commit 3aa5fa5ed0de9893cf957f4c09150f9341ce9a19 +Subproject commit 85bc11a96cf3b52d0d9b6ed2be7ffddb7c390c55 diff --git a/tests/protocol_test.cpp b/tests/protocol_test.cpp new file mode 100644 index 0000000..2fd4c2d --- /dev/null +++ b/tests/protocol_test.cpp @@ -0,0 +1,179 @@ +#include +#include +#include + +#include "device_controller.h" +#include "cursor_position.h" +#include "file_transfer_protocol.h" +#include "stream_names.h" + +namespace { + +bool Expect(bool condition, const char* message) { + if (condition) return true; + std::cerr << message << '\n'; + return false; +} + +} // namespace + +int main() { + bool ok = true; + + crossdesk::RemoteAction unset_action{}; + ok &= Expect(unset_action.type == crossdesk::ControlType::invalid, + "default RemoteAction should be invalid"); + ok &= Expect(unset_action.to_json().empty(), + "invalid RemoteAction should not be serialized"); + + crossdesk::RemoteAction parsed_invalid{}; + ok &= Expect(!parsed_invalid.from_json("{\"type\":-1}"), + "invalid RemoteAction JSON should be rejected"); + + crossdesk::RemoteAction unknown_action{}; + unknown_action.type = static_cast(999); + ok &= Expect(unknown_action.to_json().empty(), + "unknown RemoteAction type should not be serialized"); + + crossdesk::RemoteAction mouse{}; + mouse.type = crossdesk::ControlType::mouse; + mouse.m = {0.25f, 0.75f, -1, crossdesk::MouseFlag::wheel_vertical}; + crossdesk::RemoteAction parsed_mouse{}; + ok &= Expect(parsed_mouse.from_json(mouse.to_json()), + "mouse JSON should decode"); + ok &= Expect(parsed_mouse.type == crossdesk::ControlType::mouse && + parsed_mouse.m.x == mouse.m.x && + parsed_mouse.m.y == mouse.m.y && + parsed_mouse.m.s == mouse.m.s && + parsed_mouse.m.flag == mouse.m.flag, + "mouse JSON round trip changed fields"); + + crossdesk::RemoteAction cursor{}; + cursor.type = crossdesk::ControlType::cursor_state; + cursor.cs = {42, true, crossdesk::RemoteCursorShape::pointer, + true, 0.25f, 0.75f, 0.001f, -0.002f, 1, true}; + crossdesk::RemoteAction parsed_cursor{}; + ok &= Expect(parsed_cursor.from_json(cursor.to_json()) && + parsed_cursor.type == crossdesk::ControlType::cursor_state && + parsed_cursor.cs.seq == cursor.cs.seq && + parsed_cursor.cs.visible == cursor.cs.visible && + parsed_cursor.cs.shape == cursor.cs.shape && + parsed_cursor.cs.position_valid && + parsed_cursor.cs.x == cursor.cs.x && + parsed_cursor.cs.y == cursor.cs.y && + parsed_cursor.cs.visual_offset_x == + cursor.cs.visual_offset_x && + parsed_cursor.cs.visual_offset_y == + cursor.cs.visual_offset_y && + parsed_cursor.cs.display_id == cursor.cs.display_id && + parsed_cursor.cs.position_update, + "cursor state JSON round trip changed position fields"); + + crossdesk::RemoteAction legacy_cursor{}; + ok &= Expect(legacy_cursor.from_json( + "{\"type\":8,\"cursor_state\":{\"seq\":1," + "\"visible\":true,\"shape\":0}}") && + !legacy_cursor.cs.position_valid && + legacy_cursor.cs.position_update && + legacy_cursor.cs.display_id == -1, + "legacy cursor state JSON should remain compatible"); + + crossdesk::RemoteAction shape_only_cursor{}; + shape_only_cursor.type = crossdesk::ControlType::cursor_state; + shape_only_cursor.cs = cursor.cs; + shape_only_cursor.cs.position_update = false; + crossdesk::RemoteAction parsed_shape_only_cursor{}; + ok &= Expect(parsed_shape_only_cursor.from_json( + shape_only_cursor.to_json()) && + !parsed_shape_only_cursor.cs.position_update && + parsed_shape_only_cursor.cs.shape == cursor.cs.shape, + "shape-only cursor state should preserve update semantics"); + + crossdesk::RemoteAction command{}; + command.type = crossdesk::ControlType::service_command; + command.c.flag = crossdesk::ServiceCommandFlag::send_sas; + crossdesk::RemoteAction parsed_command{}; + ok &= Expect(parsed_command.from_json(command.to_json()) && + parsed_command.type == + crossdesk::ControlType::service_command && + parsed_command.c.flag == + crossdesk::ServiceCommandFlag::send_sas, + "service command JSON round trip failed"); + + char display_name[] = "Built-in Display"; + char* display_names[] = {display_name}; + int left[] = {0}; + int top[] = {0}; + int right[] = {2560}; + int bottom[] = {1600}; + crossdesk::RemoteAction host{}; + host.type = crossdesk::ControlType::host_infomation; + std::strcpy(host.i.host_name, "Test Mac"); + host.i.host_name_size = std::strlen(host.i.host_name); + host.i.display_list = display_names; + host.i.display_num = 1; + host.i.left = left; + host.i.top = top; + host.i.right = right; + host.i.bottom = bottom; + crossdesk::RemoteAction parsed_host{}; + ok &= Expect(parsed_host.from_json(host.to_json()) && + parsed_host.i.display_num == 1 && + std::string(parsed_host.i.host_name) == "Test Mac" && + std::string(parsed_host.i.display_list[0]) == + "Built-in Display" && + parsed_host.i.right[0] == 2560 && + parsed_host.i.bottom[0] == 1600, + "host information JSON round trip failed"); + crossdesk::FreeRemoteAction(parsed_host); + + const std::vector displays = { + crossdesk::DisplayInfo("Test Display", 0, 0, 1920, 1080)}; + crossdesk::CursorState normalized_cursor{}; + 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"); + + const std::string name = "example.txt"; + const std::string payload = "CrossDesk protocol"; + const auto encoded = crossdesk::protocol::EncodeFileChunk( + 17, 0, payload.size(), payload.data(), + static_cast(payload.size()), &name, true, true); + crossdesk::protocol::FileChunkView chunk; + ok &= Expect(crossdesk::protocol::DecodeFileChunk( + encoded.data(), encoded.size(), &chunk), + "file chunk should decode"); + ok &= Expect(chunk.header.file_id == 17 && chunk.file_name == name && + chunk.payload_size == payload.size() && + std::memcmp(chunk.payload, payload.data(), payload.size()) == + 0, + "file chunk round trip changed fields"); + ok &= Expect(!crossdesk::protocol::DecodeFileChunk( + encoded.data(), encoded.size() - 1, &chunk), + "truncated file chunk should be rejected"); + + crossdesk::FileTransferAck ack{}; + ack.magic = crossdesk::kFileAckMagic; + ack.file_id = 17; + ack.acked_offset = payload.size(); + ack.total_size = payload.size(); + ack.flags = 1; + const auto encoded_ack = crossdesk::protocol::EncodeFileTransferAck(ack); + crossdesk::FileTransferAck decoded_ack{}; + ok &= Expect(crossdesk::protocol::DecodeFileTransferAck( + encoded_ack.data(), encoded_ack.size(), &decoded_ack) && + decoded_ack.file_id == ack.file_id && + decoded_ack.acked_offset == ack.acked_offset && + decoded_ack.flags == ack.flags, + "file acknowledgement round trip failed"); + + ok &= Expect(std::string(crossdesk::protocol::kControlStream) == + "control_data" && + std::string(crossdesk::protocol::kFileFeedbackStream) == + "file_feedback", + "stream names changed"); + return ok ? 0 : 1; +} diff --git a/xmake/targets.lua b/xmake/targets.lua index e7573ce..8cff705 100644 --- a/xmake/targets.lua +++ b/xmake/targets.lua @@ -66,8 +66,20 @@ function setup_targets() target("keyboard_state_protocol_test") set_kind("binary") set_default(false) + add_packages("nlohmann_json") add_includedirs("src/device_controller", "src/common") - add_files("tests/keyboard_state_protocol_test.cpp") + add_files("tests/keyboard_state_protocol_test.cpp", + "src/device_controller/remote_action.cpp") + + target("crossdesk_protocol_test") + set_kind("binary") + set_default(false) + add_packages("nlohmann_json") + add_includedirs("src/device_controller", "src/common", "src/tools", + "src/gui/runtime") + add_files("tests/protocol_test.cpp", + "src/device_controller/remote_action.cpp", + "src/tools/file_transfer_protocol.cpp") target("connection_status_protocol_test") set_kind("binary") @@ -177,6 +189,7 @@ function setup_targets() set_kind("object") add_deps("rd_log", "common") add_includedirs("src/device_controller", {public = true}) + add_files("src/device_controller/remote_action.cpp") if is_os("windows") then add_files("src/device_controller/mouse/windows/*.cpp", "src/device_controller/keyboard/windows/*.cpp") @@ -242,7 +255,7 @@ function setup_targets() target("tools") set_kind("object") - add_deps("rd_log") + add_deps("rd_log", "common") add_files("src/tools/*.cpp") if is_os("macosx") then add_files("src/tools/*.mm")