feat: 初始化 iOS 远程控制端项目

This commit is contained in:
2026-07-29 10:34:02 +08:00
parent 11173eb04c
commit 1e37aa64cc
20 changed files with 2610 additions and 0 deletions

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,13 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -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
}
}
}

View File

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>WebRTC控制端</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneConfigurationName</key>
<string>Default Configuration</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchScreen</key>
<dict/>
<key>UIRequiresFullScreen</key>
<false/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View File

@@ -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)
}
}

View File

@@ -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
/// REGISTERdeviceType = 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()
}
}
}

View File

@@ -0,0 +1,21 @@
import Foundation
import UIKit
/// ID Android DeviceUtils
/// : CTRL-XXXXXXXX8
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
}
}

View File

@@ -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)
]
}
/// ViewModelWebRTC 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: - RTCVideoViewDelegateWebRTC
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
}
}
}
}

View File

@@ -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 "固定密码:输入被控端预设的连接密码,验证通过后自动接受连接。"
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -0,0 +1,98 @@
import UIKit
import SwiftUI
/// UIKit 0.0~1.0
/// Android RemoteTouchView
/// - MotionEventDOWN=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<UITouch>, 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<UITouch>, 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<UITouch>, 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<UITouch>, 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
}
}

View File

@@ -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) {}
}

View File

@@ -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) {}
}

View File

@@ -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<UInt8>] = [
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 -> AVCC4 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..<end]))
}
}
return result
}
}
// MARK: -
private struct BigEndianReader {
let data: Data
private var offset: Int
init(data: Data) {
self.data = data
self.offset = data.startIndex
}
mutating func readByte() -> 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))
}
}

View File

@@ -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
}
}
}
}