dashcam 0.8.3 → 1.0.1-beta.10

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.
Files changed (55) hide show
  1. package/.dashcam/cli-config.json +3 -0
  2. package/.dashcam/recording.log +135 -0
  3. package/.dashcam/web-config.json +11 -0
  4. package/.github/RELEASE.md +59 -0
  5. package/.github/workflows/publish.yml +43 -0
  6. package/BACKWARD_COMPATIBILITY.md +177 -0
  7. package/LOG_TRACKING_GUIDE.md +225 -0
  8. package/README.md +709 -155
  9. package/bin/dashcam-background.js +177 -0
  10. package/bin/dashcam.cjs +8 -0
  11. package/bin/dashcam.js +696 -0
  12. package/bin/index.js +63 -0
  13. package/examples/execute-script.js +152 -0
  14. package/examples/simple-test.js +37 -0
  15. package/lib/applicationTracker.js +311 -0
  16. package/lib/auth.js +222 -0
  17. package/lib/binaries.js +21 -0
  18. package/lib/config.js +34 -0
  19. package/lib/extension-logs/helpers.js +182 -0
  20. package/lib/extension-logs/index.js +347 -0
  21. package/lib/extension-logs/manager.js +344 -0
  22. package/lib/ffmpeg.js +155 -0
  23. package/lib/logTracker.js +23 -0
  24. package/lib/logger.js +118 -0
  25. package/lib/logs/index.js +488 -0
  26. package/lib/permissions.js +85 -0
  27. package/lib/processManager.js +317 -0
  28. package/lib/recorder.js +690 -0
  29. package/lib/store.js +58 -0
  30. package/lib/tracking/FileTracker.js +105 -0
  31. package/lib/tracking/FileTrackerManager.js +62 -0
  32. package/lib/tracking/LogsTracker.js +161 -0
  33. package/lib/tracking/active-win.js +212 -0
  34. package/lib/tracking/icons/darwin.js +39 -0
  35. package/lib/tracking/icons/index.js +167 -0
  36. package/lib/tracking/icons/windows.js +27 -0
  37. package/lib/tracking/idle.js +82 -0
  38. package/lib/tracking.js +23 -0
  39. package/lib/uploader.js +456 -0
  40. package/lib/utilities/jsonl.js +77 -0
  41. package/lib/webLogsDaemon.js +234 -0
  42. package/lib/websocket/server.js +223 -0
  43. package/package.json +53 -21
  44. package/recording.log +814 -0
  45. package/sea-bundle.mjs +34595 -0
  46. package/test-page.html +15 -0
  47. package/test.log +1 -0
  48. package/test_run.log +48 -0
  49. package/test_workflow.sh +154 -0
  50. package/examples/crash-test.js +0 -11
  51. package/examples/github-issue.sh +0 -1
  52. package/examples/protocol.html +0 -22
  53. package/index.js +0 -177
  54. package/lib.js +0 -199
  55. package/recorder.js +0 -85
