fix: prevent iOS screen capture crash

This commit is contained in:
Peter Steinberger
2025-12-29 20:10:36 +01:00
parent 653932e50d
commit 41be9232fe
2 changed files with 59 additions and 28 deletions

View File

@@ -7,6 +7,7 @@
- iOS/Android nodes: enable scrolling for loaded web pages in the Canvas WebView (default scaffold stays touch-first). - 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 uses `node.list` (devices only; no agent/tool presence entries).
- macOS menu: device list now shows connected nodes only. - 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 ## 2.0.0-beta4 — 2025-12-27

View File

@@ -1,7 +1,6 @@
import AVFoundation import AVFoundation
import ReplayKit import ReplayKit
@MainActor
final class ScreenRecordService { final class ScreenRecordService {
private struct UncheckedSendableBox<T>: @unchecked Sendable { private struct UncheckedSendableBox<T>: @unchecked Sendable {
let value: T let value: T
@@ -52,7 +51,9 @@ final class ScreenRecordService {
try? FileManager.default.removeItem(at: outURL) try? FileManager.default.removeItem(at: outURL)
let recorder = RPScreenRecorder.shared() let recorder = RPScreenRecorder.shared()
recorder.isMicrophoneEnabled = includeAudio await MainActor.run {
recorder.isMicrophoneEnabled = includeAudio
}
var writer: AVAssetWriter? var writer: AVAssetWriter?
var videoInput: AVAssetWriterInput? var videoInput: AVAssetWriterInput?
@@ -61,16 +62,23 @@ final class ScreenRecordService {
var sawVideo = false var sawVideo = false
var lastVideoTime: CMTime? var lastVideoTime: CMTime?
var handlerError: Error? var handlerError: Error?
let lock = NSLock() let stateLock = NSLock()
func withStateLock<T>(_ body: () -> T) -> T {
stateLock.lock()
defer { stateLock.unlock() }
return body()
}
func setHandlerError(_ error: Error) { func setHandlerError(_ error: Error) {
lock.lock() withStateLock {
defer { lock.unlock() }
if handlerError == nil { handlerError = error } if handlerError == nil { handlerError = error }
}
} }
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
recorder.startCapture(handler: { sample, type, error in Task { @MainActor in
recorder.startCapture(handler: { sample, type, error in
if let error { if let error {
setHandlerError(error) setHandlerError(error)
return return
@@ -80,12 +88,16 @@ final class ScreenRecordService {
switch type { switch type {
case .video: case .video:
let pts = CMSampleBufferGetPresentationTimeStamp(sample) let pts = CMSampleBufferGetPresentationTimeStamp(sample)
if let lastVideoTime { let shouldSkip = withStateLock {
let delta = CMTimeSubtract(pts, lastVideoTime) if let lastVideoTime {
if delta.seconds < (1.0 / fpsValue) { return } 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 { guard let imageBuffer = CMSampleBufferGetImageBuffer(sample) else {
setHandlerError(ScreenRecordError.captureFailed("Missing image buffer")) setHandlerError(ScreenRecordError.captureFailed("Missing image buffer"))
return return
@@ -111,7 +123,9 @@ final class ScreenRecordService {
aInput.expectsMediaDataInRealTime = true aInput.expectsMediaDataInRealTime = true
if w.canAdd(aInput) { if w.canAdd(aInput) {
w.add(aInput) w.add(aInput)
audioInput = aInput withStateLock {
audioInput = aInput
}
} }
} }
@@ -120,29 +134,37 @@ 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)
writer = w withStateLock {
videoInput = vInput writer = w
started = true videoInput = vInput
started = true
}
} catch { } catch {
setHandlerError(error) setHandlerError(error)
return 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.isReadyForMoreMediaData {
if vInput.append(sample) { if vInput.append(sample) {
sawVideo = true withStateLock {
lastVideoTime = pts sawVideo = true
lastVideoTime = pts
}
} else { } else {
if let err = writer?.error { if let err = withStateLock({ writer?.error }) {
setHandlerError(ScreenRecordError.writeFailed(err.localizedDescription)) setHandlerError(ScreenRecordError.writeFailed(err.localizedDescription))
} }
} }
} }
case .audioApp, .audioMic: 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 { if aInput.isReadyForMoreMediaData {
_ = aInput.append(sample) _ = aInput.append(sample)
} }
@@ -150,27 +172,35 @@ final class ScreenRecordService {
@unknown default: @unknown default:
break break
} }
}, completionHandler: { error in }, completionHandler: { error in
if let error { cont.resume(throwing: error) } else { cont.resume() } if let error { cont.resume(throwing: error) } else { cont.resume() }
}) })
}
} }
try await Task.sleep(nanoseconds: UInt64(durationMs) * 1_000_000) try await Task.sleep(nanoseconds: UInt64(durationMs) * 1_000_000)
let stopError = await withCheckedContinuation { cont in let stopError = await MainActor.run {
recorder.stopCapture { error in cont.resume(returning: error) } await withCheckedContinuation { cont in
recorder.stopCapture { error in cont.resume(returning: error) }
}
} }
if let stopError { throw stopError } if let stopError { throw stopError }
if let handlerError { throw handlerError } let handlerErrorSnapshot = withStateLock { handlerError }
guard let writer, let videoInput, sawVideo else { 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") throw ScreenRecordError.captureFailed("No frames captured")
} }
videoInput.markAsFinished() videoInputSnapshot.markAsFinished()
audioInput?.markAsFinished() audioInputSnapshot?.markAsFinished()
let writerBox = UncheckedSendableBox(value: writer) let writerBox = UncheckedSendableBox(value: writerSnapshot)
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
writerBox.value.finishWriting { writerBox.value.finishWriting {
let writer = writerBox.value let writer = writerBox.value