diff --git a/webrtc_controller_ios/README.md b/webrtc_controller_ios/README.md new file mode 100644 index 0000000..2b3aba2 --- /dev/null +++ b/webrtc_controller_ios/README.md @@ -0,0 +1,57 @@ +# web_rtc_controller_ios + +iOS 版 WebRTC 远程控制端,功能对齐 Android 端 `WebRTCController`,配合 `WebRTCSignalServer` 信令服务器与被控端 `WebRTCControlled` 使用。 + +## 环境要求 + +- Xcode 15 及以上 +- iOS 15.0 及以上(iPhone / iPad) +- 依赖通过 Swift Package Manager 自动拉取:[stasel/WebRTC](https://github.com/stasel/WebRTC)(Google WebRTC 官方二进制封装) + +## 架构:UIKit 与 SwiftUI 混用 + +| 层 | 技术 | 说明 | +| --- | --- | --- | +| 应用生命周期 | UIKit (`AppDelegate` / `SceneDelegate`) | `UIHostingController` 承载 SwiftUI 根视图 | +| 界面 | SwiftUI (`ContentView` / `AuthSheetView`) | 连接设置页、鉴权弹窗、控制面板、统计信息 | +| 视频渲染(WebRTC 模式) | UIKit (`RTCMTLVideoView`) | 经 `UIViewRepresentable` 嵌入 SwiftUI | +| 视频渲染(自编码模式) | UIKit (`AVSampleBufferDisplayLayer`) | H.264 裸流 VideoToolbox 硬解直显 | +| 触控捕获 | UIKit (`RemoteTouchView`) | 原始触摸事件实时转发,保证"跟手" | +| 状态管理 | Combine (`ControllerViewModel`) | `ObservableObject` 驱动 SwiftUI | + +## 目录结构 + +``` +web_rtc_controller_ios/ +├── App/ # UIKit 生命周期入口 +├── Signaling/ # 信令:SignalMessage(JSON) + WebSocket 客户端 +├── Control/ # ControlMessage:与 control_message.proto 二进制兼容的手写 protobuf 编解码 +├── WebRTC/ # WebRTCClient(PeerConnection/DataChannel)+ SelfCodecDecoder(自编码 H.264 解码) +├── Views/ # SwiftUI 页面 + UIKit 渲染/触控视图(UIViewRepresentable 桥接) +├── ViewModel/ # ControllerViewModel +└── Utils/ # 设备 ID 等工具 +``` + +## 与 Android 端保持一致的协议 + +- **信令**(WebSocket + JSON):`REGISTER` / `OFFER`(携带 `authType`/`authValue`) / `ANSWER` / `ICE_CANDIDATE` / `TARGET_OFFLINE` / `CONNECTION_REJECTED` / `REQUEST_ERROR` / `REQUEST_TIMEOUT` +- **控制通道** `control_channel`(可靠有序):protobuf `ControlMessage`(MOTION_EVENT / SWIPE / KEY / SET_RESOLUTION / SET_STREAM_MODE 及被控端上报) +- **自编码视频通道** `video_channel`(不可靠低延迟):`0xAB` 魔数分片协议(大端序),单元内为 H.264 Annex-B 裸流(CONFIG=SPS/PPS,FRAME=视频帧) +- **ICE**:`stun:stun.l.google.com:19302`、`stun:www.ttstd.com:3478`、`turn:www.ttstd.com:3478` + +## 功能 + +- 连接信令服务器并注册(设备 ID 格式 `CTRL-XXXXXXXX`,本地持久化) +- 三种鉴权方式:免密连接 / 动态验证码 / 固定密码 +- 远程屏幕实时观看 + 触控(跟手的原始 MotionEvent 转发 + 滑动补发 SWIPE) +- 导航键:返回 / 主页 / 多任务(Android keyCode 4 / 3 / 187) +- 分辨率切换:原始画质 / 1080P / 720P / 480P +- 串流模式切换:WebRTC(SRTP 视频轨)/ 自编码(DataChannel H.264 裸流 + VideoToolbox 硬解) +- 实时统计:分辨率、帧率、码率、RTT、链路类型(P2P/TURN)、编解码器等 + +## 运行 + +1. 用 Xcode 打开 `web_rtc_controller_ios.xcodeproj`,首次打开等待 SPM 解析 WebRTC 依赖。 +2. 在 Signing & Capabilities 中选择自己的开发团队。 +3. 真机运行(模拟器无法验证硬解与摄像头相关能力,控制功能可用)。 +4. 输入信令服务器地址(默认 `wss://www.ttstd.com/signal`)与被控端设备 ID,选择鉴权方式后连接。 diff --git a/webrtc_controller_ios/web_rtc_controller_ios.xcodeproj/project.pbxproj b/webrtc_controller_ios/web_rtc_controller_ios.xcodeproj/project.pbxproj new file mode 100644 index 0000000..f0b346f --- /dev/null +++ b/webrtc_controller_ios/web_rtc_controller_ios.xcodeproj/project.pbxproj @@ -0,0 +1,462 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + FB0000000000000000000001 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0000000000000000000001 /* AppDelegate.swift */; }; + FB0000000000000000000002 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0000000000000000000002 /* SceneDelegate.swift */; }; + FB0000000000000000000003 /* SignalMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0000000000000000000003 /* SignalMessage.swift */; }; + FB0000000000000000000004 /* SignalingClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0000000000000000000004 /* SignalingClient.swift */; }; + FB0000000000000000000005 /* ControlMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0000000000000000000005 /* ControlMessage.swift */; }; + FB0000000000000000000006 /* WebRTCClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0000000000000000000006 /* WebRTCClient.swift */; }; + FB0000000000000000000007 /* SelfCodecDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0000000000000000000007 /* SelfCodecDecoder.swift */; }; + FB0000000000000000000008 /* RemoteTouchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0000000000000000000008 /* RemoteTouchView.swift */; }; + FB0000000000000000000009 /* RemoteVideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0000000000000000000009 /* RemoteVideoView.swift */; }; + FB000000000000000000000A /* SelfCodecDisplayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000000A /* SelfCodecDisplayView.swift */; }; + FB000000000000000000000B /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000000B /* ContentView.swift */; }; + FB000000000000000000000C /* AuthSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000000C /* AuthSheetView.swift */; }; + FB000000000000000000000D /* ControllerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000000D /* ControllerViewModel.swift */; }; + FB000000000000000000000E /* DeviceUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000000E /* DeviceUtils.swift */; }; + FB000000000000000000000F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000000F /* Assets.xcassets */; }; + FB0000000000000000000010 /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = FE0000000000000000000002 /* WebRTC */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + FA0000000000000000000001 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + FA0000000000000000000002 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + FA0000000000000000000003 /* SignalMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignalMessage.swift; sourceTree = ""; }; + FA0000000000000000000004 /* SignalingClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignalingClient.swift; sourceTree = ""; }; + FA0000000000000000000005 /* ControlMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlMessage.swift; sourceTree = ""; }; + FA0000000000000000000006 /* WebRTCClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRTCClient.swift; sourceTree = ""; }; + FA0000000000000000000007 /* SelfCodecDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfCodecDecoder.swift; sourceTree = ""; }; + FA0000000000000000000008 /* RemoteTouchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteTouchView.swift; sourceTree = ""; }; + FA0000000000000000000009 /* RemoteVideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteVideoView.swift; sourceTree = ""; }; + FA000000000000000000000A /* SelfCodecDisplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfCodecDisplayView.swift; sourceTree = ""; }; + FA000000000000000000000B /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + FA000000000000000000000C /* AuthSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthSheetView.swift; sourceTree = ""; }; + FA000000000000000000000D /* ControllerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControllerViewModel.swift; sourceTree = ""; }; + FA000000000000000000000E /* DeviceUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceUtils.swift; sourceTree = ""; }; + FA000000000000000000000F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + FA0000000000000000000010 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + FA0000000000000000000011 /* web_rtc_controller_ios.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = web_rtc_controller_ios.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + FD0000000000000000000004 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + FB0000000000000000000010 /* WebRTC in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + FC0000000000000000000001 = { + isa = PBXGroup; + children = ( + FC0000000000000000000003 /* web_rtc_controller_ios */, + FC0000000000000000000002 /* Products */, + ); + sourceTree = ""; + }; + FC0000000000000000000002 /* Products */ = { + isa = PBXGroup; + children = ( + FA0000000000000000000011 /* web_rtc_controller_ios.app */, + ); + name = Products; + sourceTree = ""; + }; + FC0000000000000000000003 /* web_rtc_controller_ios */ = { + isa = PBXGroup; + children = ( + FC0000000000000000000004 /* App */, + FC0000000000000000000005 /* Signaling */, + FC0000000000000000000006 /* Control */, + FC0000000000000000000007 /* WebRTC */, + FC0000000000000000000008 /* Views */, + FC0000000000000000000009 /* ViewModel */, + FC000000000000000000000A /* Utils */, + FA000000000000000000000F /* Assets.xcassets */, + FA0000000000000000000010 /* Info.plist */, + ); + path = web_rtc_controller_ios; + sourceTree = ""; + }; + FC0000000000000000000004 /* App */ = { + isa = PBXGroup; + children = ( + FA0000000000000000000001 /* AppDelegate.swift */, + FA0000000000000000000002 /* SceneDelegate.swift */, + ); + path = App; + sourceTree = ""; + }; + FC0000000000000000000005 /* Signaling */ = { + isa = PBXGroup; + children = ( + FA0000000000000000000003 /* SignalMessage.swift */, + FA0000000000000000000004 /* SignalingClient.swift */, + ); + path = Signaling; + sourceTree = ""; + }; + FC0000000000000000000006 /* Control */ = { + isa = PBXGroup; + children = ( + FA0000000000000000000005 /* ControlMessage.swift */, + ); + path = Control; + sourceTree = ""; + }; + FC0000000000000000000007 /* WebRTC */ = { + isa = PBXGroup; + children = ( + FA0000000000000000000006 /* WebRTCClient.swift */, + FA0000000000000000000007 /* SelfCodecDecoder.swift */, + ); + path = WebRTC; + sourceTree = ""; + }; + FC0000000000000000000008 /* Views */ = { + isa = PBXGroup; + children = ( + FA000000000000000000000B /* ContentView.swift */, + FA000000000000000000000C /* AuthSheetView.swift */, + FA0000000000000000000008 /* RemoteTouchView.swift */, + FA0000000000000000000009 /* RemoteVideoView.swift */, + FA000000000000000000000A /* SelfCodecDisplayView.swift */, + ); + path = Views; + sourceTree = ""; + }; + FC0000000000000000000009 /* ViewModel */ = { + isa = PBXGroup; + children = ( + FA000000000000000000000D /* ControllerViewModel.swift */, + ); + path = ViewModel; + sourceTree = ""; + }; + FC000000000000000000000A /* Utils */ = { + isa = PBXGroup; + children = ( + FA000000000000000000000E /* DeviceUtils.swift */, + ); + path = Utils; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + FD0000000000000000000002 /* web_rtc_controller_ios */ = { + isa = PBXNativeTarget; + buildConfigurationList = FD0000000000000000000007 /* Build configuration list for PBXNativeTarget "web_rtc_controller_ios" */; + buildPhases = ( + FD0000000000000000000003 /* Sources */, + FD0000000000000000000004 /* Frameworks */, + FD0000000000000000000005 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = web_rtc_controller_ios; + packageProductDependencies = ( + FE0000000000000000000002 /* WebRTC */, + ); + productName = web_rtc_controller_ios; + productReference = FA0000000000000000000011 /* web_rtc_controller_ios.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + FD0000000000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1500; + LastUpgradeCheck = 1500; + TargetAttributes = { + FD0000000000000000000002 = { + CreatedOnToolsVersion = 15.0; + }; + }; + }; + buildConfigurationList = FD0000000000000000000006 /* Build configuration list for PBXProject "web_rtc_controller_ios" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = "zh-Hans"; + hasScannedForEncodings = 0; + knownRegions = ( + "zh-Hans", + en, + Base, + ); + mainGroup = FC0000000000000000000001; + packageReferences = ( + FE0000000000000000000001 /* XCRemoteSwiftPackageReference "WebRTC" */, + ); + productRefGroup = FC0000000000000000000002 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + FD0000000000000000000002 /* web_rtc_controller_ios */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + FD0000000000000000000005 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FB000000000000000000000F /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + FD0000000000000000000003 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FB0000000000000000000001 /* AppDelegate.swift in Sources */, + FB0000000000000000000002 /* SceneDelegate.swift in Sources */, + FB0000000000000000000003 /* SignalMessage.swift in Sources */, + FB0000000000000000000004 /* SignalingClient.swift in Sources */, + FB0000000000000000000005 /* ControlMessage.swift in Sources */, + FB0000000000000000000006 /* WebRTCClient.swift in Sources */, + FB0000000000000000000007 /* SelfCodecDecoder.swift in Sources */, + FB0000000000000000000008 /* RemoteTouchView.swift in Sources */, + FB0000000000000000000009 /* RemoteVideoView.swift in Sources */, + FB000000000000000000000A /* SelfCodecDisplayView.swift in Sources */, + FB000000000000000000000B /* ContentView.swift in Sources */, + FB000000000000000000000C /* AuthSheetView.swift in Sources */, + FB000000000000000000000D /* ControllerViewModel.swift in Sources */, + FB000000000000000000000E /* DeviceUtils.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + FD0000000000000000000008 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + FD0000000000000000000009 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + FD000000000000000000000A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = web_rtc_controller_ios/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.ttstd.web-rtc-controller-ios"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + FD000000000000000000000B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = web_rtc_controller_ios/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.ttstd.web-rtc-controller-ios"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + FD0000000000000000000006 /* Build configuration list for PBXProject "web_rtc_controller_ios" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FD0000000000000000000008 /* Debug */, + FD0000000000000000000009 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + FD0000000000000000000007 /* Build configuration list for PBXNativeTarget "web_rtc_controller_ios" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FD000000000000000000000A /* Debug */, + FD000000000000000000000B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + FE0000000000000000000001 /* XCRemoteSwiftPackageReference "WebRTC" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/stasel/WebRTC.git"; + requirement = { + kind = versionRange; + minimumVersion = 120.0.0; + maximumVersion = 200.0.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + FE0000000000000000000002 /* WebRTC */ = { + isa = XCSwiftPackageProductDependency; + package = FE0000000000000000000001 /* XCRemoteSwiftPackageReference "WebRTC" */; + productName = WebRTC; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = FD0000000000000000000001 /* Project object */; +} diff --git a/webrtc_controller_ios/web_rtc_controller_ios/App/AppDelegate.swift b/webrtc_controller_ios/web_rtc_controller_ios/App/AppDelegate.swift new file mode 100644 index 0000000..caa6121 --- /dev/null +++ b/webrtc_controller_ios/web_rtc_controller_ios/App/AppDelegate.swift @@ -0,0 +1,23 @@ +import UIKit + +/// UIKit 应用入口(UIKit 生命周期 + SwiftUI 内容视图混合架构) +@main +final class AppDelegate: UIResponder, UIApplicationDelegate { + + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // 远程控制期间保持屏幕常亮 + application.isIdleTimerDisabled = true + return true + } + + // MARK: - UISceneSession Lifecycle + + func application(_ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions) -> UISceneConfiguration { + let config = UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + config.delegateClass = SceneDelegate.self + return config + } +} diff --git a/webrtc_controller_ios/web_rtc_controller_ios/App/SceneDelegate.swift b/webrtc_controller_ios/web_rtc_controller_ios/App/SceneDelegate.swift new file mode 100644 index 0000000..da7ff4a --- /dev/null +++ b/webrtc_controller_ios/web_rtc_controller_ios/App/SceneDelegate.swift @@ -0,0 +1,21 @@ +import UIKit +import SwiftUI + +/// 场景代理:以 UIHostingController 承载 SwiftUI 根视图(UIKit 与 SwiftUI 混用) +final class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + func scene(_ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = scene as? UIWindowScene else { return } + + let window = UIWindow(windowScene: windowScene) + let hosting = UIHostingController(rootView: ContentView()) + hosting.view.backgroundColor = .black + window.rootViewController = hosting + window.makeKeyAndVisible() + self.window = window + } +} diff --git a/webrtc_controller_ios/web_rtc_controller_ios/Assets.xcassets/AccentColor.colorset/Contents.json b/webrtc_controller_ios/web_rtc_controller_ios/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/webrtc_controller_ios/web_rtc_controller_ios/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/webrtc_controller_ios/web_rtc_controller_ios/Assets.xcassets/AppIcon.appiconset/Contents.json b/webrtc_controller_ios/web_rtc_controller_ios/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/webrtc_controller_ios/web_rtc_controller_ios/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/webrtc_controller_ios/web_rtc_controller_ios/Assets.xcassets/Contents.json b/webrtc_controller_ios/web_rtc_controller_ios/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/webrtc_controller_ios/web_rtc_controller_ios/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/webrtc_controller_ios/web_rtc_controller_ios/Control/ControlMessage.swift b/webrtc_controller_ios/web_rtc_controller_ios/Control/ControlMessage.swift new file mode 100644 index 0000000..9088007 --- /dev/null +++ b/webrtc_controller_ios/web_rtc_controller_ios/Control/ControlMessage.swift @@ -0,0 +1,179 @@ +import Foundation + +/// 通过 RTCDataChannel 传输的控制指令。 +/// 与 control_message.proto (proto3) 二进制格式完全兼容(手写极简 protobuf 编解码, +/// 免去 SwiftProtobuf 依赖与代码生成)。 +/// 坐标 x/y/x1/y1/x2/y2 均为相对屏幕的百分比,取值范围 0.0 ~ 1.0。 +struct ControlMessage { + + enum Action: Int { + case unknown = 0 + case touch = 1 + case swipe = 2 + case key = 3 + case longPress = 4 + case motionEvent = 5 + case setResolution = 6 // 控制端请求被控端切换屏幕采集分辨率 + case reportResolution = 7 // 被控端上报当前实际采集分辨率 + case setStreamMode = 8 // 控制端请求被控端切换屏幕串流模式 + case reportStreamMode = 9 // 被控端上报当前生效的串流模式 + } + + var action: Action = .unknown // field 1, varint + var x: Double = 0 // field 2, fixed64 + var y: Double = 0 // field 3, fixed64 + var x1: Double = 0 // field 4, fixed64 + var y1: Double = 0 // field 5, fixed64 + var x2: Double = 0 // field 6, fixed64 + var y2: Double = 0 // field 7, fixed64 + var duration: Int64 = 0 // field 8, varint + var keyCode: Int32 = 0 // field 9, varint + var keyAction: Int32 = 0 // field 10, varint (0=按下 1=抬起) + var motionAction: Int32 = 0 // field 11, varint (0=DOWN 1=UP 2=MOVE 3=CANCEL) + var width: Int32 = 0 // field 12, varint + var height: Int32 = 0 // field 13, varint + var fps: Int32 = 0 // field 14, varint + var streamMode: Int32 = 0 // field 15, varint (0=WebRTC 1=自编码) + + // MARK: - Encode + + func serializedData() -> Data { + var w = ProtoWriter() + w.writeVarintField(1, UInt64(action.rawValue)) + w.writeDoubleField(2, x) + w.writeDoubleField(3, y) + w.writeDoubleField(4, x1) + w.writeDoubleField(5, y1) + w.writeDoubleField(6, x2) + w.writeDoubleField(7, y2) + w.writeVarintField(8, UInt64(bitPattern: duration)) + w.writeVarintField(9, UInt64(bitPattern: Int64(keyCode))) + w.writeVarintField(10, UInt64(bitPattern: Int64(keyAction))) + w.writeVarintField(11, UInt64(bitPattern: Int64(motionAction))) + w.writeVarintField(12, UInt64(bitPattern: Int64(width))) + w.writeVarintField(13, UInt64(bitPattern: Int64(height))) + w.writeVarintField(14, UInt64(bitPattern: Int64(fps))) + w.writeVarintField(15, UInt64(bitPattern: Int64(streamMode))) + return w.data + } + + // MARK: - Decode + + static func parse(from data: Data) -> ControlMessage? { + var msg = ControlMessage() + var r = ProtoReader(data: data) + while let (field, wire) = r.readTag() { + switch (field, wire) { + case (1, 0): + guard let v = r.readVarint() else { return nil } + msg.action = Action(rawValue: Int(v)) ?? .unknown + case (2, 1): guard let v = r.readDouble() else { return nil }; msg.x = v + case (3, 1): guard let v = r.readDouble() else { return nil }; msg.y = v + case (4, 1): guard let v = r.readDouble() else { return nil }; msg.x1 = v + case (5, 1): guard let v = r.readDouble() else { return nil }; msg.y1 = v + case (6, 1): guard let v = r.readDouble() else { return nil }; msg.x2 = v + case (7, 1): guard let v = r.readDouble() else { return nil }; msg.y2 = v + case (8, 0): guard let v = r.readVarint() else { return nil }; msg.duration = Int64(bitPattern: v) + case (9, 0): guard let v = r.readVarint() else { return nil }; msg.keyCode = Int32(truncatingIfNeeded: Int64(bitPattern: v)) + case (10, 0): guard let v = r.readVarint() else { return nil }; msg.keyAction = Int32(truncatingIfNeeded: Int64(bitPattern: v)) + case (11, 0): guard let v = r.readVarint() else { return nil }; msg.motionAction = Int32(truncatingIfNeeded: Int64(bitPattern: v)) + case (12, 0): guard let v = r.readVarint() else { return nil }; msg.width = Int32(truncatingIfNeeded: Int64(bitPattern: v)) + case (13, 0): guard let v = r.readVarint() else { return nil }; msg.height = Int32(truncatingIfNeeded: Int64(bitPattern: v)) + case (14, 0): guard let v = r.readVarint() else { return nil }; msg.fps = Int32(truncatingIfNeeded: Int64(bitPattern: v)) + case (15, 0): guard let v = r.readVarint() else { return nil }; msg.streamMode = Int32(truncatingIfNeeded: Int64(bitPattern: v)) + default: + // 未知字段按 wire type 跳过 + if !r.skip(wireType: wire) { return nil } + } + } + return msg + } +} + +// MARK: - 极简 protobuf 读写器 + +private struct ProtoWriter { + var data = Data() + + mutating func writeVarint(_ value: UInt64) { + var v = value + while v >= 0x80 { + data.append(UInt8((v & 0x7F) | 0x80)) + v >>= 7 + } + data.append(UInt8(v)) + } + + /// proto3:默认值(0)不编码 + mutating func writeVarintField(_ field: Int, _ value: UInt64) { + guard value != 0 else { return } + writeVarint(UInt64(field << 3 | 0)) + writeVarint(value) + } + + mutating func writeDoubleField(_ field: Int, _ value: Double) { + guard value != 0 else { return } + writeVarint(UInt64(field << 3 | 1)) + var bits = value.bitPattern.littleEndian + withUnsafeBytes(of: &bits) { data.append(contentsOf: $0) } + } +} + +private struct ProtoReader { + let data: Data + var offset: Int + + init(data: Data) { + self.data = data + self.offset = data.startIndex + } + + mutating func readTag() -> (Int, Int)? { + guard offset < data.endIndex, let key = readVarint() else { return nil } + return (Int(key >> 3), Int(key & 0x7)) + } + + mutating func readVarint() -> UInt64? { + var result: UInt64 = 0 + var shift: UInt64 = 0 + while offset < data.endIndex { + let byte = data[offset] + offset += 1 + result |= UInt64(byte & 0x7F) << shift + if byte & 0x80 == 0 { return result } + shift += 7 + if shift >= 64 { return nil } + } + return nil + } + + mutating func readDouble() -> Double? { + guard offset + 8 <= data.endIndex else { return nil } + var bits: UInt64 = 0 + for i in (0..<8).reversed() { + bits = (bits << 8) | UInt64(data[offset + i]) + } + offset += 8 + return Double(bitPattern: bits) + } + + mutating func skip(wireType: Int) -> Bool { + switch wireType { + case 0: return readVarint() != nil + case 1: + guard offset + 8 <= data.endIndex else { return false } + offset += 8 + return true + case 2: + guard let len = readVarint(), offset + Int(len) <= data.endIndex else { return false } + offset += Int(len) + return true + case 5: + guard offset + 4 <= data.endIndex else { return false } + offset += 4 + return true + default: + return false + } + } +} diff --git a/webrtc_controller_ios/web_rtc_controller_ios/Info.plist b/webrtc_controller_ios/web_rtc_controller_ios/Info.plist new file mode 100644 index 0000000..0e22d0d --- /dev/null +++ b/webrtc_controller_ios/web_rtc_controller_ios/Info.plist @@ -0,0 +1,65 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + WebRTC控制端 + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchScreen + + UIRequiresFullScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/webrtc_controller_ios/web_rtc_controller_ios/Signaling/SignalMessage.swift b/webrtc_controller_ios/web_rtc_controller_ios/Signaling/SignalMessage.swift new file mode 100644 index 0000000..fd6e6e5 --- /dev/null +++ b/webrtc_controller_ios/web_rtc_controller_ios/Signaling/SignalMessage.swift @@ -0,0 +1,43 @@ +import Foundation + +/// 信令消息(JSON),与 WebRTCSignalServer 的 SignalMessage 字段一一对应。 +/// type: REGISTER / OFFER / ANSWER / ICE_CANDIDATE / TARGET_OFFLINE / +/// CONNECTION_REJECTED / REQUEST_ERROR / REQUEST_TIMEOUT / REGISTER_SUCCESS 等 +struct SignalMessage: Codable { + var type: String? + var fromDeviceId: String? + var toDeviceId: String? + var deviceType: String? + /// payload 为字符串形式的 JSON(如 {"sdp": "..."}),与服务端约定一致 + var payload: String? + var authType: String? + var authValue: String? + + init(type: String? = nil, + fromDeviceId: String? = nil, + toDeviceId: String? = nil, + deviceType: String? = nil, + payload: String? = nil, + authType: String? = nil, + authValue: String? = nil) { + self.type = type + self.fromDeviceId = fromDeviceId + self.toDeviceId = toDeviceId + self.deviceType = deviceType + self.payload = payload + self.authType = authType + self.authValue = authValue + } + + /// 解析 payload 字符串为 JSON 字典 + func payloadJSON() -> [String: Any]? { + guard let payload, let data = payload.data(using: .utf8) else { return nil } + return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + } + + /// 将字典序列化为 payload 字符串 + static func encodePayload(_ dict: [String: Any]) -> String? { + guard let data = try? JSONSerialization.data(withJSONObject: dict) else { return nil } + return String(data: data, encoding: .utf8) + } +} diff --git a/webrtc_controller_ios/web_rtc_controller_ios/Signaling/SignalingClient.swift b/webrtc_controller_ios/web_rtc_controller_ios/Signaling/SignalingClient.swift new file mode 100644 index 0000000..48001cf --- /dev/null +++ b/webrtc_controller_ios/web_rtc_controller_ios/Signaling/SignalingClient.swift @@ -0,0 +1,124 @@ +import Foundation + +protocol SignalingClientDelegate: AnyObject { + func signalingDidConnect() + func signalingDidDisconnect() + func signaling(didFail error: String) + func signaling(didReceive message: SignalMessage) +} + +/// 信令 WebSocket 客户端(URLSessionWebSocketTask 实现)。 +/// 连接成功后自动发送 REGISTER(deviceType = CONTROLLER), +/// 所有回调均已切换到主线程。 +final class SignalingClient: NSObject { + + weak var delegate: SignalingClientDelegate? + + private let serverUrl: String + private let deviceId: String + private var session: URLSession? + private var task: URLSessionWebSocketTask? + private var manuallyClosed = false + + init(serverUrl: String, deviceId: String) { + self.serverUrl = serverUrl + self.deviceId = deviceId + super.init() + } + + func connect() { + guard let url = URL(string: serverUrl) else { + notifyError("无效的服务器地址: \(serverUrl)") + return + } + manuallyClosed = false + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 15 + session = URLSession(configuration: config, delegate: self, delegateQueue: nil) + task = session?.webSocketTask(with: url) + task?.resume() + receiveLoop() + } + + func disconnect() { + manuallyClosed = true + task?.cancel(with: .normalClosure, reason: "Disconnecting".data(using: .utf8)) + task = nil + session?.invalidateAndCancel() + session = nil + } + + func send(_ message: SignalMessage) { + guard let task else { return } + guard let data = try? JSONEncoder().encode(message), + let json = String(data: data, encoding: .utf8) else { return } + task.send(.string(json)) { error in + if let error { + NSLog("[Signaling] send error: \(error.localizedDescription)") + } + } + } + + var isConnected: Bool { task != nil } + + // MARK: - Private + + private func registerDevice() { + var msg = SignalMessage() + msg.type = "REGISTER" + msg.fromDeviceId = deviceId + msg.deviceType = "CONTROLLER" + send(msg) + } + + private func receiveLoop() { + task?.receive { [weak self] result in + guard let self else { return } + switch result { + case .success(let wsMessage): + if case .string(let text) = wsMessage, + let data = text.data(using: .utf8), + let message = try? JSONDecoder().decode(SignalMessage.self, from: data) { + DispatchQueue.main.async { + self.delegate?.signaling(didReceive: message) + } + } + self.receiveLoop() + case .failure(let error): + if !self.manuallyClosed { + self.notifyError(error.localizedDescription) + } + } + } + } + + private func notifyError(_ text: String) { + DispatchQueue.main.async { + self.delegate?.signaling(didFail: text) + } + } +} + +// MARK: - URLSessionWebSocketDelegate + +extension SignalingClient: URLSessionWebSocketDelegate { + + func urlSession(_ session: URLSession, + webSocketTask: URLSessionWebSocketTask, + didOpenWithProtocol protocol: String?) { + registerDevice() + DispatchQueue.main.async { + self.delegate?.signalingDidConnect() + } + } + + func urlSession(_ session: URLSession, + webSocketTask: URLSessionWebSocketTask, + didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, + reason: Data?) { + guard !manuallyClosed else { return } + DispatchQueue.main.async { + self.delegate?.signalingDidDisconnect() + } + } +} diff --git a/webrtc_controller_ios/web_rtc_controller_ios/Utils/DeviceUtils.swift b/webrtc_controller_ios/web_rtc_controller_ios/Utils/DeviceUtils.swift new file mode 100644 index 0000000..8e1d4d8 --- /dev/null +++ b/webrtc_controller_ios/web_rtc_controller_ios/Utils/DeviceUtils.swift @@ -0,0 +1,21 @@ +import Foundation +import UIKit + +/// 设备 ID 工具:生成并持久化本机设备标识(对应 Android 端 DeviceUtils)。 +/// 格式: CTRL-XXXXXXXX(8 位大写十六进制) +enum DeviceUtils { + + private static let key = "com.ttstd.webrtccontroller.deviceId" + + static func deviceId() -> String { + let defaults = UserDefaults.standard + if let saved = defaults.string(forKey: key), !saved.isEmpty { + return saved + } + let seed = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString + let suffix = seed.replacingOccurrences(of: "-", with: "").prefix(8).uppercased() + let id = "CTRL-\(suffix)" + defaults.set(id, forKey: key) + return id + } +} diff --git a/webrtc_controller_ios/web_rtc_controller_ios/ViewModel/ControllerViewModel.swift b/webrtc_controller_ios/web_rtc_controller_ios/ViewModel/ControllerViewModel.swift new file mode 100644 index 0000000..970c900 --- /dev/null +++ b/webrtc_controller_ios/web_rtc_controller_ios/ViewModel/ControllerViewModel.swift @@ -0,0 +1,462 @@ +import Foundation +import Combine +import UIKit +import WebRTC + +/// 鉴权方式(与被控端约定一致) +enum AuthType: String, CaseIterable, Identifiable { + case none = "NONE" + case code = "CODE" + case password = "PASSWORD" + + var id: String { rawValue } + + var title: String { + switch self { + case .none: return "免密连接" + case .code: return "动态验证码" + case .password: return "固定密码" + } + } +} + +/// 串流模式 +enum StreamMode: Int { + case webrtc = 0 + case selfCodec = 1 +} + +/// 分辨率档位(与 Android 端一致,0 表示原始画质) +struct ResolutionOption: Identifiable, Equatable { + let id: Int + let title: String + let width: Int32 + let height: Int32 + + static let all: [ResolutionOption] = [ + ResolutionOption(id: 0, title: "原始画质", width: 0, height: 0), + ResolutionOption(id: 1, title: "1080P", width: 1920, height: 1080), + ResolutionOption(id: 2, title: "720P", width: 1280, height: 720), + ResolutionOption(id: 3, title: "480P", width: 854, height: 480) + ] +} + +/// 主控端 ViewModel:串联信令、WebRTC、自编码解码与 UI 状态。 +/// 所有回调均已在主线程触发,@Published 属性直接驱动 SwiftUI。 +final class ControllerViewModel: NSObject, ObservableObject { + + // MARK: - UI 状态 + + @Published var serverUrl: String = "wss://www.ttstd.com/signal" + @Published var targetDeviceId: String = "" + @Published var statusText: String = "未连接" + /// 是否显示控制界面(对应 Android 的 setupPanel/controlPanel 切换) + @Published var isControlling: Bool = false + @Published var isConnecting: Bool = false + @Published var statsText: String = "" + @Published var streamMode: StreamMode = .webrtc + @Published var selectedResolution: ResolutionOption = ResolutionOption.all[0] + /// 远端视频宽高比(宽/高),用于让触控层与画面精确对齐 + @Published var videoAspect: CGFloat = 9.0 / 16.0 + @Published var showAuthSheet: Bool = false + @Published var alertMessage: String? + + let myDeviceId: String = DeviceUtils.deviceId() + + // MARK: - UIKit 渲染视图(由 ViewModel 持有,SwiftUI 通过 Representable 嵌入) + + let remoteVideoView = RTCMTLVideoView() + let selfCodecView = SampleBufferDisplayView() + + // MARK: - 内部组件 + + private var signalingClient: SignalingClient? + private var webRTCClient: WebRTCClient? + private let selfCodecDecoder = SelfCodecDecoder() + + private var statsTimer: Timer? + private var connectionStartTime: Date? + private var lastBytesReceived: Double = 0 + private var lastStatsTime: Date? + private var pendingAuthType: AuthType = .none + private var pendingAuthValue: String = "" + /// 是否为本端主动切换串流模式(避免上报回环再次发送) + private var suppressStreamModeSend = false + + override init() { + super.init() + remoteVideoView.delegate = self + selfCodecDecoder.displayLayer = selfCodecView.sampleBufferLayer + selfCodecDecoder.onResolutionUpdate = { [weak self] w, h in + guard let self, self.streamMode == .selfCodec, w > 0, h > 0 else { return } + self.videoAspect = CGFloat(w) / CGFloat(h) + } + } + + // MARK: - 连接 / 断开 + + /// 用户点击"连接":弹出鉴权选择 + func requestConnect() { + let target = targetDeviceId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !serverUrl.trimmingCharacters(in: .whitespaces).isEmpty else { + alertMessage = "请输入信令服务器地址" + return + } + guard !target.isEmpty else { + alertMessage = "请输入目标设备 ID" + return + } + showAuthSheet = true + } + + /// 鉴权弹窗确认后开始连接 + func connect(authType: AuthType, authValue: String) { + showAuthSheet = false + pendingAuthType = authType + pendingAuthValue = authValue + isConnecting = true + statusText = "正在连接信令服务器..." + + let signaling = SignalingClient( + serverUrl: serverUrl.trimmingCharacters(in: .whitespaces), + deviceId: myDeviceId) + signaling.delegate = self + signalingClient = signaling + signaling.connect() + } + + func disconnect() { + stopStatsTimer() + selfCodecDecoder.setEnabled(false) + webRTCClient?.close() + webRTCClient = nil + signalingClient?.disconnect() + signalingClient = nil + isConnecting = false + isControlling = false + statusText = "未连接" + statsText = "" + streamMode = .webrtc + selectedResolution = ResolutionOption.all[0] + videoAspect = 9.0 / 16.0 + } + + // MARK: - 远程输入 + + func sendMotionEvent(action: Int32, x: Double, y: Double) { + var msg = ControlMessage() + msg.action = .motionEvent + msg.motionAction = action + msg.x = x + msg.y = y + webRTCClient?.sendControlCommand(msg) + } + + func sendSwipe(x1: Double, y1: Double, x2: Double, y2: Double, duration: Int64) { + var msg = ControlMessage() + msg.action = .swipe + msg.x1 = x1 + msg.y1 = y1 + msg.x2 = x2 + msg.y2 = y2 + msg.duration = duration + webRTCClient?.sendControlCommand(msg) + } + + /// 发送按键(Android KeyEvent keyCode),完整的按下+抬起 + func sendKey(_ keyCode: Int32) { + var down = ControlMessage() + down.action = .key + down.keyCode = keyCode + down.keyAction = 0 + webRTCClient?.sendControlCommand(down) + + var up = ControlMessage() + up.action = .key + up.keyCode = keyCode + up.keyAction = 1 + webRTCClient?.sendControlCommand(up) + } + + /// Android 按键常量 + enum AndroidKey { + static let back: Int32 = 4 + static let home: Int32 = 3 + static let appSwitch: Int32 = 187 + } + + // MARK: - 分辨率 / 串流模式 + + func selectResolution(_ option: ResolutionOption) { + selectedResolution = option + webRTCClient?.requestResolutionChange(width: option.width, height: option.height, fps: 0) + } + + func toggleStreamMode(_ selfCodecOn: Bool) { + let newMode: StreamMode = selfCodecOn ? .selfCodec : .webrtc + guard newMode != streamMode else { return } + streamMode = newMode + applyStreamModeLocally(newMode) + if !suppressStreamModeSend { + webRTCClient?.sendStreamMode(Int32(newMode.rawValue)) + } + } + + private func applyStreamModeLocally(_ mode: StreamMode) { + selfCodecDecoder.setEnabled(mode == .selfCodec) + } + + // MARK: - 私有 + + fileprivate func startWebRTC() { + guard let signaling = signalingClient else { return } + statusText = "正在建立 WebRTC 连接..." + let client = WebRTCClient(signaling: signaling, myDeviceId: myDeviceId) + client.delegate = self + client.selfCodecDecoder = selfCodecDecoder + webRTCClient = client + client.createOffer( + targetDeviceId: targetDeviceId.trimmingCharacters(in: .whitespacesAndNewlines), + renderer: remoteVideoView, + authType: pendingAuthType.rawValue, + authValue: pendingAuthType == .none ? nil : pendingAuthValue) + } + + fileprivate func failConnection(_ reason: String) { + alertMessage = reason + disconnect() + } + + // MARK: - 统计 + + private func startStatsTimer() { + connectionStartTime = Date() + lastBytesReceived = 0 + lastStatsTime = nil + statsTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + self?.collectStats() + } + } + + private func stopStatsTimer() { + statsTimer?.invalidate() + statsTimer = nil + } + + private func collectStats() { + webRTCClient?.stats { [weak self] report in + guard let self else { return } + let text = self.buildStatsText(report) + DispatchQueue.main.async { + self.statsText = text + } + } + } + + private func buildStatsText(_ report: RTCStatisticsReport) -> String { + var width = 0, height = 0 + var fps: Double = 0 + var bytesReceived: Double = 0 + var framesDropped: Double = 0 + var codecId: String? + var codecName = "" + var decoderImpl = "" + var rttMs: Double = -1 + var localCandidateId: String? + var remoteCandidateId: String? + + for (_, stat) in report.statistics { + switch stat.type { + case "inbound-rtp": + guard (stat.values["kind"] as? String) == "video" else { continue } + width = (stat.values["frameWidth"] as? NSNumber)?.intValue ?? width + height = (stat.values["frameHeight"] as? NSNumber)?.intValue ?? height + fps = (stat.values["framesPerSecond"] as? NSNumber)?.doubleValue ?? fps + bytesReceived = (stat.values["bytesReceived"] as? NSNumber)?.doubleValue ?? bytesReceived + framesDropped = (stat.values["framesDropped"] as? NSNumber)?.doubleValue ?? framesDropped + codecId = stat.values["codecId"] as? String + decoderImpl = (stat.values["decoderImplementation"] as? String) ?? decoderImpl + case "candidate-pair": + let nominated = (stat.values["nominated"] as? NSNumber)?.boolValue ?? false + let state = stat.values["state"] as? String + if nominated && state == "succeeded" { + rttMs = ((stat.values["currentRoundTripTime"] as? NSNumber)?.doubleValue ?? 0) * 1000 + localCandidateId = stat.values["localCandidateId"] as? String + remoteCandidateId = stat.values["remoteCandidateId"] as? String + } + default: + break + } + } + + if let codecId, let codecStat = report.statistics[codecId] { + codecName = (codecStat.values["mimeType"] as? String)? + .replacingOccurrences(of: "video/", with: "") ?? "" + } + + // 码率(与 Android 一致:按字节增量/时间差计算) + var bitrateText = "--" + let now = Date() + if let last = lastStatsTime, bytesReceived >= lastBytesReceived { + let interval = now.timeIntervalSince(last) + if interval > 0 { + let bps = (bytesReceived - lastBytesReceived) * 8 / interval + bitrateText = Self.formatBitrate(bps) + } + } + lastBytesReceived = bytesReceived + lastStatsTime = now + + // 连接类型 + var connType = "--" + if let lid = localCandidateId, let rid = remoteCandidateId, + let local = report.statistics[lid], let remote = report.statistics[rid] { + let localType = (local.values["candidateType"] as? String) ?? "" + let remoteType = (remote.values["candidateType"] as? String) ?? "" + let proto = ((local.values["protocol"] as? String) ?? "").uppercased() + if localType == "relay" || remoteType == "relay" { + connType = "TURN 中继(\(proto))" + } else if localType == "srflx" || remoteType == "srflx" { + connType = "P2P 公网直连(\(proto))" + } else if localType == "host" && remoteType == "host" { + connType = "P2P 局域网直连(\(proto))" + } else { + connType = "\(localType)/\(remoteType)(\(proto))" + } + } + + var duration = "" + if let start = connectionStartTime { + let secs = Int(Date().timeIntervalSince(start)) + duration = String(format: "%02d:%02d", secs / 60, secs % 60) + } + + let modeText = streamMode == .selfCodec ? "自编码" : "WebRTC" + var lines: [String] = [] + lines.append("模式: \(modeText) 时长: \(duration)") + if streamMode == .selfCodec { + let w = selfCodecDecoder.videoWidth + let h = selfCodecDecoder.videoHeight + lines.append("分辨率: \(w > 0 ? "\(w)x\(h)" : "--") 解码: VideoToolbox(H264)") + } else { + lines.append("分辨率: \(width > 0 ? "\(width)x\(height)" : "--") 帧率: \(Int(fps))fps 丢帧: \(Int(framesDropped))") + lines.append("编码: \(codecName.isEmpty ? "--" : codecName) 解码器: \(decoderImpl.isEmpty ? "--" : decoderImpl)") + } + lines.append("码率: \(bitrateText) 延迟: \(rttMs >= 0 ? "\(Int(rttMs))ms" : "--")") + lines.append("链路: \(connType)") + return lines.joined(separator: "\n") + } + + private static func formatBitrate(_ bps: Double) -> String { + if bps >= 1_000_000 { + return String(format: "%.1f Mbps", bps / 1_000_000) + } + if bps >= 1_000 { + return String(format: "%.0f Kbps", bps / 1_000) + } + return String(format: "%.0f bps", bps) + } +} + +// MARK: - SignalingClientDelegate + +extension ControllerViewModel: SignalingClientDelegate { + + func signalingDidConnect() { + statusText = "信令已连接,正在发起会话..." + startWebRTC() + } + + func signalingDidDisconnect() { + guard isConnecting || isControlling else { return } + failConnection("信令服务器连接断开") + } + + func signaling(didFail error: String) { + guard isConnecting || isControlling else { return } + failConnection("信令连接失败: \(error)") + } + + func signaling(didReceive message: SignalMessage) { + switch (message.type ?? "").uppercased() { + case "ANSWER": + if let sdp = message.payloadJSON()?["sdp"] as? String { + statusText = "收到应答,正在协商..." + webRTCClient?.handleAnswer(sdp: sdp) + } + case "ICE_CANDIDATE": + if let json = message.payloadJSON(), + let candidate = json["candidate"] as? String { + let sdpMid = json["sdpMid"] as? String ?? "" + let index = Int32((json["sdpMLineIndex"] as? NSNumber)?.intValue ?? 0) + webRTCClient?.addIceCandidate(sdpMid: sdpMid, sdpMLineIndex: index, candidate: candidate) + } + case "TARGET_OFFLINE": + failConnection("目标设备不在线") + case "CONNECTION_REJECTED": + let reason = message.payloadJSON()?["reason"] as? String + failConnection(reason ?? "对方拒绝了连接请求") + case "REQUEST_ERROR": + let reason = message.payloadJSON()?["reason"] as? String + failConnection(reason ?? "请求错误") + case "REQUEST_TIMEOUT": + failConnection("连接请求超时,对方未响应") + default: + break + } + } +} + +// MARK: - WebRTCClientDelegate + +extension ControllerViewModel: WebRTCClientDelegate { + + func webRTCClientDidConnect() { + isConnecting = false + isControlling = true + statusText = "已连接: \(targetDeviceId)" + applyStreamModeLocally(streamMode) + startStatsTimer() + } + + func webRTCClientDidDisconnect() { + guard isControlling else { return } + alertMessage = "连接已断开" + disconnect() + } + + func webRTCClient(didFail error: String) { + failConnection(error) + } + + func webRTCClient(didReportStreamMode mode: Int) { + let reported = StreamMode(rawValue: mode) ?? .webrtc + guard reported != streamMode else { return } + // 与被控端实际模式同步(不再回发 SET_STREAM_MODE) + suppressStreamModeSend = true + streamMode = reported + applyStreamModeLocally(reported) + suppressStreamModeSend = false + } + + func webRTCClient(didReportResolution width: Int, height: Int) { + guard width > 0, height > 0 else { return } + if streamMode == .webrtc { + videoAspect = CGFloat(width) / CGFloat(height) + } + } +} + +// MARK: - RTCVideoViewDelegate(WebRTC 模式下画面尺寸变化) + +extension ControllerViewModel: RTCVideoViewDelegate { + + func videoView(_ videoView: RTCVideoRenderer, didChangeVideoSize size: CGSize) { + DispatchQueue.main.async { + guard size.width > 0, size.height > 0 else { return } + if self.streamMode == .webrtc { + self.videoAspect = size.width / size.height + } + } + } +} diff --git a/webrtc_controller_ios/web_rtc_controller_ios/Views/AuthSheetView.swift b/webrtc_controller_ios/web_rtc_controller_ios/Views/AuthSheetView.swift new file mode 100644 index 0000000..50f684c --- /dev/null +++ b/webrtc_controller_ios/web_rtc_controller_ios/Views/AuthSheetView.swift @@ -0,0 +1,72 @@ +import SwiftUI + +/// 连接鉴权弹窗:免密 / 动态验证码 / 固定密码(对应 Android 的鉴权对话框)。 +struct AuthSheetView: View { + @Environment(\.dismiss) private var dismiss + + let onConfirm: (AuthType, String) -> Void + + @State private var authType: AuthType = .none + @State private var authValue: String = "" + + var body: some View { + NavigationView { + Form { + Section("鉴权方式") { + Picker("鉴权方式", selection: $authType) { + ForEach(AuthType.allCases) { type in + Text(type.title).tag(type) + } + } + .pickerStyle(.segmented) + } + + if authType != .none { + Section(authType == .code ? "验证码" : "密码") { + if authType == .code { + TextField("请输入被控端显示的 6 位验证码", text: $authValue) + .keyboardType(.numberPad) + } else { + SecureField("请输入被控端设置的连接密码", text: $authValue) + } + } + } + + Section { + Button { + onConfirm(authType, authValue) + } label: { + HStack { + Spacer() + Text("开始连接") + .fontWeight(.semibold) + Spacer() + } + } + .disabled(authType != .none && authValue.isEmpty) + } footer: { + Text(footerTips) + } + } + .navigationTitle("连接鉴权") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("取消") { dismiss() } + } + } + } + .navigationViewStyle(.stack) + } + + private var footerTips: String { + switch authType { + case .none: + return "免密连接:需要被控端手动确认或开启免验证。" + case .code: + return "动态验证码:输入被控端界面上显示的验证码,验证通过后自动接受连接。" + case .password: + return "固定密码:输入被控端预设的连接密码,验证通过后自动接受连接。" + } + } +} diff --git a/webrtc_controller_ios/web_rtc_controller_ios/Views/ContentView.swift b/webrtc_controller_ios/web_rtc_controller_ios/Views/ContentView.swift new file mode 100644 index 0000000..bff5929 --- /dev/null +++ b/webrtc_controller_ios/web_rtc_controller_ios/Views/ContentView.swift @@ -0,0 +1,235 @@ +import SwiftUI + +/// 主界面:未连接时显示设置面板,连接成功后显示远程控制面板。 +struct ContentView: View { + @StateObject private var viewModel = ControllerViewModel() + + var body: some View { + ZStack { + if viewModel.isControlling { + ControlPanelView(viewModel: viewModel) + } else { + SetupPanelView(viewModel: viewModel) + } + } + .sheet(isPresented: $viewModel.showAuthSheet) { + AuthSheetView { authType, authValue in + viewModel.connect(authType: authType, authValue: authValue) + } + } + .alert("提示", isPresented: Binding( + get: { viewModel.alertMessage != nil }, + set: { if !$0 { viewModel.alertMessage = nil } })) { + Button("确定", role: .cancel) { viewModel.alertMessage = nil } + } message: { + Text(viewModel.alertMessage ?? "") + } + } +} + +// MARK: - 设置面板 + +private struct SetupPanelView: View { + @ObservedObject var viewModel: ControllerViewModel + + var body: some View { + NavigationView { + Form { + Section("本机信息") { + HStack { + Text("本机设备 ID") + Spacer() + Text(viewModel.myDeviceId) + .font(.system(.body, design: .monospaced)) + .foregroundColor(.secondary) + .textSelection(.enabled) + } + } + + Section("连接设置") { + TextField("信令服务器地址 (wss://...)", text: $viewModel.serverUrl) + .keyboardType(.URL) + .autocapitalization(.none) + .disableAutocorrection(true) + TextField("目标设备 ID", text: $viewModel.targetDeviceId) + .autocapitalization(.allCharacters) + .disableAutocorrection(true) + } + + Section { + Button { + viewModel.requestConnect() + } label: { + HStack { + Spacer() + if viewModel.isConnecting { + ProgressView() + .padding(.trailing, 8) + } + Text(viewModel.isConnecting ? "连接中..." : "连接") + .fontWeight(.semibold) + Spacer() + } + } + .disabled(viewModel.isConnecting) + + if viewModel.isConnecting { + Button(role: .destructive) { + viewModel.disconnect() + } label: { + HStack { + Spacer() + Text("取消") + Spacer() + } + } + } + } footer: { + Text(viewModel.statusText) + } + } + .navigationTitle("WebRTC 控制端") + } + .navigationViewStyle(.stack) + } +} + +// MARK: - 控制面板 + +private struct ControlPanelView: View { + @ObservedObject var viewModel: ControllerViewModel + + var body: some View { + VStack(spacing: 0) { + topBar + videoArea + statsView + navButtons + } + .background(Color.black.ignoresSafeArea()) + } + + private var topBar: some View { + HStack(spacing: 12) { + Text(viewModel.statusText) + .font(.footnote) + .foregroundColor(.green) + .lineLimit(1) + + Spacer() + + // 串流模式切换(WebRTC / 自编码) + Toggle(isOn: Binding( + get: { viewModel.streamMode == .selfCodec }, + set: { viewModel.toggleStreamMode($0) })) { + Text("自编码") + .font(.footnote) + .foregroundColor(.white) + } + .toggleStyle(.switch) + .fixedSize() + + // 分辨率选择 + Menu { + ForEach(ResolutionOption.all) { option in + Button { + viewModel.selectResolution(option) + } label: { + if option == viewModel.selectedResolution { + Label(option.title, systemImage: "checkmark") + } else { + Text(option.title) + } + } + } + } label: { + Label(viewModel.selectedResolution.title, systemImage: "rectangle.compress.vertical") + .font(.footnote) + } + + Button(role: .destructive) { + viewModel.disconnect() + } label: { + Text("断开") + .font(.footnote) + .fontWeight(.semibold) + } + .buttonStyle(.borderedProminent) + .tint(.red) + .controlSize(.small) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color(white: 0.1)) + } + + /// 视频区域:按远端画面宽高比自适应,触控层与画面精确对齐 + private var videoArea: some View { + GeometryReader { _ in + ZStack { + Color.black + ZStack { + // WebRTC 视频渲染(UIKit RTCMTLVideoView) + RemoteVideoView(videoView: viewModel.remoteVideoView) + .opacity(viewModel.streamMode == .webrtc ? 1 : 0) + // 自编码 H.264 硬解渲染(UIKit AVSampleBufferDisplayLayer) + SelfCodecDisplayViewRepresentable(view: viewModel.selfCodecView) + .opacity(viewModel.streamMode == .selfCodec ? 1 : 0) + // UIKit 触控捕获层 + TouchOverlay( + onMotionEvent: { action, x, y in + viewModel.sendMotionEvent(action: action, x: x, y: y) + }, + onSwipe: { x1, y1, x2, y2, duration in + viewModel.sendSwipe(x1: x1, y1: y1, x2: x2, y2: y2, duration: duration) + }) + } + .aspectRatio(viewModel.videoAspect, contentMode: .fit) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } + + private var statsView: some View { + HStack { + Text(viewModel.statsText) + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(Color(white: 0.75)) + .multilineTextAlignment(.leading) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 4) + .background(Color(white: 0.08)) + } + + private var navButtons: some View { + HStack(spacing: 24) { + navButton(title: "返回", systemImage: "arrow.uturn.backward") { + viewModel.sendKey(ControllerViewModel.AndroidKey.back) + } + navButton(title: "主页", systemImage: "circle") { + viewModel.sendKey(ControllerViewModel.AndroidKey.home) + } + navButton(title: "多任务", systemImage: "square.on.square") { + viewModel.sendKey(ControllerViewModel.AndroidKey.appSwitch) + } + } + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(Color(white: 0.1)) + } + + private func navButton(title: String, systemImage: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + VStack(spacing: 2) { + Image(systemName: systemImage) + .font(.system(size: 18)) + Text(title) + .font(.caption2) + } + .foregroundColor(.white) + .frame(width: 64) + } + } +} diff --git a/webrtc_controller_ios/web_rtc_controller_ios/Views/RemoteTouchView.swift b/webrtc_controller_ios/web_rtc_controller_ios/Views/RemoteTouchView.swift new file mode 100644 index 0000000..e400250 --- /dev/null +++ b/webrtc_controller_ios/web_rtc_controller_ios/Views/RemoteTouchView.swift @@ -0,0 +1,98 @@ +import UIKit +import SwiftUI + +/// UIKit 触控捕获视图:将触摸转换为相对坐标(0.0~1.0)的远端输入指令。 +/// 与 Android 端 RemoteTouchView 行为一致: +/// - 实时转发原始 MotionEvent(DOWN=0 / UP=1 / MOVE=2 / CANCEL=3),远端注入实现"跟手" +/// - 手指抬起时若判定为滑动(时长 >= 200ms 或位移 >= 2%),额外发送一条 SWIPE 指令 +final class RemoteTouchView: UIView { + + /// Android MotionEvent 动作常量 + enum MotionAction { + static let down: Int32 = 0 + static let up: Int32 = 1 + static let move: Int32 = 2 + static let cancel: Int32 = 3 + } + + var onMotionEvent: ((Int32, Double, Double) -> Void)? + var onSwipe: ((Double, Double, Double, Double, Int64) -> Void)? + + private var startX: Double = 0 + private var startY: Double = 0 + private var touchStartTime: TimeInterval = 0 + + override init(frame: CGRect) { + super.init(frame: frame) + isMultipleTouchEnabled = false + backgroundColor = .clear + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + isMultipleTouchEnabled = false + backgroundColor = .clear + } + + private func relativePoint(of touch: UITouch) -> (Double, Double) { + let p = touch.location(in: self) + let w = max(bounds.width, 1) + let h = max(bounds.height, 1) + let relX = min(max(Double(p.x / w), 0), 1) + let relY = min(max(Double(p.y / h), 0), 1) + return (relX, relY) + } + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + guard let touch = touches.first else { return } + let (x, y) = relativePoint(of: touch) + startX = x + startY = y + touchStartTime = Date().timeIntervalSince1970 + onMotionEvent?(MotionAction.down, x, y) + } + + override func touchesMoved(_ touches: Set, with event: UIEvent?) { + guard let touch = touches.first else { return } + let (x, y) = relativePoint(of: touch) + onMotionEvent?(MotionAction.move, x, y) + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + guard let touch = touches.first else { return } + let (x, y) = relativePoint(of: touch) + onMotionEvent?(MotionAction.up, x, y) + + let duration = Int64((Date().timeIntervalSince1970 - touchStartTime) * 1000) + let dx = abs(x - startX) + let dy = abs(y - startY) + // 非轻点:补发 SWIPE 高层指令(与 Android 端一致) + if !(duration < 200 && dx < 0.02 && dy < 0.02) { + onSwipe?(startX, startY, x, y, duration) + } + } + + override func touchesCancelled(_ touches: Set, with event: UIEvent?) { + guard let touch = touches.first else { return } + let (x, y) = relativePoint(of: touch) + onMotionEvent?(MotionAction.cancel, x, y) + } +} + +/// SwiftUI 包装:把 UIKit 触控层嵌入 SwiftUI 视图树 +struct TouchOverlay: UIViewRepresentable { + let onMotionEvent: (Int32, Double, Double) -> Void + let onSwipe: (Double, Double, Double, Double, Int64) -> Void + + func makeUIView(context: Context) -> RemoteTouchView { + let view = RemoteTouchView() + view.onMotionEvent = onMotionEvent + view.onSwipe = onSwipe + return view + } + + func updateUIView(_ uiView: RemoteTouchView, context: Context) { + uiView.onMotionEvent = onMotionEvent + uiView.onSwipe = onSwipe + } +} diff --git a/webrtc_controller_ios/web_rtc_controller_ios/Views/RemoteVideoView.swift b/webrtc_controller_ios/web_rtc_controller_ios/Views/RemoteVideoView.swift new file mode 100644 index 0000000..7a8e933 --- /dev/null +++ b/webrtc_controller_ios/web_rtc_controller_ios/Views/RemoteVideoView.swift @@ -0,0 +1,15 @@ +import SwiftUI +import WebRTC + +/// SwiftUI 包装:WebRTC Metal 视频渲染视图(UIKit)。 +/// 渲染视图实例由 ViewModel 持有,保证视频轨道 attach 后跨 SwiftUI 刷新周期不丢失。 +struct RemoteVideoView: UIViewRepresentable { + let videoView: RTCMTLVideoView + + func makeUIView(context: Context) -> RTCMTLVideoView { + videoView.videoContentMode = .scaleAspectFit + return videoView + } + + func updateUIView(_ uiView: RTCMTLVideoView, context: Context) {} +} diff --git a/webrtc_controller_ios/web_rtc_controller_ios/Views/SelfCodecDisplayView.swift b/webrtc_controller_ios/web_rtc_controller_ios/Views/SelfCodecDisplayView.swift new file mode 100644 index 0000000..8acfdfc --- /dev/null +++ b/webrtc_controller_ios/web_rtc_controller_ios/Views/SelfCodecDisplayView.swift @@ -0,0 +1,39 @@ +import UIKit +import SwiftUI +import AVFoundation + +/// UIKit 视图:以 AVSampleBufferDisplayLayer 为 backing layer, +/// 用于渲染"自编码"模式下的 H.264 硬解画面(对应 Android 端的 SurfaceView)。 +final class SampleBufferDisplayView: UIView { + + override class var layerClass: AnyClass { + AVSampleBufferDisplayLayer.self + } + + var sampleBufferLayer: AVSampleBufferDisplayLayer { + layer as! AVSampleBufferDisplayLayer + } + + override init(frame: CGRect) { + super.init(frame: frame) + sampleBufferLayer.videoGravity = .resizeAspect + backgroundColor = .black + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + sampleBufferLayer.videoGravity = .resizeAspect + backgroundColor = .black + } +} + +/// SwiftUI 包装 +struct SelfCodecDisplayViewRepresentable: UIViewRepresentable { + let view: SampleBufferDisplayView + + func makeUIView(context: Context) -> SampleBufferDisplayView { + view + } + + func updateUIView(_ uiView: SampleBufferDisplayView, context: Context) {} +} diff --git a/webrtc_controller_ios/web_rtc_controller_ios/WebRTC/SelfCodecDecoder.swift b/webrtc_controller_ios/web_rtc_controller_ios/WebRTC/SelfCodecDecoder.swift new file mode 100644 index 0000000..61a4175 --- /dev/null +++ b/webrtc_controller_ios/web_rtc_controller_ios/WebRTC/SelfCodecDecoder.swift @@ -0,0 +1,343 @@ +import Foundation +import AVFoundation +import CoreMedia + +/// “自编码”串流模式解码器: +/// 从 video_channel DataChannel 接收被控端 MediaCodec 编码的 H.264 裸流, +/// 重组分片 -> 解析单元 -> 转换为 AVCC 格式 -> 通过 AVSampleBufferDisplayLayer 硬解渲染。 +/// +/// 二进制协议(与被控端 SelfCodecEncoder 保持一致,均为大端序): +/// 分片: MAGIC(0xAB,1B) + seq(int32) + total(int16) + idx(int16) + len(int32) + payload +/// 单元: type(1B: 1=CONFIG 2=FRAME) + pts(uint32,ms) + isKey(1B) + len(int32) + H.264 Annex-B 数据 +final class SelfCodecDecoder { + + // ---- 必须与被控端 SelfCodecEncoder 保持一致 ---- + private static let magic: UInt8 = 0xAB + private static let unitTypeConfig: UInt8 = 1 + private static let unitTypeFrame: UInt8 = 2 + + /// 渲染图层(由 SelfCodecDisplayView 提供) + weak var displayLayer: AVSampleBufferDisplayLayer? + /// 分辨率变化回调(主线程) + var onResolutionUpdate: ((Int, Int) -> Void)? + + private(set) var videoWidth = 0 + private(set) var videoHeight = 0 + + private let queue = DispatchQueue(label: "com.ttstd.selfcodec.decoder") + private var enabled = false + + // 分片重组 + private final class ChunkBuffer { + var total = 0 + var received = 0 + var chunks: [Data?] = [] + } + private var assembling: [Int32: ChunkBuffer] = [:] + private var lastSeq: Int32 = -1 + + // H.264 参数集与格式 + private var sps: Data? + private var pps: Data? + private var formatDescription: CMVideoFormatDescription? + + // MARK: - 开关 + + func setEnabled(_ on: Bool) { + queue.async { + self.enabled = on + if !on { + self.resetLocked() + } + } + } + + func release() { + queue.async { + self.enabled = false + self.resetLocked() + } + } + + private func resetLocked() { + assembling.removeAll() + lastSeq = -1 + sps = nil + pps = nil + formatDescription = nil + videoWidth = 0 + videoHeight = 0 + displayLayer?.flushAndRemoveImage() + } + + // MARK: - 入口:DataChannel 二进制消息 + + func onBinaryMessage(_ raw: Data) { + queue.async { + self.handleChunk(raw) + } + } + + private func handleChunk(_ raw: Data) { + guard enabled, raw.count >= 13 else { return } + var r = BigEndianReader(data: raw) + guard r.readByte() == Self.magic else { return } + guard let seq = r.readInt32() else { return } + + // 过期检查:同一单元的所有分片 seq 相同,只丢弃明显更旧的单元 + if lastSeq != -1 && seq < lastSeq && (lastSeq - seq) < 1000 { + return + } + guard let total = r.readInt16(), let idx = r.readInt16(), + let len = r.readInt32(), let chunk = r.readData(Int(len)), + total > 0 else { return } + + let cb: ChunkBuffer + if let existing = assembling[seq] { + cb = existing + } else { + cb = ChunkBuffer() + cb.total = Int(total) + cb.chunks = Array(repeating: nil, count: Int(total)) + assembling[seq] = cb + if assembling.count > 24, let minKey = assembling.keys.min() { + assembling.removeValue(forKey: minKey) + } + } + + let i = Int(idx) + if i >= 0 && i < cb.total && cb.chunks[i] == nil { + cb.chunks[i] = chunk + cb.received += 1 + } + if cb.received == cb.total { + assembling.removeValue(forKey: seq) + if seq > lastSeq { lastSeq = seq } + var unit = Data() + for c in cb.chunks where c != nil { unit.append(c!) } + handleUnit(unit) + } + } + + // MARK: - 单元处理 + + private func handleUnit(_ unit: Data) { + var r = BigEndianReader(data: unit) + guard let type = r.readByte(), + let ptsRaw = r.readInt32(), + let isKey = r.readByte(), + let len = r.readInt32(), + let data = r.readData(Int(len)) else { return } + let ptsMs = Int64(UInt32(bitPattern: ptsRaw)) + + if type == Self.unitTypeConfig { + handleConfig(data) + } else if type == Self.unitTypeFrame { + handleFrame(data, ptsMs: ptsMs, isKey: isKey != 0) + } + } + + /// CONFIG 单元:MediaCodec 的 csd-0(SPS)/csd-1(PPS),Annex-B 格式 + private func handleConfig(_ data: Data) { + for nal in Self.annexBNalUnits(in: data) { + guard let first = nal.first else { continue } + switch first & 0x1F { + case 7: if sps != nal { sps = nal; formatDescription = nil } + case 8: if pps != nal { pps = nal; formatDescription = nil } + default: break + } + } + rebuildFormatDescriptionIfNeeded() + } + + private func rebuildFormatDescriptionIfNeeded() { + guard formatDescription == nil, let sps, let pps else { return } + var desc: CMVideoFormatDescription? + let status = sps.withUnsafeBytes { spsPtr -> OSStatus in + pps.withUnsafeBytes { ppsPtr -> OSStatus in + let paramSets: [UnsafePointer] = [ + spsPtr.bindMemory(to: UInt8.self).baseAddress!, + ppsPtr.bindMemory(to: UInt8.self).baseAddress! + ] + let sizes: [Int] = [sps.count, pps.count] + return CMVideoFormatDescriptionCreateFromH264ParameterSets( + allocator: kCFAllocatorDefault, + parameterSetCount: 2, + parameterSetPointers: paramSets, + parameterSetSizes: sizes, + nalUnitHeaderLength: 4, + formatDescriptionOut: &desc) + } + } + guard status == noErr, let desc else { + NSLog("[SelfCodec] create format description failed: \(status)") + return + } + formatDescription = desc + displayLayer?.flush() + + let dims = CMVideoFormatDescriptionGetDimensions(desc) + let w = Int(dims.width), h = Int(dims.height) + if w != videoWidth || h != videoHeight { + videoWidth = w + videoHeight = h + DispatchQueue.main.async { + self.onResolutionUpdate?(w, h) + } + } + } + + /// FRAME 单元:Annex-B -> AVCC(4 字节大端长度前缀),封装 CMSampleBuffer 后送显 + private func handleFrame(_ data: Data, ptsMs: Int64, isKey: Bool) { + // 帧内可能携带 SPS/PPS(部分编码器关键帧前重发参数集) + var vclData = Data() + for nal in Self.annexBNalUnits(in: data) { + guard let first = nal.first else { continue } + let nalType = first & 0x1F + if nalType == 7 { + if sps != nal { sps = nal; formatDescription = nil } + continue + } + if nalType == 8 { + if pps != nal { pps = nal; formatDescription = nil } + continue + } + var lenBE = UInt32(nal.count).bigEndian + withUnsafeBytes(of: &lenBE) { vclData.append(contentsOf: $0) } + vclData.append(nal) + } + rebuildFormatDescriptionIfNeeded() + guard enabled, !vclData.isEmpty, let formatDescription, let layer = displayLayer else { return } + + // 构造 CMBlockBuffer + var blockBuffer: CMBlockBuffer? + var status = CMBlockBufferCreateWithMemoryBlock( + allocator: kCFAllocatorDefault, + memoryBlock: nil, + blockLength: vclData.count, + blockAllocator: kCFAllocatorDefault, + customBlockSource: nil, + offsetToData: 0, + dataLength: vclData.count, + flags: 0, + blockBufferOut: &blockBuffer) + guard status == kCMBlockBufferNoErr, let blockBuffer else { return } + status = vclData.withUnsafeBytes { ptr in + CMBlockBufferReplaceDataBytes( + with: ptr.baseAddress!, + blockBuffer: blockBuffer, + offsetIntoDestination: 0, + dataLength: vclData.count) + } + guard status == kCMBlockBufferNoErr else { return } + + // 构造 CMSampleBuffer(立即显示,不依赖时间轴) + var sampleBuffer: CMSampleBuffer? + var timing = CMSampleTimingInfo( + duration: .invalid, + presentationTimeStamp: CMTime(value: ptsMs, timescale: 1000), + decodeTimeStamp: .invalid) + var sampleSize = vclData.count + status = CMSampleBufferCreateReady( + allocator: kCFAllocatorDefault, + dataBuffer: blockBuffer, + formatDescription: formatDescription, + sampleCount: 1, + sampleTimingEntryCount: 1, + sampleTimingArray: &timing, + sampleSizeEntryCount: 1, + sampleSizeArray: &sampleSize, + sampleBufferOut: &sampleBuffer) + guard status == noErr, let sampleBuffer else { return } + + if let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: true) as? [CFMutableDictionary], + let dict = attachments.first { + CFDictionarySetValue( + dict, + Unmanaged.passUnretained(kCMSampleAttachmentKey_DisplayImmediately).toOpaque(), + Unmanaged.passUnretained(kCFBooleanTrue).toOpaque()) + if !isKey { + CFDictionarySetValue( + dict, + Unmanaged.passUnretained(kCMSampleAttachmentKey_NotSync).toOpaque(), + Unmanaged.passUnretained(kCFBooleanTrue).toOpaque()) + } + } + + if layer.status == .failed || layer.requiresFlushToResumeDecoding { + layer.flush() + } + layer.enqueue(sampleBuffer) + } + + // MARK: - Annex-B 拆分 + + /// 拆分 Annex-B 码流(00 00 01 / 00 00 00 01 起始码)为 NAL 单元数组(不含起始码) + static func annexBNalUnits(in data: Data) -> [Data] { + var result: [Data] = [] + let bytes = [UInt8](data) + let count = bytes.count + var starts: [Int] = [] + var i = 0 + while i + 2 < count { + if bytes[i] == 0 && bytes[i + 1] == 0 && bytes[i + 2] == 1 { + starts.append(i + 3) + i += 3 + } else { + i += 1 + } + } + guard !starts.isEmpty else { return data.isEmpty ? [] : [data] } + for (idx, start) in starts.enumerated() { + var end = count + if idx + 1 < starts.count { + end = starts[idx + 1] - 3 + // 兼容 4 字节起始码(00 00 00 01) + if end > start && bytes[end - 1] == 0 { end -= 1 } + } + if end > start { + result.append(Data(bytes[start.. UInt8? { + guard offset < data.endIndex else { return nil } + defer { offset += 1 } + return data[offset] + } + + mutating func readInt16() -> Int16? { + guard offset + 2 <= data.endIndex else { return nil } + let v = (UInt16(data[offset]) << 8) | UInt16(data[offset + 1]) + offset += 2 + return Int16(bitPattern: v) + } + + mutating func readInt32() -> Int32? { + guard offset + 4 <= data.endIndex else { return nil } + var v: UInt32 = 0 + for i in 0..<4 { v = (v << 8) | UInt32(data[offset + i]) } + offset += 4 + return Int32(bitPattern: v) + } + + mutating func readData(_ length: Int) -> Data? { + guard length >= 0, offset + length <= data.endIndex else { return nil } + defer { offset += length } + return data.subdata(in: offset..<(offset + length)) + } +} diff --git a/webrtc_controller_ios/web_rtc_controller_ios/WebRTC/WebRTCClient.swift b/webrtc_controller_ios/web_rtc_controller_ios/WebRTC/WebRTCClient.swift new file mode 100644 index 0000000..2648d2c --- /dev/null +++ b/webrtc_controller_ios/web_rtc_controller_ios/WebRTC/WebRTCClient.swift @@ -0,0 +1,321 @@ +import Foundation +import WebRTC + +protocol WebRTCClientDelegate: AnyObject { + /// ICE 连接建立(可以开始远程控制) + func webRTCClientDidConnect() + /// 连接断开(远端断开或网络故障) + func webRTCClientDidDisconnect() + /// 连接失败 + func webRTCClient(didFail error: String) + /// 被控端上报当前串流模式(0=WebRTC 1=自编码) + func webRTCClient(didReportStreamMode mode: Int) + /// 被控端上报当前采集分辨率 + func webRTCClient(didReportResolution width: Int, height: Int) +} + +/// WebRTC 客户端:负责 PeerConnection 的创建、Offer/Answer 协商、 +/// ICE 候选交换、控制/视频 DataChannel 管理与统计信息获取。 +/// 与 Android 端 WebRtcClient 行为保持一致。 +final class WebRTCClient: NSObject { + + static let streamModeWebRTC: Int32 = 0 + static let streamModeSelfCodec: Int32 = 1 + + private static let controlChannelLabel = "control_channel" + private static let videoChannelLabel = "video_channel" + + private static let factory: RTCPeerConnectionFactory = { + RTCInitializeSSL() + return RTCPeerConnectionFactory( + encoderFactory: RTCDefaultVideoEncoderFactory(), + decoderFactory: RTCDefaultVideoDecoderFactory()) + }() + + weak var delegate: WebRTCClientDelegate? + /// 自编码模式的 H.264 裸流解码器(video_channel 二进制数据直接转发给它) + var selfCodecDecoder: SelfCodecDecoder? + + private let signaling: SignalingClient + private let myDeviceId: String + private var targetDeviceId: String = "" + private var peerConnection: RTCPeerConnection? + private var controlChannel: RTCDataChannel? + private var videoChannel: RTCDataChannel? + private weak var remoteRenderer: RTCVideoRenderer? + private var remoteVideoTrack: RTCVideoTrack? + private var connected = false + + init(signaling: SignalingClient, myDeviceId: String) { + self.signaling = signaling + self.myDeviceId = myDeviceId + super.init() + } + + var isDataChannelOpen: Bool { + controlChannel?.readyState == .open + } + + // MARK: - 连接流程 + + /// 创建 PeerConnection 与 DataChannel,并向目标设备发送 Offer(携带鉴权信息)。 + func createOffer(targetDeviceId: String, + renderer: RTCVideoRenderer, + authType: String?, + authValue: String?) { + self.targetDeviceId = targetDeviceId + self.remoteRenderer = renderer + + let config = RTCConfiguration() + config.iceServers = [ + RTCIceServer(urlStrings: ["stun:stun.l.google.com:19302"]), + RTCIceServer(urlStrings: ["stun:www.ttstd.com:3478"]), + RTCIceServer(urlStrings: ["turn:www.ttstd.com:3478"], + username: "ttstd", + credential: "ttstd123") + ] + config.sdpSemantics = .unifiedPlan + config.continualGatheringPolicy = .gatherContinually + config.iceCandidatePoolSize = 10 + config.iceTransportPolicy = .all + + let pcConstraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil) + guard let pc = Self.factory.peerConnection(with: config, constraints: pcConstraints, delegate: self) else { + notifyFail("PeerConnection 创建失败") + return + } + peerConnection = pc + + // 只接收视频,不发送本地媒体 + let transceiverInit = RTCRtpTransceiverInit() + transceiverInit.direction = .recvOnly + pc.addTransceiver(of: .video, init: transceiverInit) + + // 控制通道:可靠、有序 + let controlConfig = RTCDataChannelConfiguration() + controlConfig.isOrdered = true + controlChannel = pc.dataChannel(forLabel: Self.controlChannelLabel, configuration: controlConfig) + controlChannel?.delegate = self + + // 自编码视频通道:不可靠、低延迟(丢帧优先) + let videoConfig = RTCDataChannelConfiguration() + videoConfig.isOrdered = false + videoConfig.maxRetransmits = 0 + videoChannel = pc.dataChannel(forLabel: Self.videoChannelLabel, configuration: videoConfig) + videoChannel?.delegate = self + + let offerConstraints = RTCMediaConstraints( + mandatoryConstraints: [ + "OfferToReceiveVideo": "true", + "OfferToReceiveAudio": "false" + ], + optionalConstraints: nil) + + pc.offer(for: offerConstraints) { [weak self] sdp, error in + guard let self else { return } + if let error { + self.notifyFail("创建 Offer 失败: \(error.localizedDescription)") + return + } + guard let sdp else { + self.notifyFail("创建 Offer 失败: SDP 为空") + return + } + pc.setLocalDescription(sdp) { [weak self] error in + guard let self else { return } + if let error { + self.notifyFail("设置本地 SDP 失败: \(error.localizedDescription)") + return + } + self.sendOffer(sdp: sdp.sdp, authType: authType, authValue: authValue) + } + } + } + + private func sendOffer(sdp: String, authType: String?, authValue: String?) { + var msg = SignalMessage() + msg.type = "OFFER" + msg.fromDeviceId = myDeviceId + msg.toDeviceId = targetDeviceId + msg.payload = SignalMessage.encodePayload(["sdp": sdp]) + msg.authType = authType + msg.authValue = authValue + signaling.send(msg) + } + + /// 处理被控端返回的 Answer + func handleAnswer(sdp: String) { + let desc = RTCSessionDescription(type: .answer, sdp: sdp) + peerConnection?.setRemoteDescription(desc) { [weak self] error in + if let error { + self?.notifyFail("设置远端 SDP 失败: \(error.localizedDescription)") + } + } + } + + /// 添加远端 ICE 候选 + func addIceCandidate(sdpMid: String, sdpMLineIndex: Int32, candidate: String) { + let ice = RTCIceCandidate(sdp: candidate, sdpMLineIndex: sdpMLineIndex, sdpMid: sdpMid) + peerConnection?.add(ice) { error in + if let error { + NSLog("[WebRTC] addIceCandidate error: \(error.localizedDescription)") + } + } + } + + // MARK: - 控制指令 + + func sendControlCommand(_ message: ControlMessage) { + guard let channel = controlChannel, channel.readyState == .open else { return } + let buffer = RTCDataBuffer(data: message.serializedData(), isBinary: true) + channel.sendData(buffer) + } + + /// 请求被控端切换采集分辨率(width=0 表示恢复原生分辨率) + func requestResolutionChange(width: Int32, height: Int32, fps: Int32) { + var msg = ControlMessage() + msg.action = .setResolution + msg.width = width + msg.height = height + msg.fps = fps + sendControlCommand(msg) + } + + /// 请求被控端切换串流模式(0=WebRTC 1=自编码) + func sendStreamMode(_ mode: Int32) { + var msg = ControlMessage() + msg.action = .setStreamMode + msg.streamMode = mode + sendControlCommand(msg) + } + + // MARK: - 统计 + + func stats(_ completion: @escaping (RTCStatisticsReport) -> Void) { + peerConnection?.statistics(completionHandler: completion) + } + + // MARK: - 关闭 + + func close() { + connected = false + controlChannel?.close() + videoChannel?.close() + controlChannel = nil + videoChannel = nil + if let track = remoteVideoTrack, let renderer = remoteRenderer { + track.remove(renderer) + } + remoteVideoTrack = nil + peerConnection?.close() + peerConnection = nil + } + + private func notifyFail(_ text: String) { + DispatchQueue.main.async { + self.delegate?.webRTCClient(didFail: text) + } + } +} + +// MARK: - RTCPeerConnectionDelegate + +extension WebRTCClient: RTCPeerConnectionDelegate { + + func peerConnection(_ peerConnection: RTCPeerConnection, didChange stateChanged: RTCSignalingState) {} + + func peerConnection(_ peerConnection: RTCPeerConnection, didAdd stream: RTCMediaStream) {} + + func peerConnection(_ peerConnection: RTCPeerConnection, didRemove stream: RTCMediaStream) {} + + func peerConnectionShouldNegotiate(_ peerConnection: RTCPeerConnection) {} + + func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceConnectionState) { + DispatchQueue.main.async { + switch newState { + case .connected, .completed: + if !self.connected { + self.connected = true + self.delegate?.webRTCClientDidConnect() + } + case .disconnected, .failed, .closed: + if self.connected { + self.connected = false + self.delegate?.webRTCClientDidDisconnect() + } else if newState == .failed { + self.delegate?.webRTCClient(didFail: "ICE 连接失败") + } + default: + break + } + } + } + + func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceGatheringState) {} + + func peerConnection(_ peerConnection: RTCPeerConnection, didGenerate candidate: RTCIceCandidate) { + var msg = SignalMessage() + msg.type = "ICE_CANDIDATE" + msg.fromDeviceId = myDeviceId + msg.toDeviceId = targetDeviceId + msg.payload = SignalMessage.encodePayload([ + "sdpMid": candidate.sdpMid ?? "", + "sdpMLineIndex": Int(candidate.sdpMLineIndex), + "candidate": candidate.sdp + ]) + signaling.send(msg) + } + + func peerConnection(_ peerConnection: RTCPeerConnection, didRemove candidates: [RTCIceCandidate]) {} + + func peerConnection(_ peerConnection: RTCPeerConnection, didOpen dataChannel: RTCDataChannel) { + // 作为 Offer 方,通道由本端创建;此回调用于兜底远端创建的通道 + dataChannel.delegate = self + if dataChannel.label == Self.videoChannelLabel { + videoChannel = dataChannel + } else if dataChannel.label == Self.controlChannelLabel { + controlChannel = dataChannel + } + } + + func peerConnection(_ peerConnection: RTCPeerConnection, + didAdd rtpReceiver: RTCRtpReceiver, + streams mediaStreams: [RTCMediaStream]) { + guard let track = rtpReceiver.track as? RTCVideoTrack else { return } + DispatchQueue.main.async { + self.remoteVideoTrack = track + if let renderer = self.remoteRenderer { + track.add(renderer) + } + } + } +} + +// MARK: - RTCDataChannelDelegate + +extension WebRTCClient: RTCDataChannelDelegate { + + func dataChannelDidChangeState(_ dataChannel: RTCDataChannel) { + NSLog("[WebRTC] DataChannel \(dataChannel.label) state: \(dataChannel.readyState.rawValue)") + } + + func dataChannel(_ dataChannel: RTCDataChannel, didReceiveMessageWith buffer: RTCDataBuffer) { + if dataChannel.label == Self.videoChannelLabel { + // 自编码 H.264 裸流分片 + selfCodecDecoder?.onBinaryMessage(buffer.data) + return + } + // 控制通道:被控端上报(串流模式 / 分辨率) + guard buffer.isBinary, let msg = ControlMessage.parse(from: buffer.data) else { return } + DispatchQueue.main.async { + switch msg.action { + case .reportStreamMode: + self.delegate?.webRTCClient(didReportStreamMode: Int(msg.streamMode)) + case .reportResolution: + self.delegate?.webRTCClient(didReportResolution: Int(msg.width), height: Int(msg.height)) + default: + break + } + } + } +}