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.
- package/.dashcam/cli-config.json +3 -0
- package/.dashcam/recording.log +135 -0
- package/.dashcam/web-config.json +11 -0
- package/.github/RELEASE.md +59 -0
- package/.github/workflows/publish.yml +43 -0
- package/BACKWARD_COMPATIBILITY.md +177 -0
- package/LOG_TRACKING_GUIDE.md +225 -0
- package/README.md +709 -155
- package/bin/dashcam-background.js +177 -0
- package/bin/dashcam.cjs +8 -0
- package/bin/dashcam.js +696 -0
- package/bin/index.js +63 -0
- package/examples/execute-script.js +152 -0
- package/examples/simple-test.js +37 -0
- package/lib/applicationTracker.js +311 -0
- package/lib/auth.js +222 -0
- package/lib/binaries.js +21 -0
- package/lib/config.js +34 -0
- package/lib/extension-logs/helpers.js +182 -0
- package/lib/extension-logs/index.js +347 -0
- package/lib/extension-logs/manager.js +344 -0
- package/lib/ffmpeg.js +155 -0
- package/lib/logTracker.js +23 -0
- package/lib/logger.js +118 -0
- package/lib/logs/index.js +488 -0
- package/lib/permissions.js +85 -0
- package/lib/processManager.js +317 -0
- package/lib/recorder.js +690 -0
- package/lib/store.js +58 -0
- package/lib/tracking/FileTracker.js +105 -0
- package/lib/tracking/FileTrackerManager.js +62 -0
- package/lib/tracking/LogsTracker.js +161 -0
- package/lib/tracking/active-win.js +212 -0
- package/lib/tracking/icons/darwin.js +39 -0
- package/lib/tracking/icons/index.js +167 -0
- package/lib/tracking/icons/windows.js +27 -0
- package/lib/tracking/idle.js +82 -0
- package/lib/tracking.js +23 -0
- package/lib/uploader.js +456 -0
- package/lib/utilities/jsonl.js +77 -0
- package/lib/webLogsDaemon.js +234 -0
- package/lib/websocket/server.js +223 -0
- package/package.json +53 -21
- package/recording.log +814 -0
- package/sea-bundle.mjs +34595 -0
- package/test-page.html +15 -0
- package/test.log +1 -0
- package/test_run.log +48 -0
- package/test_workflow.sh +154 -0
- package/examples/crash-test.js +0 -11
- package/examples/github-issue.sh +0 -1
- package/examples/protocol.html +0 -22
- package/index.js +0 -177
- package/lib.js +0 -199
- package/recorder.js +0 -85
package/lib/recorder.js
ADDED
|
@@ -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
|
+
}
|