diff --git a/.changes/single-pc b/.changes/single-pc new file mode 100644 index 000000000..0d44c151e --- /dev/null +++ b/.changes/single-pc @@ -0,0 +1 @@ +minor type="added" "Single peer connection mode via `RoomOptions.singlePeerConnection`. Requires LiveKit Cloud or LiveKit OSS >= 1.9.2." \ No newline at end of file diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 349e4bc13..8dcfd8fe6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -57,38 +57,38 @@ jobs: # https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md - os: macos-26 - xcode: 26.1 - platform: "iOS Simulator,name=iPhone 17 Pro,OS=26.1" + xcode: 26.3 + platform: "iOS Simulator,name=iPhone 17 Pro,OS=26.2" symbol-graph: true - os: macos-26 - xcode: 26.1 - platform: "iOS Simulator,name=iPhone 17 Pro,OS=26.1" + xcode: 26.3 + platform: "iOS Simulator,name=iPhone 17 Pro,OS=26.2" extension-api-only: true - os: macos-26 - xcode: 26.1 + xcode: 26.3 platform: "macOS" symbol-graph: true - os: macos-26 - xcode: 26.1 + xcode: 26.3 platform: "macOS" asan: true - os: macos-26 - xcode: 26.1 + xcode: 26.3 platform: "macOS" tsan: true - os: macos-26 - xcode: 26.1 + xcode: 26.3 platform: "macOS" strict-concurrency-env: true - os: macos-26 - xcode: 26.1 + xcode: 26.3 platform: "macOS,variant=Mac Catalyst" - os: macos-26 - xcode: 26.1 - platform: "visionOS Simulator,name=Apple Vision Pro,OS=26.1" + xcode: 26.3 + platform: "visionOS Simulator,name=Apple Vision Pro,OS=26.2" - os: macos-26 - xcode: 26.1 - platform: "tvOS Simulator,name=Apple TV,OS=26.1" + xcode: 26.3 + platform: "tvOS Simulator,name=Apple TV,OS=26.2" runs-on: ${{ matrix.os }} timeout-minutes: 60 @@ -102,7 +102,8 @@ jobs: run: brew install livekit - name: Run LiveKit Server - run: livekit-server --dev & + run: | + livekit-server --dev --config-body '{"room":{"departure_timeout":1}}' & - uses: maxim-lobanov/setup-xcode@v1 with: @@ -127,7 +128,7 @@ jobs: - name: Run Tests uses: nick-fields/retry@v3 env: - LIBDISPATCH_COOPERATIVE_POOL_STRICT: ${{ matrix.strict-concurrency-env == true && '1' || '' }} + LIBDISPATCH_COOPERATIVE_POOL_STRICT: ${{ matrix.strict-concurrency-env == true && '1' || '0' }} with: timeout_minutes: ${{ env.TEST_TIMEOUT_MINUTES }} max_attempts: ${{ env.TEST_RETRY_ATTEMPTS }} diff --git a/.github/workflows/cocoapods-lint.yaml b/.github/workflows/cocoapods-lint.yaml index 879368cd8..7798ba7a9 100644 --- a/.github/workflows/cocoapods-lint.yaml +++ b/.github/workflows/cocoapods-lint.yaml @@ -19,7 +19,7 @@ jobs: - os: macos-15 xcode: 16.4 - os: macos-26 - xcode: 26.1 + xcode: 26.3 runs-on: ${{ matrix.os }} timeout-minutes: 30 steps: diff --git a/.github/workflows/publish-docs.yaml b/.github/workflows/publish-docs.yaml index c49ee2bc3..74f07b8c3 100644 --- a/.github/workflows/publish-docs.yaml +++ b/.github/workflows/publish-docs.yaml @@ -23,7 +23,7 @@ jobs: strategy: matrix: include: - - platform: "iOS Simulator,name=iPhone 17 Pro,OS=26.1" + - platform: "iOS Simulator,name=iPhone 17 Pro,OS=26.2" artifact_name: symbol-graph-ios - platform: "macOS" artifact_name: symbol-graph-macos @@ -38,7 +38,7 @@ jobs: - name: Set up Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: "26.1" + xcode-version: "26.3" - name: Build for Release - Symbol Graph run: | @@ -72,7 +72,7 @@ jobs: - name: Set up Xcode uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: "26.1" + xcode-version: "26.3" - name: Download Symbol Graphs uses: actions/download-artifact@v4 diff --git a/.gitignore b/.gitignore index 1387e65ed..7c86fdd0a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store -/.build +.build +.claude /Packages /*.xcodeproj .swiftpm/ diff --git a/Sources/LiveKit/Core/Room+Engine.swift b/Sources/LiveKit/Core/Room+Engine.swift index f74855066..148fe423d 100644 --- a/Sources/LiveKit/Core/Room+Engine.swift +++ b/Sources/LiveKit/Core/Room+Engine.swift @@ -44,16 +44,11 @@ extension Room { publisherDataChannel.reset() subscriberDataChannel.reset() - let (subscriber, publisher) = _state.read { ($0.subscriber, $0.publisher) } - - // Close transports - await publisher?.close() - await subscriber?.close() + await _state.transport?.close() // Reset publish state _state.mutate { - $0.subscriber = nil - $0.publisher = nil + $0.transport = nil $0.hasPublished = false } } @@ -75,7 +70,10 @@ extension Room { func send(dataPacket packet: Livekit_DataPacket) async throws { func ensurePublisherConnected() async throws { - guard _state.isSubscriberPrimary else { return } + // Only needed when subscriber is primary in dual PC mode + guard case .subscriberPrimary = _state.transport else { + return + } let publisher = try requirePublisher() @@ -91,7 +89,7 @@ extension Room { try await ensurePublisherConnected() // At this point publisher should be .connected and dc should be .open - if await !(_state.publisher?.isConnected ?? false) { + if await !(_state.transport?.publisher.isConnected ?? false) { log("publisher is not .connected", .error) } @@ -116,7 +114,7 @@ extension Room { extension Room { // swiftlint:disable:next function_body_length - func configureTransports(connectResponse: SignalClient.ConnectResponse) async throws { + func configureTransports(connectResponse: SignalClient.ConnectResponse, singlePeerConnection: Bool) async throws { func makeConfiguration() -> LKRTCConfiguration { let connectOptions = _state.connectOptions @@ -147,23 +145,20 @@ extension Room { if case let .join(joinResponse) = connectResponse { log("Configuring transports with JOIN response...") - guard _state.subscriber == nil, _state.publisher == nil else { + guard _state.transport == nil else { log("Transports are already configured") return } - // protocol v3 - let isSubscriberPrimary = joinResponse.subscriberPrimary - log("subscriberPrimary: \(joinResponse.subscriberPrimary)") - - let subscriber = try Transport(config: rtcConfiguration, - target: .subscriber, - primary: isSubscriberPrimary, - delegate: self) + let isSinglePC = singlePeerConnection + let isSubscriberPrimary = isSinglePC ? false : joinResponse.subscriberPrimary + log("subscriberPrimary: \(isSubscriberPrimary), singlePeerConnection: \(isSinglePC)") + // Publisher always created; is primary in single PC mode let publisher = try Transport(config: rtcConfiguration, target: .publisher, - primary: !isSubscriberPrimary, + primary: isSinglePC || !isSubscriberPrimary, + singlePCMode: isSinglePC, delegate: self) await publisher.set { [weak self] offer, offerId in @@ -186,23 +181,29 @@ extension Room { log("dataChannel.\(String(describing: reliableDataChannel?.label)) : \(String(describing: reliableDataChannel?.channelId))") log("dataChannel.\(String(describing: lossyDataChannel?.label)) : \(String(describing: lossyDataChannel?.channelId))") - _state.mutate { - $0.subscriber = subscriber - $0.publisher = publisher - $0.isSubscriberPrimary = isSubscriberPrimary + let subscriber = isSinglePC ? nil : try Transport(config: rtcConfiguration, + target: .subscriber, + primary: isSubscriberPrimary, + delegate: self) + + let transport: TransportMode = if let subscriber, isSubscriberPrimary { + .subscriberPrimary(publisher: publisher, subscriber: subscriber) + } else if let subscriber { + .publisherPrimary(publisher: publisher, subscriber: subscriber) + } else { + .publisherOnly(publisher: publisher) } + _state.mutate { $0.transport = transport } log("[Connect] Fast publish enabled: \(joinResponse.fastPublish ? "true" : "false")") - if !isSubscriberPrimary || joinResponse.fastPublish { - // lazy negotiation for protocol v3+ + if isSinglePC || !isSubscriberPrimary || joinResponse.fastPublish { + // In single PC mode or when publisher is primary, negotiate immediately try await publisherShouldNegotiate() } } else if case let .reconnect(reconnectResponse) = connectResponse { log("[Connect] Configuring transports with RECONNECT response...") - let (subscriber, publisher) = _state.read { ($0.subscriber, $0.publisher) } - try await subscriber?.set(configuration: rtcConfiguration) - try await publisher?.set(configuration: rtcConfiguration) + try await _state.transport?.set(configuration: rtcConfiguration) publisherDataChannel.retryReliable(lastSequence: reconnectResponse.lastMessageSeq) } } @@ -249,16 +250,32 @@ public enum StartReconnectReason: Sendable { extension Room { // full connect sequence, doesn't update connection state func fullConnectSequence(_ url: URL, _ token: String) async throws { - let connectResponse = try await signalClient.connect(url, + var singlePC = _state.roomOptions.singlePeerConnection + + let connectResponse: SignalClient.ConnectResponse + do { + connectResponse = try await signalClient.connect(url, token, connectOptions: _state.connectOptions, reconnectMode: _state.isReconnectingWithMode, - adaptiveStream: _state.roomOptions.adaptiveStream) + adaptiveStream: _state.roomOptions.adaptiveStream, + singlePeerConnection: singlePC) + } catch let error as LiveKitError where error.type == .serviceNotFound && singlePC { + log("v1 RTC path not supported, retrying with legacy path", .warning) + singlePC = false + connectResponse = try await signalClient.connect(url, + token, + connectOptions: _state.connectOptions, + reconnectMode: _state.isReconnectingWithMode, + adaptiveStream: _state.roomOptions.adaptiveStream, + singlePeerConnection: false) + } + // Check cancellation after WebSocket connected try Task.checkCancellation() _state.mutate { $0.connectStopwatch.split(label: "signal") } - try await configureTransports(connectResponse: connectResponse) + try await configureTransports(connectResponse: connectResponse, singlePeerConnection: singlePC) // Check cancellation after configuring transports try Task.checkCancellation() @@ -288,8 +305,8 @@ extension Room { throw LiveKitError(.invalidState) } - guard _state.subscriber != nil, _state.publisher != nil else { - log("[Connect] Publisher or subscriber is nil", .error) + guard _state.transport != nil else { + log("[Connect] Transport is nil", .error) throw LiveKitError(.invalidState) } @@ -308,16 +325,19 @@ extension Room { @Sendable func quickReconnectSequence() async throws { log("[Connect] Starting .quick reconnect sequence...") + let singlePC = await !signalClient.useV0SignalPath let connectResponse = try await signalClient.connect(url, token, connectOptions: _state.connectOptions, reconnectMode: _state.isReconnectingWithMode, participantSid: localParticipant.sid, - adaptiveStream: _state.roomOptions.adaptiveStream) + adaptiveStream: _state.roomOptions.adaptiveStream, + singlePeerConnection: singlePC) try Task.checkCancellation() // Update configuration - try await configureTransports(connectResponse: connectResponse) + try await configureTransports(connectResponse: connectResponse, + singlePeerConnection: singlePC) try Task.checkCancellation() // Resume after configuring transports... @@ -337,9 +357,9 @@ extension Room { // send SyncState before offer try await sendSyncState() - await _state.subscriber?.setIsRestartingIce() + await _state.transport?.setSubscriberRestartingIce() - if let publisher = _state.publisher, _state.hasPublished { + if let publisher = _state.transport?.publisher, _state.hasPublished { // Only if published, wait for publisher to connect... log("[Connect] Waiting for publisher to connect...") try await publisher.createAndSendOffer(iceRestart: true) @@ -473,13 +493,12 @@ extension Room { extension Room { func sendSyncState() async throws { - guard let subscriber = _state.subscriber else { - log("Subscriber is nil", .error) + guard let transport = _state.transport else { + log("Transport is nil", .error) return } - let previousAnswer = await subscriber.localDescription - let previousOffer = await subscriber.remoteDescription + let (previousAnswer, previousOffer) = await transport.syncStateDescriptions() // 1. autosubscribe on, so subscribed tracks = all tracks - unsub tracks, // in this case, we send unsub tracks, so server add all tracks to this @@ -517,7 +536,7 @@ extension Room { extension Room { func requirePublisher() throws -> Transport { - guard let publisher = _state.publisher else { + guard let publisher = _state.transport?.publisher else { log("Publisher is nil", .error) throw LiveKitError(.invalidState, message: "Publisher is nil") } diff --git a/Sources/LiveKit/Core/Room+SignalClientDelegate.swift b/Sources/LiveKit/Core/Room+SignalClientDelegate.swift index 1a790f27a..0039ea3e7 100644 --- a/Sources/LiveKit/Core/Room+SignalClientDelegate.swift +++ b/Sources/LiveKit/Core/Room+SignalClientDelegate.swift @@ -342,15 +342,15 @@ extension Room: SignalClientDelegate { } func signalClient(_: SignalClient, didReceiveIceCandidate iceCandidate: IceCandidate, target: Livekit_SignalTarget) async { - guard let transport = target == .subscriber ? _state.subscriber : _state.publisher else { + guard let mode = _state.transport else { log("Failed to add ice candidate, transport is nil for target: \(target)", .error) return } do { - try await transport.add(iceCandidate: iceCandidate) + try await mode.transport(for: target).add(iceCandidate: iceCandidate) } catch { - log("Failed to add ice candidate for transport: \(transport), error: \(error)", .error) + log("Failed to add ice candidate for target: \(target), error: \(error)", .error) } } @@ -366,13 +366,13 @@ extension Room: SignalClientDelegate { } func signalClient(_ signalClient: SignalClient, didReceiveOffer offer: LKRTCSessionDescription, offerId: UInt32) async { - log("Received offer with offerId: \(offerId), creating & sending answer...") - - guard let subscriber = _state.subscriber else { - log("Failed to send answer, subscriber is nil", .error) + guard let subscriber = _state.transport?.dedicatedSubscriber else { + log("Received offer but not in dual PC mode, ignoring") return } + log("Received offer with offerId: \(offerId), creating & sending answer...") + do { try await subscriber.set(remoteDescription: offer) let answer = try await subscriber.createAnswer() @@ -405,6 +405,25 @@ extension Room: SignalClientDelegate { $0.participant?(self.localParticipant, remoteDidSubscribeTrack: track) } } + + func signalClient(_: SignalClient, didReceiveMediaSectionsRequirement requirement: Livekit_MediaSectionsRequirement) async { + guard case let .publisherOnly(publisher) = _state.transport else { return } + + let transceiverInit = LKRTCRtpTransceiverInit() + transceiverInit.direction = .recvOnly + + do { + for _ in 0 ..< requirement.numAudios { + _ = try await publisher.addTransceiver(ofType: .audio, transceiverInit: transceiverInit) + } + for _ in 0 ..< requirement.numVideos { + _ = try await publisher.addTransceiver(ofType: .video, transceiverInit: transceiverInit) + } + try await publisherShouldNegotiate() + } catch { + log("Failed to add transceivers for media sections requirement: \(error)", .error) + } + } } private extension Room { diff --git a/Sources/LiveKit/Core/Room+TransportDelegate.swift b/Sources/LiveKit/Core/Room+TransportDelegate.swift index 66cde4098..a8f6a11c1 100644 --- a/Sources/LiveKit/Core/Room+TransportDelegate.swift +++ b/Sources/LiveKit/Core/Room+TransportDelegate.swift @@ -81,37 +81,37 @@ extension Room: TransportDelegate { return } - if transport.target == .subscriber { - // execute block when connected - execute(when: { state, _ in state.connectionState == .connected }, - // always remove this block when disconnected - removeWhen: { state, _ in state.connectionState == .disconnected }) - { [weak self] in - guard let self else { return } - Task { - await self.engine(self, didAddTrack: track, rtpReceiver: rtpReceiver, stream: streams.first!) - } + guard transport.target == _state.transport?.subscriber.target else { return } + + // execute block when connected + execute(when: { state, _ in state.connectionState == .connected }, + // always remove this block when disconnected + removeWhen: { state, _ in state.connectionState == .disconnected }) + { [weak self] in + guard let self else { return } + Task { + await self.engine(self, didAddTrack: track, rtpReceiver: rtpReceiver, stream: streams.first!) } } } func transport(_ transport: Transport, didRemoveTrack track: LKRTCMediaStreamTrack) { - if transport.target == .subscriber { - Task { - await engine(self, didRemoveTrack: track) - } + guard transport.target == _state.transport?.subscriber.target else { return } + + Task { + await engine(self, didRemoveTrack: track) } } func transport(_ transport: Transport, didOpenDataChannel dataChannel: LKRTCDataChannel) { log("Server opened data channel \(dataChannel.label)(\(dataChannel.readyState))") - if _state.isSubscriberPrimary, transport.target == .subscriber { - switch dataChannel.label { - case LKRTCDataChannel.Labels.reliable: subscriberDataChannel.set(reliable: dataChannel) - case LKRTCDataChannel.Labels.lossy: subscriberDataChannel.set(lossy: dataChannel) - default: log("Unknown data channel label \(dataChannel.label)", .warning) - } + guard transport.target == _state.transport?.subscriber.target else { return } + + switch dataChannel.label { + case LKRTCDataChannel.Labels.reliable: subscriberDataChannel.set(reliable: dataChannel) + case LKRTCDataChannel.Labels.lossy: subscriberDataChannel.set(lossy: dataChannel) + default: log("Unknown data channel label \(dataChannel.label)", .warning) } } diff --git a/Sources/LiveKit/Core/Room.swift b/Sources/LiveKit/Core/Room.swift index fd66145e4..c4ed4f45c 100644 --- a/Sources/LiveKit/Core/Room.swift +++ b/Sources/LiveKit/Core/Room.swift @@ -170,9 +170,7 @@ public class Room: NSObject, @unchecked Sendable, ObservableObject, Loggable { var connectStopwatch = Stopwatch(label: "connect") var hasPublished: Bool = false - var publisher: Transport? - var subscriber: Transport? - var isSubscriberPrimary: Bool = false + var transport: TransportMode? // Agents var transcriptionReceivedTimes: [String: Date] = [:] diff --git a/Sources/LiveKit/Core/SignalClient.swift b/Sources/LiveKit/Core/SignalClient.swift index 33b46bbb5..c3ea1e27d 100644 --- a/Sources/LiveKit/Core/SignalClient.swift +++ b/Sources/LiveKit/Core/SignalClient.swift @@ -50,6 +50,8 @@ actor SignalClient: Loggable { var disconnectError: LiveKitError? { _state.disconnectError } + var useV0SignalPath: Bool { _state.useV0SignalPath } + // MARK: - Private let _delegate = AsyncSerialDelegate() @@ -92,6 +94,9 @@ actor SignalClient: Loggable { var messageLoopTask: AnyTaskCancellable? var lastJoinResponse: Livekit_JoinResponse? var rtt: Int64 = 0 + // Tracks whether the v0 signal path (/rtc) is in use, set during connect. + // Reused by reconnect to avoid re-attempting the unsupported v1 path. + var useV0SignalPath: Bool = false } let _state = StateSync(State()) @@ -115,7 +120,8 @@ actor SignalClient: Loggable { connectOptions: ConnectOptions? = nil, reconnectMode: ReconnectMode? = nil, participantSid: Participant.Sid? = nil, - adaptiveStream: Bool) async throws -> ConnectResponse + adaptiveStream: Bool, + singlePeerConnection: Bool) async throws -> ConnectResponse { await cleanUp() @@ -123,11 +129,21 @@ actor SignalClient: Loggable { log("[Connect] mode: \(String(describing: reconnectMode))") } - let url = try Utils.buildUrl(url, - connectOptions: connectOptions, - reconnectMode: reconnectMode, - participantSid: participantSid, - adaptiveStream: adaptiveStream) + let url: URL = if singlePeerConnection { + try Utils.buildJoinRequestUrl(url, + connectOptions: connectOptions, + reconnectMode: reconnectMode, + participantSid: participantSid, + adaptiveStream: adaptiveStream) + } else { + try Utils.buildUrl(url, + connectOptions: connectOptions, + reconnectMode: reconnectMode, + participantSid: participantSid, + adaptiveStream: adaptiveStream) + } + + _state.mutate { $0.useV0SignalPath = !singlePeerConnection } let isReconnect = reconnectMode != nil @@ -177,17 +193,15 @@ actor SignalClient: Loggable { await cleanUp(withError: connectionError) - // Attempt to validate with server - let validateUrl = try Utils.buildUrl(url, - connectOptions: connectOptions, - participantSid: participantSid, - adaptiveStream: adaptiveStream, - validate: true) + // Attempt to validate with server, deriving validate URL from the actual WS URL + let validateUrl = try Utils.toValidateUrl(url) log("Validating with url: \(validateUrl)...") do { try await HTTP.requestValidation(from: validateUrl, token: token) // Re-throw original error since validation passed throw LiveKitError(.network, internalError: connectionError) + } catch let error as LiveKitError where error.type == .serviceNotFound { + throw error } catch let validationError as LiveKitError where validationError.type == .validation { // Re-throw validation error throw validationError @@ -381,6 +395,9 @@ private extension SignalClient { case let .trackSubscribed(trackSubscribed): _delegate.notifyDetached { await $0.signalClient(self, didSubscribeTrack: Track.Sid(from: trackSubscribed.trackSid)) } + case let .mediaSectionsRequirement(requirement): + _delegate.notifyDetached { await $0.signalClient(self, didReceiveMediaSectionsRequirement: requirement) } + default: log("Unhandled signal message: \(message)", .warning) } diff --git a/Sources/LiveKit/Core/Transport.swift b/Sources/LiveKit/Core/Transport.swift index 56f6cf8cf..e5ecff708 100644 --- a/Sources/LiveKit/Core/Transport.swift +++ b/Sources/LiveKit/Core/Transport.swift @@ -14,6 +14,8 @@ * limitations under the License. */ +// swiftlint:disable file_length + import Foundation internal import LiveKitWebRTC @@ -27,6 +29,7 @@ actor Transport: NSObject, Loggable { nonisolated let target: Livekit_SignalTarget nonisolated let isPrimary: Bool + nonisolated let singlePCMode: Bool var connectionState: LKRTCPeerConnectionState { _pc.connectionState @@ -74,6 +77,7 @@ actor Transport: NSObject, Loggable { init(config: LKRTCConfiguration, target: Livekit_SignalTarget, primary: Bool, + singlePCMode: Bool = false, delegate: TransportDelegate) throws { // try create peerConnection @@ -84,6 +88,7 @@ actor Transport: NSObject, Loggable { self.target = target isPrimary = primary + self.singlePCMode = singlePCMode _pc = pc super.init() @@ -173,7 +178,13 @@ actor Transport: NSObject, Loggable { // Actually negotiate func _negotiateSequence() async throws { _latestOfferId += 1 - let offer = try await createOffer(for: constraints) + var offer = try await createOffer(for: constraints) + if singlePCMode { + let mungedSDP = Self.mungeInactiveToRecvOnlyForMedia(offer.sdp) + if mungedSDP != offer.sdp { + offer = RTC.createSessionDescription(type: offer.type, sdp: mungedSDP) + } + } try await set(localDescription: offer) try await _onOffer(offer, _latestOfferId) } @@ -202,6 +213,41 @@ actor Transport: NSObject, Loggable { } } +// MARK: - SDP Munging + +extension Transport { + /// Munge SDP to change `a=inactive` to `a=recvonly` for RTP media m-lines in single PC mode. + /// WebRTC can generate inactive direction even when transceivers were configured as recvonly. + /// Only rewrites RTP m-sections — non-RTP sections (e.g. data channel `m=application`) are preserved. + static func mungeInactiveToRecvOnlyForMedia(_ sdp: String) -> String { + let usesCRLF = sdp.contains("\r\n") + let eol = usesCRLF ? "\r\n" : "\n" + let lines = sdp.components(separatedBy: usesCRLF ? "\r\n" : "\n") + + var out: [String] = [] + out.reserveCapacity(lines.count) + var inRTPMediaSection = false + + for line in lines { + let l = line.trimmingCharacters(in: .whitespaces) + if l.hasPrefix("m=") { + inRTPMediaSection = l.contains("RTP/") + } + if inRTPMediaSection, l == "a=inactive" { + out.append("a=recvonly") + } else { + out.append(line) + } + } + + var result = out.joined(separator: eol) + if sdp.hasSuffix(eol), !result.hasSuffix(eol) { + result.append(eol) + } + return result + } +} + // MARK: - Stats extension Transport { @@ -335,6 +381,16 @@ extension Transport { return transceiver } + func addTransceiver(ofType mediaType: LKRTCRtpMediaType, + transceiverInit: LKRTCRtpTransceiverInit) throws -> LKRTCRtpTransceiver + { + guard let transceiver = _pc.addTransceiver(of: mediaType, init: transceiverInit) else { + throw LiveKitError(.webRTC, message: "Failed to add transceiver") + } + + return transceiver + } + func remove(track sender: LKRTCRtpSender) throws { guard _pc.removeTrack(sender) else { throw LiveKitError(.webRTC, message: "Failed to remove track") diff --git a/Sources/LiveKit/Core/TransportMode.swift b/Sources/LiveKit/Core/TransportMode.swift new file mode 100644 index 000000000..08a4235ca --- /dev/null +++ b/Sources/LiveKit/Core/TransportMode.swift @@ -0,0 +1,103 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +internal import LiveKitWebRTC + +enum TransportMode: Equatable, Sendable { + /// Single peer connection: publisher handles both publishing and receiving. + case publisherOnly(publisher: Transport) + /// Dual peer connection with subscriber as primary (default). + case subscriberPrimary(publisher: Transport, subscriber: Transport) + /// Dual peer connection with publisher as primary. + case publisherPrimary(publisher: Transport, subscriber: Transport) +} + +extension TransportMode { + /// The transport used for publishing local tracks. Always the publisher. + var publisher: Transport { + switch self { + case let .publisherOnly(p), let .subscriberPrimary(p, _), let .publisherPrimary(p, _): p + } + } + + /// The transport used for receiving remote tracks and server-opened data channels. + /// In single PC mode this is the publisher; in dual PC mode this is the subscriber. + var subscriber: Transport { + switch self { + case let .publisherOnly(p): p + case let .subscriberPrimary(_, s), let .publisherPrimary(_, s): s + } + } + + /// The dedicated subscriber transport in dual PC mode. Nil in single PC mode. + var dedicatedSubscriber: Transport? { + switch self { + case .publisherOnly: nil + case let .subscriberPrimary(_, s), let .publisherPrimary(_, s): s + } + } + + /// All distinct transports (one in single PC, two in dual PC). + var allTransports: [Transport] { + switch self { + case let .publisherOnly(publisher): [publisher] + case let .subscriberPrimary(publisher, subscriber), + let .publisherPrimary(publisher, subscriber): [publisher, subscriber] + } + } + + /// Resolve a signal target to the appropriate transport. + func transport(for target: Livekit_SignalTarget) -> Transport { + switch self { + case let .publisherOnly(publisher): + publisher + case let .subscriberPrimary(publisher, subscriber), let .publisherPrimary(publisher, subscriber): + target == .subscriber ? subscriber : publisher + } + } + + /// Close all transports. + func close() async { + for transport in allTransports { + await transport.close() + } + } + + /// Set RTC configuration on all transports. + func set(configuration: LKRTCConfiguration) async throws { + for transport in allTransports { + try await transport.set(configuration: configuration) + } + } + + /// Mark the dedicated subscriber transport as restarting ICE. No-op in single PC mode. + func setSubscriberRestartingIce() async { + if let subscriber = dedicatedSubscriber { + await subscriber.setIsRestartingIce() + } + } + + /// Returns the (previousAnswer, previousOffer) pair for sync state, + /// which differs depending on the transport mode. + func syncStateDescriptions() async -> (answer: LKRTCSessionDescription?, offer: LKRTCSessionDescription?) { + switch self { + case let .publisherOnly(publisher): + await (publisher.remoteDescription, publisher.localDescription) + case let .subscriberPrimary(_, subscriber), let .publisherPrimary(_, subscriber): + await (subscriber.localDescription, subscriber.remoteDescription) + } + } +} diff --git a/Sources/LiveKit/Errors.swift b/Sources/LiveKit/Errors.swift index 8eb0fe08a..a1d75330b 100644 --- a/Sources/LiveKit/Errors.swift +++ b/Sources/LiveKit/Errors.swift @@ -31,6 +31,9 @@ public enum LiveKitErrorType: Int, Sendable { case network // Network issue case validation // Validation issue + // HTTP 404 from validation endpoint; distinct from .validation so the + // v1 → v0 RTC path fallback can fire without masking token/permission errors. + case serviceNotFound // Server case duplicateIdentity = 500 @@ -87,6 +90,8 @@ extension LiveKitErrorType: CustomStringConvertible { "Network error" case .validation: "Validation error" + case .serviceNotFound: + "Service not found" case .duplicateIdentity: "Duplicate Participant identity" case .serverShutdown: diff --git a/Sources/LiveKit/Participant/LocalParticipant.swift b/Sources/LiveKit/Participant/LocalParticipant.swift index b5ce464fd..e4f5b4fd5 100644 --- a/Sources/LiveKit/Participant/LocalParticipant.swift +++ b/Sources/LiveKit/Participant/LocalParticipant.swift @@ -86,7 +86,7 @@ public class LocalParticipant: Participant, @unchecked Sendable { return await _notifyDidUnpublish() } - if let publisher = room._state.publisher, let sender = track._state.rtpSender { + if let publisher = room._state.transport?.publisher, let sender = track._state.rtpSender { // Remove all simulcast senders... let simulcastSenders = track._state.read { Array($0.rtpSenderForCodec.values) } for simulcastSender in simulcastSenders { diff --git a/Sources/LiveKit/Participant/RemoteParticipant.swift b/Sources/LiveKit/Participant/RemoteParticipant.swift index 9714c51c1..5ee1ab5de 100644 --- a/Sources/LiveKit/Participant/RemoteParticipant.swift +++ b/Sources/LiveKit/Participant/RemoteParticipant.swift @@ -114,10 +114,10 @@ public class RemoteParticipant: Participant, @unchecked Sendable { await publication.set(track: track) publication.set(subscriptionAllowed: true) - if let transport = room._state.subscriber { + if let transport = room._state.transport?.subscriber { await track.set(transport: transport, rtpReceiver: rtpReceiver) } else { - log("Subscriber is nil", .error) + log("Transport is nil", .error) } add(publication: publication) diff --git a/Sources/LiveKit/Protocols/SignalClientDelegate.swift b/Sources/LiveKit/Protocols/SignalClientDelegate.swift index 1bfb25cc2..9d66d0fb1 100644 --- a/Sources/LiveKit/Protocols/SignalClientDelegate.swift +++ b/Sources/LiveKit/Protocols/SignalClientDelegate.swift @@ -37,4 +37,5 @@ protocol SignalClientDelegate: AnyObject, Sendable { func signalClient(_ signalClient: SignalClient, didUpdateToken token: String) async func signalClient(_ signalClient: SignalClient, didReceiveLeave action: Livekit_LeaveRequest.Action, reason: Livekit_DisconnectReason, regions: Livekit_RegionSettings?) async func signalClient(_ signalClient: SignalClient, didSubscribeTrack trackSid: Track.Sid) async + func signalClient(_ signalClient: SignalClient, didReceiveMediaSectionsRequirement requirement: Livekit_MediaSectionsRequirement) async } diff --git a/Sources/LiveKit/Protos/livekit_metrics.pb.swift b/Sources/LiveKit/Protos/livekit_metrics.pb.swift index 162e41dc4..b8ed0474e 100644 --- a/Sources/LiveKit/Protos/livekit_metrics.pb.swift +++ b/Sources/LiveKit/Protos/livekit_metrics.pb.swift @@ -323,20 +323,25 @@ struct Livekit_MetricsRecordingHeader: Sendable { var roomID: String = String() - var enableUserDataTraining: Bool { - get {_enableUserDataTraining ?? false} - set {_enableUserDataTraining = newValue} + /// milliseconds + var duration: UInt64 = 0 + + var startTime: SwiftProtobuf.Google_Protobuf_Timestamp { + get {_startTime ?? SwiftProtobuf.Google_Protobuf_Timestamp()} + set {_startTime = newValue} } - /// Returns true if `enableUserDataTraining` has been explicitly set. - var hasEnableUserDataTraining: Bool {self._enableUserDataTraining != nil} - /// Clears the value of `enableUserDataTraining`. Subsequent reads from it will return its default value. - mutating func clearEnableUserDataTraining() {self._enableUserDataTraining = nil} + /// Returns true if `startTime` has been explicitly set. + var hasStartTime: Bool {self._startTime != nil} + /// Clears the value of `startTime`. Subsequent reads from it will return its default value. + mutating func clearStartTime() {self._startTime = nil} + + var roomTags: Dictionary = [:] var unknownFields = SwiftProtobuf.UnknownStorage() init() {} - fileprivate var _enableUserDataTraining: Bool? = nil + fileprivate var _startTime: SwiftProtobuf.Google_Protobuf_Timestamp? = nil } // MARK: - Code below here is support for the SwiftProtobuf runtime. @@ -571,7 +576,7 @@ extension Livekit_EventMetric: SwiftProtobuf.Message, SwiftProtobuf._MessageImpl extension Livekit_MetricsRecordingHeader: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { static let protoMessageName: String = _protobuf_package + ".MetricsRecordingHeader" - static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}room_id\0\u{3}enable_user_data_training\0") + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}room_id\0\u{2}\u{2}duration\0\u{3}start_time\0\u{3}room_tags\0") mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -580,7 +585,9 @@ extension Livekit_MetricsRecordingHeader: SwiftProtobuf.Message, SwiftProtobuf._ // enabled. https://github.com/apple/swift-protobuf/issues/1034 switch fieldNumber { case 1: try { try decoder.decodeSingularStringField(value: &self.roomID) }() - case 2: try { try decoder.decodeSingularBoolField(value: &self._enableUserDataTraining) }() + case 3: try { try decoder.decodeSingularUInt64Field(value: &self.duration) }() + case 4: try { try decoder.decodeSingularMessageField(value: &self._startTime) }() + case 5: try { try decoder.decodeMapField(fieldType: SwiftProtobuf._ProtobufMap.self, value: &self.roomTags) }() default: break } } @@ -594,15 +601,23 @@ extension Livekit_MetricsRecordingHeader: SwiftProtobuf.Message, SwiftProtobuf._ if !self.roomID.isEmpty { try visitor.visitSingularStringField(value: self.roomID, fieldNumber: 1) } - try { if let v = self._enableUserDataTraining { - try visitor.visitSingularBoolField(value: v, fieldNumber: 2) + if self.duration != 0 { + try visitor.visitSingularUInt64Field(value: self.duration, fieldNumber: 3) + } + try { if let v = self._startTime { + try visitor.visitSingularMessageField(value: v, fieldNumber: 4) } }() + if !self.roomTags.isEmpty { + try visitor.visitMapField(fieldType: SwiftProtobuf._ProtobufMap.self, value: self.roomTags, fieldNumber: 5) + } try unknownFields.traverse(visitor: &visitor) } static func ==(lhs: Livekit_MetricsRecordingHeader, rhs: Livekit_MetricsRecordingHeader) -> Bool { if lhs.roomID != rhs.roomID {return false} - if lhs._enableUserDataTraining != rhs._enableUserDataTraining {return false} + if lhs.duration != rhs.duration {return false} + if lhs._startTime != rhs._startTime {return false} + if lhs.roomTags != rhs.roomTags {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } diff --git a/Sources/LiveKit/Protos/livekit_models.pb.swift b/Sources/LiveKit/Protos/livekit_models.pb.swift index 68ca32ff5..db9fc30ff 100644 --- a/Sources/LiveKit/Protos/livekit_models.pb.swift +++ b/Sources/LiveKit/Protos/livekit_models.pb.swift @@ -287,6 +287,40 @@ enum Livekit_TrackSource: SwiftProtobuf.Enum, Swift.CaseIterable { } +enum Livekit_DataTrackExtensionID: SwiftProtobuf.Enum, Swift.CaseIterable { + typealias RawValue = Int + case dteiInvalid // = 0 + case dteiParticipantSid // = 1 + case UNRECOGNIZED(Int) + + init() { + self = .dteiInvalid + } + + init?(rawValue: Int) { + switch rawValue { + case 0: self = .dteiInvalid + case 1: self = .dteiParticipantSid + default: self = .UNRECOGNIZED(rawValue) + } + } + + var rawValue: Int { + switch self { + case .dteiInvalid: return 0 + case .dteiParticipantSid: return 1 + case .UNRECOGNIZED(let i): return i + } + } + + // The compiler won't synthesize support with the UNRECOGNIZED case. + static let allCases: [Livekit_DataTrackExtensionID] = [ + .dteiInvalid, + .dteiParticipantSid, + ] + +} + enum Livekit_VideoQuality: SwiftProtobuf.Enum, Swift.CaseIterable { typealias RawValue = Int case low // = 0 @@ -932,6 +966,11 @@ struct Livekit_ParticipantInfo: @unchecked Sendable { set {_uniqueStorage()._kindDetails = newValue} } + var dataTracks: [Livekit_DataTrackInfo] { + get {_storage._dataTracks} + set {_uniqueStorage()._dataTracks = newValue} + } + var unknownFields = SwiftProtobuf.UnknownStorage() enum State: SwiftProtobuf.Enum, Swift.CaseIterable { @@ -1001,6 +1040,12 @@ struct Livekit_ParticipantInfo: @unchecked Sendable { /// LiveKit agents case agent // = 4 + + /// Connectors participants + case connector // = 7 + + /// Bridge participants + case bridge // = 8 case UNRECOGNIZED(Int) init() { @@ -1014,6 +1059,8 @@ struct Livekit_ParticipantInfo: @unchecked Sendable { case 2: self = .egress case 3: self = .sip case 4: self = .agent + case 7: self = .connector + case 8: self = .bridge default: self = .UNRECOGNIZED(rawValue) } } @@ -1025,6 +1072,8 @@ struct Livekit_ParticipantInfo: @unchecked Sendable { case .egress: return 2 case .sip: return 3 case .agent: return 4 + case .connector: return 7 + case .bridge: return 8 case .UNRECOGNIZED(let i): return i } } @@ -1036,6 +1085,8 @@ struct Livekit_ParticipantInfo: @unchecked Sendable { .egress, .sip, .agent, + .connector, + .bridge, ] } @@ -1044,6 +1095,11 @@ struct Livekit_ParticipantInfo: @unchecked Sendable { typealias RawValue = Int case cloudAgent // = 0 case forwarded // = 1 + case connectorWhatsapp // = 2 + case connectorTwilio // = 3 + + /// NEXT_ID: 5 + case bridgeRtsp // = 4 case UNRECOGNIZED(Int) init() { @@ -1054,6 +1110,9 @@ struct Livekit_ParticipantInfo: @unchecked Sendable { switch rawValue { case 0: self = .cloudAgent case 1: self = .forwarded + case 2: self = .connectorWhatsapp + case 3: self = .connectorTwilio + case 4: self = .bridgeRtsp default: self = .UNRECOGNIZED(rawValue) } } @@ -1062,6 +1121,9 @@ struct Livekit_ParticipantInfo: @unchecked Sendable { switch self { case .cloudAgent: return 0 case .forwarded: return 1 + case .connectorWhatsapp: return 2 + case .connectorTwilio: return 3 + case .bridgeRtsp: return 4 case .UNRECOGNIZED(let i): return i } } @@ -1070,6 +1132,9 @@ struct Livekit_ParticipantInfo: @unchecked Sendable { static let allCases: [Livekit_ParticipantInfo.KindDetail] = [ .cloudAgent, .forwarded, + .connectorWhatsapp, + .connectorTwilio, + .bridgeRtsp, ] } @@ -1180,16 +1245,12 @@ struct Livekit_TrackInfo: @unchecked Sendable { /// original width of video (unset for audio) /// clients may receive a lower resolution version with simulcast - /// - /// NOTE: This field was marked as deprecated in the .proto file. var width: UInt32 { get {_storage._width} set {_uniqueStorage()._width = newValue} } /// original height of video (unset for audio) - /// - /// NOTE: This field was marked as deprecated in the .proto file. var height: UInt32 { get {_storage._height} set {_uniqueStorage()._height = newValue} @@ -1291,6 +1352,65 @@ struct Livekit_TrackInfo: @unchecked Sendable { fileprivate var _storage = _StorageClass.defaultInstance } +struct Livekit_DataTrackInfo: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// Client-assigned, 16-bit identifier that will be attached to packets sent by the publisher. + var pubHandle: UInt32 = 0 + + /// Server-assigned track identifier. + var sid: String = String() + + /// Human-readable identifier (e.g., `geoLocation`, `servoPosition.x`, etc.), unique per publisher. + var name: String = String() + + /// Method used for end-to-end encryption (E2EE) on packet payloads. + var encryption: Livekit_Encryption.TypeEnum = .none + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +struct Livekit_DataTrackExtensionParticipantSid: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var id: Livekit_DataTrackExtensionID = .dteiInvalid + + var participantSid: String = String() + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +struct Livekit_DataTrackSubscriptionOptions: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// Rate in frames per second (FPS) the subscriber wants to receive frames at. + /// If omitted, the subscriber defaults to the publisher's fps + var targetFps: UInt32 { + get {_targetFps ?? 0} + set {_targetFps = newValue} + } + /// Returns true if `targetFps` has been explicitly set. + var hasTargetFps: Bool {self._targetFps != nil} + /// Clears the value of `targetFps`. Subsequent reads from it will return its default value. + mutating func clearTargetFps() {self._targetFps = nil} + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} + + fileprivate var _targetFps: UInt32? = nil +} + /// provide information about available spatial layers struct Livekit_VideoLayer: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the @@ -1313,6 +1433,8 @@ struct Livekit_VideoLayer: Sendable { var rid: String = String() + var repairSsrc: UInt32 = 0 + var unknownFields = SwiftProtobuf.UnknownStorage() enum Mode: SwiftProtobuf.Enum, Swift.CaseIterable { @@ -1576,7 +1698,7 @@ struct Livekit_EncryptedPacket: Sendable { var keyIndex: UInt32 = 0 - /// This is an encrypted EncryptedPacketPayload message representation + /// This is an encrypted EncryptedPacketPayload message representation var encryptedValue: Data = Data() var unknownFields = SwiftProtobuf.UnknownStorage() @@ -2913,6 +3035,20 @@ struct Livekit_DataStream: Sendable { init() {} } +struct Livekit_FilterParams: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var includeEvents: [String] = [] + + var excludeEvents: [String] = [] + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + struct Livekit_WebhookConfig: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for @@ -2922,9 +3058,20 @@ struct Livekit_WebhookConfig: Sendable { var signingKey: String = String() + var filterParams: Livekit_FilterParams { + get {_filterParams ?? Livekit_FilterParams()} + set {_filterParams = newValue} + } + /// Returns true if `filterParams` has been explicitly set. + var hasFilterParams: Bool {self._filterParams != nil} + /// Clears the value of `filterParams`. Subsequent reads from it will return its default value. + mutating func clearFilterParams() {self._filterParams = nil} + var unknownFields = SwiftProtobuf.UnknownStorage() init() {} + + fileprivate var _filterParams: Livekit_FilterParams? = nil } struct Livekit_SubscribedAudioCodec: Sendable { @@ -2969,6 +3116,10 @@ extension Livekit_TrackSource: SwiftProtobuf._ProtoNameProviding { static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0UNKNOWN\0\u{1}CAMERA\0\u{1}MICROPHONE\0\u{1}SCREEN_SHARE\0\u{1}SCREEN_SHARE_AUDIO\0") } +extension Livekit_DataTrackExtensionID: SwiftProtobuf._ProtoNameProviding { + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0DTEI_INVALID\0\u{1}DTEI_PARTICIPANT_SID\0") +} + extension Livekit_VideoQuality: SwiftProtobuf._ProtoNameProviding { static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0LOW\0\u{1}MEDIUM\0\u{1}HIGH\0\u{1}OFF\0") } @@ -3353,7 +3504,7 @@ extension Livekit_ParticipantPermission: SwiftProtobuf.Message, SwiftProtobuf._M extension Livekit_ParticipantInfo: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { static let protoMessageName: String = _protobuf_package + ".ParticipantInfo" - static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}sid\0\u{1}identity\0\u{1}state\0\u{1}tracks\0\u{1}metadata\0\u{3}joined_at\0\u{2}\u{3}name\0\u{1}version\0\u{1}permission\0\u{1}region\0\u{3}is_publisher\0\u{1}kind\0\u{1}attributes\0\u{3}disconnect_reason\0\u{3}joined_at_ms\0\u{3}kind_details\0") + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}sid\0\u{1}identity\0\u{1}state\0\u{1}tracks\0\u{1}metadata\0\u{3}joined_at\0\u{2}\u{3}name\0\u{1}version\0\u{1}permission\0\u{1}region\0\u{3}is_publisher\0\u{1}kind\0\u{1}attributes\0\u{3}disconnect_reason\0\u{3}joined_at_ms\0\u{3}kind_details\0\u{3}data_tracks\0") fileprivate class _StorageClass { var _sid: String = String() @@ -3372,6 +3523,7 @@ extension Livekit_ParticipantInfo: SwiftProtobuf.Message, SwiftProtobuf._Message var _attributes: Dictionary = [:] var _disconnectReason: Livekit_DisconnectReason = .unknownReason var _kindDetails: [Livekit_ParticipantInfo.KindDetail] = [] + var _dataTracks: [Livekit_DataTrackInfo] = [] // This property is used as the initial default value for new instances of the type. // The type itself is protecting the reference to its storage via CoW semantics. @@ -3398,6 +3550,7 @@ extension Livekit_ParticipantInfo: SwiftProtobuf.Message, SwiftProtobuf._Message _attributes = source._attributes _disconnectReason = source._disconnectReason _kindDetails = source._kindDetails + _dataTracks = source._dataTracks } } @@ -3432,6 +3585,7 @@ extension Livekit_ParticipantInfo: SwiftProtobuf.Message, SwiftProtobuf._Message case 16: try { try decoder.decodeSingularEnumField(value: &_storage._disconnectReason) }() case 17: try { try decoder.decodeSingularInt64Field(value: &_storage._joinedAtMs) }() case 18: try { try decoder.decodeRepeatedEnumField(value: &_storage._kindDetails) }() + case 19: try { try decoder.decodeRepeatedMessageField(value: &_storage._dataTracks) }() default: break } } @@ -3492,6 +3646,9 @@ extension Livekit_ParticipantInfo: SwiftProtobuf.Message, SwiftProtobuf._Message if !_storage._kindDetails.isEmpty { try visitor.visitPackedEnumField(value: _storage._kindDetails, fieldNumber: 18) } + if !_storage._dataTracks.isEmpty { + try visitor.visitRepeatedMessageField(value: _storage._dataTracks, fieldNumber: 19) + } } try unknownFields.traverse(visitor: &visitor) } @@ -3517,6 +3674,7 @@ extension Livekit_ParticipantInfo: SwiftProtobuf.Message, SwiftProtobuf._Message if _storage._attributes != rhs_storage._attributes {return false} if _storage._disconnectReason != rhs_storage._disconnectReason {return false} if _storage._kindDetails != rhs_storage._kindDetails {return false} + if _storage._dataTracks != rhs_storage._dataTracks {return false} return true } if !storagesAreEqual {return false} @@ -3531,11 +3689,11 @@ extension Livekit_ParticipantInfo.State: SwiftProtobuf._ProtoNameProviding { } extension Livekit_ParticipantInfo.Kind: SwiftProtobuf._ProtoNameProviding { - static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0STANDARD\0\u{1}INGRESS\0\u{1}EGRESS\0\u{1}SIP\0\u{1}AGENT\0") + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0STANDARD\0\u{1}INGRESS\0\u{1}EGRESS\0\u{1}SIP\0\u{1}AGENT\0\u{2}\u{3}CONNECTOR\0\u{1}BRIDGE\0") } extension Livekit_ParticipantInfo.KindDetail: SwiftProtobuf._ProtoNameProviding { - static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0CLOUD_AGENT\0\u{1}FORWARDED\0") + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0CLOUD_AGENT\0\u{1}FORWARDED\0\u{1}CONNECTOR_WHATSAPP\0\u{1}CONNECTOR_TWILIO\0\u{1}BRIDGE_RTSP\0") } extension Livekit_Encryption: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { @@ -3819,9 +3977,123 @@ extension Livekit_TrackInfo: SwiftProtobuf.Message, SwiftProtobuf._MessageImplem } } +extension Livekit_DataTrackInfo: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".DataTrackInfo" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}pub_handle\0\u{1}sid\0\u{1}name\0\u{1}encryption\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularUInt32Field(value: &self.pubHandle) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.sid) }() + case 3: try { try decoder.decodeSingularStringField(value: &self.name) }() + case 4: try { try decoder.decodeSingularEnumField(value: &self.encryption) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if self.pubHandle != 0 { + try visitor.visitSingularUInt32Field(value: self.pubHandle, fieldNumber: 1) + } + if !self.sid.isEmpty { + try visitor.visitSingularStringField(value: self.sid, fieldNumber: 2) + } + if !self.name.isEmpty { + try visitor.visitSingularStringField(value: self.name, fieldNumber: 3) + } + if self.encryption != .none { + try visitor.visitSingularEnumField(value: self.encryption, fieldNumber: 4) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Livekit_DataTrackInfo, rhs: Livekit_DataTrackInfo) -> Bool { + if lhs.pubHandle != rhs.pubHandle {return false} + if lhs.sid != rhs.sid {return false} + if lhs.name != rhs.name {return false} + if lhs.encryption != rhs.encryption {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Livekit_DataTrackExtensionParticipantSid: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".DataTrackExtensionParticipantSid" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}id\0\u{3}participant_sid\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularEnumField(value: &self.id) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.participantSid) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if self.id != .dteiInvalid { + try visitor.visitSingularEnumField(value: self.id, fieldNumber: 1) + } + if !self.participantSid.isEmpty { + try visitor.visitSingularStringField(value: self.participantSid, fieldNumber: 2) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Livekit_DataTrackExtensionParticipantSid, rhs: Livekit_DataTrackExtensionParticipantSid) -> Bool { + if lhs.id != rhs.id {return false} + if lhs.participantSid != rhs.participantSid {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Livekit_DataTrackSubscriptionOptions: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".DataTrackSubscriptionOptions" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}target_fps\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularUInt32Field(value: &self._targetFps) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + try { if let v = self._targetFps { + try visitor.visitSingularUInt32Field(value: v, fieldNumber: 1) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Livekit_DataTrackSubscriptionOptions, rhs: Livekit_DataTrackSubscriptionOptions) -> Bool { + if lhs._targetFps != rhs._targetFps {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + extension Livekit_VideoLayer: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { static let protoMessageName: String = _protobuf_package + ".VideoLayer" - static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}quality\0\u{1}width\0\u{1}height\0\u{1}bitrate\0\u{1}ssrc\0\u{3}spatial_layer\0\u{1}rid\0") + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}quality\0\u{1}width\0\u{1}height\0\u{1}bitrate\0\u{1}ssrc\0\u{3}spatial_layer\0\u{1}rid\0\u{3}repair_ssrc\0") mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -3836,6 +4108,7 @@ extension Livekit_VideoLayer: SwiftProtobuf.Message, SwiftProtobuf._MessageImple case 5: try { try decoder.decodeSingularUInt32Field(value: &self.ssrc) }() case 6: try { try decoder.decodeSingularInt32Field(value: &self.spatialLayer) }() case 7: try { try decoder.decodeSingularStringField(value: &self.rid) }() + case 8: try { try decoder.decodeSingularUInt32Field(value: &self.repairSsrc) }() default: break } } @@ -3863,6 +4136,9 @@ extension Livekit_VideoLayer: SwiftProtobuf.Message, SwiftProtobuf._MessageImple if !self.rid.isEmpty { try visitor.visitSingularStringField(value: self.rid, fieldNumber: 7) } + if self.repairSsrc != 0 { + try visitor.visitSingularUInt32Field(value: self.repairSsrc, fieldNumber: 8) + } try unknownFields.traverse(visitor: &visitor) } @@ -3874,6 +4150,7 @@ extension Livekit_VideoLayer: SwiftProtobuf.Message, SwiftProtobuf._MessageImple if lhs.ssrc != rhs.ssrc {return false} if lhs.spatialLayer != rhs.spatialLayer {return false} if lhs.rid != rhs.rid {return false} + if lhs.repairSsrc != rhs.repairSsrc {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } @@ -6347,9 +6624,44 @@ extension Livekit_DataStream.Trailer: SwiftProtobuf.Message, SwiftProtobuf._Mess } } +extension Livekit_FilterParams: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".FilterParams" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}include_events\0\u{3}exclude_events\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeRepeatedStringField(value: &self.includeEvents) }() + case 2: try { try decoder.decodeRepeatedStringField(value: &self.excludeEvents) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if !self.includeEvents.isEmpty { + try visitor.visitRepeatedStringField(value: self.includeEvents, fieldNumber: 1) + } + if !self.excludeEvents.isEmpty { + try visitor.visitRepeatedStringField(value: self.excludeEvents, fieldNumber: 2) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Livekit_FilterParams, rhs: Livekit_FilterParams) -> Bool { + if lhs.includeEvents != rhs.includeEvents {return false} + if lhs.excludeEvents != rhs.excludeEvents {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + extension Livekit_WebhookConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { static let protoMessageName: String = _protobuf_package + ".WebhookConfig" - static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}url\0\u{3}signing_key\0") + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}url\0\u{3}signing_key\0\u{3}filter_params\0") mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -6359,24 +6671,33 @@ extension Livekit_WebhookConfig: SwiftProtobuf.Message, SwiftProtobuf._MessageIm switch fieldNumber { case 1: try { try decoder.decodeSingularStringField(value: &self.url) }() case 2: try { try decoder.decodeSingularStringField(value: &self.signingKey) }() + case 3: try { try decoder.decodeSingularMessageField(value: &self._filterParams) }() default: break } } } func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 if !self.url.isEmpty { try visitor.visitSingularStringField(value: self.url, fieldNumber: 1) } if !self.signingKey.isEmpty { try visitor.visitSingularStringField(value: self.signingKey, fieldNumber: 2) } + try { if let v = self._filterParams { + try visitor.visitSingularMessageField(value: v, fieldNumber: 3) + } }() try unknownFields.traverse(visitor: &visitor) } static func ==(lhs: Livekit_WebhookConfig, rhs: Livekit_WebhookConfig) -> Bool { if lhs.url != rhs.url {return false} if lhs.signingKey != rhs.signingKey {return false} + if lhs._filterParams != rhs._filterParams {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } diff --git a/Sources/LiveKit/Protos/livekit_rtc.pb.swift b/Sources/LiveKit/Protos/livekit_rtc.pb.swift index cdeeaadad..c85541786 100644 --- a/Sources/LiveKit/Protos/livekit_rtc.pb.swift +++ b/Sources/LiveKit/Protos/livekit_rtc.pb.swift @@ -301,6 +301,33 @@ struct Livekit_SignalRequest: Sendable { set {message = .updateVideoTrack(newValue)} } + /// Publish a data track + var publishDataTrackRequest: Livekit_PublishDataTrackRequest { + get { + if case .publishDataTrackRequest(let v)? = message {return v} + return Livekit_PublishDataTrackRequest() + } + set {message = .publishDataTrackRequest(newValue)} + } + + /// Unpublish a data track + var unpublishDataTrackRequest: Livekit_UnpublishDataTrackRequest { + get { + if case .unpublishDataTrackRequest(let v)? = message {return v} + return Livekit_UnpublishDataTrackRequest() + } + set {message = .unpublishDataTrackRequest(newValue)} + } + + /// Update subscription state for one or more data tracks + var updateDataSubscription: Livekit_UpdateDataSubscription { + get { + if case .updateDataSubscription(let v)? = message {return v} + return Livekit_UpdateDataSubscription() + } + set {message = .updateDataSubscription(newValue)} + } + var unknownFields = SwiftProtobuf.UnknownStorage() enum OneOf_Message: Equatable, Sendable { @@ -338,6 +365,12 @@ struct Livekit_SignalRequest: Sendable { case updateAudioTrack(Livekit_UpdateLocalAudioTrack) /// Update local video track settings case updateVideoTrack(Livekit_UpdateLocalVideoTrack) + /// Publish a data track + case publishDataTrackRequest(Livekit_PublishDataTrackRequest) + /// Unpublish a data track + case unpublishDataTrackRequest(Livekit_UnpublishDataTrackRequest) + /// Update subscription state for one or more data tracks + case updateDataSubscription(Livekit_UpdateDataSubscription) } @@ -577,6 +610,33 @@ struct Livekit_SignalResponse: Sendable { set {message = .subscribedAudioCodecUpdate(newValue)} } + /// Sent in response to `PublishDataTrackRequest`. + var publishDataTrackResponse: Livekit_PublishDataTrackResponse { + get { + if case .publishDataTrackResponse(let v)? = message {return v} + return Livekit_PublishDataTrackResponse() + } + set {message = .publishDataTrackResponse(newValue)} + } + + /// Sent in response to `UnpublishDataTrackRequest` or SFU-initiated unpublish. + var unpublishDataTrackResponse: Livekit_UnpublishDataTrackResponse { + get { + if case .unpublishDataTrackResponse(let v)? = message {return v} + return Livekit_UnpublishDataTrackResponse() + } + set {message = .unpublishDataTrackResponse(newValue)} + } + + /// Sent to data track subscribers to provide mapping from track SIDs to handles. + var dataTrackSubscriberHandles: Livekit_DataTrackSubscriberHandles { + get { + if case .dataTrackSubscriberHandles(let v)? = message {return v} + return Livekit_DataTrackSubscriberHandles() + } + set {message = .dataTrackSubscriberHandles(newValue)} + } + var unknownFields = SwiftProtobuf.UnknownStorage() enum OneOf_Message: Equatable, Sendable { @@ -631,6 +691,12 @@ struct Livekit_SignalResponse: Sendable { case mediaSectionsRequirement(Livekit_MediaSectionsRequirement) /// when audio subscription changes, used to enable simulcasting of audio codecs based on subscriptions case subscribedAudioCodecUpdate(Livekit_SubscribedAudioCodecUpdate) + /// Sent in response to `PublishDataTrackRequest`. + case publishDataTrackResponse(Livekit_PublishDataTrackResponse) + /// Sent in response to `UnpublishDataTrackRequest` or SFU-initiated unpublish. + case unpublishDataTrackResponse(Livekit_UnpublishDataTrackResponse) + /// Sent to data track subscribers to provide mapping from track SIDs to handles. + case dataTrackSubscriberHandles(Livekit_DataTrackSubscriberHandles) } @@ -764,6 +830,113 @@ struct Livekit_AddTrackRequest: @unchecked Sendable { fileprivate var _storage = _StorageClass.defaultInstance } +struct Livekit_PublishDataTrackRequest: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// Client-assigned, 16-bit identifier that will be attached to packets sent by the publisher. + /// This must be non-zero and unique for each data track published by the publisher. + var pubHandle: UInt32 = 0 + + /// Human-readable identifier (e.g., `geoLocation`, `servoPosition.x`, etc.), unique per publisher. + /// This must be non-empty and no longer than 256 characters. + var name: String = String() + + /// Method used for end-to-end encryption (E2EE) on frame payloads. + var encryption: Livekit_Encryption.TypeEnum = .none + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +struct Livekit_PublishDataTrackResponse: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// Information about the published track. + var info: Livekit_DataTrackInfo { + get {_info ?? Livekit_DataTrackInfo()} + set {_info = newValue} + } + /// Returns true if `info` has been explicitly set. + var hasInfo: Bool {self._info != nil} + /// Clears the value of `info`. Subsequent reads from it will return its default value. + mutating func clearInfo() {self._info = nil} + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} + + fileprivate var _info: Livekit_DataTrackInfo? = nil +} + +struct Livekit_UnpublishDataTrackRequest: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// Publisher handle of the track to unpublish. + var pubHandle: UInt32 = 0 + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +struct Livekit_UnpublishDataTrackResponse: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// Information about the unpublished track. + var info: Livekit_DataTrackInfo { + get {_info ?? Livekit_DataTrackInfo()} + set {_info = newValue} + } + /// Returns true if `info` has been explicitly set. + var hasInfo: Bool {self._info != nil} + /// Clears the value of `info`. Subsequent reads from it will return its default value. + mutating func clearInfo() {self._info = nil} + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} + + fileprivate var _info: Livekit_DataTrackInfo? = nil +} + +struct Livekit_DataTrackSubscriberHandles: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// Maps handles from incoming packets to the track SIDs that the packets belong to. + var subHandles: Dictionary = [:] + + var unknownFields = SwiftProtobuf.UnknownStorage() + + struct PublishedDataTrack: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var publisherIdentity: String = String() + + var publisherSid: String = String() + + var trackSid: String = String() + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} + } + + init() {} +} + struct Livekit_TrickleRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for @@ -987,6 +1160,8 @@ struct Livekit_SessionDescription: Sendable { var id: UInt32 = 0 + var midToTrackID: Dictionary = [:] + var unknownFields = SwiftProtobuf.UnknownStorage() init() {} @@ -1020,6 +1195,45 @@ struct Livekit_UpdateSubscription: Sendable { init() {} } +struct Livekit_UpdateDataSubscription: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var updates: [Livekit_UpdateDataSubscription.Update] = [] + + var unknownFields = SwiftProtobuf.UnknownStorage() + + struct Update: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var trackSid: String = String() + + var subscribe: Bool = false + + /// Options to apply when initially subscribing or updating an existing subscription. + /// When unsubscribing, this field is ignored. + var options: Livekit_DataTrackSubscriptionOptions { + get {_options ?? Livekit_DataTrackSubscriptionOptions()} + set {_options = newValue} + } + /// Returns true if `options` has been explicitly set. + var hasOptions: Bool {self._options != nil} + /// Clears the value of `options`. Subsequent reads from it will return its default value. + mutating func clearOptions() {self._options = nil} + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} + + fileprivate var _options: Livekit_DataTrackSubscriptionOptions? = nil + } + + init() {} +} + struct Livekit_UpdateTrackSettings: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for @@ -1498,6 +1712,8 @@ struct Livekit_SyncState: Sendable { var datachannelReceiveStates: [Livekit_DataChannelReceiveState] = [] + var publishDataTracks: [Livekit_PublishDataTrackResponse] = [] + var unknownFields = SwiftProtobuf.UnknownStorage() init() {} @@ -1787,6 +2003,22 @@ struct Livekit_RequestResponse: Sendable { set {request = .updateVideoTrack(newValue)} } + var publishDataTrack: Livekit_PublishDataTrackRequest { + get { + if case .publishDataTrack(let v)? = request {return v} + return Livekit_PublishDataTrackRequest() + } + set {request = .publishDataTrack(newValue)} + } + + var unpublishDataTrack: Livekit_UnpublishDataTrackRequest { + get { + if case .unpublishDataTrack(let v)? = request {return v} + return Livekit_UnpublishDataTrackRequest() + } + set {request = .unpublishDataTrack(newValue)} + } + var unknownFields = SwiftProtobuf.UnknownStorage() enum OneOf_Request: Equatable, Sendable { @@ -1796,6 +2028,8 @@ struct Livekit_RequestResponse: Sendable { case updateMetadata(Livekit_UpdateParticipantMetadata) case updateAudioTrack(Livekit_UpdateLocalAudioTrack) case updateVideoTrack(Livekit_UpdateLocalVideoTrack) + case publishDataTrack(Livekit_PublishDataTrackRequest) + case unpublishDataTrack(Livekit_UnpublishDataTrackRequest) } @@ -1808,6 +2042,10 @@ struct Livekit_RequestResponse: Sendable { case queued // = 4 case unsupportedType // = 5 case unclassifiedError // = 6 + case invalidHandle // = 7 + case invalidName // = 8 + case duplicateHandle // = 9 + case duplicateName // = 10 case UNRECOGNIZED(Int) init() { @@ -1823,6 +2061,10 @@ struct Livekit_RequestResponse: Sendable { case 4: self = .queued case 5: self = .unsupportedType case 6: self = .unclassifiedError + case 7: self = .invalidHandle + case 8: self = .invalidName + case 9: self = .duplicateHandle + case 10: self = .duplicateName default: self = .UNRECOGNIZED(rawValue) } } @@ -1836,6 +2078,10 @@ struct Livekit_RequestResponse: Sendable { case .queued: return 4 case .unsupportedType: return 5 case .unclassifiedError: return 6 + case .invalidHandle: return 7 + case .invalidName: return 8 + case .duplicateHandle: return 9 + case .duplicateName: return 10 case .UNRECOGNIZED(let i): return i } } @@ -1849,6 +2095,10 @@ struct Livekit_RequestResponse: Sendable { .queued, .unsupportedType, .unclassifiedError, + .invalidHandle, + .invalidName, + .duplicateHandle, + .duplicateName, ] } @@ -1888,11 +2138,21 @@ struct Livekit_ConnectionSettings: Sendable { var disableIceLite: Bool = false + var autoSubscribeDataTrack: Bool { + get {_autoSubscribeDataTrack ?? false} + set {_autoSubscribeDataTrack = newValue} + } + /// Returns true if `autoSubscribeDataTrack` has been explicitly set. + var hasAutoSubscribeDataTrack: Bool {self._autoSubscribeDataTrack != nil} + /// Clears the value of `autoSubscribeDataTrack`. Subsequent reads from it will return its default value. + mutating func clearAutoSubscribeDataTrack() {self._autoSubscribeDataTrack = nil} + var unknownFields = SwiftProtobuf.UnknownStorage() init() {} fileprivate var _subscriberAllowPause: Bool? = nil + fileprivate var _autoSubscribeDataTrack: Bool? = nil } struct Livekit_JoinRequest: @unchecked Sendable { @@ -2058,7 +2318,7 @@ extension Livekit_CandidateProtocol: SwiftProtobuf._ProtoNameProviding { extension Livekit_SignalRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { static let protoMessageName: String = _protobuf_package + ".SignalRequest" - static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}offer\0\u{1}answer\0\u{1}trickle\0\u{3}add_track\0\u{1}mute\0\u{1}subscription\0\u{3}track_setting\0\u{1}leave\0\u{4}\u{2}update_layers\0\u{3}subscription_permission\0\u{3}sync_state\0\u{1}simulate\0\u{1}ping\0\u{3}update_metadata\0\u{3}ping_req\0\u{3}update_audio_track\0\u{3}update_video_track\0") + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}offer\0\u{1}answer\0\u{1}trickle\0\u{3}add_track\0\u{1}mute\0\u{1}subscription\0\u{3}track_setting\0\u{1}leave\0\u{4}\u{2}update_layers\0\u{3}subscription_permission\0\u{3}sync_state\0\u{1}simulate\0\u{1}ping\0\u{3}update_metadata\0\u{3}ping_req\0\u{3}update_audio_track\0\u{3}update_video_track\0\u{3}publish_data_track_request\0\u{3}unpublish_data_track_request\0\u{3}update_data_subscription\0") mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -2282,6 +2542,45 @@ extension Livekit_SignalRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageIm self.message = .updateVideoTrack(v) } }() + case 19: try { + var v: Livekit_PublishDataTrackRequest? + var hadOneofValue = false + if let current = self.message { + hadOneofValue = true + if case .publishDataTrackRequest(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.message = .publishDataTrackRequest(v) + } + }() + case 20: try { + var v: Livekit_UnpublishDataTrackRequest? + var hadOneofValue = false + if let current = self.message { + hadOneofValue = true + if case .unpublishDataTrackRequest(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.message = .unpublishDataTrackRequest(v) + } + }() + case 21: try { + var v: Livekit_UpdateDataSubscription? + var hadOneofValue = false + if let current = self.message { + hadOneofValue = true + if case .updateDataSubscription(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.message = .updateDataSubscription(v) + } + }() default: break } } @@ -2361,6 +2660,18 @@ extension Livekit_SignalRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageIm guard case .updateVideoTrack(let v)? = self.message else { preconditionFailure() } try visitor.visitSingularMessageField(value: v, fieldNumber: 18) }() + case .publishDataTrackRequest?: try { + guard case .publishDataTrackRequest(let v)? = self.message else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 19) + }() + case .unpublishDataTrackRequest?: try { + guard case .unpublishDataTrackRequest(let v)? = self.message else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 20) + }() + case .updateDataSubscription?: try { + guard case .updateDataSubscription(let v)? = self.message else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 21) + }() case nil: break } try unknownFields.traverse(visitor: &visitor) @@ -2375,7 +2686,7 @@ extension Livekit_SignalRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageIm extension Livekit_SignalResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { static let protoMessageName: String = _protobuf_package + ".SignalResponse" - static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}join\0\u{1}answer\0\u{1}offer\0\u{1}trickle\0\u{1}update\0\u{3}track_published\0\u{2}\u{2}leave\0\u{1}mute\0\u{3}speakers_changed\0\u{3}room_update\0\u{3}connection_quality\0\u{3}stream_state_update\0\u{3}subscribed_quality_update\0\u{3}subscription_permission_update\0\u{3}refresh_token\0\u{3}track_unpublished\0\u{1}pong\0\u{1}reconnect\0\u{3}pong_resp\0\u{3}subscription_response\0\u{3}request_response\0\u{3}track_subscribed\0\u{3}room_moved\0\u{3}media_sections_requirement\0\u{3}subscribed_audio_codec_update\0") + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}join\0\u{1}answer\0\u{1}offer\0\u{1}trickle\0\u{1}update\0\u{3}track_published\0\u{2}\u{2}leave\0\u{1}mute\0\u{3}speakers_changed\0\u{3}room_update\0\u{3}connection_quality\0\u{3}stream_state_update\0\u{3}subscribed_quality_update\0\u{3}subscription_permission_update\0\u{3}refresh_token\0\u{3}track_unpublished\0\u{1}pong\0\u{1}reconnect\0\u{3}pong_resp\0\u{3}subscription_response\0\u{3}request_response\0\u{3}track_subscribed\0\u{3}room_moved\0\u{3}media_sections_requirement\0\u{3}subscribed_audio_codec_update\0\u{3}publish_data_track_response\0\u{3}unpublish_data_track_response\0\u{3}data_track_subscriber_handles\0") mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -2698,6 +3009,45 @@ extension Livekit_SignalResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageI self.message = .subscribedAudioCodecUpdate(v) } }() + case 27: try { + var v: Livekit_PublishDataTrackResponse? + var hadOneofValue = false + if let current = self.message { + hadOneofValue = true + if case .publishDataTrackResponse(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.message = .publishDataTrackResponse(v) + } + }() + case 28: try { + var v: Livekit_UnpublishDataTrackResponse? + var hadOneofValue = false + if let current = self.message { + hadOneofValue = true + if case .unpublishDataTrackResponse(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.message = .unpublishDataTrackResponse(v) + } + }() + case 29: try { + var v: Livekit_DataTrackSubscriberHandles? + var hadOneofValue = false + if let current = self.message { + hadOneofValue = true + if case .dataTrackSubscriberHandles(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.message = .dataTrackSubscriberHandles(v) + } + }() default: break } } @@ -2809,6 +3159,18 @@ extension Livekit_SignalResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageI guard case .subscribedAudioCodecUpdate(let v)? = self.message else { preconditionFailure() } try visitor.visitSingularMessageField(value: v, fieldNumber: 26) }() + case .publishDataTrackResponse?: try { + guard case .publishDataTrackResponse(let v)? = self.message else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 27) + }() + case .unpublishDataTrackResponse?: try { + guard case .unpublishDataTrackResponse(let v)? = self.message else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 28) + }() + case .dataTrackSubscriberHandles?: try { + guard case .dataTrackSubscriberHandles(let v)? = self.message else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 29) + }() case nil: break } try unknownFields.traverse(visitor: &visitor) @@ -3044,6 +3406,214 @@ extension Livekit_AddTrackRequest: SwiftProtobuf.Message, SwiftProtobuf._Message } } +extension Livekit_PublishDataTrackRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".PublishDataTrackRequest" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}pub_handle\0\u{1}name\0\u{1}encryption\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularUInt32Field(value: &self.pubHandle) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.name) }() + case 3: try { try decoder.decodeSingularEnumField(value: &self.encryption) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if self.pubHandle != 0 { + try visitor.visitSingularUInt32Field(value: self.pubHandle, fieldNumber: 1) + } + if !self.name.isEmpty { + try visitor.visitSingularStringField(value: self.name, fieldNumber: 2) + } + if self.encryption != .none { + try visitor.visitSingularEnumField(value: self.encryption, fieldNumber: 3) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Livekit_PublishDataTrackRequest, rhs: Livekit_PublishDataTrackRequest) -> Bool { + if lhs.pubHandle != rhs.pubHandle {return false} + if lhs.name != rhs.name {return false} + if lhs.encryption != rhs.encryption {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Livekit_PublishDataTrackResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".PublishDataTrackResponse" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}info\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularMessageField(value: &self._info) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + try { if let v = self._info { + try visitor.visitSingularMessageField(value: v, fieldNumber: 1) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Livekit_PublishDataTrackResponse, rhs: Livekit_PublishDataTrackResponse) -> Bool { + if lhs._info != rhs._info {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Livekit_UnpublishDataTrackRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".UnpublishDataTrackRequest" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}pub_handle\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularUInt32Field(value: &self.pubHandle) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if self.pubHandle != 0 { + try visitor.visitSingularUInt32Field(value: self.pubHandle, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Livekit_UnpublishDataTrackRequest, rhs: Livekit_UnpublishDataTrackRequest) -> Bool { + if lhs.pubHandle != rhs.pubHandle {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Livekit_UnpublishDataTrackResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".UnpublishDataTrackResponse" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}info\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularMessageField(value: &self._info) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + try { if let v = self._info { + try visitor.visitSingularMessageField(value: v, fieldNumber: 1) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Livekit_UnpublishDataTrackResponse, rhs: Livekit_UnpublishDataTrackResponse) -> Bool { + if lhs._info != rhs._info {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Livekit_DataTrackSubscriberHandles: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".DataTrackSubscriberHandles" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}sub_handles\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeMapField(fieldType: SwiftProtobuf._ProtobufMessageMap.self, value: &self.subHandles) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if !self.subHandles.isEmpty { + try visitor.visitMapField(fieldType: SwiftProtobuf._ProtobufMessageMap.self, value: self.subHandles, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Livekit_DataTrackSubscriberHandles, rhs: Livekit_DataTrackSubscriberHandles) -> Bool { + if lhs.subHandles != rhs.subHandles {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Livekit_DataTrackSubscriberHandles.PublishedDataTrack: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = Livekit_DataTrackSubscriberHandles.protoMessageName + ".PublishedDataTrack" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}publisher_identity\0\u{3}publisher_sid\0\u{3}track_sid\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.publisherIdentity) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.publisherSid) }() + case 3: try { try decoder.decodeSingularStringField(value: &self.trackSid) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if !self.publisherIdentity.isEmpty { + try visitor.visitSingularStringField(value: self.publisherIdentity, fieldNumber: 1) + } + if !self.publisherSid.isEmpty { + try visitor.visitSingularStringField(value: self.publisherSid, fieldNumber: 2) + } + if !self.trackSid.isEmpty { + try visitor.visitSingularStringField(value: self.trackSid, fieldNumber: 3) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Livekit_DataTrackSubscriberHandles.PublishedDataTrack, rhs: Livekit_DataTrackSubscriberHandles.PublishedDataTrack) -> Bool { + if lhs.publisherIdentity != rhs.publisherIdentity {return false} + if lhs.publisherSid != rhs.publisherSid {return false} + if lhs.trackSid != rhs.trackSid {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + extension Livekit_TrickleRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { static let protoMessageName: String = _protobuf_package + ".TrickleRequest" static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}candidateInit\0\u{1}target\0\u{1}final\0") @@ -3407,7 +3977,7 @@ extension Livekit_TrackUnpublishedResponse: SwiftProtobuf.Message, SwiftProtobuf extension Livekit_SessionDescription: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { static let protoMessageName: String = _protobuf_package + ".SessionDescription" - static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}type\0\u{1}sdp\0\u{1}id\0") + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}type\0\u{1}sdp\0\u{1}id\0\u{3}mid_to_track_id\0") mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -3418,6 +3988,7 @@ extension Livekit_SessionDescription: SwiftProtobuf.Message, SwiftProtobuf._Mess case 1: try { try decoder.decodeSingularStringField(value: &self.type) }() case 2: try { try decoder.decodeSingularStringField(value: &self.sdp) }() case 3: try { try decoder.decodeSingularUInt32Field(value: &self.id) }() + case 4: try { try decoder.decodeMapField(fieldType: SwiftProtobuf._ProtobufMap.self, value: &self.midToTrackID) }() default: break } } @@ -3433,6 +4004,9 @@ extension Livekit_SessionDescription: SwiftProtobuf.Message, SwiftProtobuf._Mess if self.id != 0 { try visitor.visitSingularUInt32Field(value: self.id, fieldNumber: 3) } + if !self.midToTrackID.isEmpty { + try visitor.visitMapField(fieldType: SwiftProtobuf._ProtobufMap.self, value: self.midToTrackID, fieldNumber: 4) + } try unknownFields.traverse(visitor: &visitor) } @@ -3440,6 +4014,7 @@ extension Livekit_SessionDescription: SwiftProtobuf.Message, SwiftProtobuf._Mess if lhs.type != rhs.type {return false} if lhs.sdp != rhs.sdp {return false} if lhs.id != rhs.id {return false} + if lhs.midToTrackID != rhs.midToTrackID {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } @@ -3515,6 +4090,80 @@ extension Livekit_UpdateSubscription: SwiftProtobuf.Message, SwiftProtobuf._Mess } } +extension Livekit_UpdateDataSubscription: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".UpdateDataSubscription" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}updates\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeRepeatedMessageField(value: &self.updates) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if !self.updates.isEmpty { + try visitor.visitRepeatedMessageField(value: self.updates, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Livekit_UpdateDataSubscription, rhs: Livekit_UpdateDataSubscription) -> Bool { + if lhs.updates != rhs.updates {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Livekit_UpdateDataSubscription.Update: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = Livekit_UpdateDataSubscription.protoMessageName + ".Update" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}track_sid\0\u{1}subscribe\0\u{1}options\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.trackSid) }() + case 2: try { try decoder.decodeSingularBoolField(value: &self.subscribe) }() + case 3: try { try decoder.decodeSingularMessageField(value: &self._options) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + if !self.trackSid.isEmpty { + try visitor.visitSingularStringField(value: self.trackSid, fieldNumber: 1) + } + if self.subscribe != false { + try visitor.visitSingularBoolField(value: self.subscribe, fieldNumber: 2) + } + try { if let v = self._options { + try visitor.visitSingularMessageField(value: v, fieldNumber: 3) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Livekit_UpdateDataSubscription.Update, rhs: Livekit_UpdateDataSubscription.Update) -> Bool { + if lhs.trackSid != rhs.trackSid {return false} + if lhs.subscribe != rhs.subscribe {return false} + if lhs._options != rhs._options {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + extension Livekit_UpdateTrackSettings: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { static let protoMessageName: String = _protobuf_package + ".UpdateTrackSettings" static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}track_sids\0\u{2}\u{2}disabled\0\u{1}quality\0\u{1}width\0\u{1}height\0\u{1}fps\0\u{1}priority\0") @@ -4385,7 +5034,7 @@ extension Livekit_RoomMovedResponse: SwiftProtobuf.Message, SwiftProtobuf._Messa extension Livekit_SyncState: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { static let protoMessageName: String = _protobuf_package + ".SyncState" - static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}answer\0\u{1}subscription\0\u{3}publish_tracks\0\u{3}data_channels\0\u{1}offer\0\u{3}track_sids_disabled\0\u{3}datachannel_receive_states\0") + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}answer\0\u{1}subscription\0\u{3}publish_tracks\0\u{3}data_channels\0\u{1}offer\0\u{3}track_sids_disabled\0\u{3}datachannel_receive_states\0\u{3}publish_data_tracks\0") mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -4400,6 +5049,7 @@ extension Livekit_SyncState: SwiftProtobuf.Message, SwiftProtobuf._MessageImplem case 5: try { try decoder.decodeSingularMessageField(value: &self._offer) }() case 6: try { try decoder.decodeRepeatedStringField(value: &self.trackSidsDisabled) }() case 7: try { try decoder.decodeRepeatedMessageField(value: &self.datachannelReceiveStates) }() + case 8: try { try decoder.decodeRepeatedMessageField(value: &self.publishDataTracks) }() default: break } } @@ -4431,6 +5081,9 @@ extension Livekit_SyncState: SwiftProtobuf.Message, SwiftProtobuf._MessageImplem if !self.datachannelReceiveStates.isEmpty { try visitor.visitRepeatedMessageField(value: self.datachannelReceiveStates, fieldNumber: 7) } + if !self.publishDataTracks.isEmpty { + try visitor.visitRepeatedMessageField(value: self.publishDataTracks, fieldNumber: 8) + } try unknownFields.traverse(visitor: &visitor) } @@ -4442,6 +5095,7 @@ extension Livekit_SyncState: SwiftProtobuf.Message, SwiftProtobuf._MessageImplem if lhs._offer != rhs._offer {return false} if lhs.trackSidsDisabled != rhs.trackSidsDisabled {return false} if lhs.datachannelReceiveStates != rhs.datachannelReceiveStates {return false} + if lhs.publishDataTracks != rhs.publishDataTracks {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } @@ -4840,7 +5494,7 @@ extension Livekit_SubscriptionResponse: SwiftProtobuf.Message, SwiftProtobuf._Me extension Livekit_RequestResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { static let protoMessageName: String = _protobuf_package + ".RequestResponse" - static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}request_id\0\u{1}reason\0\u{1}message\0\u{1}trickle\0\u{3}add_track\0\u{1}mute\0\u{3}update_metadata\0\u{3}update_audio_track\0\u{3}update_video_track\0") + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}request_id\0\u{1}reason\0\u{1}message\0\u{1}trickle\0\u{3}add_track\0\u{1}mute\0\u{3}update_metadata\0\u{3}update_audio_track\0\u{3}update_video_track\0\u{3}publish_data_track\0\u{3}unpublish_data_track\0") mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -4929,6 +5583,32 @@ extension Livekit_RequestResponse: SwiftProtobuf.Message, SwiftProtobuf._Message self.request = .updateVideoTrack(v) } }() + case 10: try { + var v: Livekit_PublishDataTrackRequest? + var hadOneofValue = false + if let current = self.request { + hadOneofValue = true + if case .publishDataTrack(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.request = .publishDataTrack(v) + } + }() + case 11: try { + var v: Livekit_UnpublishDataTrackRequest? + var hadOneofValue = false + if let current = self.request { + hadOneofValue = true + if case .unpublishDataTrack(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.request = .unpublishDataTrack(v) + } + }() default: break } } @@ -4973,6 +5653,14 @@ extension Livekit_RequestResponse: SwiftProtobuf.Message, SwiftProtobuf._Message guard case .updateVideoTrack(let v)? = self.request else { preconditionFailure() } try visitor.visitSingularMessageField(value: v, fieldNumber: 9) }() + case .publishDataTrack?: try { + guard case .publishDataTrack(let v)? = self.request else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 10) + }() + case .unpublishDataTrack?: try { + guard case .unpublishDataTrack(let v)? = self.request else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 11) + }() case nil: break } try unknownFields.traverse(visitor: &visitor) @@ -4989,7 +5677,7 @@ extension Livekit_RequestResponse: SwiftProtobuf.Message, SwiftProtobuf._Message } extension Livekit_RequestResponse.Reason: SwiftProtobuf._ProtoNameProviding { - static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0OK\0\u{1}NOT_FOUND\0\u{1}NOT_ALLOWED\0\u{1}LIMIT_EXCEEDED\0\u{1}QUEUED\0\u{1}UNSUPPORTED_TYPE\0\u{1}UNCLASSIFIED_ERROR\0") + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0OK\0\u{1}NOT_FOUND\0\u{1}NOT_ALLOWED\0\u{1}LIMIT_EXCEEDED\0\u{1}QUEUED\0\u{1}UNSUPPORTED_TYPE\0\u{1}UNCLASSIFIED_ERROR\0\u{1}INVALID_HANDLE\0\u{1}INVALID_NAME\0\u{1}DUPLICATE_HANDLE\0\u{1}DUPLICATE_NAME\0") } extension Livekit_TrackSubscribed: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { @@ -5024,7 +5712,7 @@ extension Livekit_TrackSubscribed: SwiftProtobuf.Message, SwiftProtobuf._Message extension Livekit_ConnectionSettings: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { static let protoMessageName: String = _protobuf_package + ".ConnectionSettings" - static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}auto_subscribe\0\u{3}adaptive_stream\0\u{3}subscriber_allow_pause\0\u{3}disable_ice_lite\0") + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}auto_subscribe\0\u{3}adaptive_stream\0\u{3}subscriber_allow_pause\0\u{3}disable_ice_lite\0\u{3}auto_subscribe_data_track\0") mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -5036,6 +5724,7 @@ extension Livekit_ConnectionSettings: SwiftProtobuf.Message, SwiftProtobuf._Mess case 2: try { try decoder.decodeSingularBoolField(value: &self.adaptiveStream) }() case 3: try { try decoder.decodeSingularBoolField(value: &self._subscriberAllowPause) }() case 4: try { try decoder.decodeSingularBoolField(value: &self.disableIceLite) }() + case 5: try { try decoder.decodeSingularBoolField(value: &self._autoSubscribeDataTrack) }() default: break } } @@ -5058,6 +5747,9 @@ extension Livekit_ConnectionSettings: SwiftProtobuf.Message, SwiftProtobuf._Mess if self.disableIceLite != false { try visitor.visitSingularBoolField(value: self.disableIceLite, fieldNumber: 4) } + try { if let v = self._autoSubscribeDataTrack { + try visitor.visitSingularBoolField(value: v, fieldNumber: 5) + } }() try unknownFields.traverse(visitor: &visitor) } @@ -5066,6 +5758,7 @@ extension Livekit_ConnectionSettings: SwiftProtobuf.Message, SwiftProtobuf._Mess if lhs.adaptiveStream != rhs.adaptiveStream {return false} if lhs._subscriberAllowPause != rhs._subscriberAllowPause {return false} if lhs.disableIceLite != rhs.disableIceLite {return false} + if lhs._autoSubscribeDataTrack != rhs._autoSubscribeDataTrack {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } diff --git a/Sources/LiveKit/Support/Network/HTTP.swift b/Sources/LiveKit/Support/Network/HTTP.swift index 255b3502d..d0c108494 100644 --- a/Sources/LiveKit/Support/Network/HTTP.swift +++ b/Sources/LiveKit/Support/Network/HTTP.swift @@ -51,8 +51,10 @@ class HTTP: NSObject { let details = "HTTP \(statusCode): \(body)" // Treat request/token/permissions issues as validation errors. + // 404 is reported separately so the v1 → v0 RTC path fallback can + // distinguish "endpoint doesn't exist" from other client errors. if (400 ..< 500).contains(statusCode), statusCode != 429 { - throw LiveKitError(.validation, message: details) + throw LiveKitError(statusCode == 404 ? .serviceNotFound : .validation, message: details) } // Treat server/rate-limit issues as network errors. diff --git a/Sources/LiveKit/Support/Utils.swift b/Sources/LiveKit/Support/Utils.swift index 6379d69c0..b185067d7 100644 --- a/Sources/LiveKit/Support/Utils.swift +++ b/Sources/LiveKit/Support/Utils.swift @@ -134,9 +134,7 @@ class Utils: Loggable { connectOptions: ConnectOptions? = nil, reconnectMode: ReconnectMode? = nil, participantSid: Participant.Sid? = nil, - adaptiveStream: Bool, - validate: Bool = false, - forceSecure: Bool = false + adaptiveStream: Bool ) throws -> URL { // use default options if nil let connectOptions = connectOptions ?? ConnectOptions() @@ -147,9 +145,7 @@ class Utils: Loggable { throw LiveKitError(.failedToParseUrl) } - let useSecure = url.isSecure || forceSecure - let httpScheme = useSecure ? "https" : "http" - let wsScheme = useSecure ? "wss" : "ws" + let wsScheme = url.isSecure ? "wss" : "ws" var pathSegments = url.pathComponents // strip empty & slashes @@ -165,12 +161,8 @@ class Utils: Loggable { } // add the correct segment pathSegments.append("rtc") - // add validate after rtc if validate mode - if validate { - pathSegments.append("validate") - } - builder.scheme = validate ? httpScheme : wsScheme + builder.scheme = wsScheme builder.path = "/" + pathSegments.joined(separator: "/") var queryItems = [ @@ -210,6 +202,103 @@ class Utils: Loggable { return result } + static func buildJoinRequestUrl( + _ url: URL, + connectOptions: ConnectOptions? = nil, + reconnectMode: ReconnectMode? = nil, + participantSid: Participant.Sid? = nil, + adaptiveStream: Bool + ) throws -> URL { + let connectOptions = connectOptions ?? ConnectOptions() + + guard var builder = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + throw LiveKitError(.failedToParseUrl) + } + + let wsScheme = url.isSecure ? "wss" : "ws" + + var pathSegments = url.pathComponents + pathSegments.removeAll(where: { $0.isEmpty || $0 == "/" }) + if !url.hasDirectoryPath, let last = pathSegments.last, + ["rtc", "validate"].contains(last) + { + pathSegments.removeLast() + } + pathSegments.append("rtc") + pathSegments.append("v1") + + builder.scheme = wsScheme + builder.path = "/" + pathSegments.joined(separator: "/") + + let encoded = try buildWrappedJoinRequest(connectOptions: connectOptions, + reconnectMode: reconnectMode, + participantSid: participantSid, + adaptiveStream: adaptiveStream) + + builder.queryItems = [URLQueryItem(name: "join_request", value: encoded)] + + guard let result = builder.url else { + throw LiveKitError(.failedToParseUrl) + } + + return result + } + + /// Converts a WebSocket URL to its HTTP validation counterpart. + /// - `wss://host/rtc?...` → `https://host/rtc/validate?...` + /// - `wss://host/rtc/v1?...` → `https://host/rtc/v1/validate?...` + static func toValidateUrl(_ wsUrl: URL) throws -> URL { + guard var components = URLComponents(url: wsUrl, resolvingAgainstBaseURL: false) else { + throw LiveKitError(.failedToParseUrl) + } + components.scheme = components.scheme == "wss" ? "https" : "http" + components.path = components.path.hasSuffix("/") + ? components.path + "validate" + : components.path + "/validate" + guard let result = components.url else { + throw LiveKitError(.failedToParseUrl) + } + return result + } + + private static func buildWrappedJoinRequest( + connectOptions: ConnectOptions, + reconnectMode: ReconnectMode?, + participantSid: Participant.Sid?, + adaptiveStream: Bool + ) throws -> String { + var joinRequest = Livekit_JoinRequest() + joinRequest.clientInfo = Livekit_ClientInfo.with { + $0.sdk = .swift + $0.version = LiveKitSDK.version + $0.protocol = Int32(connectOptions.protocolVersion.rawValue) + $0.os = String(describing: os()) + $0.osVersion = osVersionString() + if let model = modelIdentifier() { $0.deviceModel = model } + if let network = networkTypeString() { $0.network = network } + } + joinRequest.connectionSettings = Livekit_ConnectionSettings.with { + $0.autoSubscribe = connectOptions.autoSubscribe + $0.adaptiveStream = adaptiveStream + } + + if reconnectMode == .quick { + joinRequest.reconnect = true + joinRequest.reconnectReason = .rrSignalDisconnected + if let sid = participantSid { + joinRequest.participantSid = sid.stringValue + } + } + + let joinRequestData = try joinRequest.serializedData() + let wrappedData = try Livekit_WrappedJoinRequest.with { + $0.compression = .none + $0.joinRequest = joinRequestData + }.serializedData() + + return wrappedData.base64EncodedString() + } + static func computeVideoEncodings( dimensions: Dimensions, publishOptions: VideoPublishOptions?, diff --git a/Sources/LiveKit/TrackPublications/RemoteTrackPublication.swift b/Sources/LiveKit/TrackPublications/RemoteTrackPublication.swift index ea8148f88..c74e9bd45 100644 --- a/Sources/LiveKit/TrackPublications/RemoteTrackPublication.swift +++ b/Sources/LiveKit/TrackPublications/RemoteTrackPublication.swift @@ -70,6 +70,13 @@ public class RemoteTrackPublication: TrackPublication, @unchecked Sendable { _state.mutate { $0.isSubscribePreferred = newValue } + if !newValue { + // Proactively clear the track. In single PC mode the transceiver is + // reused (direction changes) rather than removed, so the WebRTC + // didRemove(rtpReceiver:) callback may never fire. + await set(track: nil) + } + try await room.signalClient.sendUpdateSubscription(participantSid: participantSid, trackSid: sid, isSubscribed: newValue) diff --git a/Sources/LiveKit/Types/Options/RoomOptions.swift b/Sources/LiveKit/Types/Options/RoomOptions.swift index 86285f238..a00b7c0f9 100644 --- a/Sources/LiveKit/Types/Options/RoomOptions.swift +++ b/Sources/LiveKit/Types/Options/RoomOptions.swift @@ -60,6 +60,11 @@ public final class RoomOptions: NSObject, Sendable, Loggable { public let reportRemoteTrackStatistics: Bool + /// Use a single peer connection for both publishing and subscribing. + /// + /// - Note: Requires LiveKit Cloud or LiveKit OSS >= 1.9.2. + public let singlePeerConnection: Bool + override public init() { defaultCameraCaptureOptions = CameraCaptureOptions() defaultScreenShareCaptureOptions = ScreenShareCaptureOptions() @@ -74,6 +79,7 @@ public final class RoomOptions: NSObject, Sendable, Loggable { e2eeOptions = nil encryptionOptions = nil reportRemoteTrackStatistics = false + singlePeerConnection = false } public init(defaultCameraCaptureOptions: CameraCaptureOptions = CameraCaptureOptions(), @@ -88,7 +94,8 @@ public final class RoomOptions: NSObject, Sendable, Loggable { suspendLocalVideoTracksInBackground: Bool = true, e2eeOptions: E2EEOptions? = nil, encryptionOptions: EncryptionOptions? = nil, - reportRemoteTrackStatistics: Bool = false) + reportRemoteTrackStatistics: Bool = false, + singlePeerConnection: Bool = false) { self.defaultCameraCaptureOptions = defaultCameraCaptureOptions self.defaultScreenShareCaptureOptions = defaultScreenShareCaptureOptions @@ -103,6 +110,7 @@ public final class RoomOptions: NSObject, Sendable, Loggable { self.e2eeOptions = e2eeOptions self.encryptionOptions = encryptionOptions self.reportRemoteTrackStatistics = reportRemoteTrackStatistics + self.singlePeerConnection = singlePeerConnection super.init() @@ -127,7 +135,8 @@ public final class RoomOptions: NSObject, Sendable, Loggable { suspendLocalVideoTracksInBackground == other.suspendLocalVideoTracksInBackground && e2eeOptions == other.e2eeOptions && encryptionOptions == other.encryptionOptions && - reportRemoteTrackStatistics == other.reportRemoteTrackStatistics + reportRemoteTrackStatistics == other.reportRemoteTrackStatistics && + singlePeerConnection == other.singlePeerConnection } override public var hash: Int { @@ -145,6 +154,7 @@ public final class RoomOptions: NSObject, Sendable, Loggable { hasher.combine(e2eeOptions) hasher.combine(encryptionOptions) hasher.combine(reportRemoteTrackStatistics) + hasher.combine(singlePeerConnection) return hasher.finalize() } } diff --git a/Tests/LiveKitCoreTests/PeerConnectionSignalingTests.swift b/Tests/LiveKitCoreTests/PeerConnectionSignalingTests.swift new file mode 100644 index 000000000..2804fff35 --- /dev/null +++ b/Tests/LiveKitCoreTests/PeerConnectionSignalingTests.swift @@ -0,0 +1,557 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// swiftlint:disable file_length + +/// Peer Connection Signaling Tests +/// +/// Ported from rust-sdks `peer_connection_signaling_test.rs`. +/// Tests verify that both V0 (dual PC) and V1 (single PC) signaling modes work correctly. +/// +/// V0 (Dual PC): Traditional mode with separate publisher and subscriber peer connections. +/// V1 (Single PC): Single peer connection for both publish and subscribe. +/// +/// V1 tests will fall back to V0 on localhost if the server doesn't support /rtc/v1. + +import Combine +import CoreVideo +@testable import LiveKit +#if canImport(LiveKitTestSupport) +import LiveKitTestSupport +#endif + +// MARK: - SignalingMode + +enum SignalingMode: CustomStringConvertible { + /// V0: Dual peer connection (/rtc path) + case dualPC + /// V1: Single peer connection (/rtc/v1 path) + case singlePC + + var isSinglePeerConnection: Bool { self == .singlePC } + + var description: String { + switch self { + case .dualPC: "V0 (Dual PC)" + case .singlePC: "V1 (Single PC)" + } + } +} + +// MARK: - ReconnectWatcher + +/// Watches for reconnect completion via RoomDelegate. +/// Uses didStartReconnectWithMode/didCompleteReconnectWithMode which fire for +/// BOTH quick and full reconnect (unlike roomDidReconnect which skips quick). +private final class ReconnectWatcher: NSObject, RoomDelegate, @unchecked Sendable { + private struct State { + var reconnectStartExpectation: XCTestExpectation? + var reconnectCompleteExpectation: XCTestExpectation? + var tracksRepublishedExpectation: XCTestExpectation? + var expectedTrackCount: Int = 0 + var publishedTrackCount: Int = 0 + // Gate: only count didPublishTrack after reconnect completes. + // All delegate callbacks flow through the same serial queue, so + // the initial publish's didPublishTrack is guaranteed to fire + // before didCompleteReconnectWithMode (enqueued earlier). + var isCountingRepublishedTracks: Bool = false + } + + private let _state = StateSync(State()) + + func expectReconnect(description: String = "reconnect") -> (start: XCTestExpectation, complete: XCTestExpectation) { + _state.mutate { + let start = XCTestExpectation(description: "\(description) start") + start.assertForOverFulfill = false + let complete = XCTestExpectation(description: "\(description) complete") + complete.assertForOverFulfill = false + $0.reconnectStartExpectation = start + $0.reconnectCompleteExpectation = complete + return (start: start, complete: complete) + } + } + + func expectTracksRepublished(count: Int, description: String = "tracks republished") -> XCTestExpectation { + _state.mutate { + let expectation = XCTestExpectation(description: description) + expectation.assertForOverFulfill = false + $0.tracksRepublishedExpectation = expectation + $0.expectedTrackCount = count + $0.publishedTrackCount = 0 + $0.isCountingRepublishedTracks = false + return expectation + } + } + + // MARK: - RoomDelegate + + func room(_: Room, didStartReconnectWithMode _: ReconnectMode) { + _state.mutate { $0.reconnectStartExpectation?.fulfill() } + } + + func room(_: Room, didCompleteReconnectWithMode _: ReconnectMode) { + _state.mutate { + $0.reconnectCompleteExpectation?.fulfill() + $0.isCountingRepublishedTracks = true + } + } + + func room(_: Room, participant _: LocalParticipant, didPublishTrack _: LocalTrackPublication) { + _state.mutate { + guard $0.isCountingRepublishedTracks else { return } + $0.publishedTrackCount += 1 + if $0.publishedTrackCount >= $0.expectedTrackCount { + $0.tracksRepublishedExpectation?.fulfill() + } + } + } +} + +// MARK: - PeerConnectionSignalingTests + +class PeerConnectionSignalingTests: LKTestCase { + // MARK: - Helpers + + private func roomTestingOptions( + mode: SignalingMode, + delegate: RoomDelegate? = nil, + canPublish: Bool = false, + canPublishData: Bool = false, + canSubscribe: Bool = false + ) -> RoomTestingOptions { + RoomTestingOptions( + delegate: delegate, + singlePeerConnection: mode.isSinglePeerConnection, + canPublish: canPublish, + canPublishData: canPublishData, + canSubscribe: canSubscribe + ) + } + + private func assertSignalingModeState(_ room: Room, mode: SignalingMode) { + guard let transport = room._state.transport else { + XCTFail("Transport is nil after connection") + return + } + + switch transport { + case .publisherOnly: + if mode == .dualPC { + XCTFail("DualPC test should not have single-PC mode active") + } + case .subscriberPrimary, .publisherPrimary: + if mode == .singlePC { + let url = room.url ?? "" + if url.contains("localhost") || url.contains("127.0.0.1") { + print("[\(mode)] SinglePC on localhost: fell back to dual PC (expected on older servers)") + } else { + XCTFail("SinglePC requested on non-localhost URL should stay in single-PC mode") + } + } + } + } + + // MARK: - V0 (Dual PC) Tests + + func testV0Connect() async throws { try await _testConnect(mode: .dualPC) } + func testV0TwoParticipants() async throws { try await _testTwoParticipants(mode: .dualPC) } + func testV0AudioTrack() async throws { try await _testAudioTrack(mode: .dualPC) } + func testV0Reconnect() async throws { try await _testReconnect(mode: .dualPC) } + func testV0DataChannel() async throws { try await _testDataChannel(mode: .dualPC) } + func testV0FullReconnect() async throws { try await _testFullReconnect(mode: .dualPC) } + func testV0PublishManyTracks() async throws { try await _testPublishManyTracks(mode: .dualPC) } + func testV0DoubleReconnect() async throws { try await _testDoubleReconnect(mode: .dualPC) } + + // MARK: - V1 (Single PC) Tests + + func testV1Connect() async throws { try await _testConnect(mode: .singlePC) } + func testV1TwoParticipants() async throws { try await _testTwoParticipants(mode: .singlePC) } + func testV1AudioTrack() async throws { try await _testAudioTrack(mode: .singlePC) } + func testV1Reconnect() async throws { try await _testReconnect(mode: .singlePC) } + func testV1DataChannel() async throws { try await _testDataChannel(mode: .singlePC) } + func testV1FullReconnect() async throws { try await _testFullReconnect(mode: .singlePC) } + func testV1PublishManyTracks() async throws { try await _testPublishManyTracks(mode: .singlePC) } + func testV1DoubleReconnect() async throws { try await _testDoubleReconnect(mode: .singlePC) } + + func testV1LocalhostFallback() async throws { + let url = liveKitServerUrl() + guard url.contains("localhost") || url.contains("127.0.0.1") else { + print("Skipping localhost fallback test because LIVEKIT_TESTING_URL override is set") + return + } + + try await withRooms([roomTestingOptions(mode: .singlePC, canPublish: true)]) { rooms in + let room = rooms[0] + XCTAssertEqual(room.connectionState, .connected) + + guard let transport = room._state.transport else { + XCTFail("Transport is nil") + return + } + + switch transport { + case .publisherOnly: + print("Localhost server supports /rtc/v1 (single PC active)") + case .subscriberPrimary, .publisherPrimary: + print("Localhost server fell back to V0 as expected") + } + } + } +} + +// MARK: - Test Implementations + +extension PeerConnectionSignalingTests { + /// Test basic connection and verify transport mode. + private func _testConnect(mode: SignalingMode) async throws { + print("[\(mode)] Testing basic connection...") + + try await withRooms([roomTestingOptions(mode: mode, canPublish: true)]) { rooms in + let room = rooms[0] + + XCTAssertEqual(room.connectionState, .connected) + XCTAssertNotNil(room.localParticipant.identity, "LocalParticipant.identity should not be nil") + self.assertSignalingModeState(room, mode: mode) + + print("[\(mode)] Connected! Room: \(room.name ?? "nil")") + print("[\(mode)] Test passed - connection working!") + } + } + + /// Test two participants discovering each other. + private func _testTwoParticipants(mode: SignalingMode) async throws { + print("[\(mode)] Testing two participants...") + + try await withRooms([ + roomTestingOptions(mode: mode, canPublish: true, canSubscribe: true), + roomTestingOptions(mode: mode, canPublish: true, canSubscribe: true), + ]) { rooms in + let room1 = rooms[0] + let room2 = rooms[1] + + // withRooms already waits for participants to see each other + XCTAssertEqual(room1.remoteParticipants.count, 1, "Room1 should see 1 remote participant") + XCTAssertEqual(room2.remoteParticipants.count, 1, "Room2 should see 1 remote participant") + + self.assertSignalingModeState(room1, mode: mode) + self.assertSignalingModeState(room2, mode: mode) + + print("[\(mode)] Test passed - two participants working!") + } + } + + private func _testAudioTrack(mode: SignalingMode) async throws { + print("[\(mode)] Testing audio track...") + + try await withRooms([ + roomTestingOptions(mode: mode, canPublish: true), + roomTestingOptions(mode: mode, canSubscribe: true), + ]) { rooms in + let room1 = rooms[0] + let room2 = rooms[1] + + guard let publisherIdentity = room1.localParticipant.identity else { + XCTFail("Publisher's identity is nil") + return + } + + guard let remoteParticipant = room2.remoteParticipants[publisherIdentity] else { + XCTFail("Failed to lookup Publisher (RemoteParticipant)") + return + } + + // Watch for remote audio track subscription + let didSubscribe = self.expectation(description: "Did subscribe to remote audio track") + didSubscribe.assertForOverFulfill = false + var remoteAudioTrack: RemoteAudioTrack? + + let watchParticipant = remoteParticipant.objectWillChange.sink { _ in + if let track = remoteParticipant.firstAudioPublication?.track as? RemoteAudioTrack, remoteAudioTrack == nil { + remoteAudioTrack = track + didSubscribe.fulfill() + } + } + + // Publish audio via TestAudioTrack (bypasses AudioManager) + let audioTrack = TestAudioTrack() + try await room1.localParticipant.publish(audioTrack: audioTrack) + + print("[\(mode)] Waiting for remote audio track...") + await self.fulfillment(of: [didSubscribe], timeout: 30) + + guard let remoteAudioTrack else { + XCTFail("RemoteAudioTrack is nil") + return + } + + // Wait for actual audio frames + let didReceiveFrame = self.expectation(description: "Did receive audio frame") + didReceiveFrame.assertForOverFulfill = false + + let audioWatcher = AudioTrackWatcher(id: "audio-watcher") { _ in + didReceiveFrame.fulfill() + } + remoteAudioTrack.add(audioRenderer: audioWatcher) + + print("[\(mode)] Waiting for audio frames...") + await self.fulfillment(of: [didReceiveFrame], timeout: 30) + + remoteAudioTrack.remove(audioRenderer: audioWatcher) + watchParticipant.cancel() + + print("[\(mode)] Test passed - audio track working!") + } + } + + private func _testReconnect(mode: SignalingMode) async throws { + print("[\(mode)] Testing reconnection...") + + let reconnectWatcher = ReconnectWatcher() + + try await withRooms([ + roomTestingOptions(mode: mode, delegate: reconnectWatcher, canPublish: true), + roomTestingOptions(mode: mode, canSubscribe: true), + ]) { rooms in + let room1 = rooms[0] + let room2 = rooms[1] + + // Publish audio via TestAudioTrack (bypasses AudioManager) + let audioTrack = TestAudioTrack() + try await room1.localParticipant.publish(audioTrack: audioTrack) + + guard let publisherIdentity = room1.localParticipant.identity else { + XCTFail("Publisher's identity is nil") + return + } + + guard let remoteParticipant = room2.remoteParticipants[publisherIdentity] else { + XCTFail("Failed to lookup Publisher (RemoteParticipant)") + return + } + + // Wait for initial track subscription + let didSubscribe = self.expectation(description: "Did subscribe to remote audio track") + didSubscribe.assertForOverFulfill = false + + let watchParticipant = remoteParticipant.objectWillChange.sink { _ in + if remoteParticipant.firstAudioPublication?.track != nil { + didSubscribe.fulfill() + } + } + + await self.fulfillment(of: [didSubscribe], timeout: 30) + watchParticipant.cancel() + + let tracksBefore = room1.localParticipant.trackPublications.count + print("[\(mode)] Tracks before reconnect: \(tracksBefore)") + + // Trigger quick reconnect + let expectations = reconnectWatcher.expectReconnect(description: "quick reconnect") + try await room1.debug_simulate(scenario: .quickReconnect) + + await self.fulfillment(of: [expectations.start, expectations.complete], timeout: 30) + + // Verify state after reconnect + XCTAssertEqual(room1.connectionState, .connected, "Room should be connected after reconnect") + + let tracksAfter = room1.localParticipant.trackPublications.count + print("[\(mode)] Tracks after reconnect: \(tracksAfter)") + XCTAssertEqual(tracksBefore, tracksAfter, "Track count should be preserved after reconnect") + + print("[\(mode)] Test passed - reconnection working!") + } + } + + /// Test data channel send/receive. + private func _testDataChannel(mode: SignalingMode) async throws { + print("[\(mode)] Testing data channel...") + + struct TestPayload: Codable { + let content: String + } + + try await withRooms([ + roomTestingOptions(mode: mode, canPublish: true, canPublishData: true, canSubscribe: true), + roomTestingOptions(mode: mode, canPublish: true, canPublishData: true, canSubscribe: true), + ]) { rooms in + let room1 = rooms[0] + let room2 = rooms[1] + + let topic = "test_signaling_data" + let testPayload = TestPayload(content: UUID().uuidString) + let jsonData = try JSONEncoder().encode(testPayload) + + // Create watcher on receiver + let room2Watcher: RoomWatcher = room2.createWatcher() + + // Publish data + try await room1.localParticipant.publish(data: jsonData, options: DataPublishOptions(topic: topic)) + print("[\(mode)] Sent data, waiting for receiver...") + + // Wait for received data + let received = try await room2Watcher.didReceiveDataCompleters.completer(for: topic).wait() + XCTAssertEqual(received.content, testPayload.content, "Received data should match sent data") + + print("[\(mode)] Test passed - data channel working!") + } + } + + /// Test full reconnect restores tracks. + private func _testFullReconnect(mode: SignalingMode) async throws { + print("[\(mode)] Testing full reconnect...") + + let reconnectWatcher = ReconnectWatcher() + + try await withRooms([ + roomTestingOptions(mode: mode, delegate: reconnectWatcher, canPublish: true), + ]) { rooms in + let room = rooms[0] + + // Publish audio via TestAudioTrack (bypasses AudioManager) + let audioTrack = TestAudioTrack() + try await room.localParticipant.publish(audioTrack: audioTrack) + + let tracksBefore = room.localParticipant.trackPublications.count + print("[\(mode)] Tracks before full reconnect: \(tracksBefore)") + + // Set up expectations BEFORE triggering reconnect so no callbacks are missed + let reconnectExpectations = reconnectWatcher.expectReconnect(description: "full reconnect") + let tracksExpectation = reconnectWatcher.expectTracksRepublished(count: tracksBefore) + + // Simulate full reconnect (client-initiated, doesn't degrade server state) + try await room.debug_simulate(scenario: .fullReconnect) + print("[\(mode)] Simulated full reconnect, waiting for completion...") + + await self.fulfillment( + of: [reconnectExpectations.start, reconnectExpectations.complete, tracksExpectation], + timeout: 30 + ) + + XCTAssertEqual(room.connectionState, .connected, "Room should be connected after full reconnect") + + let tracksAfter = room.localParticipant.trackPublications.count + print("[\(mode)] Tracks after full reconnect: \(tracksAfter)") + XCTAssertEqual(tracksBefore, tracksAfter, "Tracks should be restored after full reconnect") + + print("[\(mode)] Test passed - full reconnect working!") + } + } + + // swiftlint:disable:next function_body_length + private func _testPublishManyTracks(mode: SignalingMode) async throws { + print("[\(mode)] Testing publish many tracks...") + + try await withRooms([ + roomTestingOptions(mode: mode, canPublish: true), + roomTestingOptions(mode: mode, canSubscribe: true), + ]) { rooms in + let room1 = rooms[0] + let room2 = rooms[1] + + // Keep total track count modest — CI runners have limited resources. + let audioCount = 3 + let videoCount = 3 + let totalExpected = audioCount + videoCount + + // Publish audio tracks + for i in 0 ..< audioCount { + let track = TestAudioTrack(name: "audio-\(i)") + try await room1.localParticipant.publish(audioTrack: track) + try await Task.sleep(nanoseconds: 500_000_000) // 500ms + } + + // Publish video tracks using dummy pixel buffers (no network download needed) + for i in 0 ..< videoCount { + let track = LocalVideoTrack.createBufferTrack(name: "video-\(i)") + guard let capturer = track.capturer as? BufferCapturer else { + XCTFail("Expected BufferCapturer") + return + } + // BufferCapturer requires at least one frame before publish (to resolve dimensions) + var pixelBuffer: CVPixelBuffer? + CVPixelBufferCreate(kCFAllocatorDefault, 320, 240, kCVPixelFormatType_32BGRA, nil, &pixelBuffer) + if let pixelBuffer { capturer.capture(pixelBuffer) } + try await room1.localParticipant.publish(videoTrack: track) + try await Task.sleep(nanoseconds: 500_000_000) // 500ms + } + + print("[\(mode)] Published \(totalExpected) tracks, waiting for subscriber...") + + guard let publisherIdentity = room1.localParticipant.identity else { + XCTFail("Publisher's identity is nil") + return + } + + guard let remoteParticipant = room2.remoteParticipants[publisherIdentity] else { + XCTFail("Failed to lookup Publisher (RemoteParticipant)") + return + } + + // Wait for subscriber to see all tracks + let didReceiveAll = self.expectation(description: "Did receive all \(totalExpected) tracks") + didReceiveAll.assertForOverFulfill = false + + let watchParticipant = remoteParticipant.objectWillChange.sink { _ in + let trackCount = remoteParticipant.trackPublications.count + if trackCount >= totalExpected { + didReceiveAll.fulfill() + } + } + + // Also check immediately in case tracks already arrived + if remoteParticipant.trackPublications.count >= totalExpected { + didReceiveAll.fulfill() + } + + await self.fulfillment(of: [didReceiveAll], timeout: 60) + watchParticipant.cancel() + + let subscriberTrackCount = remoteParticipant.trackPublications.count + print("[\(mode)] Subscriber sees \(subscriberTrackCount) tracks") + XCTAssertGreaterThanOrEqual(subscriberTrackCount, totalExpected, + "Subscriber should see all \(totalExpected) published tracks") + + print("[\(mode)] Test passed - publish many tracks working!") + } + } + + /// Test two sequential quick reconnect cycles. + private func _testDoubleReconnect(mode: SignalingMode) async throws { + print("[\(mode)] Testing double reconnect...") + + let reconnectWatcher = ReconnectWatcher() + + try await withRooms([ + roomTestingOptions(mode: mode, delegate: reconnectWatcher, canPublish: true), + ]) { rooms in + let room = rooms[0] + + self.assertSignalingModeState(room, mode: mode) + + for attempt in 1 ... 2 { + print("[\(mode)] Triggering reconnect attempt \(attempt)...") + let expectations = reconnectWatcher.expectReconnect(description: "reconnect #\(attempt)") + try await room.debug_simulate(scenario: .quickReconnect) + + await self.fulfillment(of: [expectations.start, expectations.complete], timeout: 30) + XCTAssertEqual(room.connectionState, .connected, "Room should be connected after reconnect #\(attempt)") + print("[\(mode)] Reconnect attempt \(attempt) succeeded") + } + + print("[\(mode)] Test passed - double reconnect working!") + } + } +} diff --git a/Tests/LiveKitCoreTests/Room/RoomTests.swift b/Tests/LiveKitCoreTests/Room/RoomTests.swift index b656b09b4..86755e8b3 100644 --- a/Tests/LiveKitCoreTests/Room/RoomTests.swift +++ b/Tests/LiveKitCoreTests/Room/RoomTests.swift @@ -51,9 +51,10 @@ class RoomTests: LKTestCase, @unchecked Sendable { let socket = await room.signalClient._state.socket try self.noLeaks(of: XCTUnwrap(socket)) - let (publisher, subscriber) = room._state.read { ($0.publisher, $0.subscriber) } - if let publisher { self.noLeaks(of: publisher) } - if let subscriber { self.noLeaks(of: subscriber) } + if let transport = room._state.transport { + self.noLeaks(of: transport.publisher) + self.noLeaks(of: transport.subscriber) + } self.noLeaks(of: room.publisherDataChannel) self.noLeaks(of: room.subscriberDataChannel) diff --git a/Tests/LiveKitCoreTests/SDPMungingTests.swift b/Tests/LiveKitCoreTests/SDPMungingTests.swift new file mode 100644 index 000000000..251ec835f --- /dev/null +++ b/Tests/LiveKitCoreTests/SDPMungingTests.swift @@ -0,0 +1,102 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@testable import LiveKit +#if canImport(LiveKitTestSupport) +import LiveKitTestSupport +#endif + +class SDPMungingTests: LKTestCase { + /// All RTP m-sections (audio, video, text) should have `a=inactive` rewritten to `a=recvonly`. + func testAllRTPSectionsAreMunged() { + let sdp = [ + "v=0", + "o=- 0 0 IN IP4 127.0.0.1", + "s=-", + "t=0 0", + "m=audio 9 UDP/TLS/RTP/SAVPF 111", + "a=mid:0", + "a=inactive", + "m=audio 9 UDP/TLS/RTP/SAVPF 111", + "a=mid:1", + "a=inactive", + "m=video 9 UDP/TLS/RTP/SAVPF 96", + "a=mid:2", + "a=inactive", + "m=text 9 RTP/AVP 98", + "a=mid:3", + "a=inactive", + "", + ].joined(separator: "\r\n") + + let result = Transport.mungeInactiveToRecvOnlyForMedia(sdp) + + // All four RTP sections should be munged + XCTAssertFalse(result.contains("a=inactive"), "All a=inactive lines in RTP sections should be rewritten") + + let recvOnlyCount = result.components(separatedBy: "a=recvonly").count - 1 + XCTAssertEqual(recvOnlyCount, 4, "Should have 4 a=recvonly lines (2 audio + 1 video + 1 text)") + } + + /// `m=application` sections (data channels) should NOT be munged. + func testApplicationSectionNotMunged() { + let sdp = [ + "v=0", + "o=- 0 0 IN IP4 127.0.0.1", + "s=-", + "t=0 0", + "m=audio 9 UDP/TLS/RTP/SAVPF 111", + "a=mid:0", + "a=inactive", + "m=application 9 UDP/DTLS/SCTP webrtc-datachannel", + "a=mid:1", + "a=inactive", + "", + ].joined(separator: "\r\n") + + let result = Transport.mungeInactiveToRecvOnlyForMedia(sdp) + + // Audio section should be munged + XCTAssertTrue(result.contains("a=recvonly"), "Audio section a=inactive should become a=recvonly") + + // Application section should still have a=inactive + let lines = result.components(separatedBy: "\r\n") + let appSectionStart = lines.firstIndex(where: { $0.hasPrefix("m=application") })! + let inactiveAfterApp = lines[appSectionStart...].contains("a=inactive") + XCTAssertTrue(inactiveAfterApp, "Application section should preserve a=inactive") + } + + /// SDP without any `a=inactive` lines should pass through unchanged. + func testNoOpWhenNoInactiveLines() { + let sdp = [ + "v=0", + "o=- 0 0 IN IP4 127.0.0.1", + "s=-", + "t=0 0", + "m=audio 9 UDP/TLS/RTP/SAVPF 111", + "a=mid:0", + "a=sendrecv", + "m=video 9 UDP/TLS/RTP/SAVPF 96", + "a=mid:1", + "a=recvonly", + "", + ].joined(separator: "\r\n") + + let result = Transport.mungeInactiveToRecvOnlyForMedia(sdp) + + XCTAssertEqual(result, sdp, "SDP without a=inactive should pass through unchanged") + } +} diff --git a/Tests/LiveKitCoreTests/Track/TrackTests.swift b/Tests/LiveKitCoreTests/Track/TrackTests.swift index f6e9d3d9b..2305d1636 100644 --- a/Tests/LiveKitCoreTests/Track/TrackTests.swift +++ b/Tests/LiveKitCoreTests/Track/TrackTests.swift @@ -21,22 +21,6 @@ import Foundation import LiveKitTestSupport #endif -class TestTrack: LocalAudioTrack, @unchecked Sendable { - init() { - let source = RTC.createAudioSource(nil) - let _track = RTC.createAudioTrack(source: source) - super.init(name: "test_audio_track", source: .microphone, track: _track, reportStatistics: false, captureOptions: AudioCaptureOptions()) - } - - override func startCapture() async throws { - try? await Task.sleep(nanoseconds: UInt64(Double.random(in: 0.0 ... 1.0) * 1_000_000)) - } - - override func stopCapture() async throws { - try? await Task.sleep(nanoseconds: UInt64(Double.random(in: 0.0 ... 1.0) * 1_000_000)) - } -} - class TrackTests: LKTestCase { #if os(iOS) || os(visionOS) || os(tvOS) func testConcurrentStartStop() async throws { @@ -47,8 +31,8 @@ class TrackTests: LKTestCase { if newState.localTracksCount > 2 { XCTFail("localTracksCount should never higher than 2 in this test") } } - let track1 = TestTrack() - let track2 = TestTrack() + let track1 = TestAudioTrack() + let track2 = TestAudioTrack() try await withThrowingTaskGroup(of: Void.self) { group in for _ in 0 ..< 1000 { diff --git a/Tests/LiveKitTestSupport/Room.swift b/Tests/LiveKitTestSupport/Room.swift index d910f18c1..7983d2edc 100644 --- a/Tests/LiveKitTestSupport/Room.swift +++ b/Tests/LiveKitTestSupport/Room.swift @@ -23,6 +23,7 @@ public struct RoomTestingOptions { public let token: String? public let enableMicrophone: Bool public let encryptionOptions: EncryptionOptions? + public let singlePeerConnection: Bool // Perms public let canPublish: Bool @@ -35,6 +36,7 @@ public struct RoomTestingOptions { token: String? = nil, enableMicrophone: Bool = false, encryptionOptions: EncryptionOptions? = nil, + singlePeerConnection: Bool = false, canPublish: Bool = false, canPublishData: Bool = false, canPublishSources: Set = [], @@ -45,6 +47,7 @@ public struct RoomTestingOptions { self.token = token self.enableMicrophone = enableMicrophone self.encryptionOptions = encryptionOptions + self.singlePeerConnection = singlePeerConnection self.canPublish = canPublish self.canPublishData = canPublishData self.canPublishSources = canPublishSources @@ -115,7 +118,7 @@ public extension LKTestCase { // Room options let encryptionOptions = $0.element.encryptionOptions ?? EncryptionOptions(keyProvider: BaseKeyProvider(isSharedKey: true, sharedKey: sharedKey)) - let roomOptions = RoomOptions(encryptionOptions: encryptionOptions, reportRemoteTrackStatistics: true) + let roomOptions = RoomOptions(encryptionOptions: encryptionOptions, reportRemoteTrackStatistics: true, singlePeerConnection: $0.element.singlePeerConnection) let room = Room(delegate: $0.element.delegate, connectOptions: connectOptions, roomOptions: roomOptions) let identity = "identity-\($0.offset)" @@ -138,17 +141,19 @@ public extension LKTestCase { token: token) } - // Connect all Rooms concurrently - try await withThrowingTaskGroup(of: Void.self) { group in - for element in rooms { - group.addTask { - try await element.room.connect(url: element.url, token: element.token) - XCTAssert(element.room.localParticipant.identity != nil, "LocalParticipant.identity is nil") - print("LocalParticipant.identity: \(String(describing: element.room.localParticipant.identity))") + // Connect all Rooms concurrently (retry on transient failure) + try await Task.retrying(totalAttempts: 3, retryDelay: 2) { _, _ in + try await withThrowingTaskGroup(of: Void.self) { group in + for element in rooms { + group.addTask { + try await element.room.connect(url: element.url, token: element.token) + XCTAssert(element.room.localParticipant.identity != nil, "LocalParticipant.identity is nil") + print("LocalParticipant.identity: \(String(describing: element.room.localParticipant.identity))") + } } + try await group.waitForAll() } - try await group.waitForAll() - } + }.value let observerToken = try liveKitServerToken(for: roomName, identity: "observer", @@ -197,15 +202,19 @@ public extension LKTestCase { // Execute block try await block(allRooms) - // Disconnect all Rooms concurrently + // Gracefully unpublish all tracks then disconnect. try await withThrowingTaskGroup(of: Void.self) { group in for element in rooms { group.addTask { + await element.room.localParticipant.unpublishAll() await element.room.disconnect() } } try await group.waitForAll() } + + // Allow the server to fully tear down resources before the next test. + try await Task.sleep(nanoseconds: 1_000_000_000) } } diff --git a/Tests/LiveKitTestSupport/Tracks.swift b/Tests/LiveKitTestSupport/Tracks.swift index 12a8cfdf4..d4745fb08 100644 --- a/Tests/LiveKitTestSupport/Tracks.swift +++ b/Tests/LiveKitTestSupport/Tracks.swift @@ -216,6 +216,21 @@ public class VideoTrackWatcher: TrackDelegate, VideoRenderer, @unchecked Sendabl } } +/// LocalAudioTrack subclass that bypasses AudioManager, avoiding audio engine errors in test environments. +public class TestAudioTrack: LocalAudioTrack, @unchecked Sendable { + override public func startCapture() async throws {} + override public func stopCapture() async throws {} + // Bypass frame-waiting since no real audio engine is running. + override public func startWaitingForFrames() async throws {} + + public convenience init(name: String = Track.microphoneName) { + let source = RTC.createAudioSource(nil) + let rtcTrack = RTC.createAudioTrack(source: source) + rtcTrack.isEnabled = true + self.init(name: name, source: .microphone, track: rtcTrack, reportStatistics: false, captureOptions: AudioCaptureOptions()) + } +} + public class AudioTrackWatcher: AudioRenderer, @unchecked Sendable { public let id: String public var didRenderFirstFrame: Bool { _state.didRenderFirstFrame } diff --git a/protocol b/protocol index 4c05a3325..765a80e42 160000 --- a/protocol +++ b/protocol @@ -1 +1 @@ -Subproject commit 4c05a3325ec35760bee1c0bfe57b7011604a124f +Subproject commit 765a80e4298e376593859c3f11cf748c725f68f9