diff --git a/Packaging/Calendar.entitlements b/Packaging/Calendar.entitlements new file mode 100644 index 0000000..56a1470 --- /dev/null +++ b/Packaging/Calendar.entitlements @@ -0,0 +1,23 @@ + + + + + + com.apple.security.personal-information.calendars + + + diff --git a/Packaging/DMonteApp.entitlements b/Packaging/DMonteApp.entitlements new file mode 100644 index 0000000..f7e50cf --- /dev/null +++ b/Packaging/DMonteApp.entitlements @@ -0,0 +1,41 @@ + + + + + + com.apple.security.device.audio-input + + + + com.apple.security.personal-information.calendars + + + diff --git a/Packaging/Info.plist b/Packaging/Info.plist index 4da686c..df372d0 100644 --- a/Packaging/Info.plist +++ b/Packaging/Info.plist @@ -30,6 +30,12 @@ NSHumanReadableCopyright Copyright © 2026 Yahushad Monte + + NSCalendarsFullAccessUsageDescription + Show your events in the menu-bar calendar. + NSCalendarsUsageDescription + Show your events in the menu-bar calendar. NSMicrophoneUsageDescription 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. SUEnableInstallerLauncherService diff --git a/Scripts/package_app.sh b/Scripts/package_app.sh index 66aefe7..107b313 100755 --- a/Scripts/package_app.sh +++ b/Scripts/package_app.sh @@ -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; @@ -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)" diff --git a/Sources/DMonteClipboardApp/ClipboardAppDelegate.swift b/Sources/DMonteClipboardApp/ClipboardAppDelegate.swift index bd5c21c..0782594 100644 --- a/Sources/DMonteClipboardApp/ClipboardAppDelegate.swift +++ b/Sources/DMonteClipboardApp/ClipboardAppDelegate.swift @@ -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 diff --git a/Sources/DMonteCore/AudioMonitorKit.swift b/Sources/DMonteCore/AudioMonitorKit.swift index a31fc53..ad8af2a 100644 --- a/Sources/DMonteCore/AudioMonitorKit.swift +++ b/Sources/DMonteCore/AudioMonitorKit.swift @@ -1,13 +1,31 @@ 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). @@ -15,24 +33,45 @@ public enum AudioMonitorPermission { 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 } } } diff --git a/Sources/DMonteCore/AudioRouterView.swift b/Sources/DMonteCore/AudioRouterView.swift index 0db2b16..08f6ca5 100644 --- a/Sources/DMonteCore/AudioRouterView.swift +++ b/Sources/DMonteCore/AudioRouterView.swift @@ -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() diff --git a/Sources/DMonteCore/AudioSwitcherView.swift b/Sources/DMonteCore/AudioSwitcherView.swift index 9565990..8f6d792 100644 --- a/Sources/DMonteCore/AudioSwitcherView.swift +++ b/Sources/DMonteCore/AudioSwitcherView.swift @@ -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 } } } @@ -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 diff --git a/Sources/DMonteCore/VolumeMixerView.swift b/Sources/DMonteCore/VolumeMixerView.swift index 5d5a249..d8bb981 100644 --- a/Sources/DMonteCore/VolumeMixerView.swift +++ b/Sources/DMonteCore/VolumeMixerView.swift @@ -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() } @@ -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) { diff --git a/Sources/DMonteDevToolsApp/DevToolsAppDelegate.swift b/Sources/DMonteDevToolsApp/DevToolsAppDelegate.swift index e15194b..a06d331 100644 --- a/Sources/DMonteDevToolsApp/DevToolsAppDelegate.swift +++ b/Sources/DMonteDevToolsApp/DevToolsAppDelegate.swift @@ -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", diff --git a/Sources/DMonteImageConverterApp/ImageConverterAppDelegate.swift b/Sources/DMonteImageConverterApp/ImageConverterAppDelegate.swift index f77cb24..4d464f1 100644 --- a/Sources/DMonteImageConverterApp/ImageConverterAppDelegate.swift +++ b/Sources/DMonteImageConverterApp/ImageConverterAppDelegate.swift @@ -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", diff --git a/Sources/DMonteNetworkInfoApp/NetworkInfoAppDelegate.swift b/Sources/DMonteNetworkInfoApp/NetworkInfoAppDelegate.swift index d8effbb..2107306 100644 --- a/Sources/DMonteNetworkInfoApp/NetworkInfoAppDelegate.swift +++ b/Sources/DMonteNetworkInfoApp/NetworkInfoAppDelegate.swift @@ -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() }) diff --git a/Sources/DMonteQRApp/QRAppDelegate.swift b/Sources/DMonteQRApp/QRAppDelegate.swift index 40d7526..184e366 100644 --- a/Sources/DMonteQRApp/QRAppDelegate.swift +++ b/Sources/DMonteQRApp/QRAppDelegate.swift @@ -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", diff --git a/Sources/DMonteScratchpadApp/ScratchpadAppDelegate.swift b/Sources/DMonteScratchpadApp/ScratchpadAppDelegate.swift index 83272eb..d428969 100644 --- a/Sources/DMonteScratchpadApp/ScratchpadAppDelegate.swift +++ b/Sources/DMonteScratchpadApp/ScratchpadAppDelegate.swift @@ -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() }) diff --git a/Sources/DMonteVolumeMixerApp/VolumeMixerAppDelegate.swift b/Sources/DMonteVolumeMixerApp/VolumeMixerAppDelegate.swift index e50b2fa..04d1f07 100644 --- a/Sources/DMonteVolumeMixerApp/VolumeMixerAppDelegate.swift +++ b/Sources/DMonteVolumeMixerApp/VolumeMixerAppDelegate.swift @@ -78,7 +78,9 @@ final class VolumeMixerAppDelegate: NSObject, NSApplicationDelegate { } private func cleanup() { - controller?.stopProcessing() + // Teardown, not the footer gesture: `stopProcessing()` persists a force-off for every live + // engine, which on quit would resurrect as stops the user never pressed. + controller?.shutdownProcessing() panelHost?.removeOutsideClickMonitor() panelHost?.stopObservingShowNotifications() } diff --git a/Tests/DMonteCoreTests/VolumeMixerReconcileTests.swift b/Tests/DMonteCoreTests/VolumeMixerReconcileTests.swift index 76da8b7..68b54c7 100644 --- a/Tests/DMonteCoreTests/VolumeMixerReconcileTests.swift +++ b/Tests/DMonteCoreTests/VolumeMixerReconcileTests.swift @@ -182,4 +182,43 @@ final class VolumeMixerReconcileTests: XCTestCase { ) } + /// Quitting must not look like the footer gesture. `stopProcessing()` marks every live engine + /// force-off, and that write is persisted — so routing teardown through it brings back every app + /// that merely happened to be playing at quit as a stop the user never pressed, with the row's + /// slider reading 40% while the audio plays at full volume. Asserted at the source level because + /// the real path runs through CoreAudio and the shared prefs domain, neither of which a unit + /// test may touch. + func testQuitTearsDownWithoutPersistingAStop() throws { + let repoRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // Tests/DMonteCoreTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // repo root + let source = try String( + contentsOf: repoRoot.appendingPathComponent("Sources/DMonteVolumeMixerApp/VolumeMixerAppDelegate.swift"), + encoding: .utf8 + ) + + XCTAssertTrue( + source.contains("shutdownProcessing()"), + "Quit must release the taps through the non-persisting teardown" + ) + XCTAssertFalse( + source.contains("controller?.stopProcessing()"), + "stopProcessing() persists a force-off per live engine — it is the footer gesture, not teardown" + ) + } + + /// The stopped-keys list is read back off disk, so it is not ours to trust: a hand-edited or + /// half-merged prefs plist can carry the same key twice. Building the map with + /// `Dictionary(uniqueKeysWithValues:)` traps on that, which crashes the tool on launch — for a + /// value whose meaning is unambiguous. A repeat must collapse, not kill the process. + func testDuplicatePersistedKeysCollapseInsteadOfCrashing() { + let restored = AppVolumeMixerController.overrides(fromStoppedKeys: ["a.app", "a.app", "b.app"]) + XCTAssertEqual(restored, ["a.app": false, "b.app": false]) + XCTAssertFalse( + shouldRun(gain: 0.4, manualOverride: restored["a.app"]), + "A duplicated key still means stopped" + ) + } + }