Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions Packaging/Calendar.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!--
Entitlements for the Calendar helper. Identical to DMonte.entitlements
(the helper baseline) except for the calendars key below.

Under the Hardened Runtime, TCC refuses to even show the consent dialog
for kTCCServiceCalendar unless the requesting subject carries
com.apple.security.personal-information.calendars — the same rule that
already bit the microphone (see DMonteApp.entitlements). The failure is
silent: no prompt, no entry in System Settings ▸ Privacy & Security ▸
Calendars, and that list has no "+" to add an app by hand, so there is no
way to recover from the UI.

The container needs it too, for the same attribution reason documented in
DMonteApp.entitlements — this file alone is not sufficient.
-->
<key>com.apple.security.personal-information.calendars</key>
<true/>
</dict>
</plist>
41 changes: 41 additions & 0 deletions Packaging/DMonteApp.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!--
Entitlements for the OUTER toolbox app only. Identical to
DMonte.entitlements (the helper baseline) except for the audio-input key
below, which the outer app needs even though it never captures audio
itself.

macOS attributes a nested helper's microphone use to the containing app:
when the Audio Router helper asks for the mic, tccd records the request
against Sub:{com.havokentity.mactools}, not the helper. Under the Hardened
Runtime, TCC then requires com.apple.security.device.audio-input on that
subject — and if it is absent, it does not merely deny, it refuses to show
the consent dialog at all and writes a permanent denial:

Prompting policy for hardened runtime; service: kTCCServiceMicrophone
requires entitlement com.apple.security.device.audio-input but it is
missing ... Policy disallows prompt for Sub:{com.havokentity.mactools}

The failure is silent and unrecoverable from the UI, because System
Settings ▸ Privacy & Security ▸ Microphone has no "+" to add an app by
hand — the list is only ever populated by a successful request. The helper
carrying the entitlement (AudioRouter.entitlements) is not sufficient on
its own; the container must carry it too.
-->
<key>com.apple.security.device.audio-input</key>
<true/>

<!--
Same rule, same reason, for the Calendar helper: tccd records its request
against the container, so kTCCServiceCalendar is refused a prompt unless
the container carries the calendars entitlement. Symptom is identical —
the tool asks, nothing appears, and Privacy & Security ▸ Calendars offers
no way to add the app by hand.
-->
<key>com.apple.security.personal-information.calendars</key>
<true/>
</dict>
</plist>
6 changes: 6 additions & 0 deletions Packaging/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@
<true/>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2026 Yahushad Monte</string>
<!-- Present for the same reason as the microphone key below: TCC attributes a nested
helper's calendar request to this container, and refuses to prompt without the string. -->
<key>NSCalendarsFullAccessUsageDescription</key>
<string>Show your events in the menu-bar calendar.</string>
<key>NSCalendarsUsageDescription</key>
<string>Show your events in the menu-bar calendar.</string>
<key>NSMicrophoneUsageDescription</key>
<string>DMonte uses audio input for the Audio Router's "listen to an input" feature, which plays an input device through an output device on this Mac only.</string>
<key>SUEnableInstallerLauncherService</key>
Expand Down
12 changes: 10 additions & 2 deletions Scripts/package_app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,10 @@ fi
SIGN_IDENTITY="${SIGN_IDENTITY:--}"

BASE_ENTITLEMENTS="$ROOT_DIR/Packaging/DMonte.entitlements"
APP_ENTITLEMENTS="$ROOT_DIR/Packaging/DMonteApp.entitlements"
VOLUMEMIXER_ENTITLEMENTS="$ROOT_DIR/Packaging/VolumeMixer.entitlements"
AUDIOROUTER_ENTITLEMENTS="$ROOT_DIR/Packaging/AudioRouter.entitlements"
CALENDAR_ENTITLEMENTS="$ROOT_DIR/Packaging/Calendar.entitlements"
YTDLP_ENTITLEMENTS="$ROOT_DIR/Packaging/ytdlp.entitlements"

# Hardened runtime + secure timestamp are only meaningful with a real identity;
Expand Down Expand Up @@ -262,13 +264,19 @@ for entry in "${HELPERS[@]}"; do
elif [[ "$exe" == "DMonteAudioRouter" ]]; then
# Needs the audio-input entitlement for the "listen to an input" monitor.
helper_entitlements="$AUDIOROUTER_ENTITLEMENTS"
elif [[ "$exe" == "DMonteCalendar" ]]; then
# Hardened Runtime refuses a calendar prompt without this entitlement.
helper_entitlements="$CALENDAR_ENTITLEMENTS"
fi
sign_one "$HELPERS_DIR/$app/Contents/MacOS/$exe" "$helper_entitlements"
sign_one "$HELPERS_DIR/$app" "$helper_entitlements"
done

