fix: isolate ReplayKit capture state

This commit is contained in:
Peter Steinberger
2025-12-29 20:24:34 +01:00
parent c14d738d37
commit 7a849ab7d1

View File

@@ -6,6 +6,23 @@ final class ScreenRecordService {
let value: T let value: T
} }
private final class CaptureState: @unchecked Sendable {
private let lock = NSLock()
var writer: AVAssetWriter?
var videoInput: AVAssetWriterInput?
var audioInput: AVAssetWriterInput?
var started = false
var sawVideo = false
var lastVideoTime: CMTime?
var handlerError: Error?
func withLock<T>(_ body: (CaptureState) -> T) -> T {
self.lock.lock()
defer { lock.unlock() }
return body(self)
}
}
enum ScreenRecordError: LocalizedError { enum ScreenRecordError: LocalizedError {
case invalidScreenIndex(Int) case invalidScreenIndex(Int)
case captureFailed(String) case captureFailed(String)
@@ -50,31 +67,14 @@ final class ScreenRecordService {
}() }()
try? FileManager.default.removeItem(at: outURL) try? FileManager.default.removeItem(at: outURL)
var writer: AVAssetWriter? let state = CaptureState()
var videoInput: AVAssetWriterInput?
var audioInput: AVAssetWriterInput?
var started = false
var sawVideo = false
var lastVideoTime: CMTime?
var handlerError: Error?
let stateLock = NSLock()
func withStateLock<T>(_ body: () -> T) -> T {
stateLock.lock()
defer { stateLock.unlock() }
return body()
}
func setHandlerError(_ error: Error) {
withStateLock {
if handlerError == nil { handlerError = error }
}
}
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
let handler: @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void = { sample, type, error in let handler: @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void = { sample, type, error in
if let error { if let error {
setHandlerError(error) state.withLock { state in
if state.handlerError == nil { state.handlerError = error }
}
return return
} }
guard CMSampleBufferDataIsReady(sample) else { return } guard CMSampleBufferDataIsReady(sample) else { return }
@@ -82,8 +82,8 @@ final class ScreenRecordService {
switch type { switch type {
case .video: case .video:
let pts = CMSampleBufferGetPresentationTimeStamp(sample) let pts = CMSampleBufferGetPresentationTimeStamp(sample)
let shouldSkip = withStateLock { let shouldSkip = state.withLock { state in
if let lastVideoTime { if let lastVideoTime = state.lastVideoTime {
let delta = CMTimeSubtract(pts, lastVideoTime) let delta = CMTimeSubtract(pts, lastVideoTime)
return delta.seconds < (1.0 / fpsValue) return delta.seconds < (1.0 / fpsValue)
} }
@@ -91,9 +91,13 @@ final class ScreenRecordService {
} }
if shouldSkip { return } if shouldSkip { return }
if withStateLock({ writer == nil }) { if state.withLock({ $0.writer == nil }) {
guard let imageBuffer = CMSampleBufferGetImageBuffer(sample) else { guard let imageBuffer = CMSampleBufferGetImageBuffer(sample) else {
setHandlerError(ScreenRecordError.captureFailed("Missing image buffer")) state.withLock { state in
if state.handlerError == nil {
state.handlerError = ScreenRecordError.captureFailed("Missing image buffer")
}
}
return return
} }
let width = CVPixelBufferGetWidth(imageBuffer) let width = CVPixelBufferGetWidth(imageBuffer)
@@ -117,8 +121,8 @@ final class ScreenRecordService {
aInput.expectsMediaDataInRealTime = true aInput.expectsMediaDataInRealTime = true
if w.canAdd(aInput) { if w.canAdd(aInput) {
w.add(aInput) w.add(aInput)
withStateLock { state.withLock { state in
audioInput = aInput state.audioInput = aInput
} }
} }
} }
@@ -128,36 +132,43 @@ final class ScreenRecordService {
.writeFailed(w.error?.localizedDescription ?? "Failed to start writer") .writeFailed(w.error?.localizedDescription ?? "Failed to start writer")
} }
w.startSession(atSourceTime: pts) w.startSession(atSourceTime: pts)
withStateLock { state.withLock { state in
writer = w state.writer = w
videoInput = vInput state.videoInput = vInput
started = true state.started = true
} }
} catch { } catch {
setHandlerError(error) state.withLock { state in
if state.handlerError == nil { state.handlerError = error }
}
return return
} }
} }
let vInput = withStateLock { videoInput } let vInput = state.withLock { $0.videoInput }
let isStarted = withStateLock { started } let isStarted = state.withLock { $0.started }
guard let vInput, isStarted else { return } guard let vInput, isStarted else { return }
if vInput.isReadyForMoreMediaData { if vInput.isReadyForMoreMediaData {
if vInput.append(sample) { if vInput.append(sample) {
withStateLock { state.withLock { state in
sawVideo = true state.sawVideo = true
lastVideoTime = pts state.lastVideoTime = pts
} }
} else { } else {
if let err = withStateLock({ writer?.error }) { let err = state.withLock { $0.writer?.error }
setHandlerError(ScreenRecordError.writeFailed(err.localizedDescription)) if let err {
state.withLock { state in
if state.handlerError == nil {
state.handlerError = ScreenRecordError.writeFailed(err.localizedDescription)
}
}
} }
} }
} }
case .audioApp, .audioMic: case .audioApp, .audioMic:
let aInput = withStateLock { audioInput } let aInput = state.withLock { $0.audioInput }
let isStarted = withStateLock { started } let isStarted = state.withLock { $0.started }
guard includeAudio, let aInput, isStarted else { return } guard includeAudio, let aInput, isStarted else { return }
if aInput.isReadyForMoreMediaData { if aInput.isReadyForMoreMediaData {
_ = aInput.append(sample) _ = aInput.append(sample)
@@ -173,9 +184,10 @@ final class ScreenRecordService {
} }
Task { @MainActor in Task { @MainActor in
let recorder = RPScreenRecorder.shared() self.startCapture(
recorder.isMicrophoneEnabled = includeAudio includeAudio: includeAudio,
recorder.startCapture(handler: handler, completionHandler: completion) handler: handler,
completion: completion)
} }
} }
@@ -183,18 +195,17 @@ final class ScreenRecordService {
let stopError = await withCheckedContinuation { cont in let stopError = await withCheckedContinuation { cont in
Task { @MainActor in Task { @MainActor in
let recorder = RPScreenRecorder.shared() self.stopCapture { error in cont.resume(returning: error) }
recorder.stopCapture { error in cont.resume(returning: error) }
} }
} }
if let stopError { throw stopError } if let stopError { throw stopError }
let handlerErrorSnapshot = withStateLock { handlerError } let handlerErrorSnapshot = state.withLock { $0.handlerError }
if let handlerErrorSnapshot { throw handlerErrorSnapshot } if let handlerErrorSnapshot { throw handlerErrorSnapshot }
let writerSnapshot = withStateLock { writer } let writerSnapshot = state.withLock { $0.writer }
let videoInputSnapshot = withStateLock { videoInput } let videoInputSnapshot = state.withLock { $0.videoInput }
let audioInputSnapshot = withStateLock { audioInput } let audioInputSnapshot = state.withLock { $0.audioInput }
let sawVideoSnapshot = withStateLock { sawVideo } let sawVideoSnapshot = state.withLock { $0.sawVideo }
guard let writerSnapshot, let videoInputSnapshot, sawVideoSnapshot else { guard let writerSnapshot, let videoInputSnapshot, sawVideoSnapshot else {
throw ScreenRecordError.captureFailed("No frames captured") throw ScreenRecordError.captureFailed("No frames captured")
} }
@@ -219,6 +230,22 @@ final class ScreenRecordService {
return outURL.path return outURL.path
} }
@MainActor
private func startCapture(
includeAudio: Bool,
handler: @escaping (CMSampleBuffer, RPSampleBufferType, Error?) -> Void,
completion: @escaping (Error?) -> Void)
{
let recorder = RPScreenRecorder.shared()
recorder.isMicrophoneEnabled = includeAudio
recorder.startCapture(handler: handler, completionHandler: completion)
}
@MainActor
private func stopCapture(_ completion: @escaping (Error?) -> Void) {
RPScreenRecorder.shared().stopCapture(completionHandler: completion)
}
private nonisolated static func clampDurationMs(_ ms: Int?) -> Int { private nonisolated static func clampDurationMs(_ ms: Int?) -> Int {
let v = ms ?? 10000 let v = ms ?? 10000
return min(60000, max(250, v)) return min(60000, max(250, v))