From f2856bc3639e7affdb0dc94b1f43fd4e093c7b02 Mon Sep 17 00:00:00 2001 From: Grant Gilliam Date: Sat, 6 Jun 2026 11:50:58 -0500 Subject: [PATCH] Fix Signal (Electron) call detection: resolve mic-using helper to its app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- Ten31Transcripts/Detection/CallDetector.swift | 38 +++++++++++++++++-- 1 file changed, 34 insertions(+), 4 deletions(-) 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? {