From 111a5a6c56c039e4223e70f3d220410ca73155f4 Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Sat, 18 Jul 2026 21:08:32 +0530 Subject: [PATCH 1/5] Integration: wire the Edit menu into every text-entry tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scratchpad, Dev Tools, QR, Clipboard, Network Info and Image Converter all present text fields and all shipped without a main menu, so ⌘C/⌘V/⌘A/⌘Z were dead in each of them for the same reason they were dead in Snippets. Staged here so the integration build is worth testing. This needs to land as a proper commit on main once the feature PRs merge — HelperMainMenu itself arrives with the Snippets PR, so the per-tool calls cannot go on their own branches until that is in. Co-Authored-By: Claude Opus 4.8 (cherry picked from commit 13cfc121fbfa6509f39d1234865ec2193b0a502c) --- Sources/DMonteClipboardApp/ClipboardAppDelegate.swift | 4 ++++ Sources/DMonteDevToolsApp/DevToolsAppDelegate.swift | 4 ++++ .../DMonteImageConverterApp/ImageConverterAppDelegate.swift | 4 ++++ Sources/DMonteNetworkInfoApp/NetworkInfoAppDelegate.swift | 4 ++++ Sources/DMonteQRApp/QRAppDelegate.swift | 4 ++++ Sources/DMonteScratchpadApp/ScratchpadAppDelegate.swift | 4 ++++ 6 files changed, 24 insertions(+) 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/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() }) From b6f866b76f5d2f6ee4fd4efc49b094e522e349fa Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Tue, 21 Jul 2026 00:05:09 +0530 Subject: [PATCH 2/5] Audio Router: give the outer app the audio-input entitlement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Listen to an input" never worked from a normal toolbox launch: clicking Listen created no monitor, showed no consent dialog, and added nothing to System Settings ▸ Privacy & Security ▸ Microphone. macOS attributes a nested helper's microphone use to the containing app. PR #4 diagnosed that correctly and put NSMicrophoneUsageDescription on the outer Info.plist for exactly that reason — but the audio-input entitlement only ever went on the Audio Router helper. Under the Hardened Runtime TCC requires it on the subject it attributes to, and when absent it does not merely deny, it refuses to prompt and writes a permanent denial: requires entitlement com.apple.security.device.audio-input but it is missing ... Policy disallows prompt for Sub:{com.havokentity.mactools} Unrecoverable from the UI, because the Microphone list has no "+" — it is only ever populated by a successful request. Sign the outer app with new DMonteApp.entitlements (the helper baseline plus audio-input) rather than the shared helper baseline. Two supporting changes, both of which this bug needed to be findable: - The permission gate logs via os_log instead of stderr. Under NSWorkspace.openApplication stderr goes nowhere, so MonitorDebug output was invisible on the only launch path users have — which is why every Terminal-launched reproduction succeeded (inheriting Terminal's own TCC grant) and hid the defect. - ensureMicrophoneAccess returns MicrophoneAccess instead of Bool, so .restricted (MDM/Screen Time, not user-grantable) stops masquerading as .denied and sending people to a settings pane with nothing listed. Existing TCC denial records from before this fix must be cleared with `tccutil reset Microphone com.havokentity.mactools`; the stored denial survives re-signing. 741 tests pass. Verified on a packaged, Developer ID signed build launched from the toolbox. Co-Authored-By: Claude Opus 4.8 (cherry picked from commit 6d2857eaf60247cba3b7bbfe6614abca43a8baf0) --- Packaging/DMonteApp.entitlements | 31 +++++++++++++ Scripts/package_app.sh | 8 +++- Sources/DMonteCore/AudioMonitorKit.swift | 57 ++++++++++++++++++++---- Sources/DMonteCore/AudioRouterView.swift | 20 ++++++--- 4 files changed, 100 insertions(+), 16 deletions(-) create mode 100644 Packaging/DMonteApp.entitlements diff --git a/Packaging/DMonteApp.entitlements b/Packaging/DMonteApp.entitlements new file mode 100644 index 0000000..7cae213 --- /dev/null +++ b/Packaging/DMonteApp.entitlements @@ -0,0 +1,31 @@ + + + + + + com.apple.security.device.audio-input + + + diff --git a/Scripts/package_app.sh b/Scripts/package_app.sh index 66aefe7..82c364a 100755 --- a/Scripts/package_app.sh +++ b/Scripts/package_app.sh @@ -175,6 +175,7 @@ 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" YTDLP_ENTITLEMENTS="$ROOT_DIR/Packaging/ytdlp.entitlements" @@ -267,8 +268,11 @@ for entry in "${HELPERS[@]}"; do 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/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() From 4b351d85d8f8092b04c0599cc3e01a27bc488e93 Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Tue, 21 Jul 2026 00:35:13 +0530 Subject: [PATCH 3/5] Calendar: give the container the calendars entitlement and usage string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Granting Calendar access was impossible: the tool asked, no consent dialog appeared, nothing was added to System Settings ▸ Privacy & Security ▸ Calendars, and that list has no "+" to add an app by hand — so there was no way to recover from the UI. This is the microphone bug from 6d2857e, one service over. tccd attributes a nested helper's request to the containing app, so the Calendar helper's request is recorded against Sub:{com.havokentity.mactools}. Under the Hardened Runtime TCC then requires com.apple.security.personal-information.calendars on that subject, and when it is missing it does not deny — it refuses to prompt at all. The container had neither the entitlement nor NSCalendarsFullAccessUsageDescription; it carries the microphone equivalents of both because that fix was made service-by-service rather than as a rule. Both are added here, plus a Calendar.entitlements for the helper itself, mirroring AudioRouter. Verified on the signed bundle: the entitlement is present on the container and on the helper. Not fixed here, but the same shape and worth testing: Volume Mixer declares NSAudioCaptureUsageDescription while the container does not, so system-audio capture may fail the same silent way. Co-Authored-By: Claude Opus 4.8 (cherry picked from commit 73795c79211f1bd38d8cb1686d4d134e40a5113e) --- Packaging/Calendar.entitlements | 23 +++++++++++++++++++++++ Packaging/DMonteApp.entitlements | 10 ++++++++++ Packaging/Info.plist | 6 ++++++ Scripts/package_app.sh | 4 ++++ 4 files changed, 43 insertions(+) create mode 100644 Packaging/Calendar.entitlements 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 index 7cae213..f7e50cf 100644 --- a/Packaging/DMonteApp.entitlements +++ b/Packaging/DMonteApp.entitlements @@ -27,5 +27,15 @@ --> 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 82c364a..107b313 100755 --- a/Scripts/package_app.sh +++ b/Scripts/package_app.sh @@ -178,6 +178,7 @@ 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; @@ -263,6 +264,9 @@ 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" From a639acbdfb08cbf02e3a53b6cab13929debb8c08 Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Tue, 21 Jul 2026 11:30:19 +0530 Subject: [PATCH 4/5] Volume Mixer: stop quit from persisting stops nobody asked for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `stopProcessing()` is the footer gesture: it marks every live engine force-off so the 2-second reconciler will not restart them. Since stops became persistent, that write also goes to disk — and the app delegate was calling the same method from `cleanup()` on quit and terminate. So quitting wrote a force-off for every app that merely happened to be playing, and the next launch restored all of them as stops the user never pressed: the row's slider still reading 40% while the audio played at full volume, until each one was nudged by hand. Split the two intents. `stopProcessing()` keeps marking the overrides; the new `shutdownProcessing()` releases the taps and touches nothing persisted, and that is what teardown now calls. Also collapse duplicate keys when reading the stopped list back instead of trapping on them. `stoppedKeys(from:)` reads a dictionary and cannot emit a repeat, but the list comes back off disk, where a hand-edited or half-merged prefs plist is outside our control — and `Dictionary(uniqueKeysWithValues:)` would crash the tool on launch over a value whose meaning is unambiguous. Co-Authored-By: Claude Opus 4.8 (cherry picked from commit 2a8f1cef1766c163057c56c750033154cdf802e5) --- Sources/DMonteCore/VolumeMixerView.swift | 18 ++++++++- .../VolumeMixerAppDelegate.swift | 4 +- .../VolumeMixerReconcileTests.swift | 39 +++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) 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/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" + ) + } + } From b12d5e76e8abc1ce9500c300e5c8747511bf02fe Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Tue, 21 Jul 2026 11:30:19 +0530 Subject: [PATCH 5/5] Audio Switcher: stop the volume slider stuttering mid-drag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `refreshVolumeAndMute()` wrote `volumeSupported`, `muteSupported` and `isMuted` unconditionally. They are `@Published`, which does not dedupe, so a same-value write still fires `objectWillChange` and re-evaluates the whole popover. CoreAudio posts a VolumeScalar notification for every step of a drag, so the popover re-rendered on each one — the `isAdjustingVolume` guard held `volume` steady, but the redundant publishes alone were enough to make the drag stutter. Every write is now guarded on an actual change. The drag latch also had no way back. Only the slider's `onEditingChanged(false)` cleared it, so dismissing the popover mid-drag left it set — and because the controller is a `@StateObject` that outlives an open/close cycle, volume stopped tracking the hardware for the rest of the process. Release it on disappear, which also re-reads the device so the next open shows the true level. Co-Authored-By: Claude Opus 4.8 (cherry picked from commit 577907a36c7c18fac5ff341d530f4e94038432b0) --- Sources/DMonteCore/AudioSwitcherView.swift | 37 +++++++++++++++------- 1 file changed, 25 insertions(+), 12 deletions(-) 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