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