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:
@@ -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? {
|
||||||
|
|||||||
Reference in New Issue
Block a user