fix: prevent iOS screen capture crash
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user