@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/README.md +62 -18
- package/dist/detector.d.ts +36 -1
- package/dist/detector.d.ts.map +1 -1
- package/dist/detector.js +559 -84
- package/dist/detector.js.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +39 -1
- package/dist/types.d.ts.map +1 -1
- package/meeting-detect.sh +160 -72
- package/package.json +5 -4
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
|
|
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
|
-
|
|
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:',
|
|
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:',
|
|
73
|
+
console.log('[MeetingDetector] Parsed signal:', outputSignal);
|
|
51
74
|
}
|
|
52
|
-
this.emit('meeting',
|
|
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:',
|
|
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'
|
|
134
|
-
|
|
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
|
|
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
|
|
145
|
-
'safari',
|
|
146
|
-
'firefox',
|
|
147
|
-
'microsoft edge',
|
|
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
|
-
|
|
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
|
|
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
|
|
170
|
-
//
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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);
|