fix: isolate ReplayKit capture state
This commit is contained in:
@@ -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<T>(_ 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<T>(_ 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<Void, Error>) 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))
|
||||
|
||||
Reference in New Issue
Block a user