@mostrom/meeting-detector 1.0.3 → 1.0.5

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/dist/detector.js CHANGED
@@ -6,12 +6,20 @@ export class MeetingDetector extends EventEmitter {
6
6
  constructor(options = {}) {
7
7
  super();
8
8
  this.activeSessions = new Map();
9
+ this.pendingConfidence = new Map();
10
+ this.serviceContext = new Map();
11
+ this.activeMeeting = null;
9
12
  // Get the absolute path to the script relative to this package
10
13
  const defaultScriptPath = options.scriptPath || join(dirname(fileURLToPath(import.meta.url)), '../meeting-detect.sh');
11
14
  this.options = {
12
15
  scriptPath: defaultScriptPath,
13
16
  debug: options.debug || false,
14
- sessionDeduplicationMs: options.sessionDeduplicationMs || 60000
17
+ sessionDeduplicationMs: options.sessionDeduplicationMs || 60000,
18
+ meetingEndTimeoutMs: options.meetingEndTimeoutMs || 30000,
19
+ emitUnknown: options.emitUnknown || false,
20
+ includeSensitiveMetadata: options.includeSensitiveMetadata || false,
21
+ includeRawSignalInLifecycle: options.includeRawSignalInLifecycle || false,
22
+ startupProbe: options.startupProbe !== false
15
23
  };
16
24
  }
17
25
  /**
@@ -28,28 +36,43 @@ export class MeetingDetector extends EventEmitter {
28
36
  this.process = spawn('sh', [this.options.scriptPath], {
29
37
  stdio: ['pipe', 'pipe', 'pipe']
30
38
  });
39
+ // Probe for an already-active meeting so detectors that start mid-call emit immediately.
40
+ if (this.options.startupProbe) {
41
+ this.probeActiveMeetingAtStartup();
42
+ }
43
+ let stderrBuffer = '';
31
44
  this.process.stdout?.on('data', (data) => {
32
45
  const lines = data.toString().trim().split('\n');
33
46
  for (const line of lines) {
34
47
  if (line.trim()) {
35
48
  try {
36
- const signal = this.parseSignal(line);
49
+ const parsedSignal = this.parseSignal(line);
50
+ const signal = this.stabilizeSignalContext(parsedSignal);
37
51
  if (this.shouldIgnoreSignal(signal)) {
38
52
  if (this.options.debug) {
39
53
  console.log('[MeetingDetector] Ignoring signal:', signal);
40
54
  }
41
55
  continue;
42
56
  }
43
- if (this.isDuplicateSession(signal)) {
57
+ const confidentSignal = this.resolveConfidence(signal);
58
+ if (!confidentSignal) {
59
+ if (this.options.debug) {
60
+ console.log('[MeetingDetector] Holding low-confidence signal:', signal);
61
+ }
62
+ continue;
63
+ }
64
+ this.updateMeetingLifecycle(confidentSignal);
65
+ if (this.isDuplicateSession(confidentSignal)) {
44
66
  if (this.options.debug) {
45
- console.log('[MeetingDetector] Skipping duplicate session:', signal);
67
+ console.log('[MeetingDetector] Skipping duplicate session:', confidentSignal);
46
68
  }
47
69
  continue;
48
70
  }
71
+ const outputSignal = this.sanitizeSignalForOutput(confidentSignal);
49
72
  if (this.options.debug) {
50
- console.log('[MeetingDetector] Parsed signal:', signal);
73
+ console.log('[MeetingDetector] Parsed signal:', outputSignal);
51
74
  }
52
- this.emit('meeting', signal);
75
+ this.emit('meeting', outputSignal);
53
76
  }
54
77
  catch (error) {
55
78
  if (this.options.debug) {
@@ -61,8 +84,10 @@ export class MeetingDetector extends EventEmitter {
61
84
  }
62
85
  });
63
86
  this.process.stderr?.on('data', (data) => {
87
+ const text = data.toString();
88
+ stderrBuffer += text;
64
89
  if (this.options.debug) {
65
- console.log('[MeetingDetector] stderr:', data.toString());
90
+ console.log('[MeetingDetector] stderr:', text);
66
91
  }
67
92
  });
68
93
  this.process.on('error', (error) => {
@@ -72,6 +97,26 @@ export class MeetingDetector extends EventEmitter {
72
97
  if (this.options.debug) {
73
98
  console.log(`[MeetingDetector] Process exited with code ${code}, signal ${signal}`);
74
99
  }
100
+ // Detect permission errors: log stream exits immediately with non-zero code and
101
+ // a relevant message when macOS privacy access has not been granted.
102
+ if (code !== 0 && code !== null && !signal) {
103
+ const stderr = stderrBuffer.toLowerCase();
104
+ if (stderr.includes('not allowed') ||
105
+ stderr.includes('authorization') ||
106
+ stderr.includes('permission denied') ||
107
+ stderr.includes('operation not permitted')) {
108
+ this.emit('error', new Error('Meeting detector failed to access macOS privacy logs (exit code ' + code + '). ' +
109
+ 'Grant Full Disk Access or Automation permissions in System Settings > Privacy & Security.'));
110
+ }
111
+ }
112
+ if (this.meetingEndTimer) {
113
+ clearTimeout(this.meetingEndTimer);
114
+ this.meetingEndTimer = undefined;
115
+ }
116
+ if (this.activeMeeting) {
117
+ this.emitMeetingLifecycle('meeting_ended', this.activeMeeting.platform, this.activeMeeting.confidence, 'stop', this.activeMeeting.signal);
118
+ this.activeMeeting = null;
119
+ }
75
120
  this.process = undefined;
76
121
  this.emit('exit', { code, signal });
77
122
  });
@@ -86,8 +131,18 @@ export class MeetingDetector extends EventEmitter {
86
131
  if (this.process) {
87
132
  this.process.kill('SIGTERM');
88
133
  this.process = undefined;
134
+ if (this.meetingEndTimer) {
135
+ clearTimeout(this.meetingEndTimer);
136
+ this.meetingEndTimer = undefined;
137
+ }
138
+ if (this.activeMeeting) {
139
+ this.emitMeetingLifecycle('meeting_ended', this.activeMeeting.platform, this.activeMeeting.confidence, 'stop', this.activeMeeting.signal);
140
+ }
141
+ this.activeMeeting = null;
89
142
  // Clear active sessions when stopping
90
143
  this.activeSessions.clear();
144
+ this.pendingConfidence.clear();
145
+ this.serviceContext.clear();
91
146
  if (this.options.debug) {
92
147
  console.log('[MeetingDetector] Stopped monitoring');
93
148
  }
@@ -111,6 +166,18 @@ export class MeetingDetector extends EventEmitter {
111
166
  onError(callback) {
112
167
  this.on('error', callback);
113
168
  }
169
+ /**
170
+ * Add lifecycle event listeners
171
+ */
172
+ onMeetingStarted(callback) {
173
+ this.on('meeting_started', callback);
174
+ }
175
+ onMeetingChanged(callback) {
176
+ this.on('meeting_changed', callback);
177
+ }
178
+ onMeetingEnded(callback) {
179
+ this.on('meeting_ended', callback);
180
+ }
114
181
  /**
115
182
  * Comprehensive filtering to prevent false positives
116
183
  * Filters out:
@@ -130,10 +197,13 @@ export class MeetingDetector extends EventEmitter {
130
197
  'granola helper', // Screen recording helper
131
198
  'webkit.gpu', // WebKit GPU process
132
199
  'webkit.networking', // WebKit networking
133
- 'chrome helper', // Chrome helper processes (too generic)
134
- 'electron helper', // Electron helper processes
200
+ // NOTE: 'chrome helper' intentionally NOT listed — Chrome Helper is the process
201
+ // used by Google Meet, Zoom web, and other browser-based meeting platforms.
202
+ 'electron helper', // Electron helper processes (not meeting-specific)
203
+ 'caphost', // Zoom internal media helper (emitted separately)
204
+ 'webview helper', // Generic WKWebView helper
135
205
  ];
136
- // Services/apps that are too generic or development-related
206
+ // Services/apps that are too generic or non-meeting
137
207
  const genericServices = [
138
208
  'electron',
139
209
  'terminal',
@@ -141,14 +211,20 @@ export class MeetingDetector extends EventEmitter {
141
211
  'finder',
142
212
  'xcode',
143
213
  'tips',
144
- 'google chrome', // Generic Chrome service (not a specific meeting)
145
- 'safari', // Generic Safari service
146
- 'firefox', // Generic Firefox service
147
- 'microsoft edge', // Generic Edge service
214
+ 'google chrome', // Generic Chrome (resolved to 'Google Meet' when in a call)
215
+ 'safari',
216
+ 'firefox',
217
+ 'microsoft edge',
218
+ 'photo booth',
219
+ 'quicktime player',
220
+ 'quicktime playerx',
148
221
  ];
149
222
  const processName = signal.process?.toLowerCase() || '';
150
223
  const serviceName = signal.service?.toLowerCase() || '';
151
- // Filter by process name patterns (partial match for flexibility)
224
+ if (serviceName === 'unknown' && !this.options.emitUnknown) {
225
+ return true;
226
+ }
227
+ // Filter by process name patterns (partial match)
152
228
  if (systemProcessPatterns.some(pattern => processName.includes(pattern))) {
153
229
  return true;
154
230
  }
@@ -156,38 +232,282 @@ export class MeetingDetector extends EventEmitter {
156
232
  if (genericServices.includes(serviceName)) {
157
233
  return true;
158
234
  }
159
- // Camera initialization filter: if verdict is 'requested' and window_title is empty,
160
- // it's likely just camera initialization, not an actual meeting
161
- // This applies to ALL apps to prevent false positives during camera setup
235
+ // Camera initialization filter: if verdict is 'requested' and window_title is empty
236
+ // AND the camera hardware is not yet active, it's a pre-check, not an active call.
162
237
  if (signal.verdict === 'requested' && (!signal.window_title || signal.window_title.trim() === '')) {
163
- // Exception: if camera is actively being used (not just requested), allow it through
164
- // This helps distinguish between "requesting permission" vs "actively in a call"
165
238
  if (!signal.camera_active) {
166
239
  return true;
167
240
  }
168
241
  }
169
- // For Google Meet specifically: require window title to contain meeting URL patterns
170
- // This prevents false positives from just opening Chrome with camera permissions
242
+ // For Google Meet (Chrome-based): validate title only when it is available.
243
+ // When Chrome is backgrounded the title is empty still allow the signal through
244
+ // because the meeting is genuinely active (camera_active guard above already handles
245
+ // the pre-check case).
171
246
  if (serviceName === 'google meet') {
172
- const windowTitle = signal.window_title || '';
173
- // Valid Google Meet windows should contain:
174
- // - meet.google.com URL
175
- // - "Meet - " prefix in title
176
- // - Valid meeting code pattern (e.g., abc-defg-hij)
177
- const hasValidMeetTitle = windowTitle.includes('meet.google.com') ||
178
- windowTitle.includes('Meet - ') ||
179
- /[a-z]{3}-[a-z]{4}-[a-z]{3}/.test(windowTitle); // Meeting code pattern
180
- if (!hasValidMeetTitle) {
181
- return true;
247
+ const windowTitle = signal.window_title?.trim() || '';
248
+ if (windowTitle !== '') {
249
+ // Title present → require it to look like an actual meeting room
250
+ const hasValidMeetTitle = windowTitle.includes('meet.google.com') ||
251
+ windowTitle.includes('Meet - ') ||
252
+ /[a-z]{3}-[a-z]{4}-[a-z]{3}/.test(windowTitle);
253
+ if (!hasValidMeetTitle) {
254
+ return true;
255
+ }
182
256
  }
257
+ // Empty title → pass through (Chrome is backgrounded but meeting is active)
183
258
  }
184
259
  return false;
185
260
  }
261
+ sanitizeSignalForOutput(signal) {
262
+ if (this.options.includeSensitiveMetadata) {
263
+ return { ...signal };
264
+ }
265
+ return {
266
+ ...signal,
267
+ window_title: '',
268
+ chrome_url: undefined,
269
+ };
270
+ }
271
+ normalizePlatform(service) {
272
+ const key = (service || '').trim().toLowerCase();
273
+ if (!key)
274
+ return 'Unknown';
275
+ if (key === 'microsoft teams')
276
+ return 'Microsoft Teams';
277
+ if (key === 'zoom')
278
+ return 'Zoom';
279
+ if (key === 'google meet')
280
+ return 'Google Meet';
281
+ if (key === 'slack')
282
+ return 'Slack';
283
+ if (key === 'webex' || key === 'cisco webex')
284
+ return 'Cisco Webex';
285
+ if (key === 'discord')
286
+ return 'Discord';
287
+ if (key === 'facetime')
288
+ return 'FaceTime';
289
+ if (key === 'skype')
290
+ return 'Skype';
291
+ if (key === 'whereby')
292
+ return 'Whereby';
293
+ if (key === 'gotomeeting')
294
+ return 'GoToMeeting';
295
+ if (key === 'bluejeans')
296
+ return 'BlueJeans';
297
+ if (key === 'jitsi meet')
298
+ return 'Jitsi Meet';
299
+ if (key === '8x8')
300
+ return '8x8';
301
+ if (key === 'ringcentral')
302
+ return 'RingCentral';
303
+ if (key === 'bigbluebutton')
304
+ return 'BigBlueButton';
305
+ if (key === 'amazon chime')
306
+ return 'Amazon Chime';
307
+ if (key === 'google hangouts')
308
+ return 'Google Hangouts';
309
+ if (key === 'adobe connect')
310
+ return 'Adobe Connect';
311
+ if (key === 'teamviewer')
312
+ return 'TeamViewer';
313
+ if (key === 'anydesk')
314
+ return 'AnyDesk';
315
+ if (key === 'clickmeeting')
316
+ return 'ClickMeeting';
317
+ if (key === 'appear.in')
318
+ return 'Appear.in';
319
+ return 'Unknown';
320
+ }
321
+ getSignalConfidence(signal) {
322
+ if (signal.verdict === 'allowed' || signal.preflight === false) {
323
+ return 'high';
324
+ }
325
+ if ((signal.window_title && signal.window_title.trim() !== '') || signal.camera_active) {
326
+ return 'medium';
327
+ }
328
+ return 'low';
329
+ }
330
+ emitMeetingLifecycle(event, platform, confidence, reason, signal, previousPlatform) {
331
+ const payload = {
332
+ event,
333
+ timestamp: new Date().toISOString(),
334
+ platform,
335
+ confidence,
336
+ reason,
337
+ previous_platform: previousPlatform,
338
+ raw_signal: this.options.includeRawSignalInLifecycle ? this.sanitizeSignalForOutput(signal) : undefined,
339
+ };
340
+ this.emit(event, payload);
341
+ this.emit('meeting_lifecycle', payload);
342
+ }
343
+ scheduleMeetingEndCheck() {
344
+ if (this.meetingEndTimer) {
345
+ clearTimeout(this.meetingEndTimer);
346
+ }
347
+ if (!this.activeMeeting) {
348
+ this.meetingEndTimer = undefined;
349
+ return;
350
+ }
351
+ this.meetingEndTimer = setTimeout(() => {
352
+ this.handleMeetingEndTimeout();
353
+ }, this.options.meetingEndTimeoutMs);
354
+ this.meetingEndTimer.unref?.();
355
+ }
356
+ handleMeetingEndTimeout() {
357
+ if (!this.activeMeeting) {
358
+ this.meetingEndTimer = undefined;
359
+ return;
360
+ }
361
+ const idleMs = Date.now() - this.activeMeeting.lastSeen;
362
+ if (idleMs >= this.options.meetingEndTimeoutMs) {
363
+ const ended = this.activeMeeting;
364
+ this.activeMeeting = null;
365
+ this.meetingEndTimer = undefined;
366
+ this.emitMeetingLifecycle('meeting_ended', ended.platform, ended.confidence, 'timeout', ended.signal);
367
+ return;
368
+ }
369
+ this.scheduleMeetingEndCheck();
370
+ }
371
+ updateMeetingLifecycle(signal) {
372
+ const platform = this.normalizePlatform(signal.service);
373
+ if (platform === 'Unknown' && !this.options.emitUnknown) {
374
+ return;
375
+ }
376
+ const confidence = this.getSignalConfidence(signal);
377
+ const now = Date.now();
378
+ if (!this.activeMeeting) {
379
+ this.activeMeeting = {
380
+ platform,
381
+ lastSeen: now,
382
+ confidence,
383
+ signal,
384
+ };
385
+ this.emitMeetingLifecycle('meeting_started', platform, confidence, 'signal', signal);
386
+ this.scheduleMeetingEndCheck();
387
+ return;
388
+ }
389
+ if (this.activeMeeting.platform !== platform) {
390
+ const previousPlatform = this.activeMeeting.platform;
391
+ this.activeMeeting = {
392
+ platform,
393
+ lastSeen: now,
394
+ confidence,
395
+ signal,
396
+ };
397
+ this.emitMeetingLifecycle('meeting_changed', platform, confidence, 'switch', signal, previousPlatform);
398
+ this.scheduleMeetingEndCheck();
399
+ return;
400
+ }
401
+ this.activeMeeting.lastSeen = now;
402
+ this.activeMeeting.confidence = confidence;
403
+ this.activeMeeting.signal = signal;
404
+ this.scheduleMeetingEndCheck();
405
+ }
406
+ getServiceKey(signal) {
407
+ return (signal.service || signal.front_app || signal.process || '').toLowerCase();
408
+ }
409
+ isFrontAppConsistentWithService(frontApp, service) {
410
+ const f = (frontApp || '').toLowerCase();
411
+ const s = (service || '').toLowerCase();
412
+ if (!f || !s)
413
+ return false;
414
+ if (s === 'microsoft teams') {
415
+ return f.includes('teams') || f.includes('msteams');
416
+ }
417
+ if (s === 'google meet') {
418
+ return f.includes('chrome') || f.includes('google meet');
419
+ }
420
+ if (s === 'zoom') {
421
+ return f.includes('zoom');
422
+ }
423
+ if (s === 'cisco webex') {
424
+ return f.includes('webex');
425
+ }
426
+ if (s === 'slack') {
427
+ return f.includes('slack');
428
+ }
429
+ return f.includes(s);
430
+ }
431
+ stabilizeSignalContext(signal) {
432
+ const key = this.getServiceKey(signal);
433
+ const existing = this.serviceContext.get(key) || {};
434
+ const next = { ...signal };
435
+ const rawFront = (signal.front_app || '').trim();
436
+ const rawTitle = (signal.window_title || '').trim();
437
+ const frontConsistent = this.isFrontAppConsistentWithService(rawFront, signal.service);
438
+ if (rawFront && frontConsistent) {
439
+ next.front_app = rawFront;
440
+ existing.frontApp = rawFront;
441
+ }
442
+ else if (existing.frontApp) {
443
+ next.front_app = existing.frontApp;
444
+ }
445
+ else {
446
+ // Keep context aligned with detected service when OS foreground sampling is stale.
447
+ next.front_app = signal.service;
448
+ existing.frontApp = signal.service;
449
+ }
450
+ if (rawTitle && frontConsistent) {
451
+ next.window_title = rawTitle;
452
+ existing.windowTitle = rawTitle;
453
+ }
454
+ else if (existing.windowTitle && this.isFrontAppConsistentWithService(next.front_app, signal.service)) {
455
+ next.window_title = existing.windowTitle;
456
+ }
457
+ else {
458
+ next.window_title = '';
459
+ }
460
+ this.serviceContext.set(key, existing);
461
+ return next;
462
+ }
463
+ isLowConfidenceSignal(signal) {
464
+ const serviceKey = this.getServiceKey(signal);
465
+ if (!MeetingDetector.PRECHECK_PRONE_SERVICES.has(serviceKey)) {
466
+ return false;
467
+ }
468
+ const hasNoWindowTitle = !signal.window_title || signal.window_title.trim() === '';
469
+ return signal.verdict === 'requested' && signal.preflight === true && hasNoWindowTitle;
470
+ }
471
+ hasStrongMeetingEvidence(signal) {
472
+ if (signal.verdict === 'allowed' || signal.preflight === false) {
473
+ return true;
474
+ }
475
+ return !!signal.window_title && signal.window_title.trim() !== '';
476
+ }
477
+ cleanupExpiredPendingConfidence(now) {
478
+ for (const [key, pending] of this.pendingConfidence.entries()) {
479
+ if (now - pending.lastSeen > MeetingDetector.LOW_CONFIDENCE_WINDOW_MS) {
480
+ this.pendingConfidence.delete(key);
481
+ }
482
+ }
483
+ }
484
+ resolveConfidence(signal) {
485
+ const now = Date.now();
486
+ this.cleanupExpiredPendingConfidence(now);
487
+ const key = this.getServiceKey(signal);
488
+ const pending = this.pendingConfidence.get(key);
489
+ const lowConfidence = this.isLowConfidenceSignal(signal);
490
+ const strongEvidence = this.hasStrongMeetingEvidence(signal);
491
+ if (strongEvidence) {
492
+ this.pendingConfidence.delete(key);
493
+ return signal;
494
+ }
495
+ if (!lowConfidence) {
496
+ return signal;
497
+ }
498
+ // For precheck-prone services (Teams, Zoom, Webex, Slack) only strong evidence
499
+ // is trusted. These apps continuously send preflight checks even when idle, so
500
+ // the burst+sparse-follow-up pattern would satisfy any time/count threshold.
501
+ // Real meetings from these apps produce FORWARD events (preflight=false) which
502
+ // are caught by hasStrongMeetingEvidence above.
503
+ return null;
504
+ }
186
505
  /**
187
506
  * Generate a unique session key based on the signal properties
188
507
  */
189
508
  getSessionKey(signal) {
190
- return `${signal.pid}:${signal.service}:${signal.front_app}`;
509
+ // Session key is service-centric to collapse helper-process bursts from the same app.
510
+ return signal.service || signal.front_app || signal.process;
191
511
  }
192
512
  /**
193
513
  * Check if this signal is a duplicate of an existing session
@@ -234,9 +554,10 @@ export class MeetingDetector extends EventEmitter {
234
554
  }
235
555
  parseSignal(line) {
236
556
  const signal = JSON.parse(line);
557
+ const chromeUrl = signal.chrome_url || '';
237
558
  // Use transformed app name as service if the original service is a system service like 'microphone' or 'camera'
238
559
  const originalService = signal.service || '';
239
- const transformedService = this.transformAppName(signal.front_app, signal.process);
560
+ const transformedService = this.transformAppName(signal.front_app, signal.process, signal.window_title || '', chromeUrl);
240
561
  const finalService = (originalService === 'microphone' || originalService === 'camera' || !originalService)
241
562
  ? transformedService
242
563
  : originalService;
@@ -245,6 +566,7 @@ export class MeetingDetector extends EventEmitter {
245
566
  timestamp: signal.timestamp,
246
567
  service: finalService,
247
568
  verdict: signal.verdict || '',
569
+ preflight: signal.preflight === 'true' || signal.preflight === true,
248
570
  process: signal.process || '',
249
571
  pid: signal.pid || '',
250
572
  parent_pid: signal.parent_pid || '',
@@ -252,84 +574,237 @@ export class MeetingDetector extends EventEmitter {
252
574
  front_app: signal.front_app || '',
253
575
  window_title: signal.window_title || '',
254
576
  session_id: signal.session_id || '',
255
- camera_active: signal.camera_active === 'true' || signal.camera_active === true
577
+ camera_active: signal.camera_active === 'true' || signal.camera_active === true,
578
+ chrome_url: chromeUrl
256
579
  };
257
580
  }
258
- transformAppName(frontApp, process) {
581
+ includesAny(value, patterns) {
582
+ return patterns.some((pattern) => value.includes(pattern));
583
+ }
584
+ transformAppName(frontApp, process, windowTitle = '', chromeUrl = '') {
259
585
  const app = frontApp?.toLowerCase() || '';
260
586
  const proc = process?.toLowerCase() || '';
261
- if (app.includes('slack') || proc.includes('slack')) {
262
- return 'Slack';
263
- }
264
- else if (app.includes('msteams') || proc.includes('microsoft teams') || proc.includes('teams')) {
587
+ const title = windowTitle?.toLowerCase() || '';
588
+ const url = chromeUrl.toLowerCase();
589
+ // For Chrome Helper processes, the active tab URL is the definitive source —
590
+ // it does not depend on which app is currently frontmost.
591
+ if (url && proc.includes('chrome')) {
592
+ if (url.includes('meet.google.com'))
593
+ return 'Google Meet';
594
+ if (url.includes('zoom.us/wc/') || url.includes('zoom.us/j/'))
595
+ return 'Zoom';
596
+ if (url.includes('teams.microsoft.com') || url.includes('teams.live.com'))
597
+ return 'Microsoft Teams';
598
+ if (url.includes('web.webex.com') || url.includes('webex.com/meet'))
599
+ return 'Cisco Webex';
600
+ if (url.includes('app.slack.com') && url.includes('huddle'))
601
+ return 'Slack';
602
+ if (url.includes('meet.jit.si') || url.includes('jitsi'))
603
+ return 'Jitsi Meet';
604
+ if (url.includes('whereby.com'))
605
+ return 'Whereby';
606
+ if (url.includes('bluejeans.com'))
607
+ return 'BlueJeans';
608
+ if (url.includes('ringcentral.com'))
609
+ return 'RingCentral';
610
+ if (url.includes('chime.aws'))
611
+ return 'Amazon Chime';
612
+ if (url.includes('goto.com') || url.includes('gotomeeting.com'))
613
+ return 'GoToMeeting';
614
+ }
615
+ // Prefer process identity next because front_app sampling can be stale.
616
+ if (this.includesAny(proc, ['microsoft teams', 'msteams']))
265
617
  return 'Microsoft Teams';
266
- }
267
- else if (app.includes('zoom') || proc.includes('zoom')) {
618
+ if (this.includesAny(proc, ['zoom']))
268
619
  return 'Zoom';
269
- }
270
- else if (app.includes('webex') || proc.includes('webex') || proc.includes('cisco webex')) {
271
- return 'Webex';
272
- }
273
- else if (app.includes('google meet') || proc.includes('google meet') || proc.includes('meet.google.com') || (app.includes('chrome') && proc.includes('chrome'))) {
620
+ if (this.includesAny(proc, ['webex', 'cisco webex']))
621
+ return 'Cisco Webex';
622
+ if (this.includesAny(proc, ['slack']))
623
+ return 'Slack';
624
+ if (this.includesAny(proc, ['google meet', 'meet.google.com']))
625
+ return 'Google Meet';
626
+ if (proc.includes('chrome') && (title.includes('meet.google.com') || /[a-z]{3}-[a-z]{4}-[a-z]{3}/.test(title))) {
274
627
  return 'Google Meet';
275
628
  }
276
- else if (app.includes('skype') || proc.includes('skype')) {
629
+ if (this.includesAny(proc, ['skype']))
277
630
  return 'Skype';
278
- }
279
- else if (app.includes('discord') || proc.includes('discord')) {
631
+ if (this.includesAny(proc, ['discord']))
280
632
  return 'Discord';
281
- }
282
- else if (app.includes('facetime') || proc.includes('facetime')) {
633
+ if (this.includesAny(proc, ['facetime']))
283
634
  return 'FaceTime';
284
- }
285
- else if (app.includes('gotomeeting') || proc.includes('gotomeeting') || proc.includes('goto meeting')) {
635
+ if (this.includesAny(proc, ['gotomeeting', 'goto meeting']))
286
636
  return 'GoToMeeting';
287
- }
288
- else if (app.includes('bluejeans') || proc.includes('bluejeans') || proc.includes('blue jeans')) {
637
+ if (this.includesAny(proc, ['bluejeans', 'blue jeans']))
289
638
  return 'BlueJeans';
290
- }
291
- else if (app.includes('jitsi') || proc.includes('jitsi')) {
639
+ if (this.includesAny(proc, ['jitsi']))
292
640
  return 'Jitsi Meet';
293
- }
294
- else if (app.includes('whereby') || proc.includes('whereby')) {
641
+ if (this.includesAny(proc, ['whereby']))
295
642
  return 'Whereby';
296
- }
297
- else if (app.includes('8x8') || proc.includes('8x8')) {
643
+ if (this.includesAny(proc, ['8x8']))
298
644
  return '8x8';
299
- }
300
- else if (app.includes('ringcentral') || proc.includes('ringcentral') || proc.includes('ring central')) {
645
+ if (this.includesAny(proc, ['ringcentral', 'ring central']))
301
646
  return 'RingCentral';
302
- }
303
- else if (app.includes('bigbluebutton') || proc.includes('bigbluebutton') || proc.includes('big blue button')) {
647
+ if (this.includesAny(proc, ['bigbluebutton', 'big blue button']))
304
648
  return 'BigBlueButton';
305
- }
306
- else if (app.includes('chime') || proc.includes('chime') || proc.includes('amazon chime')) {
649
+ if (this.includesAny(proc, ['amazon chime', 'chime']))
307
650
  return 'Amazon Chime';
308
- }
309
- else if (app.includes('hangouts') || proc.includes('hangouts') || proc.includes('google hangouts')) {
651
+ if (this.includesAny(proc, ['google hangouts', 'hangouts']))
310
652
  return 'Google Hangouts';
311
- }
312
- else if (app.includes('adobe connect') || proc.includes('adobe connect')) {
653
+ if (this.includesAny(proc, ['adobe connect']))
313
654
  return 'Adobe Connect';
314
- }
315
- else if (app.includes('teamviewer') || proc.includes('teamviewer')) {
655
+ if (this.includesAny(proc, ['teamviewer']))
316
656
  return 'TeamViewer';
317
- }
318
- else if (app.includes('anydesk') || proc.includes('anydesk')) {
657
+ if (this.includesAny(proc, ['anydesk']))
319
658
  return 'AnyDesk';
320
- }
321
- else if (app.includes('clickmeeting') || proc.includes('clickmeeting')) {
659
+ if (this.includesAny(proc, ['clickmeeting']))
322
660
  return 'ClickMeeting';
323
- }
324
- else if (app.includes('appear.in') || proc.includes('appear.in')) {
661
+ if (this.includesAny(proc, ['appear.in']))
325
662
  return 'Appear.in';
663
+ // Fallback to front_app when process identity is generic/indirect (e.g., Chrome Helper
664
+ // without a chrome_url resolved). front_app is unreliable when Chrome is backgrounded
665
+ // — only use it when we have no better signal.
666
+ if (this.includesAny(app, ['microsoft teams', 'msteams']))
667
+ return 'Microsoft Teams';
668
+ if (this.includesAny(app, ['zoom']))
669
+ return 'Zoom';
670
+ if (this.includesAny(app, ['webex']))
671
+ return 'Cisco Webex';
672
+ if (this.includesAny(app, ['slack']))
673
+ return 'Slack';
674
+ if (this.includesAny(app, ['google meet']))
675
+ return 'Google Meet';
676
+ if (this.includesAny(app, ['chrome']) && (title.includes('meet.google.com') || /[a-z]{3}-[a-z]{4}-[a-z]{3}/.test(title))) {
677
+ return 'Google Meet';
326
678
  }
327
- else {
328
- // Fallback to front_app if no match
329
- return frontApp || 'Meeting App';
330
- }
679
+ if (this.includesAny(app, ['skype']))
680
+ return 'Skype';
681
+ if (this.includesAny(app, ['discord']))
682
+ return 'Discord';
683
+ if (this.includesAny(app, ['facetime']))
684
+ return 'FaceTime';
685
+ if (this.includesAny(app, ['gotomeeting']))
686
+ return 'GoToMeeting';
687
+ if (this.includesAny(app, ['bluejeans']))
688
+ return 'BlueJeans';
689
+ if (this.includesAny(app, ['jitsi']))
690
+ return 'Jitsi Meet';
691
+ if (this.includesAny(app, ['whereby']))
692
+ return 'Whereby';
693
+ if (this.includesAny(app, ['8x8']))
694
+ return '8x8';
695
+ if (this.includesAny(app, ['ringcentral']))
696
+ return 'RingCentral';
697
+ if (this.includesAny(app, ['chime']))
698
+ return 'Amazon Chime';
699
+ if (this.includesAny(app, ['hangouts']))
700
+ return 'Google Hangouts';
701
+ if (this.includesAny(app, ['adobe connect']))
702
+ return 'Adobe Connect';
703
+ if (this.includesAny(app, ['teamviewer']))
704
+ return 'TeamViewer';
705
+ if (this.includesAny(app, ['anydesk']))
706
+ return 'AnyDesk';
707
+ if (this.includesAny(app, ['clickmeeting']))
708
+ return 'ClickMeeting';
709
+ if (this.includesAny(app, ['appear.in']))
710
+ return 'Appear.in';
711
+ // Final fallback: do not guess.
712
+ return 'Unknown';
713
+ }
714
+ /**
715
+ * Check at startup whether a supported meeting is already active.
716
+ * Emits a synthetic meeting_started lifecycle event if found.
717
+ * This handles the case where the detector starts while a call is already in progress.
718
+ */
719
+ probeActiveMeetingAtStartup() {
720
+ // Check whether the camera daemon is already running (indicates active camera use).
721
+ const cameraProbe = spawn('sh', ['-c',
722
+ 'pgrep -xq VDCAssistant 2>/dev/null || pgrep -xq AppleCameraAssistant 2>/dev/null'
723
+ ]);
724
+ cameraProbe.on('close', (cameraCode) => {
725
+ // P2 guard: abort if the detector was stopped before this callback fired.
726
+ if (!this.process)
727
+ return;
728
+ if (cameraCode !== 0)
729
+ return; // Camera not active — no meeting in progress.
730
+ // Candidates in priority order. Run ALL checks independently (semicolons, not ||) so
731
+ // every running app is discovered, then resolve ambiguity via front-app tiebreak.
732
+ const candidates = [
733
+ ['Microsoft Teams', 'Microsoft Teams'],
734
+ ['zoom.us', 'Zoom'],
735
+ ['Webex', 'Cisco Webex'],
736
+ ['Slack', 'Slack'],
737
+ ['Discord', 'Discord'],
738
+ ['FaceTime', 'FaceTime'],
739
+ ];
740
+ // Query front app in parallel with process checks so we can resolve ambiguity
741
+ // without adding extra latency.
742
+ const procScript = candidates
743
+ .map(([proc, label]) => `pgrep -xq "${proc}" 2>/dev/null && echo "${label}"; true`)
744
+ .join('; ');
745
+ const fullScript = `(${procScript}); echo "FRONTAPP=$(osascript -e 'tell application "System Events" to name of first application process whose frontmost is true' 2>/dev/null || echo '')"`;
746
+ const procProbe = spawn('sh', ['-c', fullScript]);
747
+ let output = '';
748
+ procProbe.stdout?.on('data', (d) => { output += d.toString(); });
749
+ procProbe.on('close', () => {
750
+ // P2 guard: abort if the detector was stopped while the probe was running.
751
+ if (!this.process)
752
+ return;
753
+ const matched = candidates.filter(([, label]) => output.includes(label));
754
+ if (matched.length === 0)
755
+ return; // No known meeting process found.
756
+ let selected = matched[0]; // Priority-order fallback (first in candidate list).
757
+ if (matched.length > 1) {
758
+ // Multiple meeting apps are running. Use the frontmost app to tiebreak:
759
+ // the focused window is almost always the active meeting.
760
+ const frontMatch = output.match(/FRONTAPP=(.+)/);
761
+ const frontApp = (frontMatch?.[1] || '').trim().toLowerCase();
762
+ const frontCandidate = matched.find(([proc]) => proc.toLowerCase().includes(frontApp) || frontApp.includes(proc.toLowerCase()));
763
+ if (frontCandidate) {
764
+ selected = frontCandidate;
765
+ }
766
+ // If frontmost app is not a meeting app (e.g. user is in Zoom but looking at
767
+ // a browser), fall through to priority-order selection (selected = matched[0]).
768
+ if (this.options.debug) {
769
+ console.log('[MeetingDetector] Startup probe: multiple apps found, front app resolution', { matched: matched.map(([, l]) => l), frontApp, selected: selected[1] });
770
+ }
771
+ }
772
+ const [procName, platform] = selected;
773
+ const now = new Date().toISOString().slice(0, 19) + 'Z';
774
+ const syntheticSignal = {
775
+ event: 'meeting_signal',
776
+ timestamp: now,
777
+ service: platform,
778
+ verdict: 'allowed',
779
+ preflight: false,
780
+ process: procName,
781
+ pid: '',
782
+ parent_pid: '',
783
+ process_path: '',
784
+ front_app: procName,
785
+ window_title: '',
786
+ session_id: '',
787
+ camera_active: true,
788
+ };
789
+ if (this.options.debug) {
790
+ console.log('[MeetingDetector] Startup probe found active meeting:', platform);
791
+ }
792
+ this.updateMeetingLifecycle(syntheticSignal);
793
+ const outputSignal = this.sanitizeSignalForOutput(syntheticSignal);
794
+ this.emit('meeting', outputSignal);
795
+ });
796
+ });
331
797
  }
332
798
  }
799
+ MeetingDetector.LOW_CONFIDENCE_WINDOW_MS = 45000;
800
+ MeetingDetector.LOW_CONFIDENCE_FALLBACK_MIN_SIGNALS = 4;
801
+ MeetingDetector.LOW_CONFIDENCE_FALLBACK_MIN_DURATION_MS = 30000;
802
+ MeetingDetector.PRECHECK_PRONE_SERVICES = new Set([
803
+ 'microsoft teams',
804
+ 'zoom',
805
+ 'cisco webex',
806
+ 'slack'
807
+ ]);
333
808
  // Convenience function for simple usage
334
809
  export function detector(callback, options) {
335
810
  const detector = new MeetingDetector(options);