DEV Community

Mohamed-Zakaria
Mohamed-Zakaria

Posted on Fully Autonomous

How sigstop knows you are on a call, with zero permissions

A break reminder has one job it must never get wrong: it must not fire in the middle of a call. So when I wrote sigstop, a menu bar break reminder for macOS, the first question was how an app can know a call is happening without the Microphone permission, which would not help anyway: that permission is for capturing audio, not for knowing whether somebody else is. This is what the code does, with the caveats the docs state next to it.

The fact macOS will tell anyone

CoreAudio exposes a property on every audio device called kAudioDevicePropertyDeviceIsRunningSomewhere. It answers one question: is some process on this machine running I/O on this device right now. Reading it needs no permission, because it is a property read on a device object, not a capture session, in the same way that ps is different from attaching a debugger.

AudioDeviceCollector enumerates kAudioHardwarePropertyDevices, keeps the devices whose input-scope kAudioDevicePropertyStreamConfiguration has at least one channel, and reads the bit on each:

static func deviceIsRunningSomewhere(_ device: AudioObjectID) -> Bool? {
    var address = AudioObjectPropertyAddress(
        mSelector: kAudioDevicePropertyDeviceIsRunningSomewhere,
        mScope: kAudioObjectPropertyScopeGlobal,
        mElement: kAudioObjectPropertyElementMain
    )
    var value: UInt32 = 0
    var size = UInt32(MemoryLayout<UInt32>.size)
    guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, &value) == noErr else {
        return nil
    }
    return value != 0
}
Enter fullscreen mode Exit fullscreen mode

The return type is Bool? on purpose. A read that fails is nil, never false. "I could not read it" and "nothing is running" are different facts, and collapsing them is how an app ends up interrupting a call it could not see.

The collector registers AudioObjectAddPropertyListenerBlock on each device for that property, plus one on the device list for hot-plug, so it is event-driven rather than polled.

The camera is the same shape in CoreMediaIO. CameraDeviceCollector enumerates kCMIOHardwarePropertyDevices, reads kCMIODevicePropertyDeviceIsRunningSomewhere per device through CMIOObjectGetPropertyData, registers CMIO listeners, and returns nil on a failed read. Probed on the machine the docs were written on: three devices, no Camera permission, no prompt, no entry in tccd's log. Check it with log show --last 5m --predicate 'process == "tccd"' after a run.

There is a stronger guarantee than "it does not ask". The app's Info.plist carries no NSMicrophoneUsageDescription and no NSCameraUsageDescription, so if any future code path tried to open a capture session, macOS would terminate the process with TCC_CRASHING_DUE_TO_PRIVACY_VIOLATION rather than show a prompt.

What the bit does not say

It does not say who. It does not say why: dictation, Voice Control, a voice memo, a browser tab and a game all trip it. And some things hold an input device open permanently; the docs name Krisp, Loopback, BlackHole, some headset daemons and certain audio interfaces. On such a Mac the bit is a constant true and worthless as a call signal.

So the state is not a Bool:

public enum AudioInputState: String, Sendable, Codable, Hashable {
    case running
    case notRunning
    case noInputDevice
    case unreliable

    public var contributesToMeeting: Bool { self == .running }
}
Enter fullscreen mode Exit fullscreen mode

.unreliable comes from a calibration guard in the collector. If the OR'd bit has been continuously true for more than four hours, or true for more than 85 percent of the last 24 hours of awake time after at least an hour of observation, the collector reports .unreliable and says why in a sentence the menu bar and --doctor both show. The docs are plain that those thresholds were never validated against a real virtual device; they are the long-run judgement about signal quality, and two faster mechanisms sit in front of them.

One more edge: Zoom holds the device briefly after a call and in a waiting room, so the docs budget about 30 seconds of edge error and never bill meeting time to the second off this signal alone.

Who has the microphone

Since macOS 14, CoreAudio also exposes a list of process objects. AudioProcessCollector reads kAudioHardwarePropertyProcessObjectList on the system object, then kAudioProcessPropertyBundleID and kAudioProcessPropertyIsRunningInput per object:

static func isRunningInput(_ object: AudioObjectID) -> Bool? {
    var address = inputAddress()
    var value: UInt32 = 0
    var size = UInt32(MemoryLayout<UInt32>.size)
    guard AudioObjectGetPropertyData(object, &address, 0, nil, &size, &value) == noErr else {
        return nil
    }
    return value != 0
}
Enter fullscreen mode Exit fullscreen mode

No Microphone permission is required, none is requested, and a probe of it generated no tccd activity. Each bundle identifier is matched against a fixed list and dropped; nothing else about the process is read on this path.

This buys three things. It names the holder, so the hold can say "Slack has the microphone" instead of "something does". It repairs .unreliable: a driver holding the device does not make Slack's process object report input, so an attributed call-capable holder still counts. And it stops the app being fooled: a readable and empty table is evidence of absence, and a table whose only holders are the speech services is not a call. CallCapableApps.neverAMeetingPrefixes lists those: com.apple.CoreSpeech, com.apple.Siri, com.apple.assistantd, the com.apple.speech. prefix and a few more.

