From fbec343d817eac230b4da26eb97ac5d203d868df Mon Sep 17 00:00:00 2001 From: Jacob Gelman <3182119+ladvoc@users.noreply.github.com> Date: Mon, 3 Feb 2025 13:39:06 -0800 Subject: [PATCH] Properly handle broadcast capture state (#551) When using the broadcast capturer on iOS, the broadcast extension now drives whether or not screen sharing is enabled, publishing a screen sharing track when the extension begins broadcasting upon user approval. Additionally, the new `BroadcastManager` class gives developers control over the broadcast state and track publication. Fixes #444 and fixes #472. **Minor breaking changes** - Calling `setScreenShare(enabled: true)` or `set(source:enabled:captureOptions:publishOptions:)` on `LocalParticipant` currently returns a `LocalTrackPublication` representing the newly published screen share track. After this change, when using the broadcast capturer on iOS, this method will return `nil`, as the track is published asynchronously pending user approval. Developers should treat enabling screen share as a request that might not be fulfilled and should not interpret a `nil` return value from this method as an error. - Since track publication is now asynchronous, capture options must be set as room defaults rather than being passed to `set(source:enabled:captureOptions:publishOptions:)` when enabling screen sharing. --------- Co-authored-by: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> --- Docs/ios-screen-sharing.md | 61 +++++---- .../Broadcast/BroadcastBundleInfo.swift | 52 ++++++++ .../LiveKit/Broadcast/BroadcastManager.swift | 119 ++++++++++++++++++ .../Broadcast/BroadcastScreenCapturer.swift | 27 +--- .../Uploader/DarwinNotificationCenter.swift | 99 ++++++++++++++- .../Broadcast/Uploader/LKSampleHandler.swift | 18 ++- .../Participant/LocalParticipant.swift | 66 ++++++++-- .../Options/ScreenShareCaptureOptions.swift | 14 ++- .../LiveKitTests/BroadcastManagerTests.swift | 92 ++++++++++++++ .../DarwinNotificationCenterTests.swift | 47 +++++++ 10 files changed, 527 insertions(+), 68 deletions(-) create mode 100644 Sources/LiveKit/Broadcast/BroadcastBundleInfo.swift create mode 100644 Sources/LiveKit/Broadcast/BroadcastManager.swift create mode 100644 Tests/LiveKitTests/BroadcastManagerTests.swift create mode 100644 Tests/LiveKitTests/DarwinNotificationCenterTests.swift diff --git a/Docs/ios-screen-sharing.md b/Docs/ios-screen-sharing.md index 6fd07776e..998d2cee0 100644 --- a/Docs/ios-screen-sharing.md +++ b/Docs/ios-screen-sharing.md @@ -82,37 +82,14 @@ In order for the broadcast extension to communicate with your app, they must be 1. Set `RTCAppGroupIdentifier` in the Info.plist of **both targets** to the group identifier from the previous step. 2. Set `RTCScreenSharingExtension` in the Info.plist of your **primary app target** to the broadcast extension's bundle identifier. -#### 4. Use Broadcast Extension - -To use the broadcast extension for screen sharing, create an instance of `ScreenShareCaptureOptions`, setting the `useBroadcastExtension` property to `true`. The following example demonstrates making this the default for a room when connecting: - -```swift -let options = RoomOptions( - defaultScreenShareCaptureOptions: ScreenShareCaptureOptions( - useBroadcastExtension: true - ), - // other options... -) -room.connect(url: wsURL, token: token, roomOptions: options) -``` - -When connecting to a room declaratively using the `RoomScope` view from the SwiftUI components package, use the initializer's optional `roomOptions` parameter to pass the room options object: +#### 4. Begin Screen Share +With setup of the broadcast extension complete, broadcast capture will be used by default when enabling screen share: ```swift -RoomScope(url: wsURL, token: token, roomOptions: options) { - // your components here -} +try await room.localParticipant.setScreenShare(enabled: true) ``` -It is also possible to use the broadcast extension when enabling screen share without making it the default for the room: - -```swift -try await room.localParticipant.set( - source: .screenShareVideo, - enabled: true, - captureOptions: ScreenShareCaptureOptions(useBroadcastExtension: true) -) -``` +Note: When using broadcast capture, custom capture options must be set as room defaults rather than passed when enabling screen share with `set(source:enabled:captureOptions:publishOptions:)`. ### Troubleshooting @@ -122,3 +99,33 @@ While running your app in a debug session in Xcode, check the debug console for 2. Select your iOS device from the left sidebar and press "Start Streaming." 3. In the search bar, add a filter for messages with a category of "LKSampleHandler." 4. Initiate a screen share in your app and inspect Console for errors. + +### Advanced Usage + +When using broadcast capture, a broadcast can be initiated externally (for example, via control center). By default, when a broadcast begins, the local participant automatically publishes a screen share track. In some cases, however, you may want to handle track publication manually. You can achieve this by using `BroadcastManager`: + +First, disable automatic track publication: +```swift +BroadcastManager.shared.shouldPublishTrack = false +``` + +Then, use one of the two methods for detecting changes in the broadcast state: + +#### Combine Publisher +```swift +let subscription = BroadcastManager.shared + .isBroadcastingPublisher + .sink { isBroadcasting in + // Manually handle track publication + } +``` + +#### Delegate +```swift +class MyDelegate: BroadcastManagerDelegate { + func broadcastManager(didChangeState isBroadcasting: Bool) { + // Manually handle track publication + } +} +BroadcastManager.shared.delegate = MyDelegate() +``` diff --git a/Sources/LiveKit/Broadcast/BroadcastBundleInfo.swift b/Sources/LiveKit/Broadcast/BroadcastBundleInfo.swift new file mode 100644 index 000000000..dd34c1c22 --- /dev/null +++ b/Sources/LiveKit/Broadcast/BroadcastBundleInfo.swift @@ -0,0 +1,52 @@ +/* + * Copyright 2025 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. + */ + +#if os(iOS) + +import Foundation + +final class BroadcastBundleInfo { + + /// Identifier of the app group shared by the primary app and broadcast extension. + @BundleInfo("RTCAppGroupIdentifier") + static var groupIdentifier: String? + + /// Bundle identifier of the broadcast extension. + @BundleInfo("RTCScreenSharingExtension") + static var screenSharingExtension: String? + + /// Path to the socket file used for interprocess communication. + static var socketPath: String? { + guard let groupIdentifier else { return nil } + return Self.socketPath(for: groupIdentifier) + } + + /// Whether or not a broadcast extension has been configured. + static var hasExtension: Bool { + socketPath != nil && screenSharingExtension != nil + } + + private static let socketFileDescriptor = "rtc_SSFD" + + private static func socketPath(for groupIdentifier: String) -> String? { + guard let sharedContainer = FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: groupIdentifier) + else { return nil } + return sharedContainer.appendingPathComponent(Self.socketFileDescriptor).path + } +} + +#endif diff --git a/Sources/LiveKit/Broadcast/BroadcastManager.swift b/Sources/LiveKit/Broadcast/BroadcastManager.swift new file mode 100644 index 000000000..1f59f7c92 --- /dev/null +++ b/Sources/LiveKit/Broadcast/BroadcastManager.swift @@ -0,0 +1,119 @@ +/* + * Copyright 2025 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. + */ + +#if os(iOS) + +import Combine +import Foundation + +#if canImport(ReplayKit) +import ReplayKit +#endif + +/// Manages the broadcast state and track publication for screen sharing on iOS. +public final class BroadcastManager: Sendable { + /// Shared broadcast manager instance. + public static let shared = BroadcastManager() + + private struct State { + var shouldPublishTrack = true + + var cancellable = Set() + let isBroadcastingSubject: CurrentValueSubject + + weak var delegate: BroadcastManagerDelegate? + } + + private let _state: StateSync + + /// A delegate for handling broadcast state changes. + public var delegate: BroadcastManagerDelegate? { + get { _state.delegate } + set { _state.mutate { $0.delegate = newValue } } + } + + /// Indicates whether a broadcast is currently in progress. + public var isBroadcasting: Bool { + _state.isBroadcastingSubject.value + } + + /// A publisher that emits the current broadcast state as a Boolean value. + public var isBroadcastingPublisher: AnyPublisher { + _state.isBroadcastingSubject.eraseToAnyPublisher() + } + + /// Determines whether a screen share track should be automatically published when broadcasting starts. + /// + /// Set this to `false` to manually manage track publication when the broadcast starts. + /// + public var shouldPublishTrack: Bool { + get { _state.shouldPublishTrack } + set { _state.mutate { $0.shouldPublishTrack = newValue } } + } + + /// Displays the system broadcast picker, allowing the user to start the broadcast. + /// + /// - Note: This is merely a request and does not guarantee the user will choose to start the broadcast. + /// + public func requestActivation() { + Task { + await RPSystemBroadcastPickerView.show( + for: BroadcastBundleInfo.screenSharingExtension, + showsMicrophoneButton: false + ) + } + } + + /// Requests to stop the broadcast. + /// + /// If a screen share track is published, it will also be unpublished once the broadcast ends. + /// This method has no effect if no broadcast is currently in progress. + /// + public func requestStop() { + DarwinNotificationCenter.shared.postNotification(.broadcastRequestStop) + } + + init() { + var cancellable = Set() + defer { _state.mutate { $0.cancellable = cancellable } } + + let subject = CurrentValueSubject(false) + + Publishers.Merge( + DarwinNotificationCenter.shared.publisher(for: .broadcastStarted).map { _ in true }, + DarwinNotificationCenter.shared.publisher(for: .broadcastStopped).map { _ in false } + ) + .subscribe(subject) + .store(in: &cancellable) + + _state = StateSync(State(isBroadcastingSubject: subject)) + + subject.sink { [weak self] in + self?._state.delegate?.broadcastManager(didChangeState: $0) + } + .store(in: &cancellable) + } +} + +/// A delegate protocol for receiving updates about the broadcast state. +@objc +public protocol BroadcastManagerDelegate { + /// Invoked when the broadcast state changes. + /// - Parameter isBroadcasting: A Boolean value indicating whether a broadcast is currently in progress. + func broadcastManager(didChangeState isBroadcasting: Bool) +} + +#endif diff --git a/Sources/LiveKit/Broadcast/BroadcastScreenCapturer.swift b/Sources/LiveKit/Broadcast/BroadcastScreenCapturer.swift index bf759af97..5b92564da 100644 --- a/Sources/LiveKit/Broadcast/BroadcastScreenCapturer.swift +++ b/Sources/LiveKit/Broadcast/BroadcastScreenCapturer.swift @@ -36,9 +36,7 @@ class BroadcastScreenCapturer: BufferCapturer { guard didStart else { return false } - guard let groupIdentifier = Self.groupIdentifier, - let socketPath = Self.socketPath(for: groupIdentifier) - else { + guard let socketPath = BroadcastBundleInfo.socketPath else { logger.error("Bundle settings improperly configured for screen capture") return false } @@ -84,29 +82,6 @@ class BroadcastScreenCapturer: BufferCapturer { frameReader = nil return true } - - /// Identifier of the app group shared by the primary app and broadcast extension. - @BundleInfo("RTCAppGroupIdentifier") - static var groupIdentifier: String? - - /// Bundle identifier of the broadcast extension. - @BundleInfo("RTCScreenSharingExtension") - static var screenSharingExtension: String? - - /// Path to the socket file used for interprocess communication. - static var socketPath: String? { - guard let groupIdentifier = Self.groupIdentifier else { return nil } - return Self.socketPath(for: groupIdentifier) - } - - private static let kRTCScreensharingSocketFD = "rtc_SSFD" - - private static func socketPath(for groupIdentifier: String) -> String? { - guard let sharedContainer = FileManager.default - .containerURL(forSecurityApplicationGroupIdentifier: groupIdentifier) - else { return nil } - return sharedContainer.appendingPathComponent(Self.kRTCScreensharingSocketFD).path - } } public extension LocalVideoTrack { diff --git a/Sources/LiveKit/Broadcast/Uploader/DarwinNotificationCenter.swift b/Sources/LiveKit/Broadcast/Uploader/DarwinNotificationCenter.swift index 8fddcfccf..9366aee3d 100644 --- a/Sources/LiveKit/Broadcast/Uploader/DarwinNotificationCenter.swift +++ b/Sources/LiveKit/Broadcast/Uploader/DarwinNotificationCenter.swift @@ -14,16 +14,17 @@ * limitations under the License. */ +import Combine import Foundation enum DarwinNotification: String { case broadcastStarted = "iOS_BroadcastStarted" case broadcastStopped = "iOS_BroadcastStopped" + case broadcastRequestStop = "iOS_BroadcastRequestStop" } final class DarwinNotificationCenter: @unchecked Sendable { public static let shared = DarwinNotificationCenter() - private let notificationCenter = CFNotificationCenterGetDarwinNotifyCenter() func postNotification(_ name: DarwinNotification) { @@ -34,3 +35,99 @@ final class DarwinNotificationCenter: @unchecked Sendable { true) } } + +extension DarwinNotificationCenter { + /// Returns a publisher that emits events when broadcasting notifications matching the given name. + func publisher(for name: DarwinNotification) -> Publisher { + Publisher(notificationCenter, name) + } + + /// A publisher that emits notifications. + struct Publisher: Combine.Publisher { + typealias Output = DarwinNotification + typealias Failure = Never + + private let name: DarwinNotification + private let center: CFNotificationCenter? + + fileprivate init(_ center: CFNotificationCenter?, _ name: DarwinNotification) { + self.name = name + self.center = center + } + + func receive( + subscriber: S + ) where S: Subscriber, Never == S.Failure, DarwinNotification == S.Input { + subscriber.receive(subscription: Subscription(subscriber, center, name)) + } + } + + private class SubscriptionBase { + let name: DarwinNotification + let center: CFNotificationCenter? + + init(_ center: CFNotificationCenter?, _ name: DarwinNotification) { + self.name = name + self.center = center + } + + static var callback: CFNotificationCallback = { _, observer, _, _, _ in + guard let observer else { return } + Unmanaged + .fromOpaque(observer) + .takeUnretainedValue() + .notifySubscriber() + } + + func notifySubscriber() { + // Overridden by generic subclass to call specific subscriber's + // receive method. This allows forming a C function pointer to the callback. + } + } + + private class Subscription: SubscriptionBase, Combine.Subscription where S.Input == DarwinNotification, S.Failure == Never { + private var subscriber: S? + + init(_ subscriber: S, _ center: CFNotificationCenter?, _ name: DarwinNotification) { + self.subscriber = subscriber + super.init(center, name) + addObserver() + } + + func request(_: Subscribers.Demand) {} + + private var opaqueSelf: UnsafeRawPointer { + UnsafeRawPointer(Unmanaged.passUnretained(self).toOpaque()) + } + + private func addObserver() { + CFNotificationCenterAddObserver(center, + opaqueSelf, + Self.callback, + name.rawValue as CFString, + nil, + .deliverImmediately) + } + + private func removeObserver() { + guard subscriber != nil else { return } + CFNotificationCenterRemoveObserver(center, + opaqueSelf, + CFNotificationName(name.rawValue as CFString), + nil) + subscriber = nil + } + + override func notifySubscriber() { + _ = subscriber?.receive(name) + } + + func cancel() { + removeObserver() + } + + deinit { + removeObserver() + } + } +} diff --git a/Sources/LiveKit/Broadcast/Uploader/LKSampleHandler.swift b/Sources/LiveKit/Broadcast/Uploader/LKSampleHandler.swift index b1385f055..e24cba497 100644 --- a/Sources/LiveKit/Broadcast/Uploader/LKSampleHandler.swift +++ b/Sources/LiveKit/Broadcast/Uploader/LKSampleHandler.swift @@ -26,6 +26,7 @@ internal import Logging @_implementationOnly import Logging #endif +import Combine import LKObjCHelpers import OSLog @@ -33,13 +34,14 @@ import OSLog open class LKSampleHandler: RPBroadcastSampleHandler { private var clientConnection: BroadcastUploadSocketConnection? private var uploader: SampleUploader? + private var cancellable = Set() override public init() { super.init() bootstrapLogging() logger.info("LKSampleHandler created") - let socketPath = BroadcastScreenCapturer.socketPath + let socketPath = BroadcastBundleInfo.socketPath if socketPath == nil { logger.error("Bundle settings improperly configured for screen capture") } @@ -49,6 +51,14 @@ open class LKSampleHandler: RPBroadcastSampleHandler { uploader = SampleUploader(connection: connection) } + + DarwinNotificationCenter.shared + .publisher(for: .broadcastRequestStop) + .sink { [weak self] _ in + logger.info("Received stop request") + self?.finishBroadcastWithoutError() + } + .store(in: &cancellable) } override public func broadcastStarted(withSetupInfo _: [String: NSObject]?) { @@ -102,9 +112,13 @@ open class LKSampleHandler: RPBroadcastSampleHandler { if let error { finishBroadcastWithError(error) } else { - LKObjCHelpers.finishBroadcastWithoutError(self) + finishBroadcastWithoutError() } } + + private func finishBroadcastWithoutError() { + LKObjCHelpers.finishBroadcastWithoutError(self) + } private func setupConnection() { clientConnection?.didClose = { [weak self] error in diff --git a/Sources/LiveKit/Participant/LocalParticipant.swift b/Sources/LiveKit/Participant/LocalParticipant.swift index 8085a8e6d..64c4af373 100644 --- a/Sources/LiveKit/Participant/LocalParticipant.swift +++ b/Sources/LiveKit/Participant/LocalParticipant.swift @@ -14,12 +14,9 @@ * limitations under the License. */ +import Combine import Foundation -#if canImport(ReplayKit) -import ReplayKit -#endif - #if swift(>=5.9) internal import LiveKitWebRTC #else @@ -229,6 +226,45 @@ public class LocalParticipant: Participant { return didUpdate } + + // MARK: - Broadcast Activation + + #if os(iOS) + + private var cancellable = Set() + + override init(room: Room, sid: Participant.Sid? = nil, identity: Participant.Identity? = nil) { + super.init(room: room, sid: sid, identity: identity) + + guard BroadcastBundleInfo.hasExtension else { return } + BroadcastManager.shared.isBroadcastingPublisher.sink { [weak self] in + self?.broadcastStateChanged($0) + } + .store(in: &cancellable) + } + + private func broadcastStateChanged(_ isBroadcasting: Bool) { + guard isBroadcasting else { + logger.debug("Broadcast stopped") + return + } + logger.debug("Broadcast started") + + Task { [weak self] in + guard let self else { return } + + guard BroadcastManager.shared.shouldPublishTrack else { + logger.debug("Will not publish screen share track") + return + } + do { + try await self.setScreenShare(enabled: true) + } catch { + logger.error("Failed to enable screen share: \(error)") + } + } + } + #endif } // MARK: - Session Migration @@ -336,15 +372,23 @@ public extension LocalParticipant { return try await self._publish(track: localTrack, options: publishOptions) } else if source == .screenShareVideo { #if os(iOS) + let localTrack: LocalVideoTrack - let options = (captureOptions as? ScreenShareCaptureOptions) ?? room._state.roomOptions.defaultScreenShareCaptureOptions - if options.useBroadcastExtension { - await RPSystemBroadcastPickerView.show( - for: BroadcastScreenCapturer.screenSharingExtension, - showsMicrophoneButton: false - ) - localTrack = LocalVideoTrack.createBroadcastScreenCapturerTrack(options: options) + let defaultOptions = room._state.roomOptions.defaultScreenShareCaptureOptions + + if defaultOptions.useBroadcastExtension { + if captureOptions != nil { + logger.warning("Ignoring screen capture options passed to local participant's `\(#function)`; using room defaults instead.") + logger.warning("When using a broadcast extension, screen capture options must be set as room defaults.") + } + guard BroadcastManager.shared.isBroadcasting else { + BroadcastManager.shared.requestActivation() + return nil + } + // Wait until broadcasting to publish track + localTrack = LocalVideoTrack.createBroadcastScreenCapturerTrack(options: defaultOptions) } else { + let options = (captureOptions as? ScreenShareCaptureOptions) ?? defaultOptions localTrack = LocalVideoTrack.createInAppScreenShareTrack(options: options) } return try await self._publish(track: localTrack, options: publishOptions) diff --git a/Sources/LiveKit/Types/Options/ScreenShareCaptureOptions.swift b/Sources/LiveKit/Types/Options/ScreenShareCaptureOptions.swift index e894f42c5..3ac6ce32d 100644 --- a/Sources/LiveKit/Types/Options/ScreenShareCaptureOptions.swift +++ b/Sources/LiveKit/Types/Options/ScreenShareCaptureOptions.swift @@ -28,16 +28,28 @@ public final class ScreenShareCaptureOptions: NSObject, VideoCaptureOptions, Sen @objc public let showCursor: Bool + /// Use broadcast extension for screen capture (iOS only). + /// + /// If a broadcast extension has been properly configured, this defaults to `true`. + /// @objc public let useBroadcastExtension: Bool @objc public let includeCurrentApplication: Bool + + public static let defaultToBroadcastExtension: Bool = { + #if os(iOS) + return BroadcastBundleInfo.hasExtension + #else + return false + #endif + }() public init(dimensions: Dimensions = .h1080_169, fps: Int = 30, showCursor: Bool = true, - useBroadcastExtension: Bool = false, + useBroadcastExtension: Bool = defaultToBroadcastExtension, includeCurrentApplication: Bool = false) { self.dimensions = dimensions diff --git a/Tests/LiveKitTests/BroadcastManagerTests.swift b/Tests/LiveKitTests/BroadcastManagerTests.swift new file mode 100644 index 000000000..1171af60a --- /dev/null +++ b/Tests/LiveKitTests/BroadcastManagerTests.swift @@ -0,0 +1,92 @@ +/* + * Copyright 2025 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. + */ + +#if os(iOS) + +import Combine +@testable import LiveKit +import XCTest + +class BroadcastManagerTests: XCTestCase { + private var manager: BroadcastManager! + + override func setUp() { + super.setUp() + manager = BroadcastManager() + } + + func testInitialState() { + XCTAssertFalse(manager.isBroadcasting) + XCTAssertTrue(manager.shouldPublishTrack) + XCTAssertNil(manager.delegate) + } + + func testSetDelegate() { + let delegate = MockDelegate() + manager.delegate = delegate + XCTAssertTrue(manager.delegate === delegate) + } + + func testSetShouldPublishTrack() { + manager.shouldPublishTrack = false + XCTAssertFalse(manager.shouldPublishTrack) + } + + func testBroadcastStarted() async throws { + let delegateMethodCalled = expectation(description: "Delegate state change method called") + let publisherPublished = expectation(description: "Publisher published new state") + let propertyReflectsState = expectation(description: "Property reflects state change") + + let delegate = MockDelegate() + manager.delegate = delegate + + delegate.didChangeStateCalled = { + XCTAssertTrue($0) + delegateMethodCalled.fulfill() + } + + var cancellable = Set() + manager.isBroadcastingPublisher.sink { + guard $0 else { return } // first call is initial value of false + publisherPublished.fulfill() + } + .store(in: &cancellable) + + // Simulate broadcast start + DarwinNotificationCenter.shared.postNotification(.broadcastStarted) + + Task { + try await Task.sleep(nanoseconds: 500_000_000) // wait for delivery + XCTAssertTrue(manager.isBroadcasting) + propertyReflectsState.fulfill() + } + + await fulfillment( + of: [propertyReflectsState, delegateMethodCalled, publisherPublished], + timeout: 1.0 + ) + } + + private class MockDelegate: BroadcastManagerDelegate { + var didChangeStateCalled: ((Bool) -> Void)? + + func broadcastManager(didChangeState isBroadcasting: Bool) { + didChangeStateCalled?(isBroadcasting) + } + } +} + +#endif diff --git a/Tests/LiveKitTests/DarwinNotificationCenterTests.swift b/Tests/LiveKitTests/DarwinNotificationCenterTests.swift new file mode 100644 index 000000000..0a3fc3c54 --- /dev/null +++ b/Tests/LiveKitTests/DarwinNotificationCenterTests.swift @@ -0,0 +1,47 @@ +/* + * Copyright 2025 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. + */ + +import Combine +@testable import LiveKit +import XCTest + +class DarwinNotificationCenterTests: XCTestCase { + func testPublisher() throws { + let receiveFirst = XCTestExpectation(description: "Receive from 1st subscriber") + let receiveSecond = XCTestExpectation(description: "Receive from 2nd subscriber") + + let name = DarwinNotification.broadcastStarted + + var cancellable = Set() + DarwinNotificationCenter.shared + .publisher(for: name) + .sink { + XCTAssertEqual($0, name) + receiveFirst.fulfill() + } + .store(in: &cancellable) + DarwinNotificationCenter.shared + .publisher(for: name) + .sink { + XCTAssertEqual($0, name) + receiveSecond.fulfill() + } + .store(in: &cancellable) + + DarwinNotificationCenter.shared.postNotification(name) + wait(for: [receiveFirst, receiveSecond], timeout: 10.0) + } +}