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.
@@ -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
- process.send({
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
- process.send({ type: 'pong' });
84
+ sendToParent({ type: 'pong' });
66
85
  break;
67
86
  default:
68
- process.send({
87
+ sendToParent({
69
88
  type: 'error',
70
89
  message: `Unknown message type: ${msg.type}`
71
90
  });
72
91
  }
73
92
  } catch (error) {
74
- process.send({
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
- process.send({
104
+ sendToParent({
86
105
  type: 'getWindows:response',
87
106
  data: windows
88
107
  });
89
108
  } catch (error) {
90
- process.send({
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
- process.send({
119
+ sendToParent({
101
120
  type: 'getDisplays:response',
102
121
  data: displays
103
122
  });
104
123
  } catch (error) {
105
- process.send({
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
- process.send({
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
- const elapsed = Math.floor((Date.now() - recordingStartTime) / 1000);
155
- process.send({
186
+ sendToParent({
156
187
  type: 'event',
157
188
  event: 'timeUpdate',
158
- data: elapsed
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
- process.send({
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
- process.send({
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
- process.send({
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
- process.send({
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 = recordingStartTime
224
- ? (Date.now() - recordingStartTime) / 1000
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
- process.send({
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
- process.send({
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
- // Small delay to ensure file is written
332
+ const completedPath = outputPath;
333
+ const completedGeneration = captureGeneration;
248
334
  setTimeout(() => {
249
- process.send({
335
+ if (completedGeneration !== captureGeneration) return;
336
+ sendToParent({
250
337
  type: 'event',
251
338
  event: 'completed',
252
- data: outputPath
339
+ data: completedPath
253
340
  });
254
341
  }, 1000);
255
342
 
256
343
  } catch (error) {
257
- process.send({
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
- const nativeStatus = nativeBinding.getRecordingStatus();
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
- process.send({
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
- process.send({
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
- process.send({
399
+ sendToParent({
309
400
  type: 'startCursorCapture:response',
310
401
  success: true,
311
402
  data: { filepath }
312
403
  });
313
404
 
314
- process.send({
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
- process.send({
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
- process.send({
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
- process.send({
446
+ sendToParent({
356
447
  type: 'stopCursorCapture:response',
357
448
  success: true,
358
449
  data: { filepath }
359
450
  });
360
451
 
361
- process.send({
452
+ sendToParent({
362
453
  type: 'event',
363
454
  event: 'cursorCaptureStopped',
364
455
  data: { filepath }
365
456
  });
366
457
  } catch (error) {
367
- process.send({
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
- // Graceful shutdown
376
- process.on('SIGTERM', () => {
377
- if (isRecording) {
378
- try {
379
- nativeBinding.stopRecording(0);
380
- } catch (error) {
381
- // Ignore cleanup errors
382
- }
383
- }
384
- process.exit(0);
385
- });
386
-
387
- process.on('SIGINT', () => {
388
- if (isRecording) {
389
- try {
390
- nativeBinding.stopRecording(0);
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
- process.exit(0);
396
- });
483
+ }
484
+ process.on('SIGTERM', shutdown);
485
+ process.on('SIGINT', shutdown);
486
+ process.on('disconnect', shutdown);
397
487
 
398
488
  // Signal ready
399
- process.send({ type: 'ready' });
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;