@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.
- package/ElizaosCapacitorScreencapture.podspec +18 -0
- package/android/build.gradle +50 -0
- package/android/src/main/AndroidManifest.xml +6 -0
- package/android/src/main/java/ai/eliza/plugins/screencapture/ScreenCapturePlugin.kt +777 -0
- package/dist/esm/definitions.d.ts +101 -0
- package/dist/esm/definitions.d.ts.map +1 -0
- package/dist/esm/definitions.js +1 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +6 -0
- package/dist/esm/web.d.ts +56 -0
- package/dist/esm/web.d.ts.map +1 -0
- package/dist/esm/web.js +330 -0
- package/dist/plugin.cjs.js +346 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +349 -0
- package/dist/plugin.js.map +1 -0
- package/electrobun/src/index.ts +102 -0
- package/electrobun/tsconfig.json +18 -0
- package/ios/Sources/ScreenCapturePlugin/ScreenCapturePlugin.swift +758 -0
- package/package.json +84 -0
|
@@ -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
|
+
}
|