Skip to content

Screen sharing via ScreenCaptureKit on iOS 27+ - #1135

Merged
pblazej merged 17 commits into
mainfrom
blaze/replaykit-migration
Sep 22, 2026
Merged

pblazej merged 17 commits into
mainfrom
blaze/replaykit-migration

Conversation

@pblazej

@pblazej pblazej commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Screen sharing on iOS 27+ now runs in-process through ScreenCaptureKit — no Broadcast Upload Extension, no app group, no second process.

Architecture

ScreenCapturer (renamed from SCStreamVideoCapturer) is the shared base for both platforms. It owns everything downstream of the SCContentFilter: SCStream lifecycle, SCStreamOutput/SCStreamDelegate, frame capture with contentRect/scaleFactor aspect-fit, and the 1 fps static-screen resend timer.

Subclasses supply only what differs:

Content selection Availability
MacOSScreenCapturer enumerated displays and windows macOS 12.3+
IOSScreenCapturer system SCContentSharingPicker iOS 27+

iOS 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.
  • Stopping while the picker is still up cancels it ahead of _publishSerialRunner, instead of queueing behind the pending enable.
  • Stopping the share from system UI unpublishes the track — LocalTrackPublication previously matched only BroadcastScreenCapturer on iOS, which left the publication up and frozen.

Xcode 27 cleanup

Drops both #if compiler(>=6.4) workarounds the betas needed — 27.0 imports SCStreamConfiguration.width/height again and restores AVAudioNode.AUAudioUnit to macos(10.13) — along with the three LKObjCHelpers shims behind them.

Builds on macOS, iOS (device), Mac Catalyst, tvOS and visionOS with Xcode 27.0.

Public API

ScreenShareCaptureOptions.useScreenCaptureKit is new and defaults to true. setScreenShare(enabled: true) takes the first mode that applies:

Mode Applies when Captures other apps Extra setup
ScreenCaptureKit iOS 27+, useScreenCaptureKit, picker available Yes None
Broadcast Capture useBroadcastExtension Yes Broadcast Upload Extension + app group
In-app Capture otherwise No None

So useScreenCaptureKit takes precedence over useBroadcastExtension; set it to false to 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.md covers all three modes.

pblazej and others added 8 commits September 21, 2026 09:08
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>
@pblazej
pblazej marked this pull request as ready for review September 21, 2026 08:46
devin-ai-integration[bot]

This comment was marked as resolved.

pblazej and others added 2 commits September 21, 2026 10:53
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>
devin-ai-integration[bot]

This comment was marked as resolved.

…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>
devin-ai-integration[bot]

This comment was marked as resolved.

pblazej and others added 2 commits September 21, 2026 11:26
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>
devin-ai-integration[bot]

This comment was marked as resolved.

pblazej and others added 2 commits September 21, 2026 14:32
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>

@hiroshihorie hiroshihorie left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Devin Review

Comment thread Sources/LiveKit/Types/Options/ScreenShareCaptureOptions.swift
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>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

Comment on lines +138 to +146
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@pblazej
pblazej merged commit 57a84d7 into main Sep 22, 2026
23 of 32 checks passed
@pblazej
pblazej deleted the blaze/replaykit-migration branch September 22, 2026 09:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants