Files
iOS/Sources/App/WebView/WebRTC/WebRTCClient.swift
2025-07-07 23:30:02 +02:00

217 lines
8.2 KiB
Swift

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<T: RTCMediaStreamTrack>(_ 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)
}
}