@@ -0,0 +1,690 @@
1
+ import { execa } from 'execa';
2
+ import { logger, logFunctionCall } from './logger.js';
3
+ import { createGif, createSnapshot } from './ffmpeg.js';
4
+ import { applicationTracker } from './applicationTracker.js';
5
+ import { logsTrackerManager, trimLogs } from './logs/index.js';
6
+ import { getFfmpegPath } from './binaries.js';
7
+ import path from 'path';
8
+ import os from 'os';
9
+ import fs from 'fs';
10
+
11
+ /**
12
+ * Dynamically detect the primary screen capture device for the current platform
13
+ */
14
+ async function detectPrimaryScreenDevice() {
15
+ const logExit = logFunctionCall('detectPrimaryScreenDevice');
16
+
17
+ const platform = os.platform();
18
+
19
+ if (platform === 'darwin') {
20
+ try {
21
+ // Get ffmpeg path
22
+ const ffmpegPath = await getFfmpegPath();
23
+
24
+ // List AVFoundation devices to find screen capture devices
25
+ const { stdout, stderr } = await execa(ffmpegPath, [
26
+ '-f', 'avfoundation',
27
+ '-list_devices', 'true',
28
+ '-i', ''
29
+ ], { reject: false });
30
+
31
+ // Parse the output to find screen capture devices
32
+ const output = stdout + stderr;
33
+ const lines = output.split('\n');
34
+
35
+ logger.debug('AVFoundation device list output:', { totalLines: lines.length });
36
+
37
+ // Look for screen capture devices (usually named "Capture screen X")
38
+ const screenDevices = [];
39
+ for (const line of lines) {
40
+ const match = line.match(/\[(\d+)\]\s+Capture screen (\d+)/);
41
+ if (match) {
42
+ const deviceIndex = parseInt(match[1]);
43
+ const screenNumber = parseInt(match[2]);
44
+ screenDevices.push({ deviceIndex, screenNumber });
45
+ logger.debug('Found screen capture device:', { deviceIndex, screenNumber });
46
+ }
47
+ }
48
+
49
+ if (screenDevices.length === 0) {
50
+ logger.warn('No screen capture devices found in AVFoundation output');
51
+ logger.debug('Full output for debugging:', { output: output.slice(0, 1000) }); // Truncate for readability
52
+
53
+ // Try alternative patterns that might match screen devices
54
+ for (const line of lines) {
55
+ if (line.toLowerCase().includes('screen') || line.toLowerCase().includes('display')) {
56
+ logger.debug('Found potential screen device line:', { line });
57
+ }
58
+ }
59
+
60
+ logger.warn('Falling back to device index 1');
61
+ logExit();
62
+ return '1:none'; // Fallback
63
+ }
64
+
65
+ // Sort by screen number and prefer screen 0 (primary display)
66
+ screenDevices.sort((a, b) => a.screenNumber - b.screenNumber);
67
+ const primaryScreen = screenDevices[0];
68
+ const screenInput = `${primaryScreen.deviceIndex}:none`;
69
+
70
+ logger.info('Detected primary screen device:', {
71
+ deviceIndex: primaryScreen.deviceIndex,
72
+ screenNumber: primaryScreen.screenNumber,
73
+ screenInput,
74
+ totalScreenDevices: screenDevices.length
75
+ });
76
+
77
+ logExit();
78
+ return screenInput;
79
+ } catch (error) {
80
+ logger.error('Failed to detect screen devices:', error);
81
+ logger.warn('Falling back to device index 1');
82
+ logExit();
83
+ return '1:none'; // Fallback
84
+ }
85
+ } else if (platform === 'win32') {
86
+ // For Windows, we could potentially detect multiple monitors
87
+ // For now, use 'desktop' which captures the entire desktop spanning all monitors
88
+ logger.info('Using Windows desktop capture (all monitors)');
89
+ logExit();
90
+ return 'desktop';
91
+ } else {
92
+ // For Linux, we could potentially detect the DISPLAY environment variable
93
+ // or query X11 for available displays
94
+ const display = process.env.DISPLAY || ':0.0';
95
+ logger.info('Using Linux X11 display capture', { display });
96
+ logExit();
97
+ return display;
98
+ }
99
+ }
100
+
101
+ // State management
102
+ let currentRecording = null;
103
+ let outputPath = null;
104
+ let recordingStartTime = null;
105
+ let currentTempFile = null;
106
+
107
+ // File paths - use system temp for runtime data
108
+ const DASHCAM_TEMP_DIR = path.join(os.tmpdir(), 'dashcam');
109
+ const TEMP_FILE_INFO_PATH = path.join(DASHCAM_TEMP_DIR, 'temp-file.json');
110
+
111
+ // Platform-specific configurations
112
+ const PLATFORM_CONFIG = {
113
+ darwin: {
114
+ inputFormat: 'avfoundation',
115
+ screenInput: null, // Will be dynamically detected
116
+ audioInput: '0', // Default audio device if needed
117
+ audioFormat: 'avfoundation',
118
+ extraArgs: [
119
+ '-video_size', '1920x1080', // Set explicit resolution
120
+ '-pixel_format', 'uyvy422', // Use supported pixel format
121
+ '-r', '30' // Set frame rate
122
+ ]
123
+ },
124
+ win32: {
125
+ inputFormat: 'gdigrab',
126
+ screenInput: null, // Will be dynamically detected
127
+ audioInput: 'audio="virtual-audio-capturer"',
128
+ audioFormat: 'dshow'
129
+ },
130
+ linux: {
131
+ inputFormat: 'x11grab',
132
+ screenInput: null, // Will be dynamically detected
133
+ audioInput: 'default',
134
+ audioFormat: 'pulse'
135
+ }
136
+ };
137
+
138
+ /**
139
+ * Get the FFmpeg arguments for the current platform
140
+ */
141
+ async function getPlatformArgs({ fps, includeAudio }) {
142
+ const logExit = logFunctionCall('getPlatformArgs', { fps, includeAudio });
143
+
144
+ const platform = os.platform();
145
+ const config = PLATFORM_CONFIG[platform] || PLATFORM_CONFIG.darwin;
146
+
147
+ // Dynamically detect screen input if not set
148
+ let screenInput = config.screenInput;
149
+ if (!screenInput) {
150
+ screenInput = await detectPrimaryScreenDevice();
151
+ }
152
+
153
+ logger.verbose('Using platform configuration', {
154
+ platform,
155
+ config: {
156
+ inputFormat: config.inputFormat,
157
+ screenInput: screenInput,
158
+ audioInput: config.audioInput
159
+ }
160
+ });
161
+
162
+ const args = [
163
+ '-f', config.inputFormat
164
+ ];
165
+
166
+ // Add platform-specific extra args before input
167
+ if (config.extraArgs) {
168
+ args.push(...config.extraArgs);
169
+ }
170
+
171
+ args.push(
172
+ '-framerate', fps.toString(),
173
+ '-i', screenInput
174
+ );
175
+
176
+ // Add audio capture if enabled
177
+ if (includeAudio) {
178
+ args.push(
179
+ '-f', config.audioFormat,
180
+ '-i', config.audioInput
181
+ );
182
+ }
183
+
184
+ // Log the command being constructed
185
+ logger.debug('FFmpeg capture command:', { args: args.join(' ') });
186
+ logger.verbose('Platform-specific arguments added', {
187
+ totalArgs: args.length,
188
+ includeAudio,
189
+ fps,
190
+ screenInput
191
+ });
192
+
193
+ logExit();
194
+ return args;
195
+ }
196
+
197
+ /**
198
+ * Clear the tmp/recordings directory
199
+ */
200
+ function clearRecordingsDirectory() {
201
+ const logExit = logFunctionCall('clearRecordingsDirectory');
202
+
203
+ const directory = path.join(process.cwd(), 'tmp', 'recordings');
204
+
205
+ try {
206
+ if (fs.existsSync(directory)) {
207
+ const files = fs.readdirSync(directory);
208
+ logger.info('Clearing recordings directory', {
209
+ directory,
210
+ fileCount: files.length
211
+ });
212
+
213
+ for (const file of files) {
214
+ const filePath = path.join(directory, file);
215
+ try {
216
+ fs.unlinkSync(filePath);
217
+ logger.debug('Deleted file', { filePath });
218
+ } catch (error) {
219
+ logger.warn('Failed to delete file', { filePath, error: error.message });
220
+ }
221
+ }
222
+
223
+ logger.info('Successfully cleared recordings directory');
224
+ } else {
225
+ logger.debug('Recordings directory does not exist, nothing to clear');
226
+ }
227
+ } catch (error) {
228
+ logger.warn('Error clearing recordings directory', { error: error.message });
229
+ }
230
+
231
+ logExit();
232
+ }
233
+
234
+ /**
235
+ * Generate a valid output path for the recording
236
+ */
237
+ function generateOutputPath() {
238
+ const logExit = logFunctionCall('generateOutputPath');
239
+
240
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
241
+ // Use system temp directory with dashcam subdirectory
242
+ const directory = path.join(os.tmpdir(), 'dashcam', 'recordings');
243
+ const filepath = path.join(directory, `recording-${timestamp}.webm`);
244
+
245
+ logger.verbose('Generating output path', {
246
+ timestamp,
247
+ directory,
248
+ filepath,
249
+ directoryExists: fs.existsSync(directory)
250
+ });
251
+
252
+ // Ensure directory exists
253
+ fs.mkdirSync(directory, { recursive: true });
254
+
255
+ logger.debug('Created recordings directory', { directory });
256
+
257
+ logExit();
258
+ return filepath;
259
+ }
260
+
261
+ /**
262
+ * Start a new screen recording
263
+ */
264
+ export async function startRecording({
265
+ fps = 10,
266
+ includeAudio = false,
267
+ customOutputPath = null
268
+ } = {}) {
269
+ if (currentRecording) {
270
+ throw new Error('Recording already in progress');
271
+ }
272
+
273
+ // Clear the tmp directory before starting a new recording
274
+ clearRecordingsDirectory();
275
+
276
+ outputPath = customOutputPath || generateOutputPath();
277
+
278
+ // Construct FFmpeg command arguments
279
+ const platformArgs = await getPlatformArgs({ fps, includeAudio });
280
+ const outputArgs = [
281
+ '-c:v', 'libvpx-vp9', // Use VP9 codec for better quality and compression
282
+ // '-quality', 'realtime', // Use realtime quality preset for lower CPU usage
283
+ // '-cpu-used', '5', // Faster encoding (0-5, higher = faster but lower quality)
284
+ // '-deadline', 'realtime', // Realtime encoding mode for lower latency/CPU
285
+ // '-b:v', '2M', // Lower bitrate to reduce CPU load (was 5M)
286
+ // Remove explicit pixel format to let ffmpeg handle conversion automatically
287
+ '-r', fps.toString(), // Ensure output framerate matches input
288
+ // '-g', '60', // Keyframe every 60 frames (reduced frequency)
289
+ // WebM options for more frequent disk writes
290
+ '-f', 'webm', // Force WebM container format
291
+ // '-flush_packets', '1', // Flush packets immediately to disk
292
+ // '-max_muxing_queue_size', '1024' // Limit muxing queue to prevent delays
293
+ ];
294
+
295
+ if (includeAudio) {
296
+ outputArgs.push(
297
+ '-c:a', 'libopus', // Opus audio codec for WebM
298
+ '-b:a', '128k'
299
+ );
300
+ }
301
+
302
+ // Create a temporary file for the recording in our workspace
303
+ const tempDir = path.dirname(outputPath);
304
+ const timestamp = Date.now();
305
+ const tempFile = path.join(tempDir, `temp-${timestamp}.webm`);
306
+ currentTempFile = tempFile; // Store globally for stopRecording
307
+
308
+ logger.info('Generated temp file path', { tempFile, timestamp, tempDir });
309
+
310
+ // Also persist temp file path to disk for cross-process access
311
+ try {
312
+ // Ensure temp directory exists
313
+ if (!fs.existsSync(DASHCAM_TEMP_DIR)) {
314
+ fs.mkdirSync(DASHCAM_TEMP_DIR, { recursive: true });
315
+ }
316
+
317
+ const tempFileInfo = {
318
+ tempFile,
319
+ outputPath,
320
+ startTime: timestamp
321
+ };
322
+ fs.writeFileSync(TEMP_FILE_INFO_PATH, JSON.stringify(tempFileInfo));
323
+ logger.info('Wrote temp file info to disk', { path: TEMP_FILE_INFO_PATH, tempFileInfo });
324
+ } catch (error) {
325
+ logger.warn('Failed to write temp file info', { error });
326
+ }
327
+
328
+ // Get ffmpeg path (async)
329
+ const ffmpegPath = await getFfmpegPath();
330
+
331
+ // WebM doesn't need movflags (those are MP4-specific)
332
+ const args = [
333
+ ...platformArgs,
334
+ ...outputArgs,
335
+ '-y', // Overwrite output file if it exists
336
+ tempFile
337
+ ];
338
+
339
+ const fullCommand = [ffmpegPath, ...args].join(' ');
340
+ logger.info('Starting recording with options:', {
341
+ fps,
342
+ includeAudio,
343
+ platform: os.platform(),
344
+ outputPath,
345
+ tempFile
346
+ });
347
+
348
+ logger.verbose('FFmpeg command details', {
349
+ ffmpegPath,
350
+ totalArgs: args.length,
351
+ outputArgs: outputArgs.join(' '),
352
+ platformArgs: platformArgs.join(' ')
353
+ });
354
+
355
+ logger.trace('Full FFmpeg command', { command: fullCommand });
356
+
357
+ try {
358
+ logger.debug('Spawning FFmpeg process...');
359
+ currentRecording = execa(ffmpegPath, args, {
360
+ reject: false,
361
+ all: true, // Capture both stdout and stderr
362
+ stdin: 'pipe' // Enable stdin for sending 'q' to stop recording
363
+ });
364
+
365
+ logger.info('FFmpeg process spawned', {
366
+ pid: currentRecording.pid,
367
+ args: args.slice(-5), // Log last 5 args including output file
368
+ tempFile
369
+ });
370
+
371
+ // Check if temp file gets created within first few seconds
372
+ setTimeout(() => {
373
+ if (fs.existsSync(tempFile)) {
374
+ logger.info('Temp file created successfully', {
375
+ path: tempFile,
376
+ size: fs.statSync(tempFile).size
377
+ });
378
+ } else {
379
+ logger.warn('Temp file not created yet', { path: tempFile });
380
+ }
381
+ }, 3000);
382
+
383
+ // Start application tracking
384
+ logger.debug('Starting application tracking...');
385
+ applicationTracker.start();
386
+
387
+ // Start log tracking for this recording
388
+ const recorderId = path.basename(outputPath).replace('.webm', '');
389
+ logger.debug('Starting log tracking...', { recorderId });
390
+ await logsTrackerManager.startNew({
391
+ recorderId,
392
+ screenId: '1', // Default screen ID for CLI
393
+ directory: path.dirname(outputPath)
394
+ });
395
+
396
+ // Set recording start time AFTER log tracker is initialized
397
+ // This ensures the timeline starts when the tracker is ready to capture events
398
+ recordingStartTime = Date.now();
399
+ logger.info('Recording timeline started', {
400
+ recordingStartTime,
401
+ recordingStartTimeReadable: new Date(recordingStartTime).toISOString()
402
+ });
403
+
404
+ if (currentRecording.all) {
405
+ currentRecording.all.setEncoding('utf8');
406
+ currentRecording.all.on('data', (data) => {
407
+ // Parse FFmpeg output for useful information
408
+ const output = data.toString().trim();
409
+ logger.info(`FFmpeg output: ${output}`);
410
+
411
+ // Check for actual permission issues (not fallback messages)
412
+ // Only show error if it says "failed" without "falling back"
413
+ if (output.includes('Configuration of video device failed') &&
414
+ !output.includes('falling back')) {
415
+ logger.error('PERMISSION ISSUE DETECTED: Screen recording failed. This happens because the Node.js subprocess doesn\'t inherit VS Code\'s screen recording permissions.');
416
+ logger.error('SOLUTION: Add Node.js to screen recording permissions:');
417
+ logger.error('1. Open System Preferences > Security & Privacy > Privacy > Screen Recording');
418
+ logger.error('2. Click the lock to unlock');
419
+ logger.error('3. Click the + button and add: /usr/local/bin/node (or your Node.js installation path)');
420
+ logger.error('4. Alternatively, find node with: which node');
421
+ logger.error('5. Restart the terminal after adding permissions');
422
+ } else if (output.includes('falling back to default')) {
423
+ logger.debug(`FFmpeg fallback: ${output}`);
424
+ }
425
+
426
+ if (output.includes('frame=') || output.includes('time=')) {
427
+ logger.verbose(`FFmpeg progress: ${output}`);
428
+ } else if (output.includes('error') || output.includes('Error')) {
429
+ logger.warn(`FFmpeg warning: ${output}`);
430
+ } else {
431
+ logger.debug(`FFmpeg: ${output}`);
432
+ }
433
+ });
434
+ }
435
+
436
+ logger.info('Recording process started successfully', {
437
+ pid: currentRecording.pid,
438
+ startTime: recordingStartTime
439
+ });
440
+
441
+ // Return immediately since FFmpeg is running
442
+ return { outputPath, startTime: recordingStartTime };
443
+ } catch (error) {
444
+ logger.error('Failed to start recording:', error);
445
+ currentRecording = null;
446
+ recordingStartTime = null;
447
+ currentTempFile = null;
448
+ throw error;
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Stop the current recording
454
+ */
455
+ export async function stopRecording() {
456
+ const logExit = logFunctionCall('stopRecording');
457
+
458
+ if (!currentRecording) {
459
+ throw new Error('No recording in progress');
460
+ }
461
+
462
+ const recordingDuration = Date.now() - recordingStartTime;
463
+ logger.info('Stopping recording', {
464
+ pid: currentRecording.pid,
465
+ duration: recordingDuration,
466
+ durationSeconds: (recordingDuration / 1000).toFixed(1)
467
+ });
468
+
469
+ try {
470
+ // First try to gracefully stop FFmpeg by sending 'q'
471
+ if (currentRecording && currentRecording.stdin) {
472
+ logger.debug('Sending quit signal to FFmpeg...');
473
+ currentRecording.stdin.write('q');
474
+ }
475
+
476
+ // Wait for FFmpeg to finish gracefully
477
+ const gracefulTimeout = setTimeout(() => {
478
+ if (currentRecording && !currentRecording.killed) {
479
+ // If still running, try SIGTERM
480
+ process.kill(currentRecording.pid, 'SIGTERM');
481
+ }
482
+ }, 2000);
483
+
484
+ // Wait up to 5 seconds for SIGTERM to work
485
+ const hardKillTimeout = setTimeout(() => {
486
+ if (currentRecording && !currentRecording.killed) {
487
+ // If still not dead, use SIGKILL as last resort
488
+ process.kill(currentRecording.pid, 'SIGKILL');
489
+ }
490
+ }, 5000);
491
+
492
+ // Wait for the process to fully exit
493
+ if (currentRecording) {
494
+ await currentRecording;
495
+ }
496
+
497
+ // Clear timeouts
498
+ clearTimeout(gracefulTimeout);
499
+ clearTimeout(hardKillTimeout);
500
+
501
+ // Additional wait to ensure filesystem is synced - increased for reliability
502
+ await new Promise(resolve => setTimeout(resolve, 3000));
503
+
504
+ // Read temp file path from disk (for cross-process access)
505
+ let tempFile = currentTempFile; // Try in-memory first
506
+
507
+ logger.info('Looking for temp file', {
508
+ inMemory: currentTempFile,
509
+ infoFileExists: fs.existsSync(TEMP_FILE_INFO_PATH),
510
+ infoPath: TEMP_FILE_INFO_PATH
511
+ });
512
+
513
+ console.log('DEBUG: Looking for temp file', {
514
+ inMemory: currentTempFile,
515
+ infoFileExists: fs.existsSync(TEMP_FILE_INFO_PATH),
516
+ infoPath: TEMP_FILE_INFO_PATH
517
+ });
518
+
519
+ if (!tempFile && fs.existsSync(TEMP_FILE_INFO_PATH)) {
520
+ try {
521
+ const tempFileInfo = JSON.parse(fs.readFileSync(TEMP_FILE_INFO_PATH, 'utf8'));
522
+ tempFile = tempFileInfo.tempFile;
523
+ logger.info('Loaded temp file path from disk', { tempFile, tempFileInfo });
524
+ } catch (error) {
525
+ logger.error('Failed to read temp file info', { error });
526
+ }
527
+ }
528
+
529
+ logger.info('Debug: temp file check', {
530
+ tempFile,
531
+ exists: tempFile ? fs.existsSync(tempFile) : false,
532
+ size: tempFile && fs.existsSync(tempFile) ? fs.statSync(tempFile).size : 0
533
+ });
534
+
535
+ if (!tempFile) {
536
+ throw new Error('No temp file path available');
537
+ }
538
+
539
+ if (!fs.existsSync(tempFile) || fs.statSync(tempFile).size === 0) {
540
+ throw new Error('Recording file is empty or missing');
541
+ }
542
+
543
+ // Analyze temp file before processing
544
+ const tempStats = fs.statSync(tempFile);
545
+ logger.debug('Temp file stats:', {
546
+ size: tempStats.size,
547
+ path: tempFile
548
+ });
549
+
550
+ // Since WebM is already a valid streaming format, just copy the temp file
551
+ // instead of trying to re-encode it which can hang
552
+ logger.debug('Copying temp file to final output...');
553
+
554
+ try {
555
+ fs.copyFileSync(tempFile, outputPath);
556
+ logger.info('Successfully copied temp file to final output');
557
+
558
+ // Verify the final file exists and has content
559
+ if (!fs.existsSync(outputPath) || fs.statSync(outputPath).size === 0) {
560
+ throw new Error('Final output file is empty or missing after copy');
561
+ }
562
+
563
+ } catch (error) {
564
+ logger.error('Failed to copy temp file:', error);
565
+ throw new Error('Failed to finalize recording: ' + error.message);
566
+ }
567
+
568
+ // Clean up temp file only after successful finalization
569
+ try {
570
+ fs.unlinkSync(tempFile);
571
+ } catch (e) {
572
+ logger.debug('Failed to delete temp file:', e);
573
+ }
574
+
575
+ // Generate paths for additional assets
576
+ const basePath = outputPath.substring(0, outputPath.lastIndexOf('.'));
577
+ const gifPath = `${basePath}.gif`;
578
+ const snapshotPath = `${basePath}.png`;
579
+
580
+ // Stop application tracking and get results
581
+ logger.debug('Stopping application tracking...');
582
+ const appTrackingResults = applicationTracker.stop();
583
+
584
+ // Stop log tracking and get results
585
+ const recorderId = path.basename(outputPath).replace('.webm', '');
586
+ logger.debug('Stopping log tracking...', { recorderId });
587
+ const logTrackingResults = await logsTrackerManager.stop({
588
+ recorderId,
589
+ screenId: '1'
590
+ });
591
+
592
+ logger.debug('Tracking results collected', {
593
+ appResults: {
594
+ apps: appTrackingResults.apps?.length || 0,
595
+ icons: appTrackingResults.icons?.length || 0,
596
+ events: appTrackingResults.events?.length || 0
597
+ },
598
+ logResults: {
599
+ trackers: logTrackingResults.length,
600
+ totalEvents: logTrackingResults.reduce((sum, result) => sum + result.count, 0)
601
+ }
602
+ });
603
+
604
+ // Wait a moment for file system to sync
605
+ await new Promise(resolve => setTimeout(resolve, 1000));
606
+
607
+ // Create GIF and snapshot (non-blocking, don't fail if these fail)
608
+ logger.debug('Creating GIF and snapshot...');
609
+ try {
610
+ await Promise.all([
611
+ createGif(outputPath, gifPath).catch(err => {
612
+ logger.warn('Failed to create GIF', { error: err.message });
613
+ }),
614
+ createSnapshot(outputPath, snapshotPath, 0).catch(err => {
615
+ logger.warn('Failed to create snapshot', { error: err.message });
616
+ })
617
+ ]);
618
+ logger.debug('GIF and snapshot created successfully');
619
+ } catch (error) {
620
+ logger.warn('Failed to create preview assets', { error: error.message });
621
+ }
622
+
623
+ const result = {
624
+ outputPath,
625
+ gifPath,
626
+ snapshotPath,
627
+ duration: Date.now() - recordingStartTime,
628
+ fileSize: fs.statSync(outputPath).size,
629
+ clientStartDate: recordingStartTime, // Include the recording start timestamp
630
+ apps: appTrackingResults.apps, // Include tracked applications
631
+ icons: appTrackingResults.icons, // Include application icons metadata
632
+ logs: logTrackingResults // Include log tracking results
633
+ };
634
+
635
+ logger.info('Recording stopped with clientStartDate', {
636
+ clientStartDate: recordingStartTime,
637
+ clientStartDateReadable: new Date(recordingStartTime).toISOString(),
638
+ duration: result.duration
639
+ });
640
+
641
+ currentRecording = null;
642
+ recordingStartTime = null;
643
+ currentTempFile = null;
644
+
645
+ // Clean up temp file info
646
+ try {
647
+ if (fs.existsSync(TEMP_FILE_INFO_PATH)) {
648
+ fs.unlinkSync(TEMP_FILE_INFO_PATH);
649
+ }
650
+ } catch (error) {
651
+ logger.warn('Failed to clean up temp file info', { error });
652
+ }
653
+
654
+ // Stop application tracking on error
655
+ applicationTracker.stop();
656
+ return result;
657
+ } catch (error) {
658
+ currentRecording = null;
659
+ recordingStartTime = null;
660
+ currentTempFile = null;
661
+
662
+ // Clean up temp file info on error
663
+ try {
664
+ if (fs.existsSync(TEMP_FILE_INFO_PATH)) {
665
+ fs.unlinkSync(TEMP_FILE_INFO_PATH);
666
+ }
667
+ } catch (cleanupError) {
668
+ logger.warn('Failed to clean up temp file info on error', { cleanupError });
669
+ }
670
+
671
+ // Stop application tracking on error
672
+ applicationTracker.stop();
673
+ throw error;
674
+ }
675
+ }
676
+
677
+ /**
678
+ * Get current recording status
679
+ */
680
+ export function getRecordingStatus() {
681
+ if (!currentRecording) {
682
+ return { isRecording: false };
683
+ }
684
+
685
+ return {
686
+ isRecording: true,
687
+ duration: recordingStartTime ? Date.now() - recordingStartTime : 0,
688
+ outputPath
689
+ };
690
+ }