Screen sharing via ScreenCaptureKit on iOS 27+ - #1135
Conversation
Add `ScreenCaptureKitCapturer`, an in-process `SCStream`-based screen capturer for iOS 27+ that requires no Broadcast Upload Extension or app group. Content is selected through the system `SCContentSharingPicker`; captured sample buffers feed the existing `VideoCapturer` pipeline, mirroring `MacOSScreenCapturer`. Exposed via `LocalVideoTrack.createScreenCaptureKitTrack(...)`. Route `LocalParticipant.set(source: .screenShareVideo)` through it whenever the platform provides it (iOS 27+, ScreenCaptureKit SDK, non-Catalyst); the broadcast-extension / in-app ReplayKit paths remain as the pre-iOS-27 fallback. Extend the `LKObjCHelpers.setWidth(...)` shim to iOS/tvOS so the `size_t` width/height setters (rejected by the Swift 6.4 importer) are reachable from Obj-C, guarded by `__has_include` and excluding visionOS where those properties are unavailable. Compiles against the iOS 27 SDK (Xcode 27 beta); iOS 26 and macOS fall back unchanged. Runtime behavior (frame flow, backgrounded system-wide capture without an extension) is not yet verified on a device. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract `SCStreamVideoCapturer`, a base owning everything downstream of the `SCContentFilter`: `SCStream` lifecycle (`makeStream`/`teardownStream`), `SCStreamOutput`/`SCStreamDelegate`, frame capture with contentRect/scale aspect-fit, the static-screen resend timer, and the `size_t` width/height shim. `MacOSScreenCapturer` (enumerated sources) and `ScreenCaptureKitCapturer` (system picker) now subclass it and keep only platform-specific filter acquisition and configuration. iOS gains two behaviors it previously lacked: the 1 fps resend timer for static screens, and contentRect/scaleFactor aspect-fit sizing. The base is gated to `(macOS || (iOS && !macCatalyst)) && canImport(ScreenCaptureKit)`, matching the union of the two subclasses; visionOS/tvOS are excluded since their ScreenCaptureKit lacks `width`/`height`. macOS `stopCapture` now returns gracefully when no stream is active instead of throwing. Verified building on iOS 27 (Xcode 27 beta), macOS, iOS 26, visionOS, and tvOS; runtime behavior remains unverified on a device. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`ScreenCaptureKitCapturer` was framework-named, which is now misleading since `MacOSScreenCapturer` also runs on ScreenCaptureKit via the shared `SCStreamVideoCapturer` base. Rename it (and its file) to `IOSScreenCapturer`, and `createScreenCaptureKitTrack` to `createIOSScreenShareTrack`, so the two platform subclasses read symmetrically (MacOS/IOS) under the framework-named base. Both symbols are unreleased, so no deprecation shim is needed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Xcode 27 betas' Swift importer rejected the `size_t` `width`/`height` setters on `SCStreamConfiguration`, so they were reached through `LKObjCHelpers`. Xcode 27.0 (Swift 6.4, swiftlang-6.4.0.34.1) imports them again: `configuration.width = ...` typechecks against the macOS 27.0, iPhoneOS 27.0 and AppleTVOS 27.0 SDKs, so the shim is dead code under every supported toolchain. Assign directly in both subclasses and delete `setSize`, the ObjC `setWidth:height:onConfiguration:` helper, and the ScreenCaptureKit import it pulled into the LKObjCHelpers umbrella header. Verified building on macOS, iOS (device), Mac Catalyst, tvOS and visionOS with Xcode 27.0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The macOS 27 beta SDK declared `AVAudioNode.AUAudioUnit` as macos(13.0), which the Swift importer enforced at the SDK's deployment floor, so #1035 routed `maximumFramesToRender` through `LKObjCHelpers`. The released 27.0 SDK restores `API_AVAILABLE(macos(10.13), ios(11.0), ...)`, and `auAudioUnit` typechecks at the package's macOS 10.15 / iOS 13 deployment targets again. Collapse the extension to a plain passthrough and delete both ObjC helpers along with the AVFAudio/AudioToolbox imports they needed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`SCStreamVideoCapturer` was named for the framework rather than its role; rename it to `ScreenCapturer`, the base both `MacOSScreenCapturer` and `IOSScreenCapturer` extend. `startCapture()` previously returned as soon as the picker was on screen, so the track published before the user had chosen anything — and if they stopped sharing right away, the disable queued behind the enable in `_publishSerialRunner` and blocked until the picker timed out. It now presents the picker and waits on the selection before starting the stream, so publishing fails instead of succeeding with a track that carries no frames. Stopping calls `cancelPendingPick()` ahead of the serial runner, which resolves the wait and lets the enable unwind immediately. `SCContentFilter` is not `Sendable`, so it is handed over through `StateSync` alongside a `Void` completer. Add `ScreenShareCaptureOptions.useScreenCaptureKit`, defaulting to `true`: on iOS 27+ every consumer gets the in-process path, and apps that already ship a Broadcast Upload Extension can set it to `false` to stay on ReplayKit instead of being switched over silently. Document the new mode in Docs/ios-screen-sharing.md and drop the wording that described the capturer as an experimental prototype. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`LocalTrackPublication` already unpublishes a screen share whose capturer reaches `.stopped` on its own, but the iOS branch only matched `BroadcastScreenCapturer`. Tapping "Stop Sharing" right after picking content left `IOSScreenCapturer` stopped with its publication still up, and the track frozen. Match any `ScreenCapturer` too, and fold the two platform branches into one check. `teardownStream` also aborted before clearing `scStream`, because stopping a stream the user has already stopped fails with SCStreamErrorDomain -3808. That error surfaced as a discarded-task failure and left the capturer holding a dead `SCStream`; releasing it is the right outcome either way, so the teardown no longer throws. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The iOS 27 branch committed to ScreenCaptureKit on the version check and the `useScreenCaptureKit` switch alone, so a device where `SCContentSharingPicker` is not available failed the publish inside `startCapture()` rather than falling through. Check availability while routing instead, completing the order: ScreenCaptureKit, then Broadcast Capture when an extension is configured, then In-app Capture. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ilure Review found the process-wide statics let unrelated screen shares interfere. `SCContentSharingPicker` delivers one selection to every registered observer, so two concurrent `startCapture()` calls both resumed on the same pick and published the same content — in rooms that never asked for it. Give each capturer its own completer and filter, and add a single-slot claim so a second capturer is turned away with `.invalidState` instead of sharing the first one's selection. `cancelPendingPick()` becomes an instance method for the same reason: it was static, so any participant's disable — including a no-op one — aborted whatever pick was in flight. `LocalParticipant` now remembers the capturer of its own in-flight publish and cancels only that. A failed `SCStream.startCapture()` also left the stream and its registered outputs retained: `track.start()` never reached `.started`, so the unwind never reached `stopCapture()`. Tear the stream down in the catch path. `SCStreamConfiguration.minimumFrameInterval` is unavailable on iOS, so `ScreenShareCaptureOptions.fps` cannot be applied on this path; say so on both declarations rather than leaving it to be rediscovered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…r attempt Two follow-ups from review, both introduced by the previous commit. `presentPicker()` rearmed the completer just before waiting, which erased a cancellation recorded for the attempt already under way: `LocalParticipant` publishes the capturer to `_pendingScreenCapturer` before `_publish` reaches the picker, so a disable racing in that window cached a failure that `rearm()` then dropped, leaving the disable queued behind the enable after all. Clear the cached result when an attempt ends instead, so only a later restart is affected. `dismissPicker()` released `_presenting` before its main-actor cleanup ran, so the next capturer could claim and present while the previous one still had `isActive = false` and `remove(self)` pending — closing the new picker. Hold the claim until teardown finishes and release it after, with the same identity check. Also silence a `try?` result-unused warning added alongside the earlier rebalance. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous guard read the claim and released it in a separate `defer`, so two teardowns of the same capturer — `startCapture()` dismissing after a selection while the stream's `didStopWithError` drives `stopCapture()` — both passed the check. The first release let another capturer present, and the second teardown then deactivated that new picker. Replace the `ObjectIdentifier?` with a three-state claim. `dismissPicker()` moves `presenting` to `dismissing` in one `mutate`, so only the winner tears down and no one can claim until it is finished; the loser returns. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`startCapture()`'s catch re-listed what `stopCapture()` already does — dismiss the picker, tear the stream down, rebalance the counter — so the two had to be kept in sync. Call `stopCapture()` instead. Also fold the file's three nested `#if` blocks into one condition, matching `ScreenCapturer`, and make `isAvailable` `@MainActor` rather than a `MainActor.run` closure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Routing the failure path through `stopCapture()` was wrong: `VideoCapturer` balances starts against stops, so an overlapping `startCapture()` leaves the counter above zero and `stopCapture()` returns early without dismissing the picker or tearing down the stream — stranding the process-wide claim. Dismiss and tear down unconditionally, using `super.stopCapture()` only to rebalance this attempt's counter. Both paths share one `releasePicker()` so the pair still has a single definition. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…izer Adding `useScreenCaptureKit` to the existing initializer left Swift callers compiling — the parameter is defaulted — but the class is `@objcMembers`, so `initWithDimensions:fps:showCursor:appAudio:useBroadcastExtension:includeCurrentApplication:excludeWindowIDs:` was replaced rather than extended. That breaks Obj-C consumers and ABI on a change released as a minor bump, which is what the API check caught. Restore it as a convenience initializer delegating with `useScreenCaptureKit: true`, and make the new parameter non-defaulted on the designated one so the two never overlap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… initializer" This reverts commit f23f9f9.
There was a problem hiding this comment.
Note
Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.
Devin Review found 1 new potential issue.
2 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)
createIOSScreenShareTrack now hops to the RTC executor like every sibling creator instead of blocking its caller on WebRTC's factory. The process-wide picker claim is SCContentSharingPicker.isActive itself, checked and flipped on the main actor, plus a per-instance flag so only the presenting capturer deactivates it; the static PickerClaim state goes away. The microphone-control configuration was a no-op (the default is already off). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
Devin Review found 1 new potential issue.
1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)
| let didPresent = _isPresentingPicker.mutate { didPresent -> Bool in | ||
| defer { didPresent = false } | ||
| return didPresent | ||
| } | ||
| guard didPresent else { return } | ||
|
|
||
| await MainActor.run { | ||
| let picker = SCContentSharingPicker.shared | ||
| picker.isActive = false |
There was a problem hiding this comment.
🟡 Teardown closes a newer picker
Concurrent dismissPicker() and presentPicker() calls can let the old capturer deactivate the new picker. _isPresentingPicker releases ownership before main-actor cleanup, while isActive still guards presentation.
Learn more
The shared picker uses isActive as its process-wide ownership claim. dismissPicker() clears this capturer's local flag before awaiting MainActor, so another task can enter presentPicker() after the old cleanup deactivates the picker or while ownership is ambiguous. The old implementation retained a process-wide dismissing claim until deactivation and observer removal finished.
Example: Capturer A clears its local flag and suspends before MainActor.run. Capturer B later sees isActive == false, registers itself, and presents. A's queued cleanup then sets isActive = false and removes only A, closing B's picker unexpectedly.
Recommended fix: Keep ownership and teardown in one main-actor transaction. Track the presenting capturer process-wide through the dismissing phase, or perform the ownership check, deactivation, observer removal, and claim release atomically on MainActor.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
The presentation guard reads picker.isActive, not the local flag, and isActive is flipped to false only inside the dismissing capturer's main-actor block. So there is no window where B sees isActive == false while A still has cleanup queued: if B checks before A's block runs it sees true and throws; if it sees false, A's deactivation and remove(A) have already happened and nothing is left to run against B's picker.
The local _isPresentingPicker swap only bounds A to one cleanup per presentation, so A cannot deactivate a second time after B presents. Both the check-then-set in presentPicker() and the set in dismissPicker() run on the main actor, so they cannot interleave.
The dismissing phase in the earlier version was needed because the claim was a separate static that was released after the main-actor block; here the main-actor block is the release.
Screen sharing on iOS 27+ now runs in-process through ScreenCaptureKit — no Broadcast Upload Extension, no app group, no second process.
Architecture
ScreenCapturer(renamed fromSCStreamVideoCapturer) is the shared base for both platforms. It owns everything downstream of theSCContentFilter:SCStreamlifecycle,SCStreamOutput/SCStreamDelegate, frame capture with contentRect/scaleFactor aspect-fit, and the 1 fps static-screen resend timer.Subclasses supply only what differs:
MacOSScreenCapturerIOSScreenCapturerSCContentSharingPickeriOS gains the resend timer and aspect-fit sizing it previously lacked.
Behavior
startCapture()waits for the user's selection before starting the stream, so enabling screen share fails rather than publishing a track that carries no frames._publishSerialRunner, instead of queueing behind the pending enable.LocalTrackPublicationpreviously matched onlyBroadcastScreenCaptureron iOS, which left the publication up and frozen.Xcode 27 cleanup
Drops both
#if compiler(>=6.4)workarounds the betas needed — 27.0 importsSCStreamConfiguration.width/heightagain and restoresAVAudioNode.AUAudioUnittomacos(10.13)— along with the threeLKObjCHelpersshims behind them.Builds on macOS, iOS (device), Mac Catalyst, tvOS and visionOS with Xcode 27.0.
Public API
ScreenShareCaptureOptions.useScreenCaptureKitis new and defaults totrue.setScreenShare(enabled: true)takes the first mode that applies:useScreenCaptureKit, picker availableuseBroadcastExtensionSo
useScreenCaptureKittakes precedence overuseBroadcastExtension; set it tofalseto stay on ReplayKit on iOS 27, which apps already shipping a broadcast extension may want.LocalVideoTrack.createIOSScreenShareTrack(captureCurrentApplicationOnly:)publishes a track directly.Docs/ios-screen-sharing.mdcovers all three modes.