@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.
- package/LICENSE +21 -0
- package/README.md +74 -2
- package/bin/aira-capture +0 -0
- package/dist/attribution.d.ts +118 -0
- package/dist/attribution.d.ts.map +1 -0
- package/dist/attribution.js +626 -0
- package/dist/attribution.js.map +1 -0
- package/dist/audio.d.ts +26 -0
- package/dist/audio.d.ts.map +1 -0
- package/dist/audio.js +172 -0
- package/dist/audio.js.map +1 -0
- package/dist/brain.d.ts +11 -0
- package/dist/brain.d.ts.map +1 -0
- package/dist/brain.js +70 -0
- package/dist/brain.js.map +1 -0
- package/dist/capture.d.ts +48 -0
- package/dist/capture.d.ts.map +1 -0
- package/dist/capture.js +195 -0
- package/dist/capture.js.map +1 -0
- package/dist/engine.d.ts +61 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +471 -0
- package/dist/engine.js.map +1 -0
- package/dist/eyes.d.ts +60 -0
- package/dist/eyes.d.ts.map +1 -0
- package/dist/eyes.js +173 -0
- package/dist/eyes.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/lockfile.d.ts +14 -0
- package/dist/lockfile.d.ts.map +1 -0
- package/dist/lockfile.js +38 -0
- package/dist/lockfile.js.map +1 -0
- package/dist/providers.d.ts +162 -0
- package/dist/providers.d.ts.map +1 -0
- package/dist/providers.js +374 -0
- package/dist/providers.js.map +1 -0
- package/dist/transport.d.ts +14 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +44 -0
- package/dist/transport.js.map +1 -0
- package/dist/types.d.ts +175 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/vectors.d.ts +10 -0
- package/dist/vectors.d.ts.map +1 -0
- package/dist/vectors.js +66 -0
- package/dist/vectors.js.map +1 -0
- package/native/AiraCapture.swift +1142 -0
- package/native/Info.plist +26 -0
- package/native/NativeMeetingScraper.swift +1277 -0
- package/package.json +84 -4
- package/scripts/build-capture.cjs +57 -0
- 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
|
+
}
|