Fix Signal (Electron) call detection: resolve mic-using helper to its app

Signal 1:1 (and group) calls didn't auto-record. Root cause confirmed on-device:
Signal is Electron and holds the mic in a HELPER process
(org.whispersystems.signal-desktop.helper.Renderer, a child of the main app).
detectViaMicAttribution only matched PIDs listed in NSWorkspace.runningApplications
against the main bundle ID, so the helper's mic use was never attributed to Signal.
(Zoom worked = single native process; Meet worked = browser resolved.)

Fix: iterate the mic-using PIDs and resolve each to its owning app by walking the
parent-process chain (sysctl KERN_PROC_PID → ppid) until an NSRunningApplication is
found. Helper PIDs that return nil directly now resolve to the main app. Validated
against the live Signal helpers: pids 2383/2372 → org.whispersystems.signal-desktop.
Superset of the old behavior, so Zoom/Meet detection is preserved (browser case now
also more robust); our own recording is still skipped (selfPID).
This commit is contained in:
Grant Gilliam
2026-06-06 11:50:58 -05:00
parent 63cf3026ff
commit f2856bc363
+34 -4
View File
@@ -1,6 +1,7 @@
import AppKit import AppKit
import CoreGraphics import CoreGraphics
import Combine import Combine
import Darwin // sysctl / kinfo_proc for parent-PID resolution
/// Detects when the user joins/leaves a call and reports it via callbacks. /// 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 } guard !micPIDs.isEmpty else { return nil }
let selfPID = NSRunningApplication.current.processIdentifier let selfPID = NSRunningApplication.current.processIdentifier
for app in NSWorkspace.shared.runningApplications { // The process holding the mic is often a HELPER subprocess (Electron apps
let pid = app.processIdentifier // like Signal, browser audio/renderer processes) that isn't itself a listed
guard pid != selfPID, micPIDs.contains(pid), let id = app.bundleIdentifier else { continue } // 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 }) { if let native = Self.nativeApps.first(where: { $0.id == id }) {
return DetectedCall(app: native.app, bundleID: id, windowID: nil) // native: capture largest owned window 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 // gives reliable start/stop; the window check keeps non-Meet browser
// mic use (other web apps) from being mislabeled as a Meet recording. // mic use (other web apps) from being mislabeled as a Meet recording.
// Capture that exact browser window (by ID), not just the browser. // 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 DetectedCall(app: .meet, bundleID: id, windowID: wid)
} }
} }
return nil 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<kinfo_proc>.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 - "), /// 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. /// or nil if none also serves as the "is this a Meet call?" check.
private func meetWindowID(_ pid: pid_t) -> CGWindowID? { private func meetWindowID(_ pid: pid_t) -> CGWindowID? {