dashcam 0.8.4 → 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 -158
  54. package/lib.js +0 -199
  55. package/recorder.js +0 -85
@@ -0,0 +1,456 @@
1
+ import { S3Client } from '@aws-sdk/client-s3';
2
+ import { Upload } from '@aws-sdk/lib-storage';
3
+ import fs from 'fs';
4
+ import { logger, logFunctionCall } from './logger.js';
5
+ import path from 'path';
6
+ import { auth } from './auth.js';
7
+ import got from 'got';
8
+
9
+ class Uploader {
10
+ constructor() {
11
+ this.uploadCallbacks = new Map();
12
+ }
13
+
14
+ createS3Client(sts) {
15
+ const logExit = logFunctionCall('createS3Client', { region: sts.region });
16
+
17
+ logger.verbose('Creating S3 client', {
18
+ region: sts.region,
19
+ fallbackRegion: 'us-east-2',
20
+ bucket: sts.bucket,
21
+ hasAccessKey: !!sts.accessKeyId,
22
+ hasSecretKey: !!sts.secretAccessKey,
23
+ hasSessionToken: !!sts.sessionToken
24
+ });
25
+
26
+ const clientRegion = sts.region || 'us-east-2';
27
+
28
+ const client = new S3Client({
29
+ credentials: {
30
+ accessKeyId: sts.accessKeyId,
31
+ secretAccessKey: sts.secretAccessKey,
32
+ sessionToken: sts.sessionToken
33
+ },
34
+ region: clientRegion,
35
+ maxAttempts: 3
36
+ });
37
+
38
+ logger.debug('S3 client created', {
39
+ configuredRegion: clientRegion,
40
+ bucket: sts.bucket
41
+ });
42
+
43
+ logExit();
44
+ return client;
45
+ }
46
+
47
+ generateUploadParams(sts, fileType, extension) {
48
+ // Use the key from STS directly - it already includes proper extension
49
+ const key = sts.file;
50
+
51
+ logger.debug('Generating upload params:', {
52
+ bucket: sts.bucket,
53
+ key,
54
+ contentType: `${fileType}/${extension}`
55
+ });
56
+
57
+ return {
58
+ Bucket: sts.bucket,
59
+ Key: key,
60
+ ContentType: `${fileType}/${extension}`,
61
+ ACL: 'private'
62
+ };
63
+ }
64
+
65
+ async uploadFile(sts, clip, file, fileType, extension) {
66
+ const logExit = logFunctionCall('uploadFile', { fileType, extension, file });
67
+
68
+ logger.info(`Starting upload of ${fileType}`, {
69
+ file: path.basename(file),
70
+ fileType,
71
+ extension,
72
+ clipId: clip.id
73
+ });
74
+
75
+ const client = this.createS3Client(sts);
76
+ const uploadParams = this.generateUploadParams(sts, fileType, extension);
77
+
78
+ // Get file stats for logging
79
+ const fileStats = fs.statSync(file);
80
+ logger.verbose('File upload details', {
81
+ fileSizeBytes: fileStats.size,
82
+ fileSizeMB: (fileStats.size / (1024 * 1024)).toFixed(2),
83
+ bucket: sts.bucket,
84
+ key: uploadParams.Key,
85
+ contentType: uploadParams.ContentType
86
+ });
87
+
88
+ const fileStream = fs.createReadStream(file);
89
+
90
+ try {
91
+ const upload = new Upload({
92
+ client,
93
+ params: {
94
+ ...uploadParams,
95
+ Body: fileStream
96
+ },
97
+ partSize: 20 * 1024 * 1024, // 20 MB
98
+ queueSize: 5
99
+ });
100
+
101
+ upload.on('httpUploadProgress', (progress) => {
102
+ if (progress.loaded && progress.total) {
103
+ const percent = Math.round((progress.loaded / progress.total) * 100);
104
+ const speedMBps = progress.loaded / (1024 * 1024) / ((Date.now() - upload.startTime) / 1000);
105
+
106
+ if (percent % 10 === 0) { // Log every 10%
107
+ logger.verbose(`Upload ${fileType} progress: ${percent}%`, {
108
+ loaded: progress.loaded,
109
+ total: progress.total,
110
+ speedMBps: speedMBps.toFixed(2)
111
+ });
112
+ }
113
+
114
+ // Call progress callback if registered
115
+ const callbacks = this.uploadCallbacks.get(clip.id);
116
+ if (callbacks?.onProgress) {
117
+ callbacks.onProgress(percent);
118
+ }
119
+ }
120
+ });
121
+
122
+ upload.startTime = Date.now();
123
+ const result = await upload.done();
124
+ const uploadDuration = (Date.now() - upload.startTime) / 1000;
125
+
126
+ if (extension !== 'png') {
127
+ logger.info(`Successfully uploaded ${fileType}`, {
128
+ key: result.Key,
129
+ location: result.Location,
130
+ duration: `${uploadDuration.toFixed(1)}s`,
131
+ averageSpeed: `${(fileStats.size / (1024 * 1024) / uploadDuration).toFixed(2)} MB/s`
132
+ });
133
+
134
+ // Call complete callback if registered
135
+ const callbacks = this.uploadCallbacks.get(clip.id);
136
+ if (callbacks?.onComplete) {
137
+ callbacks.onComplete(result);
138
+ }
139
+ }
140
+
141
+ // Don't delete files here - let the main upload function handle cleanup
142
+ logExit();
143
+ return result;
144
+ } catch (error) {
145
+ logger.error('Upload error:', {
146
+ fileType,
147
+ file: path.basename(file),
148
+ error: error.message,
149
+ stack: error.stack
150
+ });
151
+
152
+ // Don't delete files on error - let the main upload function handle cleanup
153
+ logExit();
154
+ throw error;
155
+ } finally {
156
+ fileStream.destroy();
157
+ }
158
+ }
159
+
160
+ // Methods that match the desktop app's interface
161
+ async uploadVideo(meta, sts, clip) {
162
+ const file = clip.file;
163
+ await this.uploadFile(sts, clip, file, 'video', 'webm');
164
+ }
165
+
166
+ async uploadLog(app, sts, clip) {
167
+ const file = app.trimmedFileLocation;
168
+ await this.uploadFile(sts, clip, file, 'log', 'jsonl');
169
+ }
170
+
171
+ // Register callbacks for progress and completion
172
+ registerCallbacks(clipId, { onProgress, onComplete }) {
173
+ this.uploadCallbacks.set(clipId, { onProgress, onComplete });
174
+ }
175
+
176
+ // Remove callbacks
177
+ clearCallbacks(clipId) {
178
+ this.uploadCallbacks.delete(clipId);
179
+ }
180
+ }
181
+
182
+ // Create a singleton instance
183
+ const uploader = new Uploader();
184
+
185
+ // Export a simplified upload function for CLI use
186
+ export async function upload(filePath, metadata = {}) {
187
+ const logExit = logFunctionCall('upload', { filePath, metadata });
188
+
189
+ const extension = path.extname(filePath).substring(1);
190
+ const fileType = extension === 'webm' ? 'video' : 'log';
191
+
192
+ // Get current date for default title if none provided
193
+ const defaultTitle = `Recording ${new Date().toLocaleString()}`;
194
+
195
+ logger.info('Starting upload process', {
196
+ filePath: path.basename(filePath),
197
+ fileType,
198
+ extension,
199
+ title: metadata.title || defaultTitle,
200
+ hasProject: !!metadata.project
201
+ });
202
+
203
+ // Handle project ID - use provided project or fetch first available project
204
+ let projectId = metadata.project;
205
+ if (!projectId) {
206
+ logger.debug('No project ID provided, fetching user projects...');
207
+ try {
208
+ const projects = await auth.getProjects();
209
+ if (projects && projects.length > 0) {
210
+ projectId = projects[0].id;
211
+ logger.info('Automatically selected first project', {
212
+ projectId,
213
+ projectName: projects[0].name || 'Unknown'
214
+ });
215
+ } else {
216
+ logger.warn('No projects found for user, proceeding without project ID');
217
+ }
218
+ } catch (error) {
219
+ logger.warn('Failed to fetch projects, proceeding without project ID', {
220
+ error: error.message
221
+ });
222
+ }
223
+ } else {
224
+ logger.debug('Using provided project ID', { projectId });
225
+ }
226
+
227
+ // First, create a replay in the cloud (like the desktop app does)
228
+ const replayConfig = {
229
+ duration: metadata.duration || 0,
230
+ apps: metadata.apps && metadata.apps.length > 0 ? metadata.apps : ['Screen Recording'], // Use tracked apps or fallback
231
+ title: metadata.title || defaultTitle,
232
+ system: {
233
+ platform: process.platform,
234
+ arch: process.arch,
235
+ nodeVersion: process.version
236
+ },
237
+ clientStartDate: metadata.clientStartDate || Date.now() // Use actual recording start time
238
+ };
239
+
240
+ // Add project if we have one
241
+ if (projectId) {
242
+ replayConfig.project = projectId;
243
+ }
244
+
245
+ if (metadata.description) {
246
+ replayConfig.description = metadata.description;
247
+ }
248
+
249
+ logger.verbose('Creating replay with config', replayConfig);
250
+
251
+ logger.info('Creating replay', replayConfig);
252
+
253
+ // Create the replay first
254
+ const token = await auth.getToken();
255
+
256
+ let newReplay;
257
+ try {
258
+ newReplay = await got.post('https://api.testdriver.ai/api/v1/replay', {
259
+ headers: {
260
+ Authorization: `Bearer ${token}`
261
+ },
262
+ json: replayConfig,
263
+ timeout: 30000
264
+ }).json();
265
+
266
+ logger.info('Replay created successfully', {
267
+ replayId: newReplay.replay.id,
268
+ shareKey: newReplay.replay.shareKey,
269
+ shareLink: newReplay.replay.shareLink
270
+ });
271
+ } catch (error) {
272
+ logger.error('Failed to create replay', {
273
+ status: error.response?.statusCode,
274
+ statusText: error.response?.statusMessage,
275
+ body: error.response?.body,
276
+ replayConfig: replayConfig
277
+ });
278
+ throw error;
279
+ }
280
+
281
+ // Create a clip object that matches what the desktop app expects
282
+ const clip = {
283
+ id: Date.now().toString(),
284
+ file: filePath,
285
+ title: metadata.title || defaultTitle,
286
+ description: metadata.description || '',
287
+ project: projectId || undefined,
288
+ duration: metadata.duration || 0,
289
+ clientStartDate: metadata.clientStartDate || Date.now() // Use actual recording start time
290
+ };
291
+
292
+ // Get STS credentials with replay data (like the desktop app)
293
+ const replayData = {
294
+ id: newReplay.replay.id,
295
+ duration: metadata.duration || 0,
296
+ apps: metadata.apps && metadata.apps.length > 0 ? metadata.apps : ['Screen Recording'], // Use tracked apps or fallback
297
+ title: metadata.title || defaultTitle,
298
+ icons: metadata.icons || [] // Include icons metadata for STS token generation
299
+ };
300
+
301
+ // Add project if we have one
302
+ if (projectId) {
303
+ replayData.project = projectId;
304
+ }
305
+
306
+ logger.verbose('Getting STS credentials for replay', { replayId: newReplay.replay.id });
307
+ const sts = await auth.getStsCredentials(replayData);
308
+
309
+ logger.verbose('STS credentials received', {
310
+ hasVideo: !!sts.video,
311
+ hasImage: !!sts.image,
312
+ hasGif: !!sts.gif
313
+ });
314
+
315
+ // Upload all assets
316
+ const promises = [
317
+ // Upload the main video as mp4 (even though it's actually webm)
318
+ uploader.uploadFile(sts.video, clip, filePath, 'video', 'mp4')
319
+ ];
320
+
321
+ // Track files to cleanup after successful upload
322
+ const filesToCleanup = [filePath];
323
+
324
+ // Upload GIF if available
325
+ if (metadata.gifPath && fs.existsSync(metadata.gifPath)) {
326
+ logger.debug('Adding GIF upload to queue', { gifPath: metadata.gifPath });
327
+ promises.push(uploader.uploadFile(sts.gif, clip, metadata.gifPath, 'image', 'gif'));
328
+ filesToCleanup.push(metadata.gifPath);
329
+ }
330
+
331
+ // Upload snapshot if available
332
+ if (metadata.snapshotPath && fs.existsSync(metadata.snapshotPath)) {
333
+ logger.debug('Adding snapshot upload to queue', { snapshotPath: metadata.snapshotPath });
334
+ promises.push(uploader.uploadFile(sts.image, clip, metadata.snapshotPath, 'image', 'png'));
335
+ filesToCleanup.push(metadata.snapshotPath);
336
+ }
337
+
338
+ logger.info('Starting asset uploads', { totalUploads: promises.length });
339
+
340
+ // Process and upload logs if available
341
+ if (metadata.logs && metadata.logs.length > 0) {
342
+ logger.debug('Processing logs for upload', { logCount: metadata.logs.length });
343
+
344
+ // Import trimLogs function
345
+ const { trimLogs } = await import('./logs/index.js');
346
+
347
+ // Trim logs to recording duration
348
+ const recordingStartTime = metadata.clientStartDate || Date.now();
349
+ const recordingEndTime = recordingStartTime + (metadata.duration || 0);
350
+
351
+ logger.debug('Trimming logs', {
352
+ startTime: recordingStartTime,
353
+ endTime: recordingEndTime,
354
+ duration: metadata.duration
355
+ });
356
+
357
+ const trimmedLogs = await trimLogs(
358
+ metadata.logs,
359
+ 0, // startMS (relative to recording start)
360
+ metadata.duration || 0, // endMS
361
+ recordingStartTime, // clientStartDate
362
+ newReplay.replay.id // clipId
363
+ );
364
+
365
+ logger.debug('Logs trimmed', {
366
+ trimmedCount: trimmedLogs.length,
367
+ logsWithContent: trimmedLogs.filter(log => log.count > 0).length
368
+ });
369
+
370
+ // Upload each log file that has content
371
+ for (const logStatus of trimmedLogs) {
372
+ if (logStatus.count > 0 && logStatus.trimmedFileLocation && fs.existsSync(logStatus.trimmedFileLocation)) {
373
+ try {
374
+ // Use the name from the status, or a default descriptive name
375
+ // The name is what shows in the "App" dropdown, not the file path
376
+ let logName = logStatus.name || 'File Logs';
377
+
378
+ logger.debug('Creating log STS credentials', {
379
+ name: logName,
380
+ type: logStatus.type,
381
+ count: logStatus.count
382
+ });
383
+
384
+ const logSts = await auth.createLogSts(
385
+ newReplay.replay.id,
386
+ logStatus.id || `log-${Date.now()}`,
387
+ logName,
388
+ logStatus.type || 'application'
389
+ );
390
+
391
+ logger.debug('Uploading log file', {
392
+ file: path.basename(logStatus.trimmedFileLocation),
393
+ size: fs.statSync(logStatus.trimmedFileLocation).size
394
+ });
395
+
396
+ promises.push(
397
+ uploader.uploadFile(logSts, clip, logStatus.trimmedFileLocation, 'log', 'jsonl')
398
+ );
399
+
400
+ // Add to cleanup list
401
+ filesToCleanup.push(logStatus.trimmedFileLocation);
402
+ } catch (error) {
403
+ logger.warn('Failed to upload log', {
404
+ logId: logStatus.id,
405
+ error: error.message
406
+ });
407
+ }
408
+ }
409
+ }
410
+
411
+ logger.info('Added log uploads to queue', {
412
+ totalUploads: promises.length,
413
+ logUploads: promises.length - (metadata.gifPath ? 2 : 1) - (metadata.snapshotPath ? 1 : 0)
414
+ });
415
+ }
416
+
417
+ await Promise.all(promises);
418
+
419
+ // Clean up uploaded files after all uploads complete successfully
420
+ logger.debug('Cleaning up uploaded files', { files: filesToCleanup.map(f => path.basename(f)) });
421
+
422
+ for (const file of filesToCleanup) {
423
+ try {
424
+ fs.unlinkSync(file);
425
+ logger.debug(`Deleted uploaded file: ${path.basename(file)}`);
426
+ } catch (err) {
427
+ logger.warn(`Failed to delete file: ${path.basename(file)}`, { error: err.message });
428
+ }
429
+ }
430
+
431
+ // Publish the replay (like the desktop app does)
432
+ logger.debug('Publishing replay...');
433
+ await got.post('https://api.testdriver.ai/api/v1/replay/publish', {
434
+ headers: {
435
+ Authorization: `Bearer ${token}`
436
+ },
437
+ json: { id: newReplay.replay.id },
438
+ timeout: 30000
439
+ }).json();
440
+
441
+ logger.info('Upload process completed successfully', {
442
+ replayId: newReplay.replay.id,
443
+ shareLink: newReplay.replay.shareLink
444
+ });
445
+
446
+ logExit();
447
+
448
+ const shareLink = newReplay.replay.shareLink;
449
+
450
+ return {
451
+ replay: newReplay.replay,
452
+ shareLink: shareLink
453
+ };
454
+ }
455
+
456
+ export { uploader };
@@ -0,0 +1,77 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { logger } from '../logger.js';
4
+
5
+ // Throttled logging to prevent spam
6
+ const throttledLog = (() => {
7
+ const cache = {};
8
+ const LOG_THROTTLE_DURATION = 500;
9
+
10
+ return (level, msg, ...args) => {
11
+ if (!logger[level]) level = 'info';
12
+ if (!cache[level]) cache[level] = {};
13
+ if (cache[level][msg]) return;
14
+ cache[level][msg] = true;
15
+ setTimeout(() => {
16
+ delete cache[level][msg];
17
+ }, LOG_THROTTLE_DURATION);
18
+ logger[level](msg, ...args);
19
+ };
20
+ })();
21
+
22
+ export const jsonl = {
23
+ append: (file, json) => {
24
+ if (!fs.existsSync(file)) {
25
+ try {
26
+ let fd = fs.openSync(file, 'w');
27
+ fs.closeSync(fd);
28
+ } catch (error) {
29
+ throttledLog('info', `jsonl.js failed to initialize file ${error}`, {
30
+ json,
31
+ });
32
+ }
33
+ }
34
+ try {
35
+ fs.appendFileSync(file, JSON.stringify(json) + '\n', 'utf8');
36
+ } catch (error) {
37
+ throttledLog('info', `jsonl.js failed to append to file ${error}`, {
38
+ json,
39
+ });
40
+ }
41
+
42
+ return file;
43
+ },
44
+
45
+ read: (file) => {
46
+ if (!fs.existsSync(file)) {
47
+ return false;
48
+ } else {
49
+ return fs
50
+ .readFileSync(file, 'utf8')
51
+ .split('\n')
52
+ .slice(0, -1)
53
+ .map(JSON.parse);
54
+ }
55
+ },
56
+
57
+ write: (directory, fileName, arrayOfJsonObjects) => {
58
+ const file = path.join(directory, fileName);
59
+
60
+ if (!fs.existsSync(file)) {
61
+ try {
62
+ let fd = fs.openSync(file, 'w');
63
+ fs.closeSync(fd);
64
+ } catch (error) {
65
+ throttledLog('info', `jsonl.js failed to initialize file ${error}`);
66
+ }
67
+ }
68
+ try {
69
+ let data = arrayOfJsonObjects.map((x) => JSON.stringify(x)).join('\n');
70
+ fs.writeFileSync(file, data);
71
+ } catch (error) {
72
+ throttledLog('info', `jsonl.js failed to write to file ${error}`);
73
+ }
74
+
75
+ return file;
76
+ },
77
+ };