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 0000000..a0d5a54 Binary files /dev/null and b/ios/CrossDeskMobile/Assets.xcassets/AppIcon.appiconset/CrossDeskAppIcon-1024.png differ diff --git a/ios/CrossDeskMobile/Assets.xcassets/Contents.json b/ios/CrossDeskMobile/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/CrossDeskMobile/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h b/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h new file mode 100644 index 0000000..33375a5 --- /dev/null +++ b/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h @@ -0,0 +1,121 @@ +#import +#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")