The caveats are printed by --doctor rather than smoothed over. Audio attributes to helper processes: Chrome's is com.google.Chrome.helper, and Teams' media path is com.microsoft.vcxpc, not even under the Teams prefix. Matching is by prefix against a hand-checked list that will age when a vendor reshuffles helpers, and it degrades to the unattributed device bit, visibly. Safari routes page audio through com.apple.WebKit.GPU, which serves every WebKit client and names no app, so it is deliberately not on the list, or any Safari tab playing a podcast could anchor a call.

From a fact to a hold

The engine keys on the microphone, not on a list of conferencing bundle ids it would always be behind on. A live device is a hard block: no prompt is delivered on any channel, and a prompt already on screen is withdrawn. Three steps.

First, the sensor layer decides whether the device bit is backed by a holder:

var audioDeviceHold: AudioDeviceHold {
    guard audio.contributesToMeeting else { return .notRunning }
    guard let any = audioProcesses.anyInputRunning else { return .held }
    return any ? .held : .runningButUnheld
}
Enter fullscreen mode Exit fullscreen mode

Three answers. The bit is false, so no hold. The process table could not be read, so the bare bit holds. The table was read and nothing has input open, named or not, which is evidence of absence and drops the block. That last reading has to hold still for 30 seconds first (unheldDeviceDwell in AppModel), because the per-object listener's latency against the device property has never been measured, and a transient disagreement at the start of every real call would be worse than the bug.

Second, the policy in Core bounds how long a bare microphone may mean "call":

if s.audioInputRunning {
    if let c = input.calendar, c.eventInProgress, c.inProgressIsBusy, c.inProgressHasVideoLink {
        return .videoEventInProgress
    }
    if audioIsCorroborated(input)
        || budget.uncorroboratedAudioElapsed < policy.uncorroboratedAudioCeiling {
        return .audioInputInUse
    }
}
if s.cameraRunning { return .cameraInUse }
if s.meetingLatch.isHolding { return .recentCallContinuing }
Enter fullscreen mode Exit fullscreen mode

uncorroboratedAudioCeiling is 20 minutes, and it is not a new number: it is the latch's fact hold plus its anchor extension, 8 plus 12, the repo's own written answer to how long a capture fact alone may mean a call. Corroboration is anything independent of that bit:

public func audioIsCorroborated(_ input: EngineInput) -> Bool {
    let s = input.signals
    if s.cameraRunning { return true }
    if s.meetingLatch.basis == .manual { return true }
    if s.meetingLatch.anchorName != nil { return true }
    if let c = input.calendar, c.eventInProgress, c.inProgressIsBusy { return true }
    return false
}
Enter fullscreen mode Exit fullscreen mode

A camera running, a call app the latch adopted from the process table, a manual "I'm in a meeting", or a busy calendar event. Any one of them and the block is unbounded. The latch arming on the same microphone is not corroboration; that would be the same evidence counted twice. Past the ceiling with nothing agreeing, the block downgrades to a soft deferral, liveCaptureUnattributed, which the seam budget then bounds.

The cost is stated in the docs rather than discovered: a long podcast take with no camera and no calendar entry can now be interrupted after 20 minutes, as a seam wait with the sound channel suppressed. That is the trade against an app that is invisible forever on a Krisp Mac, and only the ceiling covers that case: Krisp is an app and holds the device through its own process, so the table is not empty and attribution buys nothing.

Third, the call does not end when the bit drops, because that is exactly what pressing mute does. A latch asserts something narrower than "you are in a meeting": a capture device ran continuously for at least 45 seconds and stopped less than the hold budget ago. The budget is 8 minutes unconditionally, plus 12 if the app adopted as the anchor is still running, so the longest single hold is 20 minutes. No confidence score and no window title go into it; the guess is not representable in its input. The block is named recentCallContinuing, after what was observed, and the menu bar shows a held state with the closing time and "Not in a meeting" one click away. Ceilings: 90 minutes of hold per call, three hours per day.

What it still misses

Said plainly, because --doctor says it too. A call you joined muted with the camera off and never unmuted produces no capture fact, so the latch cannot open. Screen sharing is not detectable without the Screen Recording grant, which the app does not request. Google Meet in Safari can open the latch from the unattributed bit but cannot name the app and gets the shorter hold. The manual "I'm in a meeting" hold in the menu, time-boxed to two hours, is the answer to all three.

What --doctor shows

Run the binary with --doctor. From my machine, with nothing on a call:

  0     audio input           no input device is running
  0     audio input, by app   nobody. 36 processes are known to
                              CoreAudio and none of them is running input.
                              A device running with nobody holding it is a virtual
                              device, and it does not hold your break.
  0     camera in use         no camera device is running
Enter fullscreen mode Exit fullscreen mode

And the hold section under it:

  enabled              yes
  arms after           45s of continuous microphone or camera use
  capture live now     no
  live call blocked by audioInputInUse / cameraInUse, which are device facts.
                       The latch stays out of the way until capture stops.
Enter fullscreen mode Exit fullscreen mode

Everything above is a property read plus arithmetic on an injected clock, which is why it is unit-tested without a GUI session, and why the app needs no permission for the one thing it must never get wrong.

sigstop is an open-source, GPL-3.0 break reminder for macOS, and the code in this article is at https://github.com/Mohamed-Elshesheny/sigstop.

Top comments (0)