@@ -10,18 +10,14 @@ public final class AudioMonitor {
1010
1111 public var isEnabled : Bool = true {
1212 didSet {
13- if !suppressConfigSideEffects {
14- config. writeEnabled ( isEnabled)
15- debouncedPostStatusChanged ( )
16- }
13+ config. writeEnabled ( isEnabled)
14+ debouncedPostStatusChanged ( )
1715 }
1816 }
1917 public var mode : String = " auto " {
2018 didSet {
21- if !suppressConfigSideEffects {
22- config. writeMode ( mode)
23- debouncedPostStatusChanged ( )
24- }
19+ config. writeMode ( mode)
20+ debouncedPostStatusChanged ( )
2521 }
2622 }
2723 public var preferredDevice : String = " "
@@ -41,8 +37,6 @@ public final class AudioMonitor {
4137 public var settleSeconds : TimeInterval = 2.0
4238
4339
44- private var configWatcherSource : DispatchSourceFileSystemObject ?
45- private var suppressConfigSideEffects = false
4640 private var statusDebounceWork : DispatchWorkItem ?
4741 var preMuteVolume : Int = 100
4842
@@ -61,51 +55,22 @@ public final class AudioMonitor {
6155
6256 public func start( ) {
6357 logger. info ( " MicGuard started [pid= \( ProcessInfo . processInfo. processIdentifier, privacy: . public) ] " )
64- suppressConfigSideEffects = true
65- isEnabled = config. readEnabled ( )
66- mode = config. readMode ( )
67- suppressConfigSideEffects = false
68- preferredDevice = readPreference ( )
69- settleSeconds = config. readSettleSeconds ( )
58+ reloadConfig ( )
7059 currentDevice = audio. currentInputDevice ( ) ? . name ?? " "
7160
72- startConfigWatcher ( )
73-
74- // Listen for status requests from external consumers
61+ // Listen for status requests from CLI and external consumers.
62+ // On request, re-read config (CLI may have written files) then broadcast.
7563 DistributedNotificationCenter . default ( ) . addObserver (
7664 forName: MicGuardNotification . requestStatus,
7765 object: nil ,
7866 queue: . main
7967 ) { [ weak self] _ in
8068 Task { @MainActor in
69+ self ? . reloadConfig ( )
8170 self ? . postStatusChanged ( )
8271 }
8372 }
8473
85- DistributedNotificationCenter . default ( ) . addObserver (
86- forName: MicGuardNotification . toggleMute,
87- object: nil ,
88- queue: . main
89- ) { [ weak self] _ in
90- Task { @MainActor in
91- self ? . toggleMute ( )
92- }
93- }
94-
95- DistributedNotificationCenter . default ( ) . addObserver (
96- forName: MicGuardNotification . setVolume,
97- object: nil ,
98- queue: . main
99- ) { [ weak self] notification in
100- let volumeStr = notification. userInfo ? [ " volume " ] as? String
101- Task { @MainActor in
102- guard let self,
103- let str = volumeStr,
104- let volume = Int ( str) else { return }
105- self . setVolume ( volume)
106- }
107- }
108-
10974 // Watch default input device changes
11075 var address = AudioObjectPropertyAddress (
11176 mSelector: kAudioHardwarePropertyDefaultInputDevice,
@@ -175,57 +140,42 @@ public final class AudioMonitor {
175140 postStatusChanged ( )
176141 }
177142
178- private func startConfigWatcher( ) {
179- config. ensureConfigDir ( )
180- let fd = open ( Config . configDir. path ( percentEncoded: false ) , O_EVTONLY)
181- guard fd >= 0 else {
182- logger. error ( " Failed to open config directory for watching " )
183- return
184- }
185-
186- let source = DispatchSource . makeFileSystemObjectSource (
187- fileDescriptor: fd,
188- eventMask: . write,
189- queue: . main
190- )
191- source. setEventHandler { [ weak self] in
192- Task { @MainActor in
193- self ? . handleConfigChange ( )
194- }
195- }
196- source. setCancelHandler {
197- close ( fd)
198- }
199- source. resume ( )
200- configWatcherSource = source
201- logger. info ( " Watching config directory for changes " )
202- }
203-
204- private func handleConfigChange( ) {
143+ /// Re-read all config files and update internal state.
144+ /// Called on startup, on `requestStatus` notification, and from the popover reload button.
145+ public func reloadConfig( ) {
205146 var changed = false
206147
207148 let newEnabled = config. readEnabled ( )
208149 if isEnabled != newEnabled {
209- suppressConfigSideEffects = true
210150 isEnabled = newEnabled
211- suppressConfigSideEffects = false
212- logger. info ( " Config watcher: enabled changed to \( newEnabled, privacy: . public) " )
151+ logger. info ( " reloadConfig: enabled changed to \( newEnabled, privacy: . public) " )
213152 changed = true
214153 }
215154
216155 let newMode = config. readMode ( )
217156 if mode != newMode {
218- suppressConfigSideEffects = true
219157 mode = newMode
220- suppressConfigSideEffects = false
221- logger. info ( " Config watcher: mode changed to ' \( newMode, privacy: . public) ' " )
158+ logger. info ( " reloadConfig: mode changed to ' \( newMode, privacy: . public) ' " )
222159 changed = true
223160 }
224161
225162 let newPreferred = config. readPreferredDevice ( )
226- if preferredDevice != newPreferred {
163+ if newPreferred. isEmpty {
164+ // No stored preference — derive from current device
165+ let derived = readPreference ( )
166+ if preferredDevice != derived {
167+ preferredDevice = derived
168+ changed = true
169+ }
170+ } else if preferredDevice != newPreferred {
227171 preferredDevice = newPreferred
228- logger. info ( " Config watcher: preferred device changed to ' \( newPreferred, privacy: . public) ' " )
172+ logger. info ( " reloadConfig: preferred device changed to ' \( newPreferred, privacy: . public) ' " )
173+ changed = true
174+ }
175+
176+ let newSettle = config. readSettleSeconds ( )
177+ if settleSeconds != newSettle {
178+ settleSeconds = newSettle
229179 changed = true
230180 }
231181
@@ -452,7 +402,6 @@ public final class AudioMonitor {
452402 mScope: kAudioDevicePropertyScopeInput,
453403 mElement: kAudioObjectPropertyElementWildcard
454404 )
455- let audioRef = self . audio
456405 let volumeHandler : AudioObjectPropertyListenerBlock = { [ weak self] _, _ in
457406 Task { @MainActor in
458407 guard let self, deviceID == self . volumeListenerDeviceID else { return }
@@ -579,56 +528,6 @@ public final class AudioMonitor {
579528 }
580529 }
581530
582- // MARK: - Volume/Mute Control
583-
584- public func toggleMute( ) {
585- guard let device = audio. currentInputDevice ( ) else {
586- logger. error ( " toggleMute: no current input device " )
587- return
588- }
589-
590- logger. debug ( " toggleMute: isMuted= \( self . isMuted, privacy: . public) hwVol= \( self . audio. inputVolume ( for: device. id) ?? - 1 , privacy: . public) hwMute= \( self . audio. isInputMuted ( for: device. id) . map ( String . init) ?? " nil " , privacy: . public) preMuteVolume= \( self . preMuteVolume, privacy: . public) " )
591-
592- if !isMuted {
593- // Mute: try hardware, fall back to software-only
594- let currentVolume = audio. inputVolume ( for: device. id) ?? 100
595- preMuteVolume = max ( currentVolume, 1 )
596- let volOk = audio. setInputVolume ( for: device. id, volume: 0 )
597- let muteOk = audio. setInputMuted ( for: device. id, muted: true )
598- isMuted = true
599- inputVolume = 0
600- logger. info ( " toggleMute: muted (saved volume \( self . preMuteVolume, privacy: . public) , volOk= \( volOk, privacy: . public) , muteOk= \( muteOk, privacy: . public) ) " )
601- } else {
602- // Unmute
603- let volOk = audio. setInputVolume ( for: device. id, volume: preMuteVolume)
604- let muteOk = audio. setInputMuted ( for: device. id, muted: false )
605- isMuted = false
606- inputVolume = audio. inputVolume ( for: device. id) ?? preMuteVolume
607- logger. info ( " toggleMute: unmuted (restored volume \( self . preMuteVolume, privacy: . public) , volOk= \( volOk, privacy: . public) , muteOk= \( muteOk, privacy: . public) ) " )
608- }
609- debouncedPostStatusChanged ( )
610- }
611-
612- public func setVolume( _ volume: Int ) {
613- guard let device = audio. currentInputDevice ( ) else {
614- logger. error ( " setVolume: no current input device " )
615- return
616- }
617- let clamped = min ( max ( volume, 0 ) , 100 )
618- if audio. setInputVolume ( for: device. id, volume: clamped) {
619- logger. info ( " setVolume: set to \( clamped, privacy: . public) " )
620- } else {
621- logger. error ( " setVolume: failed to set volume to \( clamped, privacy: . public) " )
622- }
623- // If setting volume > 0, clear software mute and native mute
624- if clamped > 0 { isMuted = false }
625- if clamped > 0 , audio. isInputMuted ( for: device. id) == true {
626- _ = audio. setInputMuted ( for: device. id, muted: false )
627- }
628- inputVolume = audio. inputVolume ( for: device. id) ?? 0
629- debouncedPostStatusChanged ( )
630- }
631-
632531 // MARK: - Status Notifications
633532
634533 private func debouncedPostStatusChanged( ) {
0 commit comments