diff --git a/Ten31Transcripts/Detection/CallDetector.swift b/Ten31Transcripts/Detection/CallDetector.swift index abf10d0..5600c70 100644 --- a/Ten31Transcripts/Detection/CallDetector.swift +++ b/Ten31Transcripts/Detection/CallDetector.swift @@ -1,6 +1,7 @@ import AppKit import CoreGraphics import Combine +import Darwin // sysctl / kinfo_proc for parent-PID resolution /// Detects when the user joins/leaves a call and reports it via callbacks. /// @@ -160,9 +161,14 @@ final class CallDetector: ObservableObject { guard !micPIDs.isEmpty else { return nil } let selfPID = NSRunningApplication.current.processIdentifier - for app in NSWorkspace.shared.runningApplications { - let pid = app.processIdentifier - guard pid != selfPID, micPIDs.contains(pid), let id = app.bundleIdentifier else { continue } + // The process holding the mic is often a HELPER subprocess (Electron apps + // like Signal, browser audio/renderer processes) that isn't itself a listed + // application. Resolve each mic-using PID to its owning app by walking the + // parent-process chain — that's what makes Signal/Teams (Electron) detect. + for micPID in micPIDs { + guard let app = Self.owningApp(of: micPID), + app.processIdentifier != selfPID, + let id = app.bundleIdentifier else { continue } if let native = Self.nativeApps.first(where: { $0.id == id }) { return DetectedCall(app: native.app, bundleID: id, windowID: nil) // native: capture largest owned window } @@ -170,13 +176,37 @@ final class CallDetector: ObservableObject { // gives reliable start/stop; the window check keeps non-Meet browser // mic use (other web apps) from being mislabeled as a Meet recording. // Capture that exact browser window (by ID), not just the browser. - if Self.browserIDs.contains(id), let wid = meetWindowID(pid) { + if Self.browserIDs.contains(id), let wid = meetWindowID(app.processIdentifier) { return DetectedCall(app: .meet, bundleID: id, windowID: wid) } } return nil } + /// The application that owns `pid` — `pid` itself if it's an app, else the + /// nearest ancestor that is (Electron/browser helpers run as children of their + /// main app). nil if none within a few levels. + private static func owningApp(of pid: pid_t) -> NSRunningApplication? { + var current = pid + for _ in 0..<6 { + if let app = NSRunningApplication(processIdentifier: current) { return app } + let parent = parentPID(of: current) + guard parent > 1, parent != current else { return nil } + current = parent + } + return nil + } + + /// Parent PID of `pid` via sysctl, or 0 on failure. + private static func parentPID(of pid: pid_t) -> pid_t { + var info = kinfo_proc() + var size = MemoryLayout.stride + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid] + let rc = sysctl(&mib, u_int(mib.count), &info, &size, nil, 0) + guard rc == 0, size > 0 else { return 0 } + return info.kp_eproc.e_ppid + } + /// The `CGWindowID` of this PID's Google Meet call window (title "Meet - …"), /// or nil if none — also serves as the "is this a Meet call?" check. private func meetWindowID(_ pid: pid_t) -> CGWindowID? {