node-mac-recorder 2.24.10 → 2.24.11
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/MultiWindowRecorder.js +85 -4
- package/README.md +18 -1
- package/index-multiprocess.js +50 -6
- package/index.js +153 -41
- package/package.json +1 -1
- package/recorder-worker.js +159 -69
- package/recorder_runtime_safety.cjs +101 -0
- package/src/audio_recorder.mm +43 -43
- package/src/avfoundation_recorder.mm +45 -62
- package/src/camera_recorder.mm +69 -78
- package/src/ios_device_recorder.mm +121 -20
- package/src/keyboard_tracker.mm +8 -1
- package/src/mac_recorder.mm +84 -13
- package/src/recording_writer_safety.h +66 -0
- package/src/screen_capture_kit.h +4 -2
- package/src/screen_capture_kit.mm +175 -195
- package/src/sync_timeline.h +9 -0
- package/src/sync_timeline.mm +54 -0
package/recorder-worker.js
CHANGED
|
@@ -6,6 +6,13 @@
|
|
|
6
6
|
|
|
7
7
|
const path = require('path');
|
|
8
8
|
|
|
9
|
+
// Parent windows can disappear while a native callback is completing.
|
|
10
|
+
function sendToParent(message) {
|
|
11
|
+
if (!process.connected) return;
|
|
12
|
+
try { process.send(message, () => {}); } catch (_) {}
|
|
13
|
+
}
|
|
14
|
+
const { waitForNativeIdle } = require('./recorder_runtime_safety.cjs');
|
|
15
|
+
|
|
9
16
|
// Load native binding directly
|
|
10
17
|
let nativeBinding;
|
|
11
18
|
try {
|
|
@@ -14,7 +21,7 @@ try {
|
|
|
14
21
|
try {
|
|
15
22
|
nativeBinding = require('./build/Debug/mac_recorder.node');
|
|
16
23
|
} catch (debugError) {
|
|
17
|
-
|
|
24
|
+
sendToParent({
|
|
18
25
|
type: 'error',
|
|
19
26
|
message: 'Native module not found',
|
|
20
27
|
error: error.message
|
|
@@ -28,6 +35,12 @@ let isRecording = false;
|
|
|
28
35
|
let outputPath = null;
|
|
29
36
|
let recordingTimer = null;
|
|
30
37
|
let recordingStartTime = null;
|
|
38
|
+
let isPaused = false;
|
|
39
|
+
let pauseStartedAt = null;
|
|
40
|
+
let pausedDurationMs = 0;
|
|
41
|
+
let recordingStatusInterval = null;
|
|
42
|
+
let recordingStartTimeout = null;
|
|
43
|
+
let captureGeneration = 0;
|
|
31
44
|
|
|
32
45
|
// Cursor capture state
|
|
33
46
|
let cursorCaptureInterval = null;
|
|
@@ -52,6 +65,12 @@ process.on('message', async (msg) => {
|
|
|
52
65
|
case 'stopRecording':
|
|
53
66
|
await handleStopRecording();
|
|
54
67
|
break;
|
|
68
|
+
case 'pauseRecording':
|
|
69
|
+
handlePauseRecording();
|
|
70
|
+
break;
|
|
71
|
+
case 'resumeRecording':
|
|
72
|
+
handleResumeRecording();
|
|
73
|
+
break;
|
|
55
74
|
case 'startCursorCapture':
|
|
56
75
|
await handleStartCursorCapture(msg.data);
|
|
57
76
|
break;
|
|
@@ -62,16 +81,16 @@ process.on('message', async (msg) => {
|
|
|
62
81
|
handleGetStatus();
|
|
63
82
|
break;
|
|
64
83
|
case 'ping':
|
|
65
|
-
|
|
84
|
+
sendToParent({ type: 'pong' });
|
|
66
85
|
break;
|
|
67
86
|
default:
|
|
68
|
-
|
|
87
|
+
sendToParent({
|
|
69
88
|
type: 'error',
|
|
70
89
|
message: `Unknown message type: ${msg.type}`
|
|
71
90
|
});
|
|
72
91
|
}
|
|
73
92
|
} catch (error) {
|
|
74
|
-
|
|
93
|
+
sendToParent({
|
|
75
94
|
type: 'error',
|
|
76
95
|
message: error.message,
|
|
77
96
|
stack: error.stack
|
|
@@ -82,12 +101,12 @@ process.on('message', async (msg) => {
|
|
|
82
101
|
function handleGetWindows() {
|
|
83
102
|
try {
|
|
84
103
|
const windows = nativeBinding.getWindows();
|
|
85
|
-
|
|
104
|
+
sendToParent({
|
|
86
105
|
type: 'getWindows:response',
|
|
87
106
|
data: windows
|
|
88
107
|
});
|
|
89
108
|
} catch (error) {
|
|
90
|
-
|
|
109
|
+
sendToParent({
|
|
91
110
|
type: 'error',
|
|
92
111
|
message: `Failed to get windows: ${error.message}`
|
|
93
112
|
});
|
|
@@ -97,21 +116,30 @@ function handleGetWindows() {
|
|
|
97
116
|
function handleGetDisplays() {
|
|
98
117
|
try {
|
|
99
118
|
const displays = nativeBinding.getDisplays();
|
|
100
|
-
|
|
119
|
+
sendToParent({
|
|
101
120
|
type: 'getDisplays:response',
|
|
102
121
|
data: displays
|
|
103
122
|
});
|
|
104
123
|
} catch (error) {
|
|
105
|
-
|
|
124
|
+
sendToParent({
|
|
106
125
|
type: 'error',
|
|
107
126
|
message: `Failed to get displays: ${error.message}`
|
|
108
127
|
});
|
|
109
128
|
}
|
|
110
129
|
}
|
|
111
130
|
|
|
131
|
+
function getPausedDurationMs(now = Date.now()) {
|
|
132
|
+
return pausedDurationMs + (isPaused && pauseStartedAt ? Math.max(0, now - pauseStartedAt) : 0);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function getRecordingTimeSeconds(now = Date.now()) {
|
|
136
|
+
if (!recordingStartTime) return 0;
|
|
137
|
+
return Math.floor(Math.max(0, now - recordingStartTime - getPausedDurationMs(now)) / 1000);
|
|
138
|
+
}
|
|
139
|
+
|
|
112
140
|
async function handleStartRecording(config) {
|
|
113
141
|
if (isRecording) {
|
|
114
|
-
|
|
142
|
+
sendToParent({
|
|
115
143
|
type: 'error',
|
|
116
144
|
message: 'Recording already in progress in this worker'
|
|
117
145
|
});
|
|
@@ -121,6 +149,10 @@ async function handleStartRecording(config) {
|
|
|
121
149
|
try {
|
|
122
150
|
const { outputPath: outPath, options } = config;
|
|
123
151
|
outputPath = outPath;
|
|
152
|
+
isPaused = false;
|
|
153
|
+
pauseStartedAt = null;
|
|
154
|
+
pausedDurationMs = 0;
|
|
155
|
+
++captureGeneration;
|
|
124
156
|
|
|
125
157
|
console.log(`📝 Worker ${process.pid}: Starting recording to ${outputPath}`);
|
|
126
158
|
|
|
@@ -151,21 +183,21 @@ async function handleStartRecording(config) {
|
|
|
151
183
|
|
|
152
184
|
// Start timer for timeUpdate events
|
|
153
185
|
recordingTimer = setInterval(() => {
|
|
154
|
-
|
|
155
|
-
process.send({
|
|
186
|
+
sendToParent({
|
|
156
187
|
type: 'event',
|
|
157
188
|
event: 'timeUpdate',
|
|
158
|
-
data:
|
|
189
|
+
data: getRecordingTimeSeconds()
|
|
159
190
|
});
|
|
160
191
|
}, 1000);
|
|
161
192
|
|
|
162
193
|
// Poll for recording status
|
|
163
|
-
const checkInterval = setInterval(() => {
|
|
194
|
+
const checkInterval = recordingStatusInterval = setInterval(() => {
|
|
195
|
+
if (!isRecording) { clearInterval(checkInterval); return; }
|
|
164
196
|
try {
|
|
165
197
|
const nativeStatus = nativeBinding.getRecordingStatus();
|
|
166
198
|
if (nativeStatus) {
|
|
167
199
|
clearInterval(checkInterval);
|
|
168
|
-
|
|
200
|
+
sendToParent({
|
|
169
201
|
type: 'event',
|
|
170
202
|
event: 'recordingStarted',
|
|
171
203
|
data: {
|
|
@@ -181,11 +213,11 @@ async function handleStartRecording(config) {
|
|
|
181
213
|
}, 50);
|
|
182
214
|
|
|
183
215
|
// Timeout fallback
|
|
184
|
-
setTimeout(() => {
|
|
216
|
+
recordingStartTimeout = setTimeout(() => {
|
|
185
217
|
clearInterval(checkInterval);
|
|
186
218
|
}, 5000);
|
|
187
219
|
|
|
188
|
-
|
|
220
|
+
sendToParent({
|
|
189
221
|
type: 'startRecording:response',
|
|
190
222
|
success: true,
|
|
191
223
|
data: { outputPath }
|
|
@@ -194,8 +226,17 @@ async function handleStartRecording(config) {
|
|
|
194
226
|
throw new Error('Native recording failed to start');
|
|
195
227
|
}
|
|
196
228
|
} catch (error) {
|
|
229
|
+
clearInterval(recordingTimer);
|
|
230
|
+
clearInterval(recordingStatusInterval);
|
|
231
|
+
clearTimeout(recordingStartTimeout);
|
|
232
|
+
try {
|
|
233
|
+
nativeBinding.stopRecording(0);
|
|
234
|
+
await waitForNativeIdle(nativeBinding);
|
|
235
|
+
} catch (cleanupError) {
|
|
236
|
+
console.warn('Worker startup cleanup:', cleanupError.message);
|
|
237
|
+
}
|
|
197
238
|
isRecording = false;
|
|
198
|
-
|
|
239
|
+
sendToParent({
|
|
199
240
|
type: 'startRecording:response',
|
|
200
241
|
success: false,
|
|
201
242
|
error: error.message
|
|
@@ -203,9 +244,47 @@ async function handleStartRecording(config) {
|
|
|
203
244
|
}
|
|
204
245
|
}
|
|
205
246
|
|
|
247
|
+
function handlePauseRecording() {
|
|
248
|
+
if (!isRecording) {
|
|
249
|
+
sendToParent({ type: 'pauseRecording:response', success: false, error: 'No recording in progress' });
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (!isPaused) {
|
|
253
|
+
if (typeof nativeBinding.pauseRecording !== 'function' || nativeBinding.pauseRecording() !== true) {
|
|
254
|
+
sendToParent({ type: 'pauseRecording:response', success: false, error: 'Recording could not be paused' });
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
isPaused = true;
|
|
258
|
+
pauseStartedAt = Date.now();
|
|
259
|
+
}
|
|
260
|
+
const status = buildStatus();
|
|
261
|
+
sendToParent({ type: 'event', event: 'paused', data: status });
|
|
262
|
+
sendToParent({ type: 'pauseRecording:response', success: true, data: status });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function handleResumeRecording() {
|
|
266
|
+
if (!isRecording) {
|
|
267
|
+
sendToParent({ type: 'resumeRecording:response', success: false, error: 'No recording in progress' });
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (isPaused) {
|
|
271
|
+
if (typeof nativeBinding.resumeRecording !== 'function' || nativeBinding.resumeRecording() !== true) {
|
|
272
|
+
sendToParent({ type: 'resumeRecording:response', success: false, error: 'Recording could not be resumed' });
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const resumedAt = Date.now();
|
|
276
|
+
pausedDurationMs += Math.max(0, resumedAt - pauseStartedAt);
|
|
277
|
+
pauseStartedAt = null;
|
|
278
|
+
isPaused = false;
|
|
279
|
+
}
|
|
280
|
+
const status = buildStatus();
|
|
281
|
+
sendToParent({ type: 'event', event: 'resumed', data: status });
|
|
282
|
+
sendToParent({ type: 'resumeRecording:response', success: true, data: status });
|
|
283
|
+
}
|
|
284
|
+
|
|
206
285
|
async function handleStopRecording() {
|
|
207
286
|
if (!isRecording) {
|
|
208
|
-
|
|
287
|
+
sendToParent({
|
|
209
288
|
type: 'error',
|
|
210
289
|
message: 'No recording in progress'
|
|
211
290
|
});
|
|
@@ -213,6 +292,8 @@ async function handleStopRecording() {
|
|
|
213
292
|
}
|
|
214
293
|
|
|
215
294
|
try {
|
|
295
|
+
clearInterval(recordingStatusInterval);
|
|
296
|
+
clearTimeout(recordingStartTimeout);
|
|
216
297
|
// Stop timer
|
|
217
298
|
if (recordingTimer) {
|
|
218
299
|
clearInterval(recordingTimer);
|
|
@@ -220,41 +301,47 @@ async function handleStopRecording() {
|
|
|
220
301
|
}
|
|
221
302
|
|
|
222
303
|
// Calculate elapsed time for stop limit
|
|
223
|
-
const elapsedSeconds =
|
|
224
|
-
|
|
225
|
-
: 0;
|
|
304
|
+
const elapsedSeconds = getRecordingTimeSeconds();
|
|
305
|
+
const totalPausedSeconds = getPausedDurationMs() / 1000;
|
|
226
306
|
|
|
227
307
|
// Stop native recording
|
|
228
308
|
const success = nativeBinding.stopRecording(elapsedSeconds);
|
|
309
|
+
await waitForNativeIdle(nativeBinding);
|
|
229
310
|
|
|
230
311
|
isRecording = false;
|
|
312
|
+
isPaused = false;
|
|
313
|
+
pauseStartedAt = null;
|
|
231
314
|
|
|
232
|
-
|
|
315
|
+
sendToParent({
|
|
233
316
|
type: 'event',
|
|
234
317
|
event: 'stopped',
|
|
235
318
|
data: {
|
|
236
319
|
code: success ? 0 : 1,
|
|
237
|
-
outputPath: outputPath
|
|
320
|
+
outputPath: outputPath,
|
|
321
|
+
recordingTime: elapsedSeconds,
|
|
322
|
+
pausedDuration: totalPausedSeconds
|
|
238
323
|
}
|
|
239
324
|
});
|
|
240
325
|
|
|
241
|
-
|
|
326
|
+
sendToParent({
|
|
242
327
|
type: 'stopRecording:response',
|
|
243
328
|
success: true,
|
|
244
|
-
data: { outputPath }
|
|
329
|
+
data: { outputPath, recordingTime: elapsedSeconds, pausedDuration: totalPausedSeconds }
|
|
245
330
|
});
|
|
246
331
|
|
|
247
|
-
|
|
332
|
+
const completedPath = outputPath;
|
|
333
|
+
const completedGeneration = captureGeneration;
|
|
248
334
|
setTimeout(() => {
|
|
249
|
-
|
|
335
|
+
if (completedGeneration !== captureGeneration) return;
|
|
336
|
+
sendToParent({
|
|
250
337
|
type: 'event',
|
|
251
338
|
event: 'completed',
|
|
252
|
-
data:
|
|
339
|
+
data: completedPath
|
|
253
340
|
});
|
|
254
341
|
}, 1000);
|
|
255
342
|
|
|
256
343
|
} catch (error) {
|
|
257
|
-
|
|
344
|
+
sendToParent({
|
|
258
345
|
type: 'stopRecording:response',
|
|
259
346
|
success: false,
|
|
260
347
|
error: error.message
|
|
@@ -262,21 +349,25 @@ async function handleStopRecording() {
|
|
|
262
349
|
}
|
|
263
350
|
}
|
|
264
351
|
|
|
352
|
+
function buildStatus() {
|
|
353
|
+
const nativeStatus = nativeBinding.getRecordingStatus();
|
|
354
|
+
return {
|
|
355
|
+
isRecording: isRecording && nativeStatus,
|
|
356
|
+
isPaused,
|
|
357
|
+
outputPath,
|
|
358
|
+
recordingTime: getRecordingTimeSeconds(),
|
|
359
|
+
pausedDuration: getPausedDurationMs() / 1000
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
265
363
|
function handleGetStatus() {
|
|
266
364
|
try {
|
|
267
|
-
|
|
268
|
-
process.send({
|
|
365
|
+
sendToParent({
|
|
269
366
|
type: 'getStatus:response',
|
|
270
|
-
data:
|
|
271
|
-
isRecording: isRecording && nativeStatus,
|
|
272
|
-
outputPath: outputPath,
|
|
273
|
-
recordingTime: recordingStartTime
|
|
274
|
-
? Math.floor((Date.now() - recordingStartTime) / 1000)
|
|
275
|
-
: 0
|
|
276
|
-
}
|
|
367
|
+
data: buildStatus()
|
|
277
368
|
});
|
|
278
369
|
} catch (error) {
|
|
279
|
-
|
|
370
|
+
sendToParent({
|
|
280
371
|
type: 'error',
|
|
281
372
|
message: `Failed to get status: ${error.message}`
|
|
282
373
|
});
|
|
@@ -287,7 +378,7 @@ async function handleStartCursorCapture(config) {
|
|
|
287
378
|
const fs = require('fs');
|
|
288
379
|
|
|
289
380
|
if (cursorCaptureInterval) {
|
|
290
|
-
|
|
381
|
+
sendToParent({
|
|
291
382
|
type: 'error',
|
|
292
383
|
message: 'Cursor capture already in progress'
|
|
293
384
|
});
|
|
@@ -305,13 +396,13 @@ async function handleStartCursorCapture(config) {
|
|
|
305
396
|
cursorCaptureStartTime = Date.now();
|
|
306
397
|
cursorCaptureFirstWrite = true;
|
|
307
398
|
|
|
308
|
-
|
|
399
|
+
sendToParent({
|
|
309
400
|
type: 'startCursorCapture:response',
|
|
310
401
|
success: true,
|
|
311
402
|
data: { filepath }
|
|
312
403
|
});
|
|
313
404
|
|
|
314
|
-
|
|
405
|
+
sendToParent({
|
|
315
406
|
type: 'event',
|
|
316
407
|
event: 'cursorCaptureStarted',
|
|
317
408
|
data: { filepath }
|
|
@@ -320,7 +411,7 @@ async function handleStartCursorCapture(config) {
|
|
|
320
411
|
throw new Error('Native cursor capture failed to start');
|
|
321
412
|
}
|
|
322
413
|
} catch (error) {
|
|
323
|
-
|
|
414
|
+
sendToParent({
|
|
324
415
|
type: 'startCursorCapture:response',
|
|
325
416
|
success: false,
|
|
326
417
|
error: error.message
|
|
@@ -330,7 +421,7 @@ async function handleStartCursorCapture(config) {
|
|
|
330
421
|
|
|
331
422
|
async function handleStopCursorCapture() {
|
|
332
423
|
if (!cursorCaptureFile) {
|
|
333
|
-
|
|
424
|
+
sendToParent({
|
|
334
425
|
type: 'error',
|
|
335
426
|
message: 'No cursor capture in progress'
|
|
336
427
|
});
|
|
@@ -352,19 +443,19 @@ async function handleStopCursorCapture() {
|
|
|
352
443
|
cursorCaptureInterval = null;
|
|
353
444
|
}
|
|
354
445
|
|
|
355
|
-
|
|
446
|
+
sendToParent({
|
|
356
447
|
type: 'stopCursorCapture:response',
|
|
357
448
|
success: true,
|
|
358
449
|
data: { filepath }
|
|
359
450
|
});
|
|
360
451
|
|
|
361
|
-
|
|
452
|
+
sendToParent({
|
|
362
453
|
type: 'event',
|
|
363
454
|
event: 'cursorCaptureStopped',
|
|
364
455
|
data: { filepath }
|
|
365
456
|
});
|
|
366
457
|
} catch (error) {
|
|
367
|
-
|
|
458
|
+
sendToParent({
|
|
368
459
|
type: 'stopCursorCapture:response',
|
|
369
460
|
success: false,
|
|
370
461
|
error: error.message
|
|
@@ -372,28 +463,27 @@ async function handleStopCursorCapture() {
|
|
|
372
463
|
}
|
|
373
464
|
}
|
|
374
465
|
|
|
375
|
-
//
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
} catch (error) {
|
|
392
|
-
// Ignore cleanup errors
|
|
393
|
-
}
|
|
466
|
+
// Finalize writers before releasing the child process on parent cleanup.
|
|
467
|
+
let shuttingDown = false;
|
|
468
|
+
async function shutdown() {
|
|
469
|
+
if (shuttingDown) return;
|
|
470
|
+
shuttingDown = true;
|
|
471
|
+
clearInterval(recordingTimer);
|
|
472
|
+
clearInterval(recordingStatusInterval);
|
|
473
|
+
clearTimeout(recordingStartTimeout);
|
|
474
|
+
try {
|
|
475
|
+
if (cursorCaptureFile) nativeBinding.stopCursorCapture();
|
|
476
|
+
nativeBinding.stopRecording(0);
|
|
477
|
+
await waitForNativeIdle(nativeBinding);
|
|
478
|
+
} catch (error) {
|
|
479
|
+
console.warn('Worker shutdown cleanup:', error.message);
|
|
480
|
+
} finally {
|
|
481
|
+
process.exit(0);
|
|
394
482
|
}
|
|
395
|
-
|
|
396
|
-
|
|
483
|
+
}
|
|
484
|
+
process.on('SIGTERM', shutdown);
|
|
485
|
+
process.on('SIGINT', shutdown);
|
|
486
|
+
process.on('disconnect', shutdown);
|
|
397
487
|
|
|
398
488
|
// Signal ready
|
|
399
|
-
|
|
489
|
+
sendToParent({ type: 'ready' });
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
const isBusy = (state) => !!(state && (
|
|
2
|
+
state.isRecording || state.isStarting || state.isStopping || state.hasAuxiliaryRecording
|
|
3
|
+
));
|
|
4
|
+
|
|
5
|
+
async function waitForNativeIdle(binding, {
|
|
6
|
+
timeoutMs = 30000, pollMs = 25, now = Date.now,
|
|
7
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
8
|
+
} = {}) {
|
|
9
|
+
if (typeof binding.getRecordingLifecycleStatus !== "function") return;
|
|
10
|
+
const started = now();
|
|
11
|
+
while (isBusy(binding.getRecordingLifecycleStatus())) {
|
|
12
|
+
if (now() - started >= timeoutMs) {
|
|
13
|
+
const error = new Error("The previous recording is still finalizing. Please wait before starting again.");
|
|
14
|
+
error.code = "RECORDER_STILL_FINALIZING";
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
await sleep(pollMs);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function clearRecordingTimers(recorder) {
|
|
22
|
+
clearInterval(recorder.recordingTimer);
|
|
23
|
+
clearInterval(recorder.recordingStatusInterval);
|
|
24
|
+
clearTimeout(recorder.recordingStartTimeout);
|
|
25
|
+
recorder.recordingTimer = null;
|
|
26
|
+
recorder.recordingStatusInterval = null;
|
|
27
|
+
recorder.recordingStartTimeout = null;
|
|
28
|
+
recorder._videoStartWatcherActive = false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function installRecorderSafety(MacRecorder, binding, waitOptions) {
|
|
32
|
+
const prototype = MacRecorder.prototype;
|
|
33
|
+
prototype.getNativeLifecycleStatus = function () {
|
|
34
|
+
return binding.getRecordingLifecycleStatus?.() || null;
|
|
35
|
+
};
|
|
36
|
+
for (const name of ["startRecording", "startIOSRecording"]) {
|
|
37
|
+
const start = prototype[name];
|
|
38
|
+
if (typeof start !== "function") continue;
|
|
39
|
+
prototype[name] = function (...args) {
|
|
40
|
+
if (this._startPromise) {
|
|
41
|
+
return this._startMethod === name ? this._startPromise : Promise.reject(new Error("Recording is already starting"));
|
|
42
|
+
}
|
|
43
|
+
if (this.isRecording || this._stopPromise || isBusy(this.getNativeLifecycleStatus())) {
|
|
44
|
+
return Promise.reject(new Error("Recording is already active or still finalizing"));
|
|
45
|
+
}
|
|
46
|
+
this._startMethod = name;
|
|
47
|
+
this._captureGeneration = (this._captureGeneration || 0) + 1;
|
|
48
|
+
this.videoStartTimestamp = 0;
|
|
49
|
+
this._startPromise = Promise.resolve().then(() => start.apply(this, args)).catch(async (error) => {
|
|
50
|
+
clearRecordingTimers(this);
|
|
51
|
+
for (const stopTracking of ["stopCursorCapture", "stopKeyboardCapture"]) {
|
|
52
|
+
try { await this[stopTracking]?.(); } catch (trackingError) {
|
|
53
|
+
console.warn(`[Recorder] ${stopTracking} cleanup:`, trackingError.message);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
if (name === "startIOSRecording") binding.stopIOSDeviceRecording?.();
|
|
58
|
+
else binding.stopRecording?.(0);
|
|
59
|
+
await waitForNativeIdle(binding, waitOptions);
|
|
60
|
+
} catch (cleanupError) {
|
|
61
|
+
console.warn("[Recorder] Startup cleanup is still pending:", cleanupError.message);
|
|
62
|
+
}
|
|
63
|
+
this.isRecording = isBusy(this.getNativeLifecycleStatus());
|
|
64
|
+
this.recordingMode = this.isRecording && name === "startIOSRecording" ? "iphone" : null;
|
|
65
|
+
throw error;
|
|
66
|
+
}).finally(() => {
|
|
67
|
+
this._startPromise = null;
|
|
68
|
+
this._startMethod = null;
|
|
69
|
+
});
|
|
70
|
+
return this._startPromise;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const stop = prototype.stopRecording;
|
|
75
|
+
prototype.stopRecording = function (...args) {
|
|
76
|
+
if (this._stopPromise) return this._stopPromise;
|
|
77
|
+
const pendingStart = this._startPromise;
|
|
78
|
+
let stoppedMode = this.recordingMode;
|
|
79
|
+
this._stopPromise = Promise.resolve().then(async () => {
|
|
80
|
+
if (pendingStart) await pendingStart;
|
|
81
|
+
stoppedMode = this.recordingMode;
|
|
82
|
+
clearRecordingTimers(this);
|
|
83
|
+
const result = await stop.apply(this, args);
|
|
84
|
+
// ScreenCaptureKit's native stop returns before its writers finish.
|
|
85
|
+
// Keep the JS promise pending until the native resources are reusable.
|
|
86
|
+
await waitForNativeIdle(binding, waitOptions);
|
|
87
|
+
return result;
|
|
88
|
+
}).catch((error) => {
|
|
89
|
+
clearRecordingTimers(this);
|
|
90
|
+
this.isRecording = isBusy(this.getNativeLifecycleStatus());
|
|
91
|
+
if (this.isRecording && stoppedMode === "iphone") this.recordingMode = "iphone";
|
|
92
|
+
throw error;
|
|
93
|
+
}).finally(() => {
|
|
94
|
+
this._stopPromise = null;
|
|
95
|
+
});
|
|
96
|
+
return this._stopPromise;
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = installRecorderSafety;
|
|
101
|
+
module.exports.waitForNativeIdle = waitForNativeIdle;
|