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