import Foundation import Shared import WebRTC /// Delegate protocol for WebRTCClient events. protocol WebRTCClientDelegate: AnyObject { /// Called when a new ICE candidate is discovered. func webRTCClient(_ client: WebRTCClient, didDiscoverLocalCandidate candidate: RTCIceCandidate) /// Called when the ICE connection state changes. func webRTCClient(_ client: WebRTCClient, didChangeConnectionState state: RTCIceConnectionState) /// Called when data is received over the data channel. func webRTCClient(_ client: WebRTCClient, didReceiveData data: Data) } /// WebRTCClient manages a WebRTC peer connection, media tracks, and data channels. /// It abstracts the setup and control of a WebRTC session for use in the Home Assistant iOS app. /// /// - Note: Based on example project from WebRTC iOS SDK https://github.com/stasel/WebRTC final class WebRTCClient: NSObject { // The `RTCPeerConnectionFactory` is in charge of creating new RTCPeerConnection instances. // A new RTCPeerConnection should be created every new call, but the factory is shared. private static let factory: RTCPeerConnectionFactory = { RTCInitializeSSL() let videoEncoderFactory = RTCDefaultVideoEncoderFactory() let videoDecoderFactory = RTCDefaultVideoDecoderFactory() return RTCPeerConnectionFactory(encoderFactory: videoEncoderFactory, decoderFactory: videoDecoderFactory) }() weak var delegate: WebRTCClientDelegate? private let peerConnection: RTCPeerConnection private let mediaConstrains = [ kRTCMediaConstraintsOfferToReceiveAudio: kRTCMediaConstraintsValueTrue, kRTCMediaConstraintsOfferToReceiveVideo: kRTCMediaConstraintsValueTrue, ] private var videoCapturer: RTCVideoCapturer? private var remoteVideoTrack: RTCVideoTrack? private var remoteDataChannel: RTCDataChannel? @available(*, unavailable) override init() { fatalError("WebRTCClient:init is unavailable") } required init(iceServers: [String]) { let config = RTCConfiguration() config.iceServers = [RTCIceServer(urlStrings: iceServers)] // Unified plan is more superior than planB config.sdpSemantics = .unifiedPlan // gatherContinually will let WebRTC to listen to any network changes and send any new candidates to the other // client config.continualGatheringPolicy = .gatherContinually // Define media constraints. DtlsSrtpKeyAgreement is required to be true to be able to connect with web // browsers. let constraints = RTCMediaConstraints( mandatoryConstraints: nil, optionalConstraints: ["DtlsSrtpKeyAgreement": kRTCMediaConstraintsValueTrue] ) guard let peerConnection = WebRTCClient.factory.peerConnection( with: config, constraints: constraints, delegate: nil ) else { fatalError("Could not create new RTCPeerConnection") } self.peerConnection = peerConnection super.init() createMediaTracks() // This is currently disable since the library does not offer a way to disable just the microphone usage. // TODO: Find a workaround so audio can be receveid without using microphone in parallel RTCAudioSession.sharedInstance().useManualAudio = true self.peerConnection.delegate = self } func closeConnection() { peerConnection.close() } // MARK: Signaling func offer(completion: @escaping (_ sdp: RTCSessionDescription) -> Void) { let constrains = RTCMediaConstraints( mandatoryConstraints: mediaConstrains, optionalConstraints: nil ) peerConnection.offer(for: constrains) { sdp, _ in guard let sdp else { return } self.peerConnection.setLocalDescription(sdp, completionHandler: { _ in completion(sdp) }) } } func answer(completion: @escaping (_ sdp: RTCSessionDescription) -> Void) { let constrains = RTCMediaConstraints( mandatoryConstraints: mediaConstrains, optionalConstraints: nil ) peerConnection.answer(for: constrains) { sdp, _ in guard let sdp else { return } self.peerConnection.setLocalDescription(sdp, completionHandler: { _ in completion(sdp) }) } } func set(remoteSdp: RTCSessionDescription, completion: @escaping (Error?) -> Void) { peerConnection.setRemoteDescription(remoteSdp, completionHandler: completion) } func set(remoteCandidate: RTCIceCandidate, completion: @escaping (Error?) -> Void) { peerConnection.add(remoteCandidate, completionHandler: completion) } func renderRemoteVideo(to renderer: RTCVideoRenderer) { remoteVideoTrack?.add(renderer) } private func createMediaTracks() { let streamId = "stream" let videoTrack = createVideoTrack() peerConnection.add(videoTrack, streamIds: [streamId]) remoteVideoTrack = peerConnection.transceivers.first { $0.mediaType == .video }?.receiver .track as? RTCVideoTrack } private func createVideoTrack() -> RTCVideoTrack { let videoSource = WebRTCClient.factory.videoSource() #if targetEnvironment(simulator) videoCapturer = RTCFileVideoCapturer(delegate: videoSource) #else videoCapturer = RTCCameraVideoCapturer(delegate: videoSource) #endif let videoTrack = WebRTCClient.factory.videoTrack(with: videoSource, trackId: "video0") return videoTrack } } // MARK: - RTCPeerConnectionDelegate /// Handles RTCPeerConnection events and forwards relevant events to the delegate. extension WebRTCClient: RTCPeerConnectionDelegate { func peerConnection(_ peerConnection: RTCPeerConnection, didChange stateChanged: RTCSignalingState) { Current.Log.info("peerConnection new signaling state: \(stateChanged)") } func peerConnection(_ peerConnection: RTCPeerConnection, didAdd stream: RTCMediaStream) { Current.Log.info("peerConnection did add stream") } func peerConnection(_ peerConnection: RTCPeerConnection, didRemove stream: RTCMediaStream) { Current.Log.info("peerConnection did remove stream") } func peerConnectionShouldNegotiate(_ peerConnection: RTCPeerConnection) { Current.Log.info("peerConnection should negotiate") } func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceConnectionState) { Current.Log.info("peerConnection new connection state: \(newState)") delegate?.webRTCClient(self, didChangeConnectionState: newState) } func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceGatheringState) { Current.Log.info("peerConnection new gathering state: \(newState)") } func peerConnection(_ peerConnection: RTCPeerConnection, didGenerate candidate: RTCIceCandidate) { Current.Log.info("peerConnection did generate candidate: \(candidate)") delegate?.webRTCClient(self, didDiscoverLocalCandidate: candidate) } func peerConnection(_ peerConnection: RTCPeerConnection, didRemove candidates: [RTCIceCandidate]) { Current.Log.info("peerConnection did remove candidate(s)") } func peerConnection(_ peerConnection: RTCPeerConnection, didOpen dataChannel: RTCDataChannel) { Current.Log.info("peerConnection did open data channel") remoteDataChannel = dataChannel } } extension WebRTCClient { private func setTrackEnabled(_ type: T.Type, isEnabled: Bool) { peerConnection.transceivers .compactMap { $0.sender.track as? T } .forEach { $0.isEnabled = isEnabled } } } // MARK: - RTCDataChannelDelegate /// Handles RTCDataChannel events and forwards data to the delegate. extension WebRTCClient: RTCDataChannelDelegate { func dataChannelDidChangeState(_ dataChannel: RTCDataChannel) { Current.Log.info("dataChannel did change state: \(dataChannel.readyState)") } func dataChannel(_ dataChannel: RTCDataChannel, didReceiveMessageWith buffer: RTCDataBuffer) { delegate?.webRTCClient(self, didReceiveData: buffer.data) } }