fix: avoid screen recorder data races

This commit is contained in:
Peter Steinberger
2025-12-29 20:22:26 +01:00
parent 65478a6ff3
commit c14d738d37

View File

@@ -50,11 +50,6 @@ final class ScreenRecordService {
}() }()
try? FileManager.default.removeItem(at: outURL) try? FileManager.default.removeItem(at: outURL)
let recorder = RPScreenRecorder.shared()
await MainActor.run {
recorder.isMicrophoneEnabled = includeAudio
}
var writer: AVAssetWriter? var writer: AVAssetWriter?
var videoInput: AVAssetWriterInput? var videoInput: AVAssetWriterInput?
var audioInput: AVAssetWriterInput? var audioInput: AVAssetWriterInput?
@@ -77,104 +72,110 @@ final class ScreenRecordService {
} }
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
Task { @MainActor in let handler: @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void = { sample, type, error in
recorder.startCapture(handler: { sample, type, error in if let error {
if let error { setHandlerError(error)
setHandlerError(error) return
return }
} guard CMSampleBufferDataIsReady(sample) else { return }
guard CMSampleBufferDataIsReady(sample) else { return }
switch type { switch type {
case .video: case .video:
let pts = CMSampleBufferGetPresentationTimeStamp(sample) let pts = CMSampleBufferGetPresentationTimeStamp(sample)
let shouldSkip = withStateLock { let shouldSkip = withStateLock {
if let lastVideoTime { if let lastVideoTime {
let delta = CMTimeSubtract(pts, lastVideoTime) let delta = CMTimeSubtract(pts, lastVideoTime)
return delta.seconds < (1.0 / fpsValue) return delta.seconds < (1.0 / fpsValue)
}
return false
} }
if shouldSkip { return } return false
}
if shouldSkip { return }
if withStateLock({ writer == nil }) { if withStateLock({ writer == nil }) {
guard let imageBuffer = CMSampleBufferGetImageBuffer(sample) else { guard let imageBuffer = CMSampleBufferGetImageBuffer(sample) else {
setHandlerError(ScreenRecordError.captureFailed("Missing image buffer")) setHandlerError(ScreenRecordError.captureFailed("Missing image buffer"))
return return
}
let width = CVPixelBufferGetWidth(imageBuffer)
let height = CVPixelBufferGetHeight(imageBuffer)
do {
let w = try AVAssetWriter(outputURL: outURL, fileType: .mp4)
let settings: [String: Any] = [
AVVideoCodecKey: AVVideoCodecType.h264,
AVVideoWidthKey: width,
AVVideoHeightKey: height,
]
let vInput = AVAssetWriterInput(mediaType: .video, outputSettings: settings)
vInput.expectsMediaDataInRealTime = true
guard w.canAdd(vInput) else {
throw ScreenRecordError.writeFailed("Cannot add video input")
} }
let width = CVPixelBufferGetWidth(imageBuffer) w.add(vInput)
let height = CVPixelBufferGetHeight(imageBuffer)
do {
let w = try AVAssetWriter(outputURL: outURL, fileType: .mp4)
let settings: [String: Any] = [
AVVideoCodecKey: AVVideoCodecType.h264,
AVVideoWidthKey: width,
AVVideoHeightKey: height,
]
let vInput = AVAssetWriterInput(mediaType: .video, outputSettings: settings)
vInput.expectsMediaDataInRealTime = true
guard w.canAdd(vInput) else {
throw ScreenRecordError.writeFailed("Cannot add video input")
}
w.add(vInput)
if includeAudio { if includeAudio {
let aInput = AVAssetWriterInput(mediaType: .audio, outputSettings: nil) let aInput = AVAssetWriterInput(mediaType: .audio, outputSettings: nil)
aInput.expectsMediaDataInRealTime = true aInput.expectsMediaDataInRealTime = true
if w.canAdd(aInput) { if w.canAdd(aInput) {
w.add(aInput) w.add(aInput)
withStateLock { withStateLock {
audioInput = aInput audioInput = aInput
}
} }
} }
guard w.startWriting() else {
throw ScreenRecordError
.writeFailed(w.error?.localizedDescription ?? "Failed to start writer")
}
w.startSession(atSourceTime: pts)
withStateLock {
writer = w
videoInput = vInput
started = true
}
} catch {
setHandlerError(error)
return
} }
}
let vInput = withStateLock { videoInput } guard w.startWriting() else {
let isStarted = withStateLock { started } throw ScreenRecordError
guard let vInput, isStarted else { return } .writeFailed(w.error?.localizedDescription ?? "Failed to start writer")
if vInput.isReadyForMoreMediaData {
if vInput.append(sample) {
withStateLock {
sawVideo = true
lastVideoTime = pts
}
} else {
if let err = withStateLock({ writer?.error }) {
setHandlerError(ScreenRecordError.writeFailed(err.localizedDescription))
}
} }
w.startSession(atSourceTime: pts)
withStateLock {
writer = w
videoInput = vInput
started = true
}
} catch {
setHandlerError(error)
return
} }
case .audioApp, .audioMic:
let aInput = withStateLock { audioInput }
let isStarted = withStateLock { started }
guard includeAudio, let aInput, isStarted else { return }
if aInput.isReadyForMoreMediaData {
_ = aInput.append(sample)
}
@unknown default:
break
} }
}, completionHandler: { error in
if let error { cont.resume(throwing: error) } else { cont.resume() } let vInput = withStateLock { videoInput }
}) let isStarted = withStateLock { started }
guard let vInput, isStarted else { return }
if vInput.isReadyForMoreMediaData {
if vInput.append(sample) {
withStateLock {
sawVideo = true
lastVideoTime = pts
}
} else {
if let err = withStateLock({ writer?.error }) {
setHandlerError(ScreenRecordError.writeFailed(err.localizedDescription))
}
}
}
case .audioApp, .audioMic:
let aInput = withStateLock { audioInput }
let isStarted = withStateLock { started }
guard includeAudio, let aInput, isStarted else { return }
if aInput.isReadyForMoreMediaData {
_ = aInput.append(sample)
}
@unknown default:
break
}
}
let completion: @Sendable (Error?) -> Void = { error in
if let error { cont.resume(throwing: error) } else { cont.resume() }
}
Task { @MainActor in
let recorder = RPScreenRecorder.shared()
recorder.isMicrophoneEnabled = includeAudio
recorder.startCapture(handler: handler, completionHandler: completion)
} }
} }
@@ -182,6 +183,7 @@ 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()
recorder.stopCapture { error in cont.resume(returning: error) } recorder.stopCapture { error in cont.resume(returning: error) }
} }
} }