@elizaos/capacitor-screencapture 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,758 @@
1
+ import Foundation
2
+ import Capacitor
3
+ import ReplayKit
4
+ import AVFoundation
5
+ import ImageIO
6
+
7
+ @objc(ScreenCapturePlugin)
8
+ public class ScreenCapturePlugin: CAPPlugin, CAPBridgedPlugin {
9
+ public let identifier = "ScreenCapturePlugin"
10
+ public let jsName = "ScreenCapture"
11
+ public let pluginMethods: [CAPPluginMethod] = [
12
+ CAPPluginMethod(name: "isSupported", returnType: CAPPluginReturnPromise),
13
+ CAPPluginMethod(name: "captureScreenshot", returnType: CAPPluginReturnPromise),
14
+ CAPPluginMethod(name: "startRecording", returnType: CAPPluginReturnPromise),
15
+ CAPPluginMethod(name: "stopRecording", returnType: CAPPluginReturnPromise),
16
+ CAPPluginMethod(name: "pauseRecording", returnType: CAPPluginReturnPromise),
17
+ CAPPluginMethod(name: "resumeRecording", returnType: CAPPluginReturnPromise),
18
+ CAPPluginMethod(name: "getRecordingState", returnType: CAPPluginReturnPromise),
19
+ CAPPluginMethod(name: "checkPermissions", returnType: CAPPluginReturnPromise),
20
+ CAPPluginMethod(name: "requestPermissions", returnType: CAPPluginReturnPromise),
21
+ ]
22
+
23
+ // MARK: - Thread-safe capture state
24
+
25
+ /// Serializes access to the AVAssetWriter and its inputs from the ReplayKit
26
+ /// capture handler, which may fire on arbitrary background queues.
27
+ private final class CaptureState: @unchecked Sendable {
28
+ private let lock = NSLock()
29
+
30
+ var writer: AVAssetWriter?
31
+ var videoInput: AVAssetWriterInput?
32
+ var audioInput: AVAssetWriterInput?
33
+ var sessionStarted = false
34
+ var sawVideo = false
35
+ var lastVideoTime: CMTime?
36
+ var handlerError: Error?
37
+ var isPaused = false
38
+ var outputURL: URL?
39
+
40
+ // Config captured at recording start
41
+ var targetFps: Double = 30
42
+ var videoBitrate: Int = 6_000_000
43
+ var includeSystemAudio = true
44
+ var includeMicrophone = false
45
+
46
+ func withLock<T>(_ body: (CaptureState) -> T) -> T {
47
+ lock.lock()
48
+ defer { lock.unlock() }
49
+ return body(self)
50
+ }
51
+ }
52
+
53
+ private let recorder = RPScreenRecorder.shared()
54
+ private var captureState: CaptureState?
55
+ private var recordingStartTime: Date?
56
+ private var pausedDuration: TimeInterval = 0
57
+ private var lastPauseStart: Date?
58
+ private var recordingTimer: Timer?
59
+ private var maxDurationTimer: Timer?
60
+ private var pendingStopCall: CAPPluginCall?
61
+ private let captureQueue = DispatchQueue(label: "screencapture.record", qos: .userInitiated)
62
+
63
+ // MARK: - isSupported
64
+
65
+ @objc func isSupported(_ call: CAPPluginCall) {
66
+ var features: [String] = ["screenshot"] // always available via UIKit
67
+
68
+ if recorder.isAvailable {
69
+ features.append("recording")
70
+ features.append("systemAudio")
71
+ features.append("microphone")
72
+ }
73
+
74
+ call.resolve([
75
+ "supported": recorder.isAvailable,
76
+ "features": features,
77
+ ])
78
+ }
79
+
80
+ // MARK: - captureScreenshot
81
+
82
+ @objc func captureScreenshot(_ call: CAPPluginCall) {
83
+ let format = call.getString("format") ?? "png"
84
+ let quality = call.getFloat("quality") ?? 100
85
+ let scale = call.getFloat("scale") ?? 1
86
+ let captureSystemUI = call.getBool("captureSystemUI") ?? false
87
+
88
+ DispatchQueue.main.async { [weak self] in
89
+ guard let self = self else { return }
90
+
91
+ let windows = self.gatherWindows(captureSystemUI: captureSystemUI)
92
+ guard let primaryWindow = windows.first else {
93
+ call.reject("No window available")
94
+ return
95
+ }
96
+
97
+ let bounds = primaryWindow.bounds
98
+
99
+ // UIGraphicsImageRenderer: modern replacement for UIGraphicsBeginImageContextWithOptions
100
+ let rendererFormat = UIGraphicsImageRendererFormat()
101
+ rendererFormat.scale = CGFloat(scale)
102
+ rendererFormat.opaque = true
103
+
104
+ let renderer = UIGraphicsImageRenderer(bounds: bounds, format: rendererFormat)
105
+ let image = renderer.image { ctx in
106
+ for window in windows {
107
+ window.layer.render(in: ctx.cgContext)
108
+ }
109
+ }
110
+
111
+ // Encode to the requested format
112
+ var data: Data?
113
+ var outputFormat = format
114
+
115
+ switch format {
116
+ case "jpeg":
117
+ data = image.jpegData(compressionQuality: CGFloat(quality / 100))
118
+ case "webp":
119
+ // Attempt WebP encoding via ImageIO (iOS 14+)
120
+ data = self.encodeWebP(image: image, quality: CGFloat(quality / 100))
121
+ if data == nil {
122
+ data = image.pngData()
123
+ outputFormat = "png"
124
+ }
125
+ default:
126
+ data = image.pngData()
127
+ outputFormat = "png"
128
+ }
129
+
130
+ guard let imageData = data else {
131
+ call.reject("Failed to encode image")
132
+ return
133
+ }
134
+
135
+ let outputWidth = Int(bounds.width * CGFloat(scale))
136
+ let outputHeight = Int(bounds.height * CGFloat(scale))
137
+
138
+ call.resolve([
139
+ "base64": imageData.base64EncodedString(),
140
+ "format": outputFormat,
141
+ "width": outputWidth,
142
+ "height": outputHeight,
143
+ "timestamp": Date().timeIntervalSince1970 * 1000,
144
+ ])
145
+ }
146
+ }
147
+
148
+ // MARK: - startRecording
149
+
150
+ @objc func startRecording(_ call: CAPPluginCall) {
151
+ guard recorder.isAvailable else {
152
+ call.reject("Screen recording not available")
153
+ return
154
+ }
155
+
156
+ if captureState != nil {
157
+ call.reject("Recording already in progress")
158
+ return
159
+ }
160
+
161
+ // Parse options matching the TS ScreenRecordingOptions interface
162
+ let qualityPreset = call.getString("quality") ?? "high"
163
+ let maxDuration = call.getDouble("maxDuration") // seconds
164
+ let fps = call.getDouble("fps")
165
+ let bitrate = call.getInt("bitrate")
166
+ let captureAudio = call.getBool("captureAudio") ?? true
167
+ let captureSystemAudio = call.getBool("captureSystemAudio") ?? captureAudio
168
+ let captureMicrophone = call.getBool("captureMicrophone") ?? false
169
+
170
+ // Resolve bitrate: explicit value takes precedence over quality preset
171
+ let resolvedBitrate = bitrate ?? Self.bitrateForQuality(qualityPreset)
172
+ let resolvedFps = Self.clampFps(fps ?? 30)
173
+
174
+ recorder.isMicrophoneEnabled = captureMicrophone
175
+
176
+ // Prepare temp output file
177
+ let tempDir = FileManager.default.temporaryDirectory
178
+ let fileName = "screen_\(Int(Date().timeIntervalSince1970))_\(UUID().uuidString.prefix(8)).mp4"
179
+ let outputURL = tempDir.appendingPathComponent(fileName)
180
+ try? FileManager.default.removeItem(at: outputURL)
181
+
182
+ // Thread-safe state with deferred writer init (ported from classic ScreenRecordService)
183
+ let state = CaptureState()
184
+ state.outputURL = outputURL
185
+ state.targetFps = resolvedFps
186
+ state.videoBitrate = resolvedBitrate
187
+ state.includeSystemAudio = captureSystemAudio
188
+ state.includeMicrophone = captureMicrophone
189
+ self.captureState = state
190
+
191
+ recorder.startCapture(handler: { [weak self] sampleBuffer, sampleType, error in
192
+ guard let self = self else { return }
193
+ // Serialize writes on a dedicated queue (classic ScreenRecordService pattern)
194
+ self.captureQueue.async {
195
+ self.handleSample(sampleBuffer, type: sampleType, error: error, state: state)
196
+ }
197
+ }) { [weak self] error in
198
+ guard let self = self else { return }
199
+
200
+ DispatchQueue.main.async {
201
+ if let error = error {
202
+ self.captureState = nil
203
+ call.reject("Failed to start recording: \(error.localizedDescription)")
204
+ return
205
+ }
206
+
207
+ self.recordingStartTime = Date()
208
+ self.pausedDuration = 0
209
+ self.lastPauseStart = nil
210
+ self.startRecordingTimer()
211
+
212
+ // Auto-stop after maxDuration (safety limit)
213
+ if let maxDuration = maxDuration, maxDuration > 0 {
214
+ self.maxDurationTimer = Timer.scheduledTimer(
215
+ withTimeInterval: maxDuration,
216
+ repeats: false
217
+ ) { [weak self] _ in
218
+ self?.autoStopRecording()
219
+ }
220
+ }
221
+
222
+ self.notifyListeners("recordingState", data: [
223
+ "isRecording": true,
224
+ "duration": 0,
225
+ "fileSize": 0,
226
+ "fps": resolvedFps,
227
+ ])
228
+
229
+ call.resolve()
230
+ }
231
+ }
232
+ }
233
+
234
+ // MARK: - stopRecording
235
+
236
+ @objc func stopRecording(_ call: CAPPluginCall) {
237
+ guard let state = captureState else {
238
+ call.reject("Not recording")
239
+ return
240
+ }
241
+
242
+ pendingStopCall = call
243
+ recordingTimer?.invalidate()
244
+ recordingTimer = nil
245
+ maxDurationTimer?.invalidate()
246
+ maxDurationTimer = nil
247
+
248
+ recorder.stopCapture { [weak self] error in
249
+ guard let self = self else { return }
250
+
251
+ DispatchQueue.main.async {
252
+ if let error = error {
253
+ self.cleanup()
254
+ call.reject("Failed to stop recording: \(error.localizedDescription)")
255
+ return
256
+ }
257
+ self.finishRecording(state: state)
258
+ }
259
+ }
260
+ }
261
+
262
+ // MARK: - pauseRecording
263
+
264
+ @objc func pauseRecording(_ call: CAPPluginCall) {
265
+ guard let state = captureState else {
266
+ call.reject("Not recording")
267
+ return
268
+ }
269
+
270
+ let alreadyPaused = state.withLock { $0.isPaused }
271
+ if alreadyPaused {
272
+ call.reject("Already paused")
273
+ return
274
+ }
275
+
276
+ state.withLock { $0.isPaused = true }
277
+ lastPauseStart = Date()
278
+
279
+ notifyListeners("recordingState", data: [
280
+ "isRecording": true,
281
+ "duration": currentDuration(),
282
+ "fileSize": currentFileSize(),
283
+ ])
284
+
285
+ call.resolve()
286
+ }
287
+
288
+ // MARK: - resumeRecording
289
+
290
+ @objc func resumeRecording(_ call: CAPPluginCall) {
291
+ guard let state = captureState else {
292
+ call.reject("Not recording")
293
+ return
294
+ }
295
+
296
+ let wasPaused = state.withLock { $0.isPaused }
297
+ if !wasPaused {
298
+ call.reject("Not paused")
299
+ return
300
+ }
301
+
302
+ // Accumulate elapsed pause time
303
+ if let pauseStart = lastPauseStart {
304
+ pausedDuration += Date().timeIntervalSince(pauseStart)
305
+ }
306
+ lastPauseStart = nil
307
+
308
+ state.withLock { $0.isPaused = false }
309
+
310
+ notifyListeners("recordingState", data: [
311
+ "isRecording": true,
312
+ "duration": currentDuration(),
313
+ "fileSize": currentFileSize(),
314
+ ])
315
+
316
+ call.resolve()
317
+ }
318
+
319
+ // MARK: - getRecordingState
320
+
321
+ @objc func getRecordingState(_ call: CAPPluginCall) {
322
+ let isRecording = captureState != nil
323
+ let isPaused = captureState?.withLock { $0.isPaused } ?? false
324
+ let targetFps = captureState?.withLock { $0.targetFps } ?? 0
325
+
326
+ // state string supplements the boolean for richer state reporting
327
+ let state: String
328
+ if !isRecording { state = "idle" }
329
+ else if isPaused { state = "paused" }
330
+ else { state = "recording" }
331
+
332
+ call.resolve([
333
+ "isRecording": isRecording,
334
+ "state": state,
335
+ "duration": currentDuration(),
336
+ "fileSize": currentFileSize(),
337
+ "fps": targetFps,
338
+ ])
339
+ }
340
+
341
+ // MARK: - Permissions
342
+
343
+ @objc public override func checkPermissions(_ call: CAPPluginCall) {
344
+ let micStatus = AVCaptureDevice.authorizationStatus(for: .audio)
345
+
346
+ call.resolve([
347
+ "screenCapture": recorder.isAvailable ? "granted" : "not_supported",
348
+ "microphone": permissionString(from: micStatus),
349
+ ])
350
+ }
351
+
352
+ @objc public override func requestPermissions(_ call: CAPPluginCall) {
353
+ AVCaptureDevice.requestAccess(for: .audio) { [weak self] _ in
354
+ guard let self = self else { return }
355
+
356
+ DispatchQueue.main.async {
357
+ let micStatus = AVCaptureDevice.authorizationStatus(for: .audio)
358
+
359
+ call.resolve([
360
+ "screenCapture": self.recorder.isAvailable ? "granted" : "not_supported",
361
+ "microphone": self.permissionString(from: micStatus),
362
+ ])
363
+ }
364
+ }
365
+ }
366
+
367
+ // MARK: - Sample handling (ported from classic ScreenRecordService)
368
+
369
+ private func handleSample(
370
+ _ sample: CMSampleBuffer,
371
+ type: RPSampleBufferType,
372
+ error: Error?,
373
+ state: CaptureState
374
+ ) {
375
+ if let error = error {
376
+ state.withLock { s in
377
+ if s.handlerError == nil { s.handlerError = error }
378
+ }
379
+ DispatchQueue.main.async { [weak self] in
380
+ self?.notifyListeners("error", data: [
381
+ "code": "CAPTURE_ERROR",
382
+ "message": error.localizedDescription,
383
+ ])
384
+ }
385
+ return
386
+ }
387
+
388
+ guard CMSampleBufferDataIsReady(sample) else { return }
389
+
390
+ // Discard samples while paused (recording resumes seamlessly on unpause)
391
+ let isPaused = state.withLock { $0.isPaused }
392
+ if isPaused { return }
393
+
394
+ switch type {
395
+ case .video:
396
+ handleVideoSample(sample, state: state)
397
+ case .audioApp:
398
+ if state.withLock({ $0.includeSystemAudio }) {
399
+ handleAudioSample(sample, state: state)
400
+ }
401
+ case .audioMic:
402
+ if state.withLock({ $0.includeMicrophone }) {
403
+ handleAudioSample(sample, state: state)
404
+ }
405
+ @unknown default:
406
+ break
407
+ }
408
+ }
409
+
410
+ /// Process a video sample with FPS throttling and deferred writer initialization.
411
+ private func handleVideoSample(_ sample: CMSampleBuffer, state: CaptureState) {
412
+ let pts = CMSampleBufferGetPresentationTimeStamp(sample)
413
+ let targetFps = state.withLock { $0.targetFps }
414
+
415
+ // FPS throttling: skip frames that arrive faster than requested (classic pattern)
416
+ let shouldSkip = state.withLock { s in
417
+ if let lastTime = s.lastVideoTime {
418
+ let delta = CMTimeSubtract(pts, lastTime)
419
+ return delta.seconds < (1.0 / targetFps)
420
+ }
421
+ return false
422
+ }
423
+ if shouldSkip { return }
424
+
425
+ // Deferred writer init on first video sample to get exact pixel dimensions (classic pattern)
426
+ let hasWriter = state.withLock { $0.writer != nil }
427
+ if !hasWriter {
428
+ prepareWriter(from: sample, state: state, pts: pts)
429
+ }
430
+
431
+ let (vInput, started) = state.withLock { ($0.videoInput, $0.sessionStarted) }
432
+ guard let vInput = vInput, started else { return }
433
+
434
+ if vInput.isReadyForMoreMediaData {
435
+ if vInput.append(sample) {
436
+ state.withLock { s in
437
+ s.sawVideo = true
438
+ s.lastVideoTime = pts
439
+ }
440
+ } else {
441
+ let err = state.withLock { $0.writer?.error }
442
+ if let err = err {
443
+ state.withLock { s in
444
+ if s.handlerError == nil { s.handlerError = err }
445
+ }
446
+ }
447
+ }
448
+ }
449
+ }
450
+
451
+ /// Create the AVAssetWriter lazily from the first video sample's actual pixel dimensions.
452
+ /// This is more robust than pre-calculating from UIScreen (classic ScreenRecordService pattern).
453
+ private func prepareWriter(from sample: CMSampleBuffer, state: CaptureState, pts: CMTime) {
454
+ guard let imageBuffer = CMSampleBufferGetImageBuffer(sample) else {
455
+ state.withLock { s in
456
+ if s.handlerError == nil {
457
+ s.handlerError = NSError(domain: "ScreenCapture", code: 1, userInfo: [
458
+ NSLocalizedDescriptionKey: "Missing image buffer in video sample",
459
+ ])
460
+ }
461
+ }
462
+ return
463
+ }
464
+
465
+ let width = CVPixelBufferGetWidth(imageBuffer)
466
+ let height = CVPixelBufferGetHeight(imageBuffer)
467
+ let bitrate = state.withLock { $0.videoBitrate }
468
+
469
+ guard let url = state.withLock({ $0.outputURL }) else { return }
470
+
471
+ do {
472
+ let writer = try AVAssetWriter(outputURL: url, fileType: .mp4)
473
+
474
+ let videoSettings: [String: Any] = [
475
+ AVVideoCodecKey: AVVideoCodecType.h264,
476
+ AVVideoWidthKey: width,
477
+ AVVideoHeightKey: height,
478
+ AVVideoCompressionPropertiesKey: [
479
+ AVVideoAverageBitRateKey: bitrate,
480
+ AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel,
481
+ ],
482
+ ]
483
+
484
+ let vInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings)
485
+ vInput.expectsMediaDataInRealTime = true
486
+ guard writer.canAdd(vInput) else {
487
+ throw NSError(domain: "ScreenCapture", code: 2, userInfo: [
488
+ NSLocalizedDescriptionKey: "Cannot add video input to writer",
489
+ ])
490
+ }
491
+ writer.add(vInput)
492
+
493
+ // Audio input for system audio and/or microphone
494
+ let needsAudio = state.withLock { $0.includeSystemAudio || $0.includeMicrophone }
495
+ if needsAudio {
496
+ let audioSettings: [String: Any] = [
497
+ AVFormatIDKey: kAudioFormatMPEG4AAC,
498
+ AVSampleRateKey: 44100,
499
+ AVNumberOfChannelsKey: 2,
500
+ AVEncoderBitRateKey: 128000,
501
+ ]
502
+ let aInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings)
503
+ aInput.expectsMediaDataInRealTime = true
504
+ if writer.canAdd(aInput) {
505
+ writer.add(aInput)
506
+ state.withLock { $0.audioInput = aInput }
507
+ }
508
+ }
509
+
510
+ guard writer.startWriting() else {
511
+ throw NSError(domain: "ScreenCapture", code: 3, userInfo: [
512
+ NSLocalizedDescriptionKey: writer.error?.localizedDescription
513
+ ?? "Failed to start asset writer",
514
+ ])
515
+ }
516
+
517
+ // Start session at first sample PTS, not .zero, for correct timing
518
+ writer.startSession(atSourceTime: pts)
519
+
520
+ state.withLock { s in
521
+ s.writer = writer
522
+ s.videoInput = vInput
523
+ s.sessionStarted = true
524
+ }
525
+ } catch {
526
+ state.withLock { s in
527
+ if s.handlerError == nil { s.handlerError = error }
528
+ }
529
+ }
530
+ }
531
+
532
+ private func handleAudioSample(_ sample: CMSampleBuffer, state: CaptureState) {
533
+ let (aInput, started) = state.withLock { ($0.audioInput, $0.sessionStarted) }
534
+ guard let aInput = aInput, started else { return }
535
+ if aInput.isReadyForMoreMediaData {
536
+ _ = aInput.append(sample)
537
+ }
538
+ }
539
+
540
+ // MARK: - Recording lifecycle helpers
541
+
542
+ private func startRecordingTimer() {
543
+ recordingTimer?.invalidate()
544
+ recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
545
+ guard let self = self, let state = self.captureState else { return }
546
+ let isPaused = state.withLock { $0.isPaused }
547
+
548
+ self.notifyListeners("recordingState", data: [
549
+ "isRecording": true,
550
+ "isPaused": isPaused,
551
+ "duration": self.currentDuration(),
552
+ "fileSize": self.currentFileSize(),
553
+ ])
554
+ }
555
+ }
556
+
557
+ /// Auto-stop triggered by maxDuration timer
558
+ private func autoStopRecording() {
559
+ guard let state = captureState else { return }
560
+
561
+ recordingTimer?.invalidate()
562
+ recordingTimer = nil
563
+ maxDurationTimer?.invalidate()
564
+ maxDurationTimer = nil
565
+
566
+ recorder.stopCapture { [weak self] error in
567
+ guard let self = self else { return }
568
+
569
+ DispatchQueue.main.async {
570
+ if error != nil {
571
+ self.notifyListeners("error", data: [
572
+ "code": "AUTO_STOP_FAILED",
573
+ "message": error!.localizedDescription,
574
+ ])
575
+ self.cleanup()
576
+ return
577
+ }
578
+ self.finishRecording(state: state)
579
+ }
580
+ }
581
+ }
582
+
583
+ private func finishRecording(state: CaptureState) {
584
+ let call = pendingStopCall
585
+ pendingStopCall = nil
586
+
587
+ let duration = currentDuration()
588
+
589
+ // Check for capture handler errors
590
+ if let err = state.withLock({ $0.handlerError }) {
591
+ cleanup()
592
+ call?.reject("Recording failed: \(err.localizedDescription)")
593
+ return
594
+ }
595
+
596
+ let vInput = state.withLock { $0.videoInput }
597
+ let aInput = state.withLock { $0.audioInput }
598
+ let writer = state.withLock { $0.writer }
599
+ let sawVideo = state.withLock { $0.sawVideo }
600
+
601
+ guard let writer = writer, sawVideo else {
602
+ cleanup()
603
+ call?.reject("No video frames were captured")
604
+ return
605
+ }
606
+
607
+ vInput?.markAsFinished()
608
+ aInput?.markAsFinished()
609
+
610
+ writer.finishWriting { [weak self] in
611
+ guard let self = self else { return }
612
+
613
+ DispatchQueue.main.async {
614
+ if let writerError = writer.error {
615
+ self.cleanup()
616
+ call?.reject("Failed to finalize recording: \(writerError.localizedDescription)")
617
+ return
618
+ }
619
+
620
+ guard let url = state.withLock({ $0.outputURL }) else {
621
+ self.cleanup()
622
+ call?.reject("No output file")
623
+ return
624
+ }
625
+
626
+ var fileSize: Int64 = 0
627
+ var width = 0
628
+ var height = 0
629
+
630
+ if let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) {
631
+ fileSize = attrs[.size] as? Int64 ?? 0
632
+ }
633
+
634
+ // Read actual video track dimensions from the written file
635
+ let asset = AVAsset(url: url)
636
+ if let track = asset.tracks(withMediaType: .video).first {
637
+ let size = track.naturalSize.applying(track.preferredTransform)
638
+ width = Int(abs(size.width))
639
+ height = Int(abs(size.height))
640
+ }
641
+
642
+ self.notifyListeners("recordingState", data: [
643
+ "isRecording": false,
644
+ "duration": duration,
645
+ "fileSize": fileSize,
646
+ ])
647
+
648
+ call?.resolve([
649
+ "path": url.absoluteString,
650
+ "duration": duration,
651
+ "width": width,
652
+ "height": height,
653
+ "fileSize": fileSize,
654
+ "mimeType": "video/mp4",
655
+ ])
656
+
657
+ self.cleanup()
658
+ }
659
+ }
660
+ }
661
+
662
+ private func cleanup() {
663
+ captureState = nil
664
+ recordingStartTime = nil
665
+ pausedDuration = 0
666
+ lastPauseStart = nil
667
+ recordingTimer?.invalidate()
668
+ recordingTimer = nil
669
+ maxDurationTimer?.invalidate()
670
+ maxDurationTimer = nil
671
+ }
672
+
673
+ // MARK: - Screenshot helpers
674
+
675
+ /// Gather UIWindows to render. When captureSystemUI is true, include all windows
676
+ /// (status bar, alerts, etc.) sorted by window level.
677
+ private func gatherWindows(captureSystemUI: Bool) -> [UIWindow] {
678
+ if captureSystemUI {
679
+ let scenes = UIApplication.shared.connectedScenes
680
+ .compactMap { $0 as? UIWindowScene }
681
+ let allWindows = scenes.flatMap { $0.windows }
682
+ .sorted { $0.windowLevel.rawValue < $1.windowLevel.rawValue }
683
+ return allWindows
684
+ } else {
685
+ if let window = bridge?.webView?.window {
686
+ return [window]
687
+ }
688
+ return []
689
+ }
690
+ }
691
+
692
+ /// Encode a UIImage to WebP using ImageIO (available iOS 14+).
693
+ /// Returns nil if WebP encoding is not supported on this OS version.
694
+ private func encodeWebP(image: UIImage, quality: CGFloat) -> Data? {
695
+ guard let cgImage = image.cgImage else { return nil }
696
+ let data = NSMutableData()
697
+ guard let dest = CGImageDestinationCreateWithData(
698
+ data as CFMutableData,
699
+ "public.webp" as CFString,
700
+ 1,
701
+ nil
702
+ ) else {
703
+ return nil
704
+ }
705
+
706
+ let options: [CFString: Any] = [
707
+ kCGImageDestinationLossyCompressionQuality: quality,
708
+ ]
709
+ CGImageDestinationAddImage(dest, cgImage, options as CFDictionary)
710
+
711
+ guard CGImageDestinationFinalize(dest) else { return nil }
712
+ return data as Data
713
+ }
714
+
715
+ // MARK: - Recording state helpers
716
+
717
+ /// Compute active recording duration, excluding time spent paused.
718
+ private func currentDuration() -> Double {
719
+ guard let start = recordingStartTime else { return 0 }
720
+ var elapsed = Date().timeIntervalSince(start)
721
+ elapsed -= pausedDuration
722
+ // Subtract ongoing pause if currently paused
723
+ if let pauseStart = lastPauseStart {
724
+ elapsed -= Date().timeIntervalSince(pauseStart)
725
+ }
726
+ return max(0, elapsed)
727
+ }
728
+
729
+ private func currentFileSize() -> Int64 {
730
+ guard let url = captureState?.withLock({ $0.outputURL }) else { return 0 }
731
+ return (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int64) ?? 0
732
+ }
733
+
734
+ /// Map quality preset string to video bitrate in bits per second.
735
+ private static func bitrateForQuality(_ quality: String) -> Int {
736
+ switch quality {
737
+ case "low": return 1_000_000 // 1 Mbps
738
+ case "medium": return 3_000_000 // 3 Mbps
739
+ case "high": return 6_000_000 // 6 Mbps
740
+ case "highest": return 10_000_000 // 10 Mbps
741
+ default: return 6_000_000
742
+ }
743
+ }
744
+
745
+ /// Clamp FPS to a sane range (ported from classic ScreenRecordService).
746
+ private static func clampFps(_ fps: Double) -> Double {
747
+ if !fps.isFinite { return 30 }
748
+ return min(60, max(1, fps))
749
+ }
750
+
751
+ private func permissionString(from status: AVAuthorizationStatus) -> String {
752
+ switch status {
753
+ case .authorized: return "granted"
754
+ case .denied, .restricted: return "denied"
755
+ default: return "prompt"
756
+ }
757
+ }
758
+ }