From 41be9232fea19e6d6e5cd5aeb8bf634231812c6f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 29 Dec 2025 20:10:36 +0100 Subject: [PATCH] fix: prevent iOS screen capture crash --- CHANGELOG.md | 1 + .../Sources/Screen/ScreenRecordService.swift | 86 +++++++++++++------ 2 files changed, 59 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d0f56cf0..224f37ab4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - iOS/Android nodes: enable scrolling for loaded web pages in the Canvas WebView (default scaffold stays touch-first). - macOS menu: device list now uses `node.list` (devices only; no agent/tool presence entries). - macOS menu: device list now shows connected nodes only. +- iOS node: fix ReplayKit screen recording crash caused by queue isolation assertions during capture. ## 2.0.0-beta4 — 2025-12-27 diff --git a/apps/ios/Sources/Screen/ScreenRecordService.swift b/apps/ios/Sources/Screen/ScreenRecordService.swift index d1e575868..d9a0a4d26 100644 --- a/apps/ios/Sources/Screen/ScreenRecordService.swift +++ b/apps/ios/Sources/Screen/ScreenRecordService.swift @@ -1,7 +1,6 @@ import AVFoundation import ReplayKit -@MainActor final class ScreenRecordService { private struct UncheckedSendableBox: @unchecked Sendable { let value: T @@ -52,7 +51,9 @@ final class ScreenRecordService { try? FileManager.default.removeItem(at: outURL) let recorder = RPScreenRecorder.shared() - recorder.isMicrophoneEnabled = includeAudio + await MainActor.run { + recorder.isMicrophoneEnabled = includeAudio + } var writer: AVAssetWriter? var videoInput: AVAssetWriterInput? @@ -61,16 +62,23 @@ final class ScreenRecordService { var sawVideo = false var lastVideoTime: CMTime? var handlerError: Error? - let lock = NSLock() + let stateLock = NSLock() + + func withStateLock(_ body: () -> T) -> T { + stateLock.lock() + defer { stateLock.unlock() } + return body() + } func setHandlerError(_ error: Error) { - lock.lock() - defer { lock.unlock() } + withStateLock { if handlerError == nil { handlerError = error } + } } try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - recorder.startCapture(handler: { sample, type, error in + Task { @MainActor in + recorder.startCapture(handler: { sample, type, error in if let error { setHandlerError(error) return @@ -80,12 +88,16 @@ final class ScreenRecordService { switch type { case .video: let pts = CMSampleBufferGetPresentationTimeStamp(sample) - if let lastVideoTime { - let delta = CMTimeSubtract(pts, lastVideoTime) - if delta.seconds < (1.0 / fpsValue) { return } + let shouldSkip = withStateLock { + if let lastVideoTime { + let delta = CMTimeSubtract(pts, lastVideoTime) + return delta.seconds < (1.0 / fpsValue) + } + return false } + if shouldSkip { return } - if writer == nil { + if withStateLock({ writer == nil }) { guard let imageBuffer = CMSampleBufferGetImageBuffer(sample) else { setHandlerError(ScreenRecordError.captureFailed("Missing image buffer")) return @@ -111,7 +123,9 @@ final class ScreenRecordService { aInput.expectsMediaDataInRealTime = true if w.canAdd(aInput) { w.add(aInput) - audioInput = aInput + withStateLock { + audioInput = aInput + } } } @@ -120,29 +134,37 @@ final class ScreenRecordService { .writeFailed(w.error?.localizedDescription ?? "Failed to start writer") } w.startSession(atSourceTime: pts) - writer = w - videoInput = vInput - started = true + withStateLock { + writer = w + videoInput = vInput + started = true + } } catch { setHandlerError(error) return } } - guard let vInput = videoInput, started else { return } + let vInput = withStateLock { videoInput } + let isStarted = withStateLock { started } + guard let vInput, isStarted else { return } if vInput.isReadyForMoreMediaData { if vInput.append(sample) { - sawVideo = true - lastVideoTime = pts + withStateLock { + sawVideo = true + lastVideoTime = pts + } } else { - if let err = writer?.error { + if let err = withStateLock({ writer?.error }) { setHandlerError(ScreenRecordError.writeFailed(err.localizedDescription)) } } } case .audioApp, .audioMic: - guard includeAudio, let aInput = audioInput, started else { return } + let aInput = withStateLock { audioInput } + let isStarted = withStateLock { started } + guard includeAudio, let aInput, isStarted else { return } if aInput.isReadyForMoreMediaData { _ = aInput.append(sample) } @@ -150,27 +172,35 @@ final class ScreenRecordService { @unknown default: break } - }, completionHandler: { error in + }, completionHandler: { error in if let error { cont.resume(throwing: error) } else { cont.resume() } - }) + }) + } } try await Task.sleep(nanoseconds: UInt64(durationMs) * 1_000_000) - let stopError = await withCheckedContinuation { cont in - recorder.stopCapture { error in cont.resume(returning: error) } + let stopError = await MainActor.run { + await withCheckedContinuation { cont in + recorder.stopCapture { error in cont.resume(returning: error) } + } } if let stopError { throw stopError } - if let handlerError { throw handlerError } - guard let writer, let videoInput, sawVideo else { + let handlerErrorSnapshot = withStateLock { handlerError } + if let handlerErrorSnapshot { throw handlerErrorSnapshot } + let writerSnapshot = withStateLock { writer } + let videoInputSnapshot = withStateLock { videoInput } + let audioInputSnapshot = withStateLock { audioInput } + let sawVideoSnapshot = withStateLock { sawVideo } + guard let writerSnapshot, let videoInputSnapshot, sawVideoSnapshot else { throw ScreenRecordError.captureFailed("No frames captured") } - videoInput.markAsFinished() - audioInput?.markAsFinished() + videoInputSnapshot.markAsFinished() + audioInputSnapshot?.markAsFinished() - let writerBox = UncheckedSendableBox(value: writer) + let writerBox = UncheckedSendableBox(value: writerSnapshot) try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in writerBox.value.finishWriting { let writer = writerBox.value