Skip to content

Commit 3e88ea1

Browse files
authored
feat: capture the screen in-app with ScreenCaptureKit (drop ReplayKit extension)
Replace the ReplayKit broadcast upload extension with an in-app iOS 27 ScreenCaptureKit pipeline — capture, compositing, and publishing all run in-process. Move the process-agnostic RTMP/SRT/WHIP publishers into the app target, delete the extension + host-app keepalive machinery, bump to iOS 27 with the screen-capture background mode, add a StreamCoreTests Swift Testing suite, a Haptics helper, and advance the vendored HaishinKit pointer for VideoToolbox invalid-session recovery on background transitions. CI runs the StreamCore test suite; the full app needs the iOS 27 SDK (unavailable on GitHub runners) and is verified on a physical device.
1 parent 84e057e commit 3e88ea1

33 files changed

Lines changed: 1654 additions & 1728 deletions

.github/workflows/ci.yml

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ concurrency:
1010
cancel-in-progress: true
1111

1212
jobs:
13-
build:
14-
name: Build (iOS Simulator)
13+
test:
14+
name: StreamCore tests (iOS Simulator)
1515
runs-on: macos-26
1616
steps:
1717
- name: Checkout
@@ -30,10 +30,17 @@ jobs:
3030
- name: Generate Xcode project
3131
run: xcodegen generate
3232

33-
- name: Build
33+
# The full Stream app requires the iOS 27 SDK (ScreenCaptureKit + the
34+
# AVAudioInputNode throwing-tap API), which GitHub runners do not ship
35+
# yet — the newest hosted Xcode (26.x) only carries the iOS 26 SDK. Until
36+
# runners gain Xcode 27, CI validates the pure StreamCore logic and its
37+
# test suite (both pinned to an iOS 18 floor in project.yml, so they build
38+
# and run on the runner's iOS 26 simulators). The app itself is verified
39+
# on a physical iOS 27 device.
40+
- name: Test StreamCore
3441
run: |
35-
xcodebuild build \
36-
-scheme Stream \
37-
-destination 'generic/platform=iOS Simulator' \
42+
xcodebuild test \
43+
-scheme StreamCoreTests \
44+
-destination 'platform=iOS Simulator,OS=latest,name=iPhone 17' \
3845
CODE_SIGNING_ALLOWED=NO \
3946
-skipPackagePluginValidation

README.md

Lines changed: 58 additions & 229 deletions
Large diffs are not rendered by default.

Stream/AudioInputProvider.swift

Lines changed: 11 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -75,31 +75,18 @@ final class AudioInputProvider {
7575

7676
private func configureAndEnumerate() {
7777
let session = AVAudioSession.sharedInstance()
78-
// Never ACTIVATE a second .playAndRecord session while a broadcast is
79-
// live — activation can interrupt the extension's session and steal the
80-
// Bluetooth HFP route mid-stream. Setting the category alone (without
81-
// setActive) is enough to surface BT ports in availableInputs, so the
82-
// picker still works while live. (isLive has heartbeat granularity, so
83-
// a broadcast started milliseconds ago may enumerate the old way once —
84-
// an accepted trade-off versus stealing the extension's route.)
85-
guard !BroadcastStateStore.isLive() else {
86-
for options in Self.bluetoothOptionLadder() {
87-
do {
88-
try session.setCategory(.playAndRecord, mode: .default, options: options)
89-
break
90-
} catch {
91-
continue
92-
}
78+
// Setting the category is sufficient to enumerate Bluetooth inputs. Do
79+
// not activate merely for enumeration; activation is asynchronous on
80+
// iOS 27 and can disturb ScreenCaptureKit's microphone session.
81+
for options in Self.bluetoothOptionLadder() {
82+
do {
83+
try session.setCategory(.playAndRecord, mode: .default, options: options)
84+
break
85+
} catch {
86+
continue
9387
}
94-
enumerate(from: session)
95-
return
9688
}
97-
Self.activateBluetoothRecording(session)
9889
enumerate(from: session)
99-
// Enumeration captured — release the route immediately. Holding an
100-
// activated record session for the app's entire lifetime competed with
101-
// the extension's session across broadcast restarts.
102-
try? session.setActive(false, options: .notifyOthersOnDeactivation)
10390
}
10491

10592
private func enumerate(from session: AVAudioSession) {
@@ -121,11 +108,11 @@ final class AudioInputProvider {
121108
/// mics weren't selectable). Tries the richest Bluetooth option set first and
122109
/// degrades, so one unsupported option never blocks enumeration.
123110
@discardableResult
124-
static func activateBluetoothRecording(_ session: AVAudioSession) -> Bool {
111+
static func activateBluetoothRecording(_ session: AVAudioSession) async -> Bool {
125112
for options in bluetoothOptionLadder() {
126113
do {
127114
try session.setCategory(.playAndRecord, mode: .default, options: options)
128-
try session.setActive(true)
115+
try session.setActive(true, options: [])
129116
return true
130117
} catch {
131118
continue

Stream/BroadcastKeepAlive.swift

Lines changed: 0 additions & 159 deletions
This file was deleted.

Stream/BroadcastMonitor.swift

Lines changed: 0 additions & 52 deletions
This file was deleted.

Stream/BroadcastPickerView.swift

Lines changed: 0 additions & 73 deletions
This file was deleted.

0 commit comments

Comments
 (0)