@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,1142 @@
1
+ import Foundation
2
+ import AppKit
3
+ import ApplicationServices
4
+ import CoreGraphics
5
+ import CoreAudio
6
+ import AudioToolbox
7
+ import AVFoundation
8
+ import CoreMedia
9
+ import ScreenCaptureKit
10
+
11
+ // MARK: - Core Wire Types
12
+
13
+ public struct MeetingTarget: Codable, Sendable, Equatable {
14
+ public let pid: Int32
15
+ public let browser: String
16
+ public let platform: String
17
+ public let url: String
18
+ public let title: String
19
+
20
+ public init(pid: Int32, browser: String, platform: String, url: String, title: String) {
21
+ self.pid = pid
22
+ self.browser = browser
23
+ self.platform = platform
24
+ self.url = url
25
+ self.title = title
26
+ }
27
+
28
+ public static func == (lhs: MeetingTarget, rhs: MeetingTarget) -> Bool {
29
+ lhs.pid == rhs.pid && lhs.url == rhs.url
30
+ }
31
+ }
32
+
33
+ public struct Participant: Codable, Sendable, Hashable {
34
+ public let id: String
35
+ public let name: String
36
+
37
+ public init(id: String, name: String) {
38
+ self.id = id
39
+ self.name = name
40
+ }
41
+ }
42
+
43
+ public struct ChromeParticipant: Codable, Sendable {
44
+ public let pid: String
45
+ public let name: String
46
+ public let muted: Bool?
47
+ }
48
+
49
+ public struct ChromeEvent: Codable, Sendable {
50
+ public let type: String
51
+ public let speaker: String?
52
+ public let method: String?
53
+ public let participants: [ChromeParticipant]?
54
+ }
55
+
56
+ // MARK: - Stdio Protocol Framing (`aira_capture|<json>`)
57
+
58
+ private let stdoutLock = NSLock()
59
+
60
+ private func sendProtocolJSON<T: Encodable>(_ payload: T) {
61
+ guard let data = try? JSONEncoder().encode(payload),
62
+ let str = String(data: data, encoding: .utf8) else { return }
63
+ stdoutLock.lock()
64
+ defer { stdoutLock.unlock() }
65
+ if let lineData = ("aira_capture|" + str + "\n").data(using: .utf8) {
66
+ FileHandle.standardOutput.write(lineData)
67
+ }
68
+ }
69
+
70
+ private struct RPCResponse<R: Encodable>: Encodable {
71
+ let type = "response"
72
+ let id: String
73
+ let status: String
74
+ let result: R?
75
+ let error: String?
76
+ }
77
+
78
+ private struct RPCEvent<P: Encodable>: Encodable {
79
+ let type = "event"
80
+ let event: String
81
+ let payload: P
82
+ }
83
+
84
+ private func sendResponse<R: Encodable>(id: String, result: R) {
85
+ sendProtocolJSON(RPCResponse(id: id, status: "success", result: result, error: nil))
86
+ }
87
+
88
+ private func sendErrorResponse(id: String, message: String) {
89
+ sendProtocolJSON(RPCResponse<String>(id: id, status: "error", result: nil, error: message))
90
+ }
91
+
92
+ private func emitEvent<P: Encodable>(_ name: String, payload: P) {
93
+ sendProtocolJSON(RPCEvent(event: name, payload: payload))
94
+ }
95
+
96
+ // MARK: - Permission Manager
97
+
98
+ enum EyesPermissionManager {
99
+ static func checkAll(prompt: Bool) async -> [String: String] {
100
+ let axOpts = ["AXTrustedCheckOptionPrompt": prompt] as CFDictionary
101
+ let axGranted = AXIsProcessTrustedWithOptions(axOpts)
102
+ let screenGranted = prompt ? CGRequestScreenCaptureAccess() : CGPreflightScreenCaptureAccess()
103
+
104
+ let micStatus = AVCaptureDevice.authorizationStatus(for: .audio)
105
+ let micGranted: Bool
106
+ switch micStatus {
107
+ case .authorized:
108
+ micGranted = true
109
+ case .notDetermined where prompt:
110
+ micGranted = await AVCaptureDevice.requestAccess(for: .audio)
111
+ default:
112
+ micGranted = false
113
+ }
114
+
115
+ let sysAudioGranted: Bool
116
+ if #available(macOS 14.2, *) {
117
+ sysAudioGranted = true
118
+ } else {
119
+ sysAudioGranted = screenGranted
120
+ }
121
+
122
+ return [
123
+ "accessibility": axGranted ? "granted" : "denied",
124
+ "screen-capture": screenGranted ? "granted" : "denied",
125
+ "microphone": micGranted ? "granted" : "denied",
126
+ "system-audio": sysAudioGranted ? "granted" : "denied",
127
+ ]
128
+ }
129
+ }
130
+
131
+ // MARK: - Meeting Detector
132
+
133
+ actor MeetingDetector {
134
+ static let shared = MeetingDetector()
135
+
136
+ private var isPolling = false
137
+ private var pollTask: Task<Void, Never>?
138
+ private var currentMeetings: [MeetingTarget] = []
139
+
140
+ func start() async {
141
+ if !isPolling {
142
+ isPolling = true
143
+ await poll(forceEmit: true)
144
+ pollTask = Task {
145
+ while !Task.isCancelled && isPolling {
146
+ try? await Task.sleep(nanoseconds: 2_500_000_000)
147
+ guard !Task.isCancelled && isPolling else { break }
148
+ await poll(forceEmit: false)
149
+ }
150
+ }
151
+ } else {
152
+ emitEvent("meetings", payload: currentMeetings)
153
+ }
154
+ }
155
+
156
+ func stop() {
157
+ isPolling = false
158
+ pollTask?.cancel()
159
+ pollTask = nil
160
+ }
161
+
162
+ private func poll(forceEmit: Bool) async {
163
+ var foundMeetings: [MeetingTarget] = []
164
+ let targetBundleIds: Set<String> = [
165
+ "com.google.Chrome",
166
+ "company.thebrowser.Browser",
167
+ "com.brave.Browser",
168
+ "com.microsoft.edgemac",
169
+ "com.vivaldi.Vivaldi",
170
+ "com.apple.Safari",
171
+ "us.zoom.xos",
172
+ "com.microsoft.teams",
173
+ "com.microsoft.teams2"
174
+ ]
175
+
176
+ let apps = NSWorkspace.shared.runningApplications
177
+ for app in apps {
178
+ guard app.activationPolicy == .regular,
179
+ let bundleId = app.bundleIdentifier,
180
+ targetBundleIds.contains(bundleId) else { continue }
181
+
182
+ let detected = NativeMeetingScraper.shared.detectMeetings(in: app)
183
+ for mt in detected {
184
+ if !foundMeetings.contains(where: { $0.url == mt.url && $0.pid == mt.pid }) {
185
+ foundMeetings.append(mt)
186
+ }
187
+ }
188
+ }
189
+
190
+ if let content = try? await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: false) {
191
+ for window in content.windows {
192
+ guard let app = window.owningApplication else { continue }
193
+ let bundleId = app.bundleIdentifier
194
+ guard targetBundleIds.contains(bundleId) else { continue }
195
+ let pid = app.processID
196
+ let title = window.title ?? ""
197
+
198
+ if window.frame.height < 120 && (title.contains("sharing your screen") || title.contains("is sharing")) {
199
+ continue
200
+ }
201
+
202
+ if let mt = parseMeetingTarget(title: title, bundleId: bundleId, pid: pid, appName: app.applicationName) {
203
+ if !foundMeetings.contains(where: { $0.pid == mt.pid && ($0.url == mt.url || $0.platform == mt.platform) }) {
204
+ foundMeetings.append(mt)
205
+ }
206
+ }
207
+ }
208
+ }
209
+
210
+ let changed = currentMeetings != foundMeetings
211
+ currentMeetings = foundMeetings
212
+
213
+ if changed || forceEmit {
214
+ emitEvent("meetings", payload: currentMeetings)
215
+ }
216
+ }
217
+
218
+ private func parseMeetingTarget(title: String, bundleId: String, pid: pid_t, appName: String) -> MeetingTarget? {
219
+ let browserBundles: Set<String> = [
220
+ "com.google.Chrome", "company.thebrowser.Browser", "com.brave.Browser",
221
+ "com.microsoft.edgemac", "com.vivaldi.Vivaldi", "com.apple.Safari"
222
+ ]
223
+ if browserBundles.contains(bundleId) {
224
+ if title.starts(with: "Meet - ") || title.starts(with: "Meet – ") {
225
+ let cleanTitle = NativeMeetingScraper.shared.sanitizeMeetingTitle(title, fallbackCode: nil)
226
+ let suffix = cleanTitle.replacingOccurrences(of: #"^Meet\s*[-–]\s*"#, with: "", options: .regularExpression)
227
+ let codePattern = #"([a-z]{3}-[a-z]{4}-[a-z]{3})"#
228
+ let range = NSRange(suffix.startIndex..<suffix.endIndex, in: suffix)
229
+ let slug: String
230
+ if let regex = try? NSRegularExpression(pattern: codePattern, options: [.caseInsensitive]),
231
+ let match = regex.firstMatch(in: suffix, options: [], range: range),
232
+ let r = Range(match.range(at: 1), in: suffix) {
233
+ slug = String(suffix[r]).lowercased()
234
+ } else {
235
+ slug = "active"
236
+ }
237
+ let browserSlug = NativeMeetingScraper.shared.normalizeBrowserName(bundleId: bundleId, appName: appName)
238
+ return MeetingTarget(pid: pid, browser: browserSlug, platform: "google-meet", url: "https://meet.google.com/\(slug)", title: cleanTitle)
239
+ }
240
+ } else if bundleId == "us.zoom.xos" {
241
+ if title.contains("Zoom Meeting") || title.contains("Zoom Webinar") || title.hasPrefix("Talking:") {
242
+ return MeetingTarget(pid: pid, browser: "Zoom", platform: "zoom", url: "zoom://meeting/\(pid)", title: title)
243
+ }
244
+ } else if bundleId == "com.microsoft.teams" || bundleId == "com.microsoft.teams2" {
245
+ let lower = title.lowercased()
246
+ let isMainChat = lower.hasPrefix("chat |") || lower.hasPrefix("activity |") || lower.hasPrefix("calendar |") || lower.hasPrefix("teams |")
247
+ if (lower.contains("meeting") || lower.contains("call with")) && !isMainChat {
248
+ return MeetingTarget(pid: pid, browser: appName, platform: "teams", url: "teams://meeting/\(pid)", title: title)
249
+ }
250
+ }
251
+ return nil
252
+ }
253
+ }
254
+
255
+ // MARK: - Microphone Activity & 16 kHz Resampler
256
+
257
+ enum MicActivity {
258
+ typealias PCMHandler = @Sendable ([Float]) -> Void
259
+
260
+ private static let engine = AVAudioEngine()
261
+ private static var isRunning = false
262
+ private static var rms: Float = 0
263
+ private static var onResampledPCM: PCMHandler?
264
+ private static var converter: AVAudioConverter?
265
+ private static let targetFormat = AVAudioFormat(
266
+ commonFormat: .pcmFormatFloat32,
267
+ sampleRate: 16_000,
268
+ channels: 1,
269
+ interleaved: false
270
+ )!
271
+ private static let lock = NSLock()
272
+ private static let queue = DispatchQueue(label: "aira.mic.activity", qos: .userInitiated)
273
+
274
+ static func start(onPCM: PCMHandler? = nil) {
275
+ lock.lock()
276
+ if let onPCM {
277
+ onResampledPCM = onPCM
278
+ }
279
+ if isRunning {
280
+ lock.unlock()
281
+ return
282
+ }
283
+ lock.unlock()
284
+
285
+ let status = AVCaptureDevice.authorizationStatus(for: .audio)
286
+ switch status {
287
+ case .authorized:
288
+ queue.async { startEngineIfAuthorized() }
289
+ case .notDetermined:
290
+ AVCaptureDevice.requestAccess(for: .audio) { granted in
291
+ if granted {
292
+ queue.async { startEngineIfAuthorized() }
293
+ }
294
+ }
295
+ default:
296
+ return
297
+ }
298
+ }
299
+
300
+ private static func startEngineIfAuthorized() {
301
+ lock.lock()
302
+ guard !isRunning else {
303
+ lock.unlock()
304
+ return
305
+ }
306
+ lock.unlock()
307
+
308
+ let input = engine.inputNode
309
+ let fmt = input.inputFormat(forBus: 0)
310
+ guard fmt.sampleRate > 0 && fmt.channelCount > 0 else { return }
311
+ let conv = AVAudioConverter(from: fmt, to: targetFormat)
312
+ lock.lock()
313
+ converter = conv
314
+ lock.unlock()
315
+
316
+ input.removeTap(onBus: 0)
317
+ input.installTap(onBus: 0, bufferSize: 1024, format: fmt) { buffer, _ in
318
+ let data = buffer.floatChannelData?[0]
319
+ let count = Int(buffer.frameLength)
320
+ guard let data, count > 0 else { return }
321
+ var sum: Float = 0
322
+ for i in 0..<count { sum += data[i] * data[i] }
323
+ let level = sqrt(sum / Float(count))
324
+
325
+ var callback: PCMHandler?
326
+ var localConv: AVAudioConverter?
327
+ lock.lock()
328
+ rms = level
329
+ callback = onResampledPCM
330
+ localConv = converter
331
+ lock.unlock()
332
+
333
+ if let callback, let localConv {
334
+ let ratio = targetFormat.sampleRate / buffer.format.sampleRate
335
+ let outFrames = AVAudioFrameCount(Double(buffer.frameLength) * ratio + 16)
336
+ if let outBuf = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: outFrames) {
337
+ var err: NSError?
338
+ var consumed = false
339
+ let status = localConv.convert(to: outBuf, error: &err) { _, statusOut in
340
+ if consumed {
341
+ statusOut.pointee = .noDataNow
342
+ return nil
343
+ }
344
+ consumed = true
345
+ statusOut.pointee = .haveData
346
+ return buffer
347
+ }
348
+ if status != .error, err == nil, let ch = outBuf.floatChannelData {
349
+ let n = Int(outBuf.frameLength)
350
+ if n > 0 {
351
+ let samples = Array(UnsafeBufferPointer(start: ch[0], count: n))
352
+ callback(samples)
353
+ }
354
+ }
355
+ }
356
+ }
357
+ }
358
+ engine.prepare()
359
+ do {
360
+ try engine.start()
361
+ lock.lock()
362
+ isRunning = true
363
+ lock.unlock()
364
+ } catch {
365
+ input.removeTap(onBus: 0)
366
+ }
367
+ }
368
+
369
+ static func currentRMS() -> Float {
370
+ lock.lock()
371
+ defer { lock.unlock() }
372
+ return rms
373
+ }
374
+
375
+ static func stop() {
376
+ lock.lock()
377
+ defer { lock.unlock() }
378
+ onResampledPCM = nil
379
+ guard isRunning else { return }
380
+ engine.inputNode.removeTap(onBus: 0)
381
+ engine.stop()
382
+ converter = nil
383
+ isRunning = false
384
+ }
385
+ }
386
+
387
+ // MARK: - CoreAudio Process Tap + ScreenCaptureKit Audio Capture
388
+
389
+ final class ProcessAudioCapture: NSObject, @unchecked Sendable {
390
+ typealias FrameHandler = @Sendable ([Float]) -> Void
391
+
392
+ private let pid: pid_t
393
+ private let onFrames: FrameHandler
394
+
395
+ private var tapID: AudioObjectID = AudioObjectID(kAudioObjectUnknown)
396
+ private var aggregateDeviceID: AudioObjectID = AudioObjectID(kAudioObjectUnknown)
397
+ private var ioProcID: AudioDeviceIOProcID?
398
+ private let audioTapQueue = DispatchQueue(label: "aira.audio.tap", qos: .userInitiated)
399
+
400
+ private var stream: SCStream?
401
+ private var streamOutput: AudioStreamOutput?
402
+ private var converter: AVAudioConverter?
403
+ private var sourceFormat: AVAudioFormat?
404
+ private let targetFormat = AVAudioFormat(
405
+ commonFormat: .pcmFormatFloat32,
406
+ sampleRate: 16_000,
407
+ channels: 1,
408
+ interleaved: false
409
+ )!
410
+
411
+ init(pid: pid_t, onFrames: @escaping FrameHandler) {
412
+ self.pid = pid
413
+ self.onFrames = onFrames
414
+ }
415
+
416
+ func start() async throws {
417
+ if startCoreAudioProcessTap() {
418
+ return
419
+ }
420
+
421
+ let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: false)
422
+ guard let display = content.displays.first else {
423
+ throw NSError(domain: "AiraCapture", code: 1, userInfo: [NSLocalizedDescriptionKey: "No display available"])
424
+ }
425
+
426
+ let filter = SCContentFilter(display: display, excludingApplications: [], exceptingWindows: [])
427
+ let config = SCStreamConfiguration()
428
+ config.capturesAudio = true
429
+ config.excludesCurrentProcessAudio = true
430
+ config.sampleRate = 48_000
431
+ config.channelCount = 2
432
+ config.width = 2
433
+ config.height = 2
434
+ config.minimumFrameInterval = CMTime(value: 1, timescale: 1)
435
+ config.queueDepth = 6
436
+
437
+ let stream = SCStream(filter: filter, configuration: config, delegate: nil)
438
+ let output = AudioStreamOutput { [weak self] buffer in
439
+ self?.handle(buffer: buffer)
440
+ }
441
+ try stream.addStreamOutput(output, type: .audio, sampleHandlerQueue: .global(qos: .userInitiated))
442
+ try await stream.startCapture()
443
+ self.stream = stream
444
+ self.streamOutput = output
445
+ }
446
+
447
+ func stop() async {
448
+ stopCoreAudioProcessTap()
449
+ guard let stream else { return }
450
+ try? await stream.stopCapture()
451
+ self.stream = nil
452
+ self.streamOutput = nil
453
+ self.converter = nil
454
+ self.sourceFormat = nil
455
+ }
456
+
457
+ private func startCoreAudioProcessTap() -> Bool {
458
+ guard #available(macOS 14.2, *) else { return false }
459
+
460
+ let currentPid = ProcessInfo.processInfo.processIdentifier
461
+ let excludedProcessIDs = resolveAudioObjectIDs(forPIDs: [currentPid])
462
+
463
+ let tapDesc = CATapDescription(stereoGlobalTapButExcludeProcesses: excludedProcessIDs)
464
+ let tapUUID = UUID()
465
+ tapDesc.name = "AiraSystemAudioTap-\(tapUUID.uuidString)"
466
+ tapDesc.uuid = tapUUID
467
+ tapDesc.isPrivate = true
468
+ tapDesc.muteBehavior = .unmuted
469
+
470
+ var createdTapID: AudioObjectID = AudioObjectID(kAudioObjectUnknown)
471
+ guard AudioHardwareCreateProcessTap(tapDesc, &createdTapID) == noErr,
472
+ createdTapID != kAudioObjectUnknown else {
473
+ return false
474
+ }
475
+ self.tapID = createdTapID
476
+
477
+ let aggUID = "AiraSystemAudioAggregateDevice-\(UUID().uuidString)"
478
+ let tapEntry: [String: Any] = [
479
+ kAudioSubTapUIDKey as String: tapUUID.uuidString,
480
+ kAudioSubTapDriftCompensationKey as String: true
481
+ ]
482
+ let aggDesc: [String: Any] = [
483
+ kAudioAggregateDeviceNameKey as String: "AiraSystemAudioAggregateDevice",
484
+ kAudioAggregateDeviceUIDKey as String: aggUID,
485
+ kAudioAggregateDeviceIsPrivateKey as String: true,
486
+ kAudioAggregateDeviceIsStackedKey as String: false,
487
+ kAudioAggregateDeviceTapAutoStartKey as String: true,
488
+ kAudioAggregateDeviceTapListKey as String: [tapEntry]
489
+ ]
490
+
491
+ var createdAggID: AudioObjectID = AudioObjectID(kAudioObjectUnknown)
492
+ guard AudioHardwareCreateAggregateDevice(aggDesc as CFDictionary, &createdAggID) == noErr,
493
+ createdAggID != kAudioObjectUnknown else {
494
+ AudioHardwareDestroyProcessTap(createdTapID)
495
+ self.tapID = AudioObjectID(kAudioObjectUnknown)
496
+ return false
497
+ }
498
+ self.aggregateDeviceID = createdAggID
499
+
500
+ let tapFormat = getFirstTapInputStreamFormat(for: createdAggID)
501
+ let sampleRate = (tapFormat?.mSampleRate).flatMap { $0 > 0 ? $0 : nil }
502
+ ?? getNominalSampleRate(deviceId: createdAggID)
503
+ ?? 48_000.0
504
+ let handler = self.onFrames
505
+
506
+ var createdProcID: AudioDeviceIOProcID?
507
+ let ioStatus = AudioDeviceCreateIOProcIDWithBlock(&createdProcID, createdAggID, audioTapQueue) { _, inInputData, _, _, _ in
508
+ let bufferList = UnsafeMutableAudioBufferListPointer(UnsafeMutablePointer(mutating: inInputData))
509
+ guard bufferList.count > 0 else { return }
510
+
511
+ let firstBuf = bufferList[0]
512
+ guard let rawData = firstBuf.mData, firstBuf.mDataByteSize > 0 else { return }
513
+ let channelCount = max(1, Int(firstBuf.mNumberChannels))
514
+ let totalFloats = Int(firstBuf.mDataByteSize) / MemoryLayout<Float>.size
515
+ let frameCount = totalFloats / channelCount
516
+ guard frameCount > 0 else { return }
517
+
518
+ let floatPtr = rawData.assumingMemoryBound(to: Float.self)
519
+ var mono = [Float](repeating: 0, count: frameCount)
520
+
521
+ if bufferList.count == 1 && channelCount >= 2 {
522
+ for i in 0..<frameCount {
523
+ var sum: Float = 0
524
+ for c in 0..<channelCount {
525
+ sum += floatPtr[i * channelCount + c]
526
+ }
527
+ mono[i] = sum / Float(channelCount)
528
+ }
529
+ } else if bufferList.count >= 2 {
530
+ let ch0 = bufferList[0].mData?.assumingMemoryBound(to: Float.self)
531
+ let ch1 = bufferList[1].mData?.assumingMemoryBound(to: Float.self)
532
+ for i in 0..<frameCount {
533
+ mono[i] = 0.5 * ((ch0?[i] ?? 0) + (ch1?[i] ?? 0))
534
+ }
535
+ } else {
536
+ for i in 0..<frameCount {
537
+ mono[i] = floatPtr[i]
538
+ }
539
+ }
540
+
541
+ let resampled = Self.resampleLinearTo16kHz(mono, fromRate: sampleRate)
542
+ if !resampled.isEmpty {
543
+ handler(resampled)
544
+ }
545
+ }
546
+
547
+ guard ioStatus == noErr, let procID = createdProcID else {
548
+ stopCoreAudioProcessTap()
549
+ return false
550
+ }
551
+ self.ioProcID = procID
552
+
553
+ guard AudioDeviceStart(createdAggID, procID) == noErr else {
554
+ stopCoreAudioProcessTap()
555
+ return false
556
+ }
557
+ return true
558
+ }
559
+
560
+ private func stopCoreAudioProcessTap() {
561
+ if let procID = ioProcID, aggregateDeviceID != kAudioObjectUnknown {
562
+ AudioDeviceStop(aggregateDeviceID, procID)
563
+ AudioDeviceDestroyIOProcID(aggregateDeviceID, procID)
564
+ }
565
+ ioProcID = nil
566
+ if aggregateDeviceID != kAudioObjectUnknown {
567
+ AudioHardwareDestroyAggregateDevice(aggregateDeviceID)
568
+ aggregateDeviceID = AudioObjectID(kAudioObjectUnknown)
569
+ }
570
+ if tapID != kAudioObjectUnknown {
571
+ if #available(macOS 14.2, *) {
572
+ AudioHardwareDestroyProcessTap(tapID)
573
+ }
574
+ tapID = AudioObjectID(kAudioObjectUnknown)
575
+ }
576
+ }
577
+
578
+ private func getFirstTapInputStreamFormat(for aggDeviceId: AudioObjectID) -> AudioStreamBasicDescription? {
579
+ var streamsAddr = AudioObjectPropertyAddress(
580
+ mSelector: kAudioDevicePropertyStreams,
581
+ mScope: kAudioObjectPropertyScopeInput,
582
+ mElement: kAudioObjectPropertyElementMain
583
+ )
584
+ var dataSize: UInt32 = 0
585
+ guard AudioObjectGetPropertyDataSize(aggDeviceId, &streamsAddr, 0, nil, &dataSize) == noErr,
586
+ dataSize >= UInt32(MemoryLayout<AudioStreamID>.size) else {
587
+ return nil
588
+ }
589
+ let streamCount = Int(dataSize) / MemoryLayout<AudioStreamID>.size
590
+ var streams = [AudioStreamID](repeating: 0, count: streamCount)
591
+ guard AudioObjectGetPropertyData(aggDeviceId, &streamsAddr, 0, nil, &dataSize, &streams) == noErr,
592
+ let firstStream = streams.first else {
593
+ return nil
594
+ }
595
+ var formatAddr = AudioObjectPropertyAddress(
596
+ mSelector: kAudioStreamPropertyVirtualFormat,
597
+ mScope: kAudioObjectPropertyScopeGlobal,
598
+ mElement: kAudioObjectPropertyElementMain
599
+ )
600
+ var asbd = AudioStreamBasicDescription()
601
+ var asbdSize = UInt32(MemoryLayout<AudioStreamBasicDescription>.size)
602
+ guard AudioObjectGetPropertyData(firstStream, &formatAddr, 0, nil, &asbdSize, &asbd) == noErr else {
603
+ return nil
604
+ }
605
+ return asbd
606
+ }
607
+
608
+ private func getNominalSampleRate(deviceId: AudioObjectID) -> Double? {
609
+ var addr = AudioObjectPropertyAddress(
610
+ mSelector: kAudioDevicePropertyNominalSampleRate,
611
+ mScope: kAudioObjectPropertyScopeGlobal,
612
+ mElement: kAudioObjectPropertyElementMain
613
+ )
614
+ var rate: Float64 = 0
615
+ var size = UInt32(MemoryLayout<Float64>.size)
616
+ guard AudioObjectGetPropertyData(deviceId, &addr, 0, nil, &size, &rate) == noErr, rate > 0 else {
617
+ return nil
618
+ }
619
+ return rate
620
+ }
621
+
622
+ private func resolveAudioObjectIDs(forPIDs pids: [pid_t]) -> [AudioObjectID] {
623
+ var result: [AudioObjectID] = []
624
+ var addr = AudioObjectPropertyAddress(
625
+ mSelector: kAudioHardwarePropertyTranslatePIDToProcessObject,
626
+ mScope: kAudioObjectPropertyScopeGlobal,
627
+ mElement: kAudioObjectPropertyElementMain
628
+ )
629
+ for var p in pids {
630
+ var objID: AudioObjectID = AudioObjectID(kAudioObjectUnknown)
631
+ var size = UInt32(MemoryLayout<AudioObjectID>.size)
632
+ let status = AudioObjectGetPropertyData(
633
+ AudioObjectID(kAudioObjectSystemObject),
634
+ &addr,
635
+ UInt32(MemoryLayout<pid_t>.size),
636
+ &p,
637
+ &size,
638
+ &objID
639
+ )
640
+ if status == noErr && objID != kAudioObjectUnknown {
641
+ result.append(objID)
642
+ }
643
+ }
644
+ return result
645
+ }
646
+
647
+ private static func resampleLinearTo16kHz(_ input: [Float], fromRate: Double) -> [Float] {
648
+ guard !input.isEmpty, fromRate > 0 else { return [] }
649
+ let targetRate = 16_000.0
650
+ if abs(fromRate - targetRate) < 1.0 { return input }
651
+ let ratio = fromRate / targetRate
652
+ let outLength = max(1, Int(Double(input.count) / ratio))
653
+ var output = [Float](repeating: 0, count: outLength)
654
+ for i in 0..<outLength {
655
+ let srcPos = Double(i) * ratio
656
+ let idx = Int(srcPos)
657
+ let frac = Float(srcPos - Double(idx))
658
+ let s0 = input[min(idx, input.count - 1)]
659
+ let s1 = input[min(idx + 1, input.count - 1)]
660
+ output[i] = s0 + frac * (s1 - s0)
661
+ }
662
+ return output
663
+ }
664
+
665
+ private func handle(buffer: CMSampleBuffer) {
666
+ guard CMSampleBufferDataIsReady(buffer),
667
+ let pcm = pcmBuffer(from: buffer),
668
+ let resampled = resample(pcm),
669
+ let chData = resampled.floatChannelData else { return }
670
+ let count = Int(resampled.frameLength)
671
+ guard count > 0 else { return }
672
+ let samples = Array(UnsafeBufferPointer(start: chData[0], count: count))
673
+ onFrames(samples)
674
+ }
675
+
676
+ private func pcmBuffer(from sampleBuffer: CMSampleBuffer) -> AVAudioPCMBuffer? {
677
+ guard let formatDesc = CMSampleBufferGetFormatDescription(sampleBuffer),
678
+ let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(formatDesc),
679
+ let format = AVAudioFormat(streamDescription: asbd) else { return nil }
680
+ if sourceFormat != format {
681
+ sourceFormat = format
682
+ converter = AVAudioConverter(from: format, to: targetFormat)
683
+ }
684
+ let nFrames = AVAudioFrameCount(CMSampleBufferGetNumSamples(sampleBuffer))
685
+ guard let pcm = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: nFrames) else { return nil }
686
+ pcm.frameLength = nFrames
687
+ let status = CMSampleBufferCopyPCMDataIntoAudioBufferList(
688
+ sampleBuffer, at: 0, frameCount: Int32(nFrames), into: pcm.mutableAudioBufferList
689
+ )
690
+ guard status == noErr else { return nil }
691
+ return pcm
692
+ }
693
+
694
+ private func resample(_ input: AVAudioPCMBuffer) -> AVAudioPCMBuffer? {
695
+ guard let converter else { return nil }
696
+ let ratio = targetFormat.sampleRate / input.format.sampleRate
697
+ let outFrames = AVAudioFrameCount(Double(input.frameLength) * ratio + 16)
698
+ guard let output = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: outFrames) else { return nil }
699
+ var error: NSError?
700
+ var consumed = false
701
+ let status = converter.convert(to: output, error: &error) { _, statusOut in
702
+ if consumed {
703
+ statusOut.pointee = .noDataNow
704
+ return nil
705
+ }
706
+ consumed = true
707
+ statusOut.pointee = .haveData
708
+ return input
709
+ }
710
+ if status == .error || error != nil { return nil }
711
+ return output
712
+ }
713
+ }
714
+
715
+ private final class AudioStreamOutput: NSObject, SCStreamOutput {
716
+ private let handler: (CMSampleBuffer) -> Void
717
+ init(handler: @escaping (CMSampleBuffer) -> Void) { self.handler = handler }
718
+ func stream(_ stream: SCStream, didOutputSampleBuffer buffer: CMSampleBuffer, of type: SCStreamOutputType) {
719
+ guard type == .audio else { return }
720
+ handler(buffer)
721
+ }
722
+ }
723
+
724
+ // MARK: - Live Meeting Capture Session (Streams Audio + Speaker Spans + Roster to SDK)
725
+
726
+ struct RosterPayload: Encodable {
727
+ let participants: [Participant]
728
+ let localUserId: String
729
+ let localUserName: String
730
+ }
731
+
732
+ struct SpeakerChangePayload: Encodable {
733
+ let participantId: String
734
+ let name: String
735
+ let t: Double
736
+ let source: String
737
+ }
738
+
739
+ struct AudioChunkPayload: Encodable {
740
+ let pcmBase64: String
741
+ let t0: Double
742
+ let t1: Double
743
+ let sysRMS: Float
744
+ let micRMS: Float
745
+ let micActive: Bool
746
+ }
747
+
748
+ actor CaptureSessionManager {
749
+ static let shared = CaptureSessionManager()
750
+
751
+ private var capture: ProcessAudioCapture?
752
+ private var nativeScrapeLoop: Task<Void, Never>?
753
+ private var roster: [Participant] = []
754
+ private var localUserId: String = "local"
755
+ private var localUserName: String = ""
756
+ private var currentSpeakerId: String = ""
757
+ private var totalAudioSamples: Int = 0
758
+ private var sessionStart: Double = ProcessInfo.processInfo.systemUptime
759
+ private var audioStartUptime: Double = 0
760
+ private var lastSystemAudioUptime: Double = 0
761
+ private var systemOnlyRMS: Float = 0
762
+ private var micPendingSamples: [Float] = []
763
+ private var pcmChunkBuffer: [Float] = []
764
+ private var pcmChunkStartSample: Int = 0
765
+
766
+ func start(target: MeetingTarget) async throws {
767
+ await stop()
768
+ roster = []
769
+ localUserId = "local"
770
+ localUserName = ""
771
+ currentSpeakerId = ""
772
+ totalAudioSamples = 0
773
+ sessionStart = ProcessInfo.processInfo.systemUptime
774
+ audioStartUptime = 0
775
+ lastSystemAudioUptime = 0
776
+ systemOnlyRMS = 0
777
+ micPendingSamples.removeAll()
778
+ pcmChunkBuffer.removeAll()
779
+ pcmChunkStartSample = 0
780
+
781
+ identifyLocalUser(target: target, explicitLocalName: nil)
782
+ emitEvent("roster", payload: RosterPayload(
783
+ participants: roster,
784
+ localUserId: localUserId,
785
+ localUserName: resolvedLocalUserName()
786
+ ))
787
+
788
+ startNativeScraper(target: target)
789
+
790
+ MicActivity.start { micSamples in
791
+ Task { await CaptureSessionManager.shared.receiveLocalMic(micSamples) }
792
+ }
793
+
794
+ let cap = ProcessAudioCapture(pid: target.pid) { floats in
795
+ Task { await CaptureSessionManager.shared.feedSystemAudio(floats) }
796
+ }
797
+ try await cap.start()
798
+ self.capture = cap
799
+ }
800
+
801
+ func stop() async {
802
+ nativeScrapeLoop?.cancel()
803
+ nativeScrapeLoop = nil
804
+ if !pcmChunkBuffer.isEmpty {
805
+ flushPCMChunk()
806
+ }
807
+ await capture?.stop()
808
+ capture = nil
809
+ MicActivity.stop()
810
+ micPendingSamples.removeAll()
811
+ pcmChunkBuffer.removeAll()
812
+ }
813
+
814
+ func handleChromeEvent(_ event: ChromeEvent) async {
815
+ if let participants = event.participants {
816
+ let incoming = participants.map { Participant(id: $0.pid, name: $0.name) }
817
+ mergeRoster(incoming, explicitLocalName: nil)
818
+ }
819
+ if let speakerName = event.speaker, !speakerName.isEmpty {
820
+ let audioNow = currentAudioSeconds()
821
+ if let match = roster.first(where: { $0.name == speakerName || $0.id == speakerName }) {
822
+ emitSpeakerActivity(id: match.id, name: match.name, audioTime: audioNow, source: "chrome_ext")
823
+ } else {
824
+ emitSpeakerActivity(id: speakerName, name: speakerName, audioTime: audioNow, source: "chrome_ext")
825
+ }
826
+ }
827
+ }
828
+
829
+ private func startNativeScraper(target: MeetingTarget) {
830
+ nativeScrapeLoop?.cancel()
831
+ nativeScrapeLoop = Task.detached(priority: .utility) {
832
+ while !Task.isCancelled {
833
+ if let snap = NativeMeetingScraper.shared.scrape(target: target) {
834
+ await CaptureSessionManager.shared.applyScrapeSnapshot(snap)
835
+ }
836
+ try? await Task.sleep(nanoseconds: 250_000_000)
837
+ }
838
+ }
839
+ }
840
+
841
+ private func applyScrapeSnapshot(_ snap: NativeMeetingScraper.ScrapeSnapshot) {
842
+ if !snap.participants.isEmpty || (snap.localUserName != nil && !(snap.localUserName?.isEmpty ?? true)) {
843
+ mergeRoster(snap.participants, explicitLocalName: snap.localUserName)
844
+ }
845
+ if let activeName = snap.activeSpeakerName, !activeName.isEmpty {
846
+ let activeId = snap.activeSpeakerId ?? activeName
847
+ emitSpeakerActivity(id: activeId, name: activeName, audioTime: currentAudioSeconds(), source: "native_ax")
848
+ }
849
+ }
850
+
851
+ private func mergeRoster(_ incoming: [Participant], explicitLocalName: String?) {
852
+ var updatedLocal = false
853
+ if let explicitLocalName, !explicitLocalName.isEmpty, localUserName != explicitLocalName {
854
+ localUserName = explicitLocalName
855
+ updatedLocal = true
856
+ }
857
+ var combined = roster
858
+ var changed = false
859
+ for p in incoming {
860
+ if let idx = combined.firstIndex(where: { $0.id == p.id || $0.name.localizedCaseInsensitiveCompare(p.name) == .orderedSame }) {
861
+ if combined[idx] != p {
862
+ combined[idx] = p
863
+ changed = true
864
+ }
865
+ } else {
866
+ combined.append(p)
867
+ changed = true
868
+ }
869
+ }
870
+ if changed || updatedLocal || roster.isEmpty {
871
+ roster = combined
872
+ refreshLocalUserMapping()
873
+ emitEvent("roster", payload: RosterPayload(
874
+ participants: roster,
875
+ localUserId: localUserId,
876
+ localUserName: resolvedLocalUserName()
877
+ ))
878
+ }
879
+ }
880
+
881
+ private func emitSpeakerActivity(id: String, name: String, audioTime: Double, source: String) {
882
+ emitEvent("speaker_activity", payload: SpeakerChangePayload(
883
+ participantId: id,
884
+ name: name,
885
+ t: audioTime,
886
+ source: source
887
+ ))
888
+ if currentSpeakerId != id {
889
+ currentSpeakerId = id
890
+ emitEvent("speaker_change", payload: SpeakerChangePayload(
891
+ participantId: id,
892
+ name: name,
893
+ t: audioTime,
894
+ source: source
895
+ ))
896
+ }
897
+ }
898
+
899
+ private func currentAudioSeconds() -> Double {
900
+ if totalAudioSamples > 0 {
901
+ return Double(totalAudioSamples) / 16000.0
902
+ }
903
+ let ref = audioStartUptime > 0 ? audioStartUptime : sessionStart
904
+ return max(0, ProcessInfo.processInfo.systemUptime - ref)
905
+ }
906
+
907
+ private func feedSystemAudio(_ sysFloats: [Float]) {
908
+ guard !sysFloats.isEmpty else { return }
909
+ let now = ProcessInfo.processInfo.systemUptime
910
+ lastSystemAudioUptime = now
911
+
912
+ var sum: Float = 0
913
+ for s in sysFloats { sum += s * s }
914
+ let rms = sqrt(sum / Float(sysFloats.count))
915
+ systemOnlyRMS = rms
916
+
917
+ var mixed = sysFloats
918
+ if !micPendingSamples.isEmpty {
919
+ let mixCount = min(mixed.count, micPendingSamples.count)
920
+ if rms < 0.018 && MicActivity.currentRMS() >= 0.022 {
921
+ for i in 0..<mixCount {
922
+ let v = mixed[i] + micPendingSamples[i]
923
+ mixed[i] = max(-1.0, min(1.0, v))
924
+ }
925
+ }
926
+ micPendingSamples.removeFirst(mixCount)
927
+ }
928
+
929
+ appendSamples(mixed)
930
+ }
931
+
932
+ private func receiveLocalMic(_ micFloats: [Float]) {
933
+ guard !micFloats.isEmpty else { return }
934
+ var sum: Float = 0
935
+ for s in micFloats { sum += s * s }
936
+ let micRMS = sqrt(sum / Float(micFloats.count))
937
+
938
+ let now = ProcessInfo.processInfo.systemUptime
939
+ let sysIdle = lastSystemAudioUptime == 0 || (now - lastSystemAudioUptime) > 0.20
940
+
941
+ if sysIdle {
942
+ if !micPendingSamples.isEmpty {
943
+ micPendingSamples.removeAll(keepingCapacity: true)
944
+ }
945
+ if micRMS >= 0.022 {
946
+ appendSamples(micFloats)
947
+ }
948
+ } else if micRMS >= 0.022 {
949
+ micPendingSamples.append(contentsOf: micFloats)
950
+ if micPendingSamples.count > 16_000 {
951
+ micPendingSamples.removeFirst(micPendingSamples.count - 16_000)
952
+ }
953
+ } else {
954
+ micPendingSamples.removeAll(keepingCapacity: true)
955
+ }
956
+
957
+ if micRMS > 0.022 && (systemOnlyRMS < 0.012 || micRMS > systemOnlyRMS * 1.8) {
958
+ let id = localUserId.isEmpty ? "local" : localUserId
959
+ emitSpeakerActivity(id: id, name: resolvedLocalUserName(), audioTime: currentAudioSeconds(), source: "mic")
960
+ }
961
+ }
962
+
963
+ private func appendSamples(_ floats: [Float]) {
964
+ guard !floats.isEmpty else { return }
965
+ if audioStartUptime == 0 {
966
+ audioStartUptime = ProcessInfo.processInfo.systemUptime
967
+ }
968
+ if pcmChunkBuffer.isEmpty {
969
+ pcmChunkStartSample = totalAudioSamples
970
+ }
971
+ pcmChunkBuffer.append(contentsOf: floats)
972
+ totalAudioSamples += floats.count
973
+
974
+ // Emit in ~100ms chunks (1600 samples @ 16 kHz)
975
+ if pcmChunkBuffer.count >= 1600 {
976
+ flushPCMChunk()
977
+ }
978
+ }
979
+
980
+ private func flushPCMChunk() {
981
+ guard !pcmChunkBuffer.isEmpty else { return }
982
+ let count = pcmChunkBuffer.count
983
+ let t0 = Double(pcmChunkStartSample) / 16000.0
984
+ let t1 = Double(pcmChunkStartSample + count) / 16000.0
985
+
986
+ var int16Samples = [Int16](repeating: 0, count: count)
987
+ for i in 0..<count {
988
+ let clamped = max(-1.0, min(1.0, pcmChunkBuffer[i]))
989
+ int16Samples[i] = Int16(clamped * 32767.0)
990
+ }
991
+ let data = int16Samples.withUnsafeBufferPointer { Data(buffer: $0) }
992
+ let b64 = data.base64EncodedString()
993
+ let micRMS = MicActivity.currentRMS()
994
+ let micActive = micRMS > 0.022 && (systemOnlyRMS < 0.015 || micRMS > systemOnlyRMS * 1.8)
995
+
996
+ pcmChunkBuffer.removeAll(keepingCapacity: true)
997
+ pcmChunkStartSample = totalAudioSamples
998
+
999
+ emitEvent("audio", payload: AudioChunkPayload(
1000
+ pcmBase64: b64,
1001
+ t0: t0,
1002
+ t1: t1,
1003
+ sysRMS: systemOnlyRMS,
1004
+ micRMS: micRMS,
1005
+ micActive: micActive
1006
+ ))
1007
+ }
1008
+
1009
+ private func identifyLocalUser(target: MeetingTarget, explicitLocalName: String?) {
1010
+ let extracted = explicitLocalName ?? extractLocalUserName(from: target.title)
1011
+ if !extracted.isEmpty {
1012
+ localUserName = extracted
1013
+ }
1014
+ refreshLocalUserMapping()
1015
+ }
1016
+
1017
+ private func refreshLocalUserMapping() {
1018
+ if !localUserName.isEmpty {
1019
+ if let match = roster.first(where: {
1020
+ $0.name.localizedCaseInsensitiveCompare(localUserName) == .orderedSame ||
1021
+ $0.name.lowercased().hasPrefix(localUserName.lowercased() + " ")
1022
+ }) {
1023
+ localUserId = match.id
1024
+ localUserName = match.name
1025
+ }
1026
+ }
1027
+ if localUserId.isEmpty {
1028
+ localUserId = "local"
1029
+ }
1030
+ }
1031
+
1032
+ private func resolvedLocalUserName() -> String {
1033
+ if let match = roster.first(where: { $0.id == localUserId }) {
1034
+ return match.name
1035
+ }
1036
+ return localUserName.isEmpty ? "You" : localUserName
1037
+ }
1038
+
1039
+ private func extractLocalUserName(from title: String) -> String {
1040
+ let cleaned = NativeMeetingScraper.shared.sanitizeMeetingTitle(title, fallbackCode: nil)
1041
+ let parts = cleaned.components(separatedBy: " - ").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
1042
+ guard parts.count >= 3, let candidate = parts.last else { return "" }
1043
+ let ignored: Set<String> = [
1044
+ "google chrome", "chrome", "brave", "brave browser", "microsoft edge", "edge",
1045
+ "arc", "vivaldi", "safari", "zoom", "zoom meeting", "microsoft teams", "teams",
1046
+ "microphone recording", "camera recording", "camera and microphone recording",
1047
+ "tab content shared", "audio playing"
1048
+ ]
1049
+ if ignored.contains(candidate.lowercased()) { return "" }
1050
+ return candidate
1051
+ }
1052
+ }
1053
+
1054
+ // MARK: - Main Entrypoint & Stdio Command Loop
1055
+
1056
+ private struct IncomingCommand: Decodable {
1057
+ let id: String
1058
+ let command: String
1059
+ let prompt: Bool?
1060
+ let target: MeetingTarget?
1061
+ let event: ChromeEvent?
1062
+ }
1063
+
1064
+ @main
1065
+ struct AiraCaptureMain {
1066
+ static func main() {
1067
+ // Announce ready on stdout
1068
+ emitEvent("ready", payload: ["status": "ok"])
1069
+
1070
+ // Read newline-delimited JSON commands on background thread
1071
+ DispatchQueue.global(qos: .userInitiated).async {
1072
+ while let line = readLine(strippingNewline: true) {
1073
+ let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
1074
+ if trimmed.isEmpty { continue }
1075
+ guard let data = trimmed.data(using: .utf8),
1076
+ let cmd = try? JSONDecoder().decode(IncomingCommand.self, from: data) else {
1077
+ continue
1078
+ }
1079
+ Task {
1080
+ await handleCommand(cmd)
1081
+ }
1082
+ }
1083
+ // EOF on stdin -> clean shutdown
1084
+ Task {
1085
+ await CaptureSessionManager.shared.stop()
1086
+ await MeetingDetector.shared.stop()
1087
+ _exit(0)
1088
+ }
1089
+ }
1090
+
1091
+ RunLoop.main.run()
1092
+ }
1093
+
1094
+ private static func handleCommand(_ cmd: IncomingCommand) async {
1095
+ switch cmd.command {
1096
+ case "checkPermissions":
1097
+ let perms = await EyesPermissionManager.checkAll(prompt: cmd.prompt ?? false)
1098
+ sendResponse(id: cmd.id, result: perms)
1099
+
1100
+ case "watchMeetings":
1101
+ await MeetingDetector.shared.start()
1102
+ sendResponse(id: cmd.id, result: ["watching": true])
1103
+
1104
+ case "stopWatchingMeetings":
1105
+ await MeetingDetector.shared.stop()
1106
+ sendResponse(id: cmd.id, result: ["watching": false])
1107
+
1108
+ case "startCapture":
1109
+ guard let target = cmd.target else {
1110
+ sendErrorResponse(id: cmd.id, message: "missing target")
1111
+ return
1112
+ }
1113
+ do {
1114
+ try await CaptureSessionManager.shared.start(target: target)
1115
+ sendResponse(id: cmd.id, result: ["started": true])
1116
+ } catch {
1117
+ sendErrorResponse(id: cmd.id, message: error.localizedDescription)
1118
+ }
1119
+
1120
+ case "stopCapture":
1121
+ await CaptureSessionManager.shared.stop()
1122
+ sendResponse(id: cmd.id, result: ["stopped": true])
1123
+
1124
+ case "pushChromeEvent":
1125
+ guard let ev = cmd.event else {
1126
+ sendErrorResponse(id: cmd.id, message: "missing event")
1127
+ return
1128
+ }
1129
+ await CaptureSessionManager.shared.handleChromeEvent(ev)
1130
+ sendResponse(id: cmd.id, result: ["ok": true])
1131
+
1132
+ case "shutdown":
1133
+ await CaptureSessionManager.shared.stop()
1134
+ await MeetingDetector.shared.stop()
1135
+ sendResponse(id: cmd.id, result: ["shutdown": true])
1136
+ _exit(0)
1137
+
1138
+ default:
1139
+ sendErrorResponse(id: cmd.id, message: "unknown command: \(cmd.command)")
1140
+ }
1141
+ }
1142
+ }