@infilectai/aira 0.0.0-stage → 0.1.0

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 (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +74 -2
  3. package/bin/aira-capture +0 -0
  4. package/dist/attribution.d.ts +118 -0
  5. package/dist/attribution.d.ts.map +1 -0
  6. package/dist/attribution.js +626 -0
  7. package/dist/attribution.js.map +1 -0
  8. package/dist/audio.d.ts +26 -0
  9. package/dist/audio.d.ts.map +1 -0
  10. package/dist/audio.js +172 -0
  11. package/dist/audio.js.map +1 -0
  12. package/dist/brain.d.ts +11 -0
  13. package/dist/brain.d.ts.map +1 -0
  14. package/dist/brain.js +70 -0
  15. package/dist/brain.js.map +1 -0
  16. package/dist/capture.d.ts +48 -0
  17. package/dist/capture.d.ts.map +1 -0
  18. package/dist/capture.js +195 -0
  19. package/dist/capture.js.map +1 -0
  20. package/dist/engine.d.ts +61 -0
  21. package/dist/engine.d.ts.map +1 -0
  22. package/dist/engine.js +471 -0
  23. package/dist/engine.js.map +1 -0
  24. package/dist/eyes.d.ts +60 -0
  25. package/dist/eyes.d.ts.map +1 -0
  26. package/dist/eyes.js +173 -0
  27. package/dist/eyes.js.map +1 -0
  28. package/dist/index.d.ts +11 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/index.js +9 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/lockfile.d.ts +14 -0
  33. package/dist/lockfile.d.ts.map +1 -0
  34. package/dist/lockfile.js +38 -0
  35. package/dist/lockfile.js.map +1 -0
  36. package/dist/providers.d.ts +162 -0
  37. package/dist/providers.d.ts.map +1 -0
  38. package/dist/providers.js +374 -0
  39. package/dist/providers.js.map +1 -0
  40. package/dist/transport.d.ts +14 -0
  41. package/dist/transport.d.ts.map +1 -0
  42. package/dist/transport.js +44 -0
  43. package/dist/transport.js.map +1 -0
  44. package/dist/types.d.ts +175 -0
  45. package/dist/types.d.ts.map +1 -0
  46. package/dist/types.js +2 -0
  47. package/dist/types.js.map +1 -0
  48. package/dist/vectors.d.ts +10 -0
  49. package/dist/vectors.d.ts.map +1 -0
  50. package/dist/vectors.js +66 -0
  51. package/dist/vectors.js.map +1 -0
  52. package/native/AiraCapture.swift +1142 -0
  53. package/native/Info.plist +26 -0
  54. package/native/NativeMeetingScraper.swift +1277 -0
  55. package/package.json +84 -4
  56. package/scripts/build-capture.cjs +57 -0
  57. package/scripts/postinstall.cjs +29 -0
@@ -0,0 +1,1277 @@
1
+ import Foundation
2
+ import AppKit
3
+ import ApplicationServices
4
+ import CoreGraphics
5
+ import JavaScriptCore
6
+
7
+ /// Native macOS Accessibility & AppleScript scraper for Zoom, Google Meet, and Microsoft Teams.
8
+ /// Incorporates the multi-locale AX rules, JavaScriptCore rule engine, and mouse-idle backpressure patterns
9
+ /// from Recall's desktop SDK alongside Aira's Google Meet DOM/AX probes.
10
+ public final class NativeMeetingScraper: @unchecked Sendable {
11
+ public static let shared = NativeMeetingScraper()
12
+
13
+ public struct ScrapeSnapshot: Sendable {
14
+ public let participants: [Participant]
15
+ public let activeSpeakerId: String?
16
+ public let activeSpeakerName: String?
17
+ public let localUserName: String?
18
+ public let meetingTitle: String?
19
+ public let meetingUrl: String?
20
+ }
21
+
22
+ // 17-locale Zoom strings from Recall's JSUIScrapingService
23
+ private static let zoomMeetingMenuTitles: Set<String> = [
24
+ "Meeting", "Reunión", "Réunion", "Meeting-Steuerung", "Reunião",
25
+ "ミーティング", "회의", "会议", "會議", "Встреча", "Riunione",
26
+ " Spotkanie", "Spotkanie", "Vergadering", "Toplantı", "Cuộc họp",
27
+ "Rapat", "Конференция", "Möte"
28
+ ]
29
+
30
+ private static let zoomVideoRenderRoleDescriptions: Set<String> = [
31
+ "Video render", "Renderização de vídeo", "Renderizado de video",
32
+ "Rendu vidéo", "Videorendering", "ビデオレンダリング", "비디오 렌더링",
33
+ "视频渲染", "視訊轉譯", "Видео рендеринг", "Rendering video",
34
+ "Renderowanie wideo", "Video weergeven", "Video oluşturma", "Kết xuất video",
35
+ "Video rendern", "Representación de vídeo", "Render video", "ビデオ レンダリング",
36
+ "videorender", "Обработка видео", "Videoåtergivning", "視訊轉換"
37
+ ]
38
+
39
+ private static let zoomAudioStatusKeywords: [String] = [
40
+ "unmuted", "muted", "audio", "stummgeschaltet", "stumm", "muet",
41
+ "silenciado", "silêncio", "ミュート", "음소거", "静音", "靜音", "звук"
42
+ ]
43
+
44
+ private static let browserBundleIds: Set<String> = [
45
+ "com.google.Chrome",
46
+ "company.thebrowser.Browser", // Arc
47
+ "com.brave.Browser",
48
+ "com.microsoft.edgemac",
49
+ "com.vivaldi.Vivaldi",
50
+ "com.apple.Safari",
51
+ "ai.perplexity.comet",
52
+ "company.thebrowser.dia"
53
+ ]
54
+
55
+ private let meetCodeRegex = try! NSRegularExpression(
56
+ pattern: #"meet\.google\.com/([a-z]{3}-[a-z]{4}-[a-z]{3})"#,
57
+ options: [.caseInsensitive]
58
+ )
59
+
60
+ private let jsLock = NSLock()
61
+ private let jsContext: JSContext
62
+ private var lastAppleScriptPollTime: Double = 0
63
+ private var appleScriptBackoffUntil: Double = 0
64
+ private var cachedAppleScriptSnapshot: ScrapeSnapshot?
65
+ private let axLock = NSLock()
66
+ private var enabledAccessibilityPIDs: [pid_t: Double] = [:]
67
+
68
+ private init() {
69
+ self.jsContext = JSContext()!
70
+ setupZoomJSRuleEngine()
71
+ }
72
+
73
+ private func setupZoomJSRuleEngine() {
74
+ let script = """
75
+ var ZoomScraperRules = {
76
+ isStatusLabelTitle: function(title) {
77
+ var trimmed = String(title || "").trim();
78
+ return /Participant ID:\\s*\\d+/i.test(trimmed)
79
+ || /^Meeting\\s+\\d+-minutes$/i.test(trimmed)
80
+ || trimmed.toLowerCase() === "meeting info";
81
+ },
82
+ parseDescription: function(description) {
83
+ var parts = String(description || "").split(',').map(function(p) { return p.trim(); });
84
+ var audioKeywords = ["muted", "unmuted", "audio", "stummgeschaltet", "stumm", "muet", "silenciado", "ミュート", "음소거", "静音", "靜音", "звук"];
85
+ var audioPartIdx = -1;
86
+ for (var i = 1; i < parts.length; i++) {
87
+ var lower = parts[i].toLowerCase();
88
+ var words = lower.split(/\\s+/);
89
+ for (var k = 0; k < audioKeywords.length; k++) {
90
+ var kw = audioKeywords[k];
91
+ if (words.indexOf(kw) !== -1 || (kw.length > 5 && lower.indexOf(kw) !== -1)) {
92
+ audioPartIdx = i;
93
+ break;
94
+ }
95
+ }
96
+ if (audioPartIdx !== -1) break;
97
+ }
98
+ var rawName = "";
99
+ var isActiveSpeaker = false;
100
+ if (parts.length >= 2 && audioPartIdx === 1) {
101
+ rawName = parts[0];
102
+ isActiveSpeaker = parts.slice(2).some(function(p) { return p.toLowerCase().indexOf("active speaker") !== -1; });
103
+ } else if (audioPartIdx === -1) {
104
+ rawName = parts[0] || "";
105
+ isActiveSpeaker = parts.slice(1).some(function(p) { return p.toLowerCase().indexOf("active speaker") !== -1; });
106
+ } else {
107
+ rawName = parts.slice(0, audioPartIdx).join(", ");
108
+ isActiveSpeaker = parts.slice(audioPartIdx + 1).some(function(p) { return p.toLowerCase().indexOf("active speaker") !== -1; });
109
+ }
110
+ var isLocalUser = /\\((You|Me)\\)/i.test(rawName);
111
+ return {
112
+ name: rawName.replace(/\\s*\\((Host|Co-host|You|Me)\\)\\s*/ig, "").trim(),
113
+ isActiveSpeaker: !!isActiveSpeaker,
114
+ isLocalUser: !!isLocalUser,
115
+ hasAudioStatus: audioPartIdx !== -1
116
+ };
117
+ }
118
+ };
119
+ """
120
+ jsContext.evaluateScript(script)
121
+ }
122
+
123
+ /// Enables `AXEnhancedUserInterface` (and `AXManualAccessibility` for Chromium/Electron apps)
124
+ /// on the target application's `AXUIElement`, matching Recall's `setAccessibilityForChromium`,
125
+ /// `setAccessibilityForTeams`, and `setAccessibilityForZoom`. Without this, Chromium and Electron
126
+ /// do not expose `AXWebArea` or DOM children unless VoiceOver is active.
127
+ public func ensureAccessibilityEnabled(for pid: pid_t, bundleId: String? = nil) {
128
+ let now = ProcessInfo.processInfo.systemUptime
129
+ axLock.lock()
130
+ if let last = enabledAccessibilityPIDs[pid], (now - last) < 15.0 {
131
+ axLock.unlock()
132
+ return
133
+ }
134
+ enabledAccessibilityPIDs[pid] = now
135
+ axLock.unlock()
136
+
137
+ let axApp = AXUIElementCreateApplication(pid)
138
+ _ = AXUIElementSetAttributeValue(axApp, "AXEnhancedUserInterface" as CFString, kCFBooleanTrue)
139
+ if bundleId != "us.zoom.xos" {
140
+ _ = AXUIElementSetAttributeValue(axApp, "AXManualAccessibility" as CFString, kCFBooleanTrue)
141
+ }
142
+ }
143
+
144
+ // MARK: - Mouse Activity Backpressure (PollerWithMouseDetection)
145
+
146
+ /// Returns true if the user is actively dragging or moving the mouse right now,
147
+ /// allowing callers to skip deep AX traversals and prevent UI hitches.
148
+ public func isUserActivelyDraggingMouse() -> Bool {
149
+ let dragIdle = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: .leftMouseDragged)
150
+ return dragIdle < 0.12
151
+ }
152
+
153
+ // MARK: - Active Meeting Target Discovery
154
+
155
+ /// Inspects a running application via AX to verify if it hosts an active Zoom, Meet, or Teams call.
156
+ public func detectMeetings(in app: NSRunningApplication) -> [MeetingTarget] {
157
+ guard let bundleId = app.bundleIdentifier else { return [] }
158
+ let pid = app.processIdentifier
159
+ ensureAccessibilityEnabled(for: pid, bundleId: bundleId)
160
+ let axApp = AXUIElementCreateApplication(pid)
161
+ let appName = app.localizedName ?? bundleId
162
+
163
+ if bundleId == "us.zoom.xos" {
164
+ if let target = detectZoomMeeting(axApp: axApp, pid: pid) {
165
+ return [target]
166
+ }
167
+ return []
168
+ }
169
+
170
+ if bundleId == "com.microsoft.teams" || bundleId == "com.microsoft.teams2" {
171
+ if let target = detectTeamsMeeting(axApp: axApp, pid: pid, appName: appName) {
172
+ return [target]
173
+ }
174
+ return []
175
+ }
176
+
177
+ if Self.browserBundleIds.contains(bundleId) {
178
+ return detectBrowserMeetings(axApp: axApp, pid: pid, bundleId: bundleId, appName: appName)
179
+ }
180
+
181
+ return []
182
+ }
183
+
184
+ // MARK: - Live Speaker & Roster Scraping
185
+
186
+ /// Scrapes the current participant roster and active speaker for a target meeting.
187
+ public func scrape(target: MeetingTarget) -> ScrapeSnapshot? {
188
+ if isUserActivelyDraggingMouse() {
189
+ return nil
190
+ }
191
+
192
+ ensureAccessibilityEnabled(for: target.pid)
193
+ let axApp = AXUIElementCreateApplication(target.pid)
194
+ switch target.platform {
195
+ case "zoom":
196
+ return scrapeZoom(axApp: axApp)
197
+ case "teams":
198
+ return scrapeTeams(axApp: axApp)
199
+ case "google-meet":
200
+ return scrapeGoogleMeet(axApp: axApp, target: target)
201
+ default:
202
+ return scrapeGoogleMeet(axApp: axApp, target: target)
203
+ }
204
+ }
205
+
206
+ // MARK: - 1. Zoom Native AX Scraper
207
+
208
+ private static let zoomSignOutTitles: Set<String> = [
209
+ "Abmelden", "Sign out", "Cerrar sesión", "Déconnexion", "Keluar",
210
+ "Disconnetti", "サインアウト", "로그아웃", "Afmelden", "Wyloguj się",
211
+ "Sair", "Выход", "Logga ut", "Çıkış Yap", "Đăng xuất", "退出登录", "登出"
212
+ ]
213
+
214
+ private func detectZoomMeeting(axApp: AXUIElement, pid: pid_t) -> MeetingTarget? {
215
+ let hasMeetingMenu = checkZoomHasMeetingMenu(axApp: axApp)
216
+ guard let windows = getAXWindows(axApp) else {
217
+ if hasMeetingMenu {
218
+ return MeetingTarget(pid: pid, browser: "Zoom", platform: "zoom", url: "zoom://meeting/\(pid)", title: "Zoom Meeting")
219
+ }
220
+ return nil
221
+ }
222
+
223
+ var meetingTitle: String?
224
+ var foundActiveCallWindow = false
225
+
226
+ for win in windows {
227
+ let winTitle = getStringAttr(win, kAXTitleAttribute as String) ?? ""
228
+ if winTitle.contains("Zoom Meeting") || winTitle.contains("Zoom Webinar") || winTitle.hasPrefix("Talking:") {
229
+ foundActiveCallWindow = true
230
+ if meetingTitle == nil && !winTitle.hasPrefix("Talking:") {
231
+ meetingTitle = winTitle
232
+ }
233
+ }
234
+ if let infoBtn = findElement(in: win, maxDepth: 10, maxNodes: 800, where: {
235
+ getStringAttr($0, kAXIdentifierAttribute as String) == "MeetingTopBarInfoButton"
236
+ }) {
237
+ foundActiveCallWindow = true
238
+ if let desc = getStringAttr(infoBtn, kAXDescriptionAttribute as String),
239
+ !desc.isEmpty,
240
+ !isZoomStatusLabelTitle(desc) {
241
+ meetingTitle = desc
242
+ }
243
+ }
244
+ }
245
+
246
+ guard hasMeetingMenu || foundActiveCallWindow else { return nil }
247
+ let finalTitle = meetingTitle ?? "Zoom Meeting"
248
+ return MeetingTarget(
249
+ pid: pid,
250
+ browser: "Zoom",
251
+ platform: "zoom",
252
+ url: "zoom://meeting/\(pid)",
253
+ title: finalTitle
254
+ )
255
+ }
256
+
257
+ private func isZoomStatusLabelTitle(_ title: String) -> Bool {
258
+ let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
259
+ let lower = trimmed.lowercased()
260
+ if lower == "meeting info" || lower.contains("participant id:") {
261
+ return true
262
+ }
263
+ if trimmed.range(of: #"^Meeting\s+\d+-minutes$"#, options: [.regularExpression, .caseInsensitive]) != nil {
264
+ return true
265
+ }
266
+ return false
267
+ }
268
+
269
+ private func checkZoomHasMeetingMenu(axApp: AXUIElement) -> Bool {
270
+ var val: AnyObject?
271
+ guard AXUIElementCopyAttributeValue(axApp, kAXMenuBarAttribute as CFString, &val) == .success,
272
+ let menuBar = val else { return false }
273
+ let menuBarEl = menuBar as! AXUIElement
274
+ guard let items = getAXChildren(menuBarEl) else { return false }
275
+ for item in items {
276
+ if let title = getStringAttr(item, kAXTitleAttribute as String),
277
+ Self.zoomMeetingMenuTitles.contains(title) {
278
+ return true
279
+ }
280
+ }
281
+ return false
282
+ }
283
+
284
+ /// Scrapes the local Zoom user's display name from `"Zoom Workplace" -> "Sign out" -> "✓ <Name> (<email>)"`
285
+ /// matching `ZoomScraperScripts.scrapeSignOutButton` in Recall's `6.5.9.61929.js`.
286
+ private func scrapeZoomSignOutUsername(axApp: AXUIElement) -> String? {
287
+ var val: AnyObject?
288
+ guard AXUIElementCopyAttributeValue(axApp, kAXMenuBarAttribute as CFString, &val) == .success,
289
+ let menuBar = val else { return nil }
290
+ let menuBarEl = menuBar as! AXUIElement
291
+ guard let barItems = getAXChildren(menuBarEl) else { return nil }
292
+ for barItem in barItems {
293
+ guard getStringAttr(barItem, kAXTitleAttribute as String) == "Zoom Workplace" else { continue }
294
+ if let signOutItem = findElement(in: barItem, maxDepth: 4, maxNodes: 120, where: { el in
295
+ if let t = getStringAttr(el, kAXTitleAttribute as String) {
296
+ return Self.zoomSignOutTitles.contains(t)
297
+ }
298
+ return false
299
+ }) {
300
+ if let checkedItem = findElement(in: signOutItem, maxDepth: 4, maxNodes: 80, where: { el in
301
+ getStringAttr(el, "AXMenuItemMarkChar") == "✓"
302
+ }) {
303
+ if let full = getStringAttr(checkedItem, kAXTitleAttribute as String) {
304
+ let name = full.components(separatedBy: "(").first?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
305
+ if !name.isEmpty { return name }
306
+ }
307
+ }
308
+ }
309
+ }
310
+ return nil
311
+ }
312
+
313
+ private func scrapeZoom(axApp: AXUIElement) -> ScrapeSnapshot? {
314
+ guard let windows = getAXWindows(axApp) else { return nil }
315
+
316
+ var participantsById: [String: Participant] = [:]
317
+ var activeSpeakerName: String?
318
+ var localUserName: String? = scrapeZoomSignOutUsername(axApp: axApp)
319
+ var meetingTitle: String?
320
+ var foundVideoRenderTiles = false
321
+ var immersiveCandidates: [(name: String, isSpeaking: Bool, isLocal: Bool)] = []
322
+ var bannerSpeakers: [String] = []
323
+
324
+ for win in windows {
325
+ let winTitle = getStringAttr(win, kAXTitleAttribute as String) ?? ""
326
+
327
+ // Check minimized / floating PIP "Talking: <Name>" indicator
328
+ if let idx = winTitle.range(of: "Talking:") {
329
+ let raw = String(winTitle[idx.upperBound...])
330
+ let names = raw.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }
331
+ for speaker in names {
332
+ bannerSpeakers.append(speaker)
333
+ }
334
+ }
335
+
336
+ // Walk window up to depth 14 for Video render tiles, immersive AXUnknown tiles, and Talking banners
337
+ walkElements(in: win, maxDepth: 14, maxNodes: 1500) { el in
338
+ let role = getStringAttr(el, kAXRoleAttribute as String) ?? ""
339
+ let roleDesc = getStringAttr(el, kAXRoleDescriptionAttribute as String) ?? ""
340
+ let ident = getStringAttr(el, kAXIdentifierAttribute as String) ?? ""
341
+ let desc = getStringAttr(el, kAXDescriptionAttribute as String) ?? ""
342
+ let val = getStringAttr(el, kAXValueAttribute as String) ?? ""
343
+
344
+ if ident == "MeetingTopBarInfoButton", !desc.isEmpty, !isZoomStatusLabelTitle(desc) {
345
+ meetingTitle = desc
346
+ }
347
+
348
+ // Check "Talking: <Name>" text elements in floating thumbnail bar (may list comma-separated speakers)
349
+ for textCandidate in [val, desc] {
350
+ if let idx = textCandidate.range(of: "Talking:") {
351
+ let raw = String(textCandidate[idx.upperBound...])
352
+ let names = raw.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }
353
+ for name in names {
354
+ bannerSpeakers.append(name)
355
+ }
356
+ }
357
+ }
358
+
359
+ if Self.zoomVideoRenderRoleDescriptions.contains(roleDesc) {
360
+ if let parsed = parseZoomVideoTileDescription(desc) {
361
+ foundVideoRenderTiles = true
362
+ participantsById[parsed.name] = Participant(id: parsed.name, name: parsed.name)
363
+ if parsed.isSpeaking {
364
+ activeSpeakerName = parsed.name
365
+ }
366
+ if parsed.isLocal && localUserName == nil {
367
+ localUserName = parsed.name
368
+ }
369
+ }
370
+ } else if role == "AXUnknown", !desc.isEmpty {
371
+ if let parsed = parseZoomVideoTileDescription(desc), parsed.hasAudioStatus {
372
+ immersiveCandidates.append((parsed.name, parsed.isSpeaking, parsed.isLocal))
373
+ }
374
+ }
375
+ }
376
+ }
377
+
378
+ // Fallbacks for layouts with no Video-render tiles (immersive, hide-thumbnail PIP), matching 6.5.9.61929.js
379
+ if !foundVideoRenderTiles {
380
+ for item in immersiveCandidates {
381
+ participantsById[item.name] = Participant(id: item.name, name: item.name)
382
+ if item.isSpeaking { activeSpeakerName = item.name }
383
+ if item.isLocal && localUserName == nil { localUserName = item.name }
384
+ }
385
+ for speaker in bannerSpeakers {
386
+ participantsById[speaker] = Participant(id: speaker, name: speaker)
387
+ activeSpeakerName = speaker
388
+ }
389
+ } else if activeSpeakerName == nil, let firstBanner = bannerSpeakers.first {
390
+ participantsById[firstBanner] = Participant(id: firstBanner, name: firstBanner)
391
+ activeSpeakerName = firstBanner
392
+ }
393
+
394
+ let roster = Array(participantsById.values).sorted { $0.name < $1.name }
395
+ if roster.isEmpty && activeSpeakerName == nil && meetingTitle == nil {
396
+ return nil
397
+ }
398
+
399
+ return ScrapeSnapshot(
400
+ participants: roster,
401
+ activeSpeakerId: activeSpeakerName,
402
+ activeSpeakerName: activeSpeakerName,
403
+ localUserName: localUserName,
404
+ meetingTitle: meetingTitle,
405
+ meetingUrl: nil
406
+ )
407
+ }
408
+
409
+ /// Parses Zoom's `"<Name>, unmuted, active speaker"` or `"<Last, First>, muted, video on"`
410
+ /// by anchoring on the audio status token (`muted` / `unmuted` / `audio`), matching Recall's `6.5.9.61929.js`.
411
+ private func parseZoomVideoTileDescription(_ desc: String) -> (name: String, isSpeaking: Bool, isLocal: Bool, hasAudioStatus: Bool)? {
412
+ let trimmed = desc.trimmingCharacters(in: .whitespacesAndNewlines)
413
+ guard !trimmed.isEmpty else { return nil }
414
+
415
+ // Evaluate via Recall-style JavaScriptCore rule engine first
416
+ jsLock.lock()
417
+ let jsParsed: [String: Any]? = {
418
+ guard let rules = jsContext.objectForKeyedSubscript("ZoomScraperRules"),
419
+ let val = rules.invokeMethod("parseDescription", withArguments: [trimmed]),
420
+ val.isObject else { return nil }
421
+ return val.toDictionary() as? [String: Any]
422
+ }()
423
+ jsLock.unlock()
424
+
425
+ if let dict = jsParsed,
426
+ let name = dict["name"] as? String,
427
+ !name.isEmpty,
428
+ name.count < 80 {
429
+ let speaking = (dict["isActiveSpeaker"] as? Bool) ?? false
430
+ let isLocal = (dict["isLocalUser"] as? Bool) ?? false
431
+ let hasAudio = (dict["hasAudioStatus"] as? Bool) ?? false
432
+ return (name, speaking, isLocal, hasAudio)
433
+ }
434
+
435
+ let parts = trimmed.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
436
+ guard !parts.isEmpty else { return nil }
437
+
438
+ var statusIndex: Int?
439
+ for (idx, part) in parts.enumerated() where idx > 0 {
440
+ let lower = part.lowercased()
441
+ if Self.zoomAudioStatusKeywords.contains(where: { lower.contains($0) }) ||
442
+ lower.contains("video") || lower.contains("active speaker") || lower.contains("host") || lower.contains("co-host") {
443
+ statusIndex = idx
444
+ break
445
+ }
446
+ }
447
+
448
+ let rawName: String
449
+ if let idx = statusIndex {
450
+ rawName = parts[0..<idx].joined(separator: ", ")
451
+ } else {
452
+ rawName = parts[0]
453
+ }
454
+
455
+ let isLocal = rawName.range(of: #"\((You|Me)\)"#, options: [.regularExpression, .caseInsensitive]) != nil
456
+ let cleanName = rawName
457
+ .replacingOccurrences(of: #"\s*\((Host|Co-host|You|Me)\)\s*"#, with: "", options: [.regularExpression, .caseInsensitive])
458
+ .trimmingCharacters(in: .whitespacesAndNewlines)
459
+
460
+ guard !cleanName.isEmpty, cleanName.count < 80 else { return nil }
461
+
462
+ let lowerDesc = trimmed.lowercased()
463
+ let isSpeaking = lowerDesc.contains("active speaker") || lowerDesc.contains("speaking")
464
+ return (cleanName, isSpeaking, isLocal, statusIndex != nil)
465
+ }
466
+
467
+ // MARK: - 2. Google Meet Scraper (Recall AXDOMClassList + AppleScript DOM Probe)
468
+
469
+ private func detectBrowserMeetings(axApp: AXUIElement, pid: pid_t, bundleId: String, appName: String) -> [MeetingTarget] {
470
+ guard let windows = getAXWindows(axApp) else { return [] }
471
+ var targets: [MeetingTarget] = []
472
+ let browserSlug = normalizeBrowserName(bundleId: bundleId, appName: appName)
473
+
474
+ for win in windows {
475
+ let winTitle = getStringAttr(win, kAXTitleAttribute as String) ?? ""
476
+ let docUrl = getStringAttr(win, "AXDocument") ?? ""
477
+
478
+ // Ignore tiny floating sharing notification bars that aren't actual Meet windows
479
+ if (winTitle.contains("is sharing your screen") || winTitle.contains(" is sharing a window")) &&
480
+ !winTitle.lowercased().contains("meet") && docUrl.isEmpty {
481
+ continue
482
+ }
483
+
484
+ // 1. Check active WebArea or AXDocument for a real Meet URL (`https://meet.google.com/abc-defg-hij`)
485
+ let webArea = findWebArea(in: win, maxDepth: 12)
486
+ let waUrl = (webArea.flatMap { getStringAttr($0, kAXURLAttribute as String) }) ?? docUrl
487
+ let waTitle = (webArea.flatMap { getStringAttr($0, kAXTitleAttribute as String) }) ?? winTitle
488
+
489
+ if let code = extractMeetCode(from: waUrl) ?? extractMeetCode(from: waTitle) ?? extractMeetCode(from: docUrl) {
490
+ let normalizedUrl = "https://meet.google.com/\(code)"
491
+ let cleanTitle = sanitizeMeetingTitle(waTitle.isEmpty ? winTitle : waTitle, fallbackCode: code)
492
+ if !targets.contains(where: { $0.url == normalizedUrl && $0.pid == pid }) {
493
+ targets.append(MeetingTarget(pid: pid, browser: browserSlug, platform: "google-meet", url: normalizedUrl, title: cleanTitle))
494
+ }
495
+ continue
496
+ }
497
+
498
+ // 2. Check Omnibox URL field (`OmniboxViewViews`) or tab titles (`Meet - abc-defg-hij` or active call title)
499
+ let candidateTitles = [winTitle] + findTabTitles(in: win, maxDepth: 10)
500
+ for t in candidateTitles {
501
+ if let code = extractMeetCode(from: t) {
502
+ let normalizedUrl = "https://meet.google.com/\(code)"
503
+ let cleanTitle = sanitizeMeetingTitle(t, fallbackCode: code)
504
+ if !targets.contains(where: { $0.url == normalizedUrl && $0.pid == pid }) {
505
+ targets.append(MeetingTarget(pid: pid, browser: browserSlug, platform: "google-meet", url: normalizedUrl, title: cleanTitle))
506
+ }
507
+ } else if t.hasPrefix("Meet - ") || t.hasPrefix("Meet – ") {
508
+ let cleanTitle = sanitizeMeetingTitle(t, fallbackCode: nil)
509
+ let suffix = cleanTitle.replacingOccurrences(of: #"^Meet\s*[-–]\s*"#, with: "", options: .regularExpression)
510
+ if !suffix.isEmpty && !suffix.lowercased().hasPrefix("google meet") {
511
+ let slug = suffix.lowercased().replacingOccurrences(of: #"[^a-z0-9]+"#, with: "-", options: .regularExpression)
512
+ .trimmingCharacters(in: CharacterSet(charactersIn: "-"))
513
+ if !slug.isEmpty {
514
+ let url = "https://meet.google.com/\(slug)"
515
+ if !targets.contains(where: { $0.url == url && $0.pid == pid }) {
516
+ targets.append(MeetingTarget(pid: pid, browser: browserSlug, platform: "google-meet", url: url, title: cleanTitle))
517
+ }
518
+ }
519
+ }
520
+ }
521
+ }
522
+ }
523
+
524
+ return targets
525
+ }
526
+
527
+ public func sanitizeMeetingTitle(_ rawTitle: String, fallbackCode: String?) -> String {
528
+ var cleaned = rawTitle
529
+ .replacingOccurrences(of: #"\s*-\s*(Microphone recording|Camera and microphone recording|Camera recording|Tab content shared|Audio playing|Memory usage.*)"#, with: "", options: [.regularExpression, .caseInsensitive])
530
+ .replacingOccurrences(of: #"\s*-\s*(Google Chrome|Brave|Microsoft Edge|Arc|Vivaldi|Safari)$"#, with: "", options: [.regularExpression, .caseInsensitive])
531
+ .trimmingCharacters(in: .whitespacesAndNewlines)
532
+ if cleaned.isEmpty, let code = fallbackCode {
533
+ cleaned = "Meet - \(code)"
534
+ }
535
+ return cleaned
536
+ }
537
+
538
+ private func scrapeGoogleMeet(axApp: AXUIElement, target: MeetingTarget) -> ScrapeSnapshot? {
539
+ // Strategy A: Pure macOS Accessibility traversal using Recall's `GoogleMeetScraper` DOM class rules
540
+ // (`AXLandmarkMain`, `dkjMxf`, `oZRSLe`, `kssMZb`, `fdKMD`, `FTMc0c`, `tWDL4c`, `eQJ1qd`, `AXStaticText`).
541
+ // Works without any Chrome extension or AppleScript permission.
542
+ if let axSnap = scrapeGoogleMeetViaAX(axApp: axApp, target: target),
543
+ (!axSnap.participants.isEmpty || axSnap.activeSpeakerName != nil) {
544
+ return axSnap
545
+ }
546
+
547
+ // Strategy B: AppleScript DOM inspection fallback (when Chrome has "Allow JavaScript from Apple Events" enabled)
548
+ let now = ProcessInfo.processInfo.systemUptime
549
+ if now >= appleScriptBackoffUntil {
550
+ if (now - lastAppleScriptPollTime) >= 0.35 {
551
+ lastAppleScriptPollTime = now
552
+ if let domSnap = scrapeGoogleMeetViaAppleScript(browserName: target.browser) {
553
+ cachedAppleScriptSnapshot = domSnap
554
+ return domSnap
555
+ } else {
556
+ cachedAppleScriptSnapshot = nil
557
+ }
558
+ } else if let cached = cachedAppleScriptSnapshot {
559
+ return cached
560
+ }
561
+ }
562
+
563
+ return nil
564
+ }
565
+
566
+ /// Implements Recall's `GoogleMeetScraper` (`0x100077dc4`) and `GoogleMeetParticipantName.scrape` (`0x100084998`)
567
+ /// directly against Chromium's `AXDOMClassList`, `AXSubrole`, and `AXStaticText` nodes.
568
+ public func scrapeGoogleMeetViaAX(axApp: AXUIElement, target: MeetingTarget) -> ScrapeSnapshot? {
569
+ guard let windows = getAXWindows(axApp) else { return nil }
570
+
571
+ var participantsById: [String: Participant] = [:]
572
+ var activeSpeakerId: String?
573
+ var activeSpeakerName: String?
574
+ var localUserName: String?
575
+
576
+ for win in windows {
577
+ // Find the Google Meet WebArea (or search from the window if WebArea isn't directly matched)
578
+ let webArea = findWebArea(in: win, maxDepth: 12)
579
+ let searchRoot = webArea ?? win
580
+ if let wa = webArea {
581
+ let url = getStringAttr(wa, kAXURLAttribute as String) ?? getStringAttr(win, "AXDocument") ?? ""
582
+ let title = getStringAttr(wa, kAXTitleAttribute as String) ?? ""
583
+ if !url.isEmpty && !url.contains("meet.google.com") && !title.lowercased().contains("meet") {
584
+ continue
585
+ }
586
+ }
587
+
588
+ // 1. Check Chrome's AvatarToolbarButton on the window for local user first name hint
589
+ if localUserName == nil,
590
+ let avatarBtn = findElement(in: win, maxDepth: 10, maxNodes: 400, where: { el in
591
+ getDOMClassList(el).contains("AvatarToolbarButton")
592
+ }),
593
+ let avatarDesc = getStringAttr(avatarBtn, kAXDescriptionAttribute as String),
594
+ isValidParticipantName(avatarDesc) {
595
+ localUserName = avatarDesc.trimmingCharacters(in: .whitespacesAndNewlines)
596
+ }
597
+
598
+ // 2. Check Sidepanel (`AXLandmarkComplementary`) for participant list items (`cxdMu` / `KV1GEc` or `" (You)"`)
599
+ let sidepanels = findElements(in: searchRoot, maxDepth: 14, maxNodes: 1500) { el in
600
+ getStringAttr(el, kAXSubroleAttribute as String) == "AXLandmarkComplementary"
601
+ }
602
+ for panel in sidepanels {
603
+ let listTiles = findElements(in: panel, maxDepth: 12, maxNodes: 800) { el in
604
+ let cls = getDOMClassList(el)
605
+ return cls.contains("cxdMu") || cls.contains("KV1GEc")
606
+ }
607
+ for item in listTiles {
608
+ if let name = scrapeGoogleMeetParticipantName(in: item, maxDepth: 10) {
609
+ let isYou = name.range(of: #"\s*\(You\)\s*$"#, options: [.regularExpression, .caseInsensitive]) != nil
610
+ let clean = name.replacingOccurrences(of: #"\s*\(You\)\s*$"#, with: "", options: [.regularExpression, .caseInsensitive])
611
+ .trimmingCharacters(in: .whitespacesAndNewlines)
612
+ if isValidParticipantName(clean) {
613
+ participantsById[clean] = Participant(id: clean, name: clean)
614
+ if isYou { localUserName = clean }
615
+ }
616
+ }
617
+ }
618
+ }
619
+
620
+ // 3. Locate `AXLandmarkMain` (`GoogleMeetScraper - main`)
621
+ let mainLandmark = findElement(in: searchRoot, maxDepth: 16, maxNodes: 1500) { el in
622
+ getStringAttr(el, kAXSubroleAttribute as String) == "AXLandmarkMain"
623
+ }
624
+
625
+ if let main = mainLandmark {
626
+ // Find all tile slots (`dkjMxf`) inside `AXLandmarkMain`
627
+ // In Google Meet, `dkjMxf` carries `kssMZb` in `AXDOMClassList` when that slot's participant is speaking!
628
+ let tileSlots = findElements(in: main, maxDepth: 4, maxNodes: 300) { el in
629
+ getDOMClassList(el).contains("dkjMxf")
630
+ }
631
+
632
+ // Also find any standalone `oZRSLe` tiles (e.g., floating self-view `Gv1mTb-PVLJEc` / `aGWPv`)
633
+ let videoRenders = findElements(in: main, maxDepth: 6, maxNodes: 500) { el in
634
+ getDOMClassList(el).contains("oZRSLe")
635
+ }
636
+
637
+ let tilesToInspect: [(slot: AXUIElement, render: AXUIElement)] = {
638
+ if !tileSlots.isEmpty {
639
+ var pairs: [(AXUIElement, AXUIElement)] = []
640
+ for slot in tileSlots {
641
+ let render = findElement(in: slot, maxDepth: 4, maxNodes: 60) {
642
+ getDOMClassList($0).contains("oZRSLe")
643
+ } ?? slot
644
+ pairs.append((slot, render))
645
+ }
646
+ for vr in videoRenders {
647
+ let vrClasses = getDOMClassList(vr)
648
+ if vrClasses.contains("Gv1mTb-PVLJEc") || vrClasses.contains("aGWPv") {
649
+ pairs.append((vr, vr))
650
+ }
651
+ }
652
+ return pairs
653
+ }
654
+ return videoRenders.map { ($0, $0) }
655
+ }()
656
+
657
+ for (slot, render) in tilesToInspect {
658
+ let slotClasses = Set(getDOMClassList(slot))
659
+ let renderClasses = Set(getDOMClassList(render))
660
+
661
+ // Skip screen-share presentation tile (`Wkyg5e`)
662
+ let isPresentation = findElement(in: render, maxDepth: 10, maxNodes: 250) {
663
+ getDOMClassList($0).contains("Wkyg5e")
664
+ } != nil
665
+ if isPresentation { continue }
666
+
667
+ guard let rawName = scrapeGoogleMeetParticipantName(in: render, maxDepth: 14) else {
668
+ continue
669
+ }
670
+
671
+ let hasYouSuffix = rawName.range(of: #"\s*\(You\)\s*$"#, options: [.regularExpression, .caseInsensitive]) != nil
672
+ let cleanName = rawName
673
+ .replacingOccurrences(of: #"\s*\(You\)\s*$"#, with: "", options: [.regularExpression, .caseInsensitive])
674
+ .trimmingCharacters(in: .whitespacesAndNewlines)
675
+
676
+ guard isValidParticipantName(cleanName) else { continue }
677
+
678
+ participantsById[cleanName] = Participant(id: cleanName, name: cleanName)
679
+
680
+ // Check if this tile is the local user (`tWDL4c`, `eQJ1qd`, `Gv1mTb-PVLJEc`, `aGWPv`, or `(You)`)
681
+ let isHostTile = hasYouSuffix ||
682
+ slotClasses.contains("Gv1mTb-PVLJEc") || slotClasses.contains("aGWPv") ||
683
+ renderClasses.contains("Gv1mTb-PVLJEc") || renderClasses.contains("aGWPv") ||
684
+ findElement(in: render, maxDepth: 12, maxNodes: 300) { el in
685
+ let c = getDOMClassList(el)
686
+ return c.contains("tWDL4c") || c.contains("eQJ1qd")
687
+ } != nil
688
+ if isHostTile {
689
+ localUserName = cleanName
690
+ }
691
+
692
+ // Check if muted (`FTMc0c`)
693
+ let isMuted = findElement(in: render, maxDepth: 12, maxNodes: 300) { el in
694
+ getDOMClassList(el).contains("FTMc0c")
695
+ } != nil
696
+
697
+ // Check if active speaker (`kssMZb` on slot/ancestor or `fdKMD` indicator inside tile)
698
+ let hasActiveSpeakerClass = slotClasses.contains("kssMZb") || renderClasses.contains("kssMZb") ||
699
+ findElement(in: slot, maxDepth: 12, maxNodes: 300) { el in
700
+ let c = getDOMClassList(el)
701
+ return c.contains("kssMZb") || c.contains("fdKMD")
702
+ } != nil
703
+
704
+ if hasActiveSpeakerClass && !isMuted {
705
+ activeSpeakerName = cleanName
706
+ activeSpeakerId = cleanName
707
+ }
708
+ }
709
+ }
710
+
711
+ // 4. Also scan for ARIA live-region announcements or tile descriptions ("<Name> is speaking")
712
+ walkElements(in: searchRoot, maxDepth: 16, maxNodes: 1000) { el in
713
+ let desc = getStringAttr(el, kAXDescriptionAttribute as String) ?? ""
714
+ let elTitle = getStringAttr(el, kAXTitleAttribute as String) ?? ""
715
+ let val = getStringAttr(el, kAXValueAttribute as String) ?? ""
716
+
717
+ for text in [desc, elTitle, val] where !text.isEmpty {
718
+ if let range = text.range(of: " is speaking", options: [.caseInsensitive]) {
719
+ let speaker = String(text[..<range.lowerBound]).trimmingCharacters(in: .whitespacesAndNewlines)
720
+ if isValidParticipantName(speaker) {
721
+ activeSpeakerName = speaker
722
+ activeSpeakerId = speaker
723
+ participantsById[speaker] = Participant(id: speaker, name: speaker)
724
+ }
725
+ } else if let range = text.range(of: " has their microphone muted", options: [.caseInsensitive]) {
726
+ let name = String(text[..<range.lowerBound]).trimmingCharacters(in: .whitespacesAndNewlines)
727
+ if isValidParticipantName(name) {
728
+ participantsById[name] = Participant(id: name, name: name)
729
+ }
730
+ }
731
+ }
732
+ }
733
+ }
734
+
735
+ // Upgrade single-token localUserName (e.g. "Abhi" from Chrome AvatarToolbarButton) to full roster name ("Abhi Jain")
736
+ if let local = localUserName, !participantsById.keys.contains(local) {
737
+ if let match = participantsById.values.first(where: {
738
+ $0.name.lowercased().hasPrefix(local.lowercased() + " ") || $0.name.lowercased() == local.lowercased()
739
+ }) {
740
+ localUserName = match.name
741
+ }
742
+ }
743
+
744
+ let roster = Array(participantsById.values).sorted { $0.name < $1.name }
745
+ if roster.isEmpty && activeSpeakerName == nil {
746
+ return nil
747
+ }
748
+
749
+ return ScrapeSnapshot(
750
+ participants: roster,
751
+ activeSpeakerId: activeSpeakerId,
752
+ activeSpeakerName: activeSpeakerName,
753
+ localUserName: localUserName,
754
+ meetingTitle: target.title,
755
+ meetingUrl: target.url
756
+ )
757
+ }
758
+
759
+ /// Extracts a participant's display name from a Google Meet tile (`oZRSLe` or `cxdMu`),
760
+ /// matching Recall's `GoogleMeetParticipantName.scrape` (`0x100084998`):
761
+ /// finds `AXStaticText` nodes while excluding `AXUserInterfaceTooltip` and overlay classes (`QTxFAf`, `uGwDxe`).
762
+ private func scrapeGoogleMeetParticipantName(in root: AXUIElement, maxDepth: Int) -> String? {
763
+ var candidates: [String] = []
764
+
765
+ func walk(_ el: AXUIElement, depth: Int, insideExcludedAncestor: Bool) {
766
+ guard depth <= maxDepth, candidates.count < 8 else { return }
767
+ let subrole = getStringAttr(el, kAXSubroleAttribute as String) ?? ""
768
+ let classes = getDOMClassList(el)
769
+ let excluded = insideExcludedAncestor ||
770
+ subrole == "AXUserInterfaceTooltip" ||
771
+ classes.contains("QTxFAf") ||
772
+ classes.contains("uGwDxe")
773
+ if excluded { return }
774
+
775
+ let role = getStringAttr(el, kAXRoleAttribute as String) ?? ""
776
+ if role == "AXStaticText" {
777
+ let raw = getStringAttr(el, kAXValueAttribute as String)
778
+ ?? getStringAttr(el, kAXTitleAttribute as String)
779
+ ?? getStringAttr(el, kAXDescriptionAttribute as String)
780
+ ?? ""
781
+ let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
782
+ if isUsableGoogleMeetNameCandidate(trimmed) {
783
+ candidates.append(trimmed)
784
+ }
785
+ } else if depth <= 3 {
786
+ // Also check clean aria-label / AXDescription on container groups
787
+ if let desc = getStringAttr(el, kAXDescriptionAttribute as String), !desc.isEmpty {
788
+ let clean = desc
789
+ .replacingOccurrences(of: #"( is speaking| has their microphone muted|'s screen| is pinned).*"#, with: "", options: [.regularExpression, .caseInsensitive])
790
+ .trimmingCharacters(in: .whitespacesAndNewlines)
791
+ if clean != desc && isUsableGoogleMeetNameCandidate(clean) {
792
+ candidates.append(clean)
793
+ }
794
+ }
795
+ }
796
+
797
+ guard depth < maxDepth, let children = getAXChildren(el) else { return }
798
+ for child in children {
799
+ walk(child, depth: depth + 1, insideExcludedAncestor: excluded)
800
+ }
801
+ }
802
+
803
+ walk(root, depth: 0, insideExcludedAncestor: false)
804
+ return candidates.first
805
+ }
806
+
807
+ private func isUsableGoogleMeetNameCandidate(_ text: String) -> Bool {
808
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
809
+ guard trimmed.count >= 2 && trimmed.count <= 60 else { return false }
810
+ // Reject collapsed participant counters ("+3", "12")
811
+ if let first = trimmed.first, first == "+" || first.isNumber {
812
+ return false
813
+ }
814
+ let lower = trimmed.lowercased()
815
+ if lower.contains("others might still see your full video") ||
816
+ lower.contains("microphone is off") ||
817
+ lower.contains("microphone is on") ||
818
+ lower.contains("are you talking") {
819
+ return false
820
+ }
821
+ return isValidParticipantName(trimmed)
822
+ }
823
+
824
+ /// Executes a lightweight DOM inspection in Google Meet tabs (matching `access/watch_meet_speaker.py` +
825
+ /// Recall's `.kssMZb` / `.fdKMD` + multi-signal outline/animation detection) when Chrome has
826
+ /// "Allow JavaScript from Apple Events" enabled.
827
+ private func scrapeGoogleMeetViaAppleScript(browserName: String) -> ScrapeSnapshot? {
828
+ let appName: String
829
+ switch browserName.lowercased() {
830
+ case "chrome", "google chrome": appName = "Google Chrome"
831
+ case "brave", "brave browser": appName = "Brave Browser"
832
+ case "edge", "microsoft edge": appName = "Microsoft Edge"
833
+ case "arc": appName = "Arc"
834
+ default: appName = "Google Chrome"
835
+ }
836
+
837
+ let js = #"""
838
+ (function() {
839
+ var nameMap = {};
840
+ var selfEl = document.querySelector('[data-self-name]');
841
+ var selfName = selfEl ? selfEl.getAttribute('data-self-name') : null;
842
+
843
+ document.querySelectorAll('[data-participant-id]').forEach(function(el) {
844
+ var pid = el.getAttribute('data-participant-id');
845
+ var label = el.getAttribute('aria-label');
846
+ if (pid && label && label.length < 90) {
847
+ var clean = label.replace(/( is speaking| has their microphone muted|'s screen| is pinned).*/i, '').trim();
848
+ if (clean && !/^(pin|unpin|mute|unmute|remove|more options|you)$/i.test(clean)) {
849
+ nameMap[pid] = clean;
850
+ }
851
+ }
852
+ });
853
+
854
+ var participants = [];
855
+ var activePid = null;
856
+ var bestScore = 0;
857
+
858
+ document.querySelectorAll('[data-participant-id]').forEach(function(tile) {
859
+ var pid = tile.getAttribute('data-participant-id');
860
+ if (!pid) return;
861
+ if (!tile.classList.contains('oZRSLe') && !tile.hasAttribute('data-requested-participant-id')) return;
862
+
863
+ if (!nameMap[pid]) {
864
+ var lines = (tile.innerText || '').split('\n').map(function(s){return s.trim();}).filter(Boolean);
865
+ for (var i = 0; i < lines.length; i++) {
866
+ if (!/^(You|mic_off|pin|unpin|presenting|More options|Reframe|Backgrounds|visual_effects)$/i.test(lines[i]) && lines[i].length > 1 && lines[i].length < 60) {
867
+ nameMap[pid] = lines[i];
868
+ break;
869
+ }
870
+ }
871
+ }
872
+
873
+ var isHostTile = tile.querySelector('.tWDL4c, .eQJ1qd') !== null || /Reframe|Backgrounds|visual_effects/i.test(tile.textContent || '');
874
+ var name = nameMap[pid] || (selfName && isHostTile ? selfName : null);
875
+ if (!name) return;
876
+ nameMap[pid] = name;
877
+ if (isHostTile && !selfName) selfName = name;
878
+
879
+ var muted = (tile.textContent || '').indexOf('mic_off') !== -1 || tile.querySelector('[data-is-muted="true"], .FTMc0c') !== null;
880
+ participants.push({ id: pid, name: name, muted: muted });
881
+
882
+ var score = 0;
883
+ var slot = tile.closest('.dkjMxf') || tile.parentElement;
884
+ if ((slot && slot.classList && slot.classList.contains('kssMZb')) || tile.classList.contains('kssMZb') || tile.querySelector('.kssMZb, .fdKMD')) {
885
+ if (!muted) score += 12;
886
+ }
887
+ if (tile.querySelector('[data-is-speaking="true"],[data-active-speaker="true"],[data-speaking="true"]')) {
888
+ score += 10;
889
+ }
890
+
891
+ var els = tile.querySelectorAll('div, span');
892
+ for (var j = 0; j < els.length && j < 140; j++) {
893
+ var el = els[j];
894
+ if (el.classList && (el.classList.contains('BlxGDf') || el.classList.contains('gN1Xbe') || el.classList.contains('IisKdb') || el.classList.contains('aGJE1b'))) {
895
+ score += 5;
896
+ }
897
+ var cs = window.getComputedStyle(el);
898
+ var w = parseFloat(cs.outlineWidth) || 0;
899
+ if (w > 0 && cs.outlineStyle === 'solid' && cs.outlineColor !== 'rgba(0, 0, 0, 0)' && cs.outlineColor !== 'transparent') {
900
+ score += 6 + w;
901
+ break;
902
+ }
903
+ if (cs.transform && cs.transform !== 'none' && cs.transform.indexOf('matrix') !== -1) {
904
+ var m = cs.transform.match(/matrix\(([^)]+)\)/);
905
+ if (m) {
906
+ var parts = m[1].split(',').map(Number);
907
+ if (parts.length >= 4 && parts[3] > 0.28 && !muted) score += 3;
908
+ }
909
+ }
910
+ }
911
+
912
+ if (typeof tile.getAnimations === 'function') {
913
+ var anims = tile.getAnimations({ subtree: true });
914
+ var running = 0;
915
+ for (var k = 0; k < anims.length; k++) {
916
+ if (anims[k].playState === 'running') running++;
917
+ }
918
+ if (running >= 2 && !muted) score += 4;
919
+ }
920
+
921
+ if (score > bestScore) {
922
+ bestScore = score;
923
+ activePid = pid;
924
+ }
925
+ });
926
+
927
+ return JSON.stringify({
928
+ participants: participants,
929
+ activePid: activePid,
930
+ activeName: activePid ? (nameMap[activePid] || null) : null,
931
+ selfName: selfName,
932
+ url: window.location.href,
933
+ title: document.title
934
+ });
935
+ })()
936
+ """#
937
+
938
+ let escaped = js
939
+ .replacingOccurrences(of: "\\", with: "\\\\")
940
+ .replacingOccurrences(of: "\"", with: "\\\"")
941
+ .replacingOccurrences(of: "\n", with: " ")
942
+
943
+ let script = """
944
+ tell application "\(appName)"
945
+ repeat with w in windows
946
+ repeat with t in tabs of w
947
+ if URL of t contains "meet.google.com/" then
948
+ return execute t javascript "\(escaped)"
949
+ end if
950
+ end repeat
951
+ end repeat
952
+ end tell
953
+ """
954
+
955
+ var errorDict: NSDictionary?
956
+ guard let appleScript = NSAppleScript(source: script) else { return nil }
957
+ let descriptor = appleScript.executeAndReturnError(&errorDict)
958
+ if errorDict != nil {
959
+ // Back off AppleScript polling for 10s when Chrome has AppleEvents JS turned off or permission is denied
960
+ appleScriptBackoffUntil = ProcessInfo.processInfo.systemUptime + 10.0
961
+ return nil
962
+ }
963
+ guard let raw = descriptor.stringValue, !raw.isEmpty,
964
+ let data = raw.data(using: .utf8),
965
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
966
+ return nil
967
+ }
968
+
969
+ var roster: [Participant] = []
970
+ if let rawParticipants = json["participants"] as? [[String: Any]] {
971
+ var seen = Set<String>()
972
+ for p in rawParticipants {
973
+ if let id = p["id"] as? String, let name = p["name"] as? String, !seen.contains(id) {
974
+ seen.insert(id)
975
+ roster.append(Participant(id: id, name: name))
976
+ }
977
+ }
978
+ }
979
+
980
+ let activePid = json["activePid"] as? String
981
+ let activeName = json["activeName"] as? String
982
+ let selfName = json["selfName"] as? String
983
+ let url = json["url"] as? String
984
+ let title = json["title"] as? String
985
+
986
+ return ScrapeSnapshot(
987
+ participants: roster,
988
+ activeSpeakerId: activePid,
989
+ activeSpeakerName: activeName,
990
+ localUserName: selfName,
991
+ meetingTitle: title,
992
+ meetingUrl: url
993
+ )
994
+ }
995
+
996
+ // MARK: - 3. Microsoft Teams Native AX Scraper
997
+
998
+ private func detectTeamsMeeting(axApp: AXUIElement, pid: pid_t, appName: String) -> MeetingTarget? {
999
+ guard let windows = getAXWindows(axApp) else { return nil }
1000
+
1001
+ for win in windows {
1002
+ let winTitle = getStringAttr(win, kAXTitleAttribute as String) ?? ""
1003
+ if winTitle.isEmpty { continue }
1004
+ let subrole = getStringAttr(win, kAXSubroleAttribute as String) ?? ""
1005
+ if subrole == "AXDialog" || subrole == "AXSystemDialog" { continue }
1006
+
1007
+ let lowerTitle = winTitle.lowercased()
1008
+ let isMainChatView = lowerTitle.hasPrefix("chat |") || lowerTitle.hasPrefix("activity |") || lowerTitle.hasPrefix("calendar |") || lowerTitle.hasPrefix("teams |")
1009
+ if isMainChatView { continue }
1010
+
1011
+ // Look for Recall's `hangup-button` (`AXDOMIdentifier` or `AXIdentifier`), excluding `prejoin-join-button` and `resume-button`
1012
+ var hasPrejoinOrResume = false
1013
+ var hasHangupButton = false
1014
+ walkElements(in: win, maxDepth: 20, maxNodes: 1200) { el in
1015
+ let domId = (getStringAttr(el, "AXDOMIdentifier") ?? "").lowercased()
1016
+ let axId = (getStringAttr(el, kAXIdentifierAttribute as String) ?? "").lowercased()
1017
+ let desc = (getStringAttr(el, kAXDescriptionAttribute as String) ?? "").lowercased()
1018
+ let title = (getStringAttr(el, kAXTitleAttribute as String) ?? "").lowercased()
1019
+
1020
+ if domId == "prejoin-join-button" || domId == "resume-button" || axId == "prejoin-join-button" || axId == "resume-button" {
1021
+ hasPrejoinOrResume = true
1022
+ }
1023
+ if domId == "hangup-button" || axId.contains("hangup-button") || axId.contains("call-hangup") ||
1024
+ desc == "leave" || desc == "leave call" || title == "leave" {
1025
+ hasHangupButton = true
1026
+ }
1027
+ }
1028
+
1029
+ if hasPrejoinOrResume { continue }
1030
+ let looksLikeCallTitle = lowerTitle.contains("meeting") || lowerTitle.contains("call with")
1031
+
1032
+ if hasHangupButton || looksLikeCallTitle {
1033
+ return MeetingTarget(
1034
+ pid: pid,
1035
+ browser: appName,
1036
+ platform: "teams",
1037
+ url: "teams://meeting/\(pid)",
1038
+ title: winTitle
1039
+ )
1040
+ }
1041
+ }
1042
+ return nil
1043
+ }
1044
+
1045
+ private func scrapeTeams(axApp: AXUIElement) -> ScrapeSnapshot? {
1046
+ guard let windows = getAXWindows(axApp) else { return nil }
1047
+ var participantsById: [String: Participant] = [:]
1048
+ var activeSpeakerName: String?
1049
+
1050
+ for win in windows {
1051
+ walkElements(in: win, maxDepth: 20, maxNodes: 1500) { el in
1052
+ let role = getStringAttr(el, kAXRoleAttribute as String) ?? ""
1053
+ let desc = getStringAttr(el, kAXDescriptionAttribute as String) ?? ""
1054
+ let title = getStringAttr(el, kAXTitleAttribute as String) ?? ""
1055
+ let domClasses = getDOMClassList(el)
1056
+
1057
+ // 1. Recall Teams `isSpeakingSelector`: AXGroup with AXDescription containing "is speaking" (excluding "Nobody")
1058
+ for text in [desc, title] where !text.isEmpty {
1059
+ let lower = text.lowercased()
1060
+ if !lower.hasPrefix("nobody") && (lower.contains("active speaker") || lower.contains("is speaking")) {
1061
+ let name = text
1062
+ .replacingOccurrences(of: #"(,?\s*(is speaking|active speaker|speaking|camera on|camera off|muted|unmuted|has context menu).*)"#, with: "", options: [.regularExpression, .caseInsensitive])
1063
+ .trimmingCharacters(in: .whitespacesAndNewlines)
1064
+ if isValidParticipantName(name) {
1065
+ activeSpeakerName = name
1066
+ participantsById[name] = Participant(id: name, name: name)
1067
+ }
1068
+ }
1069
+ }
1070
+
1071
+ // 2. Recall Teams `participantTileSelector`: AXMenuItem or Griffel tile classes (`___1504r6c`, `___33svd6f`)
1072
+ let isTeamsTile = (role == "AXMenuItem" && (domClasses.contains("___1504r6c") || domClasses.contains("___33svd6f"))) ||
1073
+ desc.lowercased().contains("has context menu")
1074
+ if isTeamsTile {
1075
+ let tileText: String = {
1076
+ if let st = findElement(in: el, maxDepth: 6, maxNodes: 50, where: { getStringAttr($0, kAXRoleAttribute as String) == "AXStaticText" }),
1077
+ let val = getStringAttr(st, kAXValueAttribute as String) ?? getStringAttr(st, kAXTitleAttribute as String),
1078
+ !val.isEmpty {
1079
+ return val
1080
+ }
1081
+ return desc.isEmpty ? title : desc
1082
+ }()
1083
+ let clean = tileText
1084
+ .replacingOccurrences(of: #"(,?\s*(is speaking|active speaker|camera on|camera off|muted|unmuted|has context menu|your own video).*)"#, with: "", options: [.regularExpression, .caseInsensitive])
1085
+ .trimmingCharacters(in: .whitespacesAndNewlines)
1086
+ if isValidParticipantName(clean) {
1087
+ participantsById[clean] = Participant(id: clean, name: clean)
1088
+ // Check Recall's Teams speaker overlay (`vdi-frame-occlusion` + `___1vvhwqj` or `___lrjjj0w`)
1089
+ let hasOverlay = findElement(in: el, maxDepth: 8, maxNodes: 80) { child in
1090
+ let c = getDOMClassList(child)
1091
+ return c.contains("vdi-frame-occlusion") && (c.contains("___1vvhwqj") || c.contains("___lrjjj0w"))
1092
+ } != nil
1093
+ if hasOverlay {
1094
+ activeSpeakerName = clean
1095
+ }
1096
+ }
1097
+ }
1098
+ }
1099
+ }
1100
+
1101
+ let roster = Array(participantsById.values).sorted { $0.name < $1.name }
1102
+ if roster.isEmpty && activeSpeakerName == nil {
1103
+ return nil
1104
+ }
1105
+
1106
+ return ScrapeSnapshot(
1107
+ participants: roster,
1108
+ activeSpeakerId: activeSpeakerName,
1109
+ activeSpeakerName: activeSpeakerName,
1110
+ localUserName: nil,
1111
+ meetingTitle: nil,
1112
+ meetingUrl: nil
1113
+ )
1114
+ }
1115
+
1116
+ // MARK: - AX Traversal Helpers
1117
+
1118
+ private func getAXWindows(_ axApp: AXUIElement) -> [AXUIElement]? {
1119
+ var val: AnyObject?
1120
+ guard AXUIElementCopyAttributeValue(axApp, kAXWindowsAttribute as CFString, &val) == .success,
1121
+ let windows = val as? [AXUIElement] else {
1122
+ return nil
1123
+ }
1124
+ return windows
1125
+ }
1126
+
1127
+ private func getAXChildren(_ el: AXUIElement) -> [AXUIElement]? {
1128
+ var val: AnyObject?
1129
+ if AXUIElementCopyAttributeValue(el, kAXChildrenAttribute as CFString, &val) == .success,
1130
+ let ch = val as? [AXUIElement], !ch.isEmpty {
1131
+ return ch
1132
+ }
1133
+ if AXUIElementCopyAttributeValue(el, "AXChildrenInNavigationOrder" as CFString, &val) == .success,
1134
+ let ch = val as? [AXUIElement], !ch.isEmpty {
1135
+ return ch
1136
+ }
1137
+ return nil
1138
+ }
1139
+
1140
+ private func getDOMClassList(_ el: AXUIElement) -> [String] {
1141
+ var val: AnyObject?
1142
+ guard AXUIElementCopyAttributeValue(el, "AXDOMClassList" as CFString, &val) == .success,
1143
+ let arr = val as? [String] else {
1144
+ return []
1145
+ }
1146
+ return arr
1147
+ }
1148
+
1149
+ private func getStringAttr(_ el: AXUIElement, _ attr: String) -> String? {
1150
+ var val: AnyObject?
1151
+ guard AXUIElementCopyAttributeValue(el, attr as CFString, &val) == .success,
1152
+ let obj = val else { return nil }
1153
+ if let str = obj as? String { return str }
1154
+ if let url = obj as? NSURL { return url.absoluteString }
1155
+ if let attrStr = obj as? NSAttributedString { return attrStr.string }
1156
+ return nil
1157
+ }
1158
+
1159
+ private func findWebArea(in root: AXUIElement, maxDepth: Int) -> AXUIElement? {
1160
+ return findElement(in: root, maxDepth: maxDepth, maxNodes: 800) { el in
1161
+ getStringAttr(el, kAXRoleAttribute as String) == "AXWebArea"
1162
+ }
1163
+ }
1164
+
1165
+ private func findElement(in root: AXUIElement, maxDepth: Int, maxNodes: Int = 800, where predicate: (AXUIElement) -> Bool) -> AXUIElement? {
1166
+ var queue: [(el: AXUIElement, depth: Int)] = [(root, 0)]
1167
+ var qIdx = 0
1168
+ var visited = 0
1169
+ while qIdx < queue.count && visited < maxNodes {
1170
+ let current = queue[qIdx]
1171
+ qIdx += 1
1172
+ visited += 1
1173
+ if predicate(current.el) {
1174
+ return current.el
1175
+ }
1176
+ guard current.depth < maxDepth, let children = getAXChildren(current.el) else { continue }
1177
+ for child in children {
1178
+ queue.append((child, current.depth + 1))
1179
+ }
1180
+ }
1181
+ return nil
1182
+ }
1183
+
1184
+ private func findElements(in root: AXUIElement, maxDepth: Int, maxNodes: Int = 1200, where predicate: (AXUIElement) -> Bool) -> [AXUIElement] {
1185
+ var matches: [AXUIElement] = []
1186
+ var queue: [(el: AXUIElement, depth: Int)] = [(root, 0)]
1187
+ var qIdx = 0
1188
+ var visited = 0
1189
+ while qIdx < queue.count && visited < maxNodes {
1190
+ let current = queue[qIdx]
1191
+ qIdx += 1
1192
+ visited += 1
1193
+ if predicate(current.el) {
1194
+ matches.append(current.el)
1195
+ }
1196
+ guard current.depth < maxDepth, let children = getAXChildren(current.el) else { continue }
1197
+ for child in children {
1198
+ queue.append((child, current.depth + 1))
1199
+ }
1200
+ }
1201
+ return matches
1202
+ }
1203
+
1204
+ private func walkElements(in root: AXUIElement, maxDepth: Int, maxNodes: Int = 1000, visitor: (AXUIElement) -> Void) {
1205
+ var queue: [(el: AXUIElement, depth: Int)] = [(root, 0)]
1206
+ var qIdx = 0
1207
+ var visited = 0
1208
+ while qIdx < queue.count && visited < maxNodes {
1209
+ let current = queue[qIdx]
1210
+ qIdx += 1
1211
+ visited += 1
1212
+ visitor(current.el)
1213
+ guard current.depth < maxDepth, let children = getAXChildren(current.el) else { continue }
1214
+ for child in children {
1215
+ queue.append((child, current.depth + 1))
1216
+ }
1217
+ }
1218
+ }
1219
+
1220
+ private func findTabTitles(in window: AXUIElement, maxDepth: Int) -> [String] {
1221
+ var titles: [String] = []
1222
+ walkElements(in: window, maxDepth: maxDepth, maxNodes: 600) { el in
1223
+ let role = getStringAttr(el, kAXRoleAttribute as String) ?? ""
1224
+ if role == "AXTabButton" || role == "AXRadioButton" {
1225
+ if let t = getStringAttr(el, kAXTitleAttribute as String) ?? getStringAttr(el, kAXDescriptionAttribute as String),
1226
+ !t.isEmpty {
1227
+ titles.append(t)
1228
+ }
1229
+ }
1230
+ }
1231
+ return titles
1232
+ }
1233
+
1234
+ private func extractMeetCode(from text: String) -> String? {
1235
+ let range = NSRange(text.startIndex..<text.endIndex, in: text)
1236
+ if let match = meetCodeRegex.firstMatch(in: text, options: [], range: range),
1237
+ let codeRange = Range(match.range(at: 1), in: text) {
1238
+ return String(text[codeRange]).lowercased()
1239
+ }
1240
+ // Also check `Meet - abc-defg-hij` in window/tab titles
1241
+ let titlePattern = #"Meet\s*[-–]\s*([a-z]{3}-[a-z]{4}-[a-z]{3})"#
1242
+ if let regex = try? NSRegularExpression(pattern: titlePattern, options: [.caseInsensitive]),
1243
+ let match = regex.firstMatch(in: text, options: [], range: range),
1244
+ let codeRange = Range(match.range(at: 1), in: text) {
1245
+ return String(text[codeRange]).lowercased()
1246
+ }
1247
+ return nil
1248
+ }
1249
+
1250
+ public func normalizeBrowserName(bundleId: String, appName: String) -> String {
1251
+ switch bundleId {
1252
+ case "com.google.Chrome": return "chrome"
1253
+ case "company.thebrowser.Browser": return "arc"
1254
+ case "com.brave.Browser": return "brave"
1255
+ case "com.microsoft.edgemac": return "edge"
1256
+ case "com.vivaldi.Vivaldi": return "vivaldi"
1257
+ case "com.apple.Safari": return "safari"
1258
+ default: return appName.lowercased()
1259
+ }
1260
+ }
1261
+
1262
+ private func isValidParticipantName(_ name: String) -> Bool {
1263
+ let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
1264
+ guard trimmed.count >= 2 && trimmed.count <= 60 else { return false }
1265
+ let lower = trimmed.lowercased()
1266
+ let blacklist: Set<String> = [
1267
+ "you", "nobody", "mute", "unmute", "pin", "unpin", "more options",
1268
+ "your microphone is off.", "your microphone is on.",
1269
+ "turn off microphone", "turn on microphone",
1270
+ "turn off camera", "turn on camera", "leave call",
1271
+ "google meet", "zoom meeting", "microsoft teams",
1272
+ "reframe", "backgrounds and effects", "visual_effects", "mic_off",
1273
+ "raise hand", "share screen", "present now", "call controls"
1274
+ ]
1275
+ return !blacklist.contains(lower)
1276
+ }
1277
+ }