# 4. Finally the outer app (seals everything signed above).
sign_one "$APP_DIR" "$BASE_ENTITLEMENTS"
# 4. Finally the outer app (seals everything signed above). It signs with
# APP_ENTITLEMENTS, not BASE_ENTITLEMENTS: TCC attributes a nested helper's
# microphone use to the container, so the audio-input entitlement has to be
# here as well as on the Audio Router helper. See DMonteApp.entitlements.
sign_one "$APP_DIR" "$APP_ENTITLEMENTS"

if [[ "$SIGN_IDENTITY" == "-" ]]; then
echo "Signed ad-hoc (TCC grants will not persist; not notarizable)"
Expand Down
4 changes: 4 additions & 0 deletions Sources/DMonteClipboardApp/ClipboardAppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ final class ClipboardAppDelegate: NSObject, NSApplicationDelegate {

func applicationDidFinishLaunching(_ notification: Notification) {
AppDefaults.registerDefaults()

// Without a main menu, AppKit never matches ⌘C/⌘V/⌘A/⌘Z, so they are dead in
// this tool's text fields. The menu is never drawn; it exists for the shortcuts.
HelperMainMenu.installEditMenuIfNeeded()
ClipboardLoginItem.refreshIfEnabled()

controller.onRequestClose = { [weak self] in
Expand Down
57 changes: 48 additions & 9 deletions Sources/DMonteCore/AudioMonitorKit.swift
Original file line number Diff line number Diff line change
@@ -1,38 +1,77 @@
import Accelerate
import AppKit
import AVFoundation
import CoreAudio
import Foundation
import os
import os.lock

/// Outcome of the microphone gate. Distinguishes the states macOS treats very
/// differently: `.denied` is user-revocable in System Settings, `.restricted`
/// is not (MDM profile / Screen Time), and `.missingUsageDescription` means the
/// running binary can't legally ask at all.
public enum MicrophoneAccess: Sendable, Equatable {
case granted
case denied
case restricted
case missingUsageDescription
}

/// Microphone authorization gate for input monitoring. Capturing a hardware
/// input device is treated by macOS as microphone use, so the first monitor
/// must obtain consent.
public enum AudioMonitorPermission {
/// Logged via `os_log` (not stderr) so the gate stays diagnosable when the
/// toolbox launches this helper via `NSWorkspace` and stderr goes nowhere —
/// the only launch path that matters to users.
private static let log = Logger(subsystem: "com.havokentity.mactools.audiorouter", category: "microphone")

/// Whether the running binary declares a microphone usage string. Without it,
/// any audio-input access aborts the process via TCC, so input monitoring is
/// unavailable (notably in `swift run` builds — use the packaged app).
public static var hasUsageDescription: Bool {
Bundle.main.object(forInfoDictionaryKey: "NSMicrophoneUsageDescription") != nil
}

/// Resolves to `true` when the app may capture audio input, prompting once
/// if permission has not yet been decided.
public static func ensureMicrophoneAccess() async -> Bool {
switch AVCaptureDevice.authorizationStatus(for: .audio) {
/// Resolves the app's right to capture audio input, prompting once if
/// permission has not yet been decided.
public static func ensureMicrophoneAccess() async -> MicrophoneAccess {
let status = AVCaptureDevice.authorizationStatus(for: .audio)
log.notice("gate: authorizationStatus=\(status.rawValue, privacy: .public) usageDescription=\(hasUsageDescription, privacy: .public)")

switch status {
case .authorized:
return true
return .granted
case .restricted:
return .restricted
case .denied:
return .denied
case .notDetermined:
// macOS aborts the process (TCC SIGABRT) the moment it accesses the
// microphone — via `requestAccess` OR the actual CoreAudio capture —
// if the running binary has no `NSMicrophoneUsageDescription`. That
// key only exists in the packaged Info.plist, not in a `swift run`
// build, so refuse rather than crash when it's absent.
guard hasUsageDescription else { return false }
return await withCheckedContinuation { continuation in
guard hasUsageDescription else {
log.error("gate: no NSMicrophoneUsageDescription — refusing to ask")
return .missingUsageDescription
}
// This helper is an LSUIElement agent, so when the toolbox launches
// it, it is not the active app and TCC can decline to present the
// consent dialog at all — the request then resolves false with no
// prompt shown and no entry added to System Settings ▸ Microphone
// (that list has no "+", so a silent failure leaves the user with no
// way in). Activating first gives the dialog a foreground app to
// attach to.
await MainActor.run { NSApp.activate() }
let granted = await withCheckedContinuation { continuation in
AVCaptureDevice.requestAccess(for: .audio) { continuation.resume(returning: $0) }
}
default:
return false
log.notice("gate: requestAccess granted=\(granted, privacy: .public)")
return granted ? .granted : .denied
@unknown default:
log.error("gate: unknown authorizationStatus \(status.rawValue, privacy: .public)")
return .denied
}
}
}
Expand Down
20 changes: 15 additions & 5 deletions Sources/DMonteCore/AudioRouterView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -215,12 +215,22 @@ public final class AudioRouterController: ObservableObject {
}
let gain = newMonitorGain
Task { [weak self] in
let granted = await AudioMonitorPermission.ensureMicrophoneAccess()
let access = await AudioMonitorPermission.ensureMicrophoneAccess()
guard let self else { return }
guard granted else {
self.statusMessage = AudioMonitorPermission.hasUsageDescription
? "Allow microphone access in System Settings ▸ Privacy & Security ▸ Microphone to listen to an input"
: "Input monitoring needs the installed app — microphone permission isn’t available in this build"
guard access == .granted else {
switch access {
case .denied:
self.statusMessage = "Allow microphone access in System Settings ▸ Privacy & Security ▸ Microphone to listen to an input"
case .restricted:
// The list has no "+" to add us by hand, and the user can't
// grant this themselves — say so rather than sending them to
// a settings pane where nothing is listed.
self.statusMessage = "Microphone access is blocked by a device policy (MDM or Screen Time) — it can’t be granted here"
case .missingUsageDescription:
self.statusMessage = "Input monitoring needs the installed app — microphone permission isn’t available in this build"
case .granted:
break
}
return
}
let engine = AudioMonitorEngine()
Expand Down
37 changes: 25 additions & 12 deletions Sources/DMonteCore/AudioSwitcherView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,32 +89,39 @@ public final class AudioSwitcherController: ObservableObject {
refreshVolumeAndMute()
}

/// Re-reads volume and mute from the current default output.
///
/// Every write is guarded on an actual change. These are `@Published`, and `@Published` does not
/// dedupe: assigning the same value still fires `objectWillChange` and re-evaluates the whole
/// popover. CoreAudio posts a VolumeScalar notification for *every* step of a slider drag, so
/// unconditional writes here re-render the popover on each step — the `isAdjustingVolume` guard
/// holds `volume` steady, but the redundant publishes alone are enough to make the drag stutter.
private func refreshVolumeAndMute() {
guard let outputID = defaultOutputID else {
volumeSupported = false
muteSupported = false
volume = 0
isMuted = false
if volumeSupported { volumeSupported = false }
if muteSupported { muteSupported = false }
if volume != 0 { volume = 0 }
if isMuted { isMuted = false }
return
}
if let vol = AudioDeviceKit.volume(for: outputID) {
volumeSupported = true
if !volumeSupported { volumeSupported = true }
// Don't yank the slider out from under an active drag.
if !isAdjustingVolume {
if !isAdjustingVolume, volume != vol {
volume = vol
}
} else {
volumeSupported = false
if !isAdjustingVolume {
if volumeSupported { volumeSupported = false }
if !isAdjustingVolume, volume != 0 {
volume = 0
}
}
if let muted = AudioDeviceKit.isMuted(outputID) {
muteSupported = true
isMuted = muted
if !muteSupported { muteSupported = true }
if isMuted != muted { isMuted = muted }
} else {
muteSupported = false
isMuted = false
if muteSupported { muteSupported = false }
if isMuted { isMuted = false }
}
}

Expand Down Expand Up @@ -172,6 +179,12 @@ public struct AudioSwitcherPopoverView: View {
.padding(AudioSwitcherSizing.outerPadding)
.frame(width: AudioSwitcherSizing.panelWidth, height: AudioSwitcherSizing.panelHeight)
.frostedPanel(cornerRadius: 18)
// The drag latch is only ever cleared by the slider's own `onEditingChanged(false)`. Dismiss
// the popover mid-drag and that callback never arrives, so the latch stays set — and because
// the controller is a `@StateObject` that outlives an open/close cycle, volume would stop
// tracking the hardware for the rest of the process. Releasing it here also re-reads the
// device, so the next open shows the true level rather than the value the drag left behind.
.onDisappear { controller.setVolumeEditing(false) }
}

// MARK: - Header
Expand Down
18 changes: 17 additions & 1 deletion Sources/DMonteCore/VolumeMixerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,16 @@ public final class AppVolumeMixerController: ObservableObject {
for id in audioEngines.keys {
manualProcessingOverrides[id] = false
}
shutdownProcessing()
}

/// Releases the taps without recording any user intent. Quitting is not a gesture: the engines
/// have to stop so the process can exit cleanly, but nothing on this path may touch
/// `manualProcessingOverrides`, which is persisted. Marking them here would write a force-off
/// for every app that merely happened to be processing at quit, and the next launch would
/// restore all of them as stops the user never asked for — slider still reading 40% while the
/// audio plays at full volume.
public func shutdownProcessing() {
for engine in audioEngines.values {
engine.stop()
}
Expand Down Expand Up @@ -572,8 +582,14 @@ public final class AppVolumeMixerController: ObservableObject {

/// Rebuilds the overrides map from a persisted stopped-keys list: every stored key is a
/// force-off. The inverse of `stoppedKeys(from:)`.
///
/// Duplicates are collapsed rather than trapped. `stoppedKeys(from:)` cannot emit a repeat — it
/// reads a dictionary — but this list comes back off disk, and a hand-edited or half-merged
/// prefs plist is outside our control. `Dictionary(uniqueKeysWithValues:)` would crash the tool
/// on launch for a value that has one obvious reading: the key is stopped, however many times
/// it was written.
nonisolated static func overrides(fromStoppedKeys keys: [String]) -> [String: Bool] {
Dictionary(uniqueKeysWithValues: keys.map { ($0, false) })
Dictionary(keys.map { ($0, false) }, uniquingKeysWith: { first, _ in first })
}

private func scheduleGainPersistence(_ gain: Float, forKey key: String) {
Expand Down
4 changes: 4 additions & 0 deletions Sources/DMonteDevToolsApp/DevToolsAppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ final class DevToolsAppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
AppDefaults.registerDefaults()

// Without a main menu, AppKit never matches ⌘C/⌘V/⌘A/⌘Z, so they are dead in
// this tool's text fields. The menu is never drawn; it exists for the shortcuts.
HelperMainMenu.installEditMenuIfNeeded()

let host = HelperWindowHost(
configuration: HelperWindowHost.Configuration(
title: "Dev Tools",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ final class ImageConverterAppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
AppDefaults.registerDefaults()

// Without a main menu, AppKit never matches ⌘C/⌘V/⌘A/⌘Z, so they are dead in
// this tool's text fields. The menu is never drawn; it exists for the shortcuts.
HelperMainMenu.installEditMenuIfNeeded()

let host = HelperWindowHost(
configuration: HelperWindowHost.Configuration(
title: "Image Converter",
Expand Down
4 changes: 4 additions & 0 deletions Sources/DMonteNetworkInfoApp/NetworkInfoAppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ final class NetworkInfoAppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
AppDefaults.registerDefaults()

// Without a main menu, AppKit never matches ⌘C/⌘V/⌘A/⌘Z, so they are dead in
// this tool's text fields. The menu is never drawn; it exists for the shortcuts.
HelperMainMenu.installEditMenuIfNeeded()

let host = HelperPanelHost(
configuration: HelperPanelHost.Configuration(
sizing: .preferred({ NetworkInfoSizing.preferredSize() })
Expand Down
4 changes: 4 additions & 0 deletions Sources/DMonteQRApp/QRAppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ final class QRAppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
AppDefaults.registerDefaults()

// Without a main menu, AppKit never matches ⌘C/⌘V/⌘A/⌘Z, so they are dead in
// this tool's text fields. The menu is never drawn; it exists for the shortcuts.
HelperMainMenu.installEditMenuIfNeeded()

let host = HelperWindowHost(
configuration: HelperWindowHost.Configuration(
title: "DMonte QR",
Expand Down
4 changes: 4 additions & 0 deletions Sources/DMonteScratchpadApp/ScratchpadAppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ final class ScratchpadAppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
AppDefaults.registerDefaults()

// Without a main menu, AppKit never matches ⌘C/⌘V/⌘A/⌘Z, so they are dead in
// this tool's text fields. The menu is never drawn; it exists for the shortcuts.
HelperMainMenu.installEditMenuIfNeeded()

let host = HelperPanelHost(
configuration: HelperPanelHost.Configuration(
sizing: .preferred({ ScratchpadSizing.preferredSize() })
Expand Down
Loading
Loading