Skip to content

Commit 8421080

Browse files
committed
Unify CLI-daemon communication: direct work + broadcast ping
Remove DistributedNotificationCenter command dispatch (toggleMute, setVolume) per Apple best practices — notifications are for broadcast, not IPC. All mutating CLI commands now do direct work (config writes or CoreAudio calls) then ping the daemon via requestStatus to re-read state and broadcast statusChanged. Remove config file watcher (DispatchSource) and suppressConfigSideEffects flag. Config is now re-read explicitly: on requestStatus ping, on startup, or via new "Reload Config" popover button. Net -145 lines. Consistent pattern across all CLI commands.
1 parent 5cb762c commit 8421080

5 files changed

Lines changed: 79 additions & 224 deletions

File tree

Sources/MicGuard/MicGuardApp.swift

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,13 @@ struct MicGuardApp: App {
111111
args.contains("--help") || args.contains("-h")
112112
}
113113

114+
/// Ask the running daemon to re-read config and broadcast status.
115+
private static func notifyDaemon() {
116+
DistributedNotificationCenter.default().postNotificationName(
117+
MicGuardNotification.requestStatus, object: nil,
118+
userInfo: nil, deliverImmediately: true)
119+
}
120+
114121
private static func handleCLI(command: String, args: [String], quiet: Bool) {
115122
switch command {
116123
case "list":
@@ -173,13 +180,15 @@ struct MicGuardApp: App {
173180
fputs("Failed to set input device to '\(name)'\n", stderr)
174181
exit(1)
175182
}
183+
notifyDaemon()
176184
case "enable":
177185
if wantsHelp(args) {
178186
print("Usage: mic-guard enable")
179187
print("\nEnable MicGuard.")
180188
return
181189
}
182190
Config.writeEnabled(true)
191+
notifyDaemon()
183192
if !quiet { print("enabled") }
184193
case "disable":
185194
if wantsHelp(args) {
@@ -188,6 +197,7 @@ struct MicGuardApp: App {
188197
return
189198
}
190199
Config.writeEnabled(false)
200+
notifyDaemon()
191201
if !quiet { print("disabled") }
192202
case "toggle":
193203
if wantsHelp(args) {
@@ -197,6 +207,7 @@ struct MicGuardApp: App {
197207
}
198208
let current = Config.readEnabled()
199209
Config.writeEnabled(!current)
210+
notifyDaemon()
200211
if !quiet { print(current ? "disabled" : "enabled") }
201212
case "status":
202213
if wantsHelp(args) {
@@ -230,6 +241,7 @@ struct MicGuardApp: App {
230241
fputs("Failed to set volume\n", stderr)
231242
exit(1)
232243
}
244+
notifyDaemon()
233245
case "mute":
234246
if wantsHelp(args) {
235247
print("Usage: mic-guard mute")
@@ -261,18 +273,14 @@ struct MicGuardApp: App {
261273
_ = AudioDevices.setInputVolume(for: device.id, volume: 0)
262274
_ = AudioDevices.setInputMuted(for: device.id, muted: true)
263275
}
264-
// Ask the daemon to re-read hardware state and broadcast to consumers
265-
DistributedNotificationCenter.default().postNotificationName(
266-
MicGuardNotification.requestStatus, object: nil,
267-
userInfo: nil, deliverImmediately: true)
276+
notifyDaemon()
268277
case "ping":
269278
if wantsHelp(args) {
270279
print("Usage: mic-guard ping")
271280
print("\nAsk the running daemon to re-broadcast its status.")
272281
return
273282
}
274-
DistributedNotificationCenter.default().postNotificationName(
275-
MicGuardNotification.requestStatus, object: nil)
283+
notifyDaemon()
276284
case "version", "--version", "-v":
277285
print("mic-guard \(version)")
278286
case "help", "--help", "-h":

Sources/MicGuard/PopoverView.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ struct PopoverView: View {
1010
set: { monitor.isEnabled = $0 }
1111
))
1212

13+
Button("Reload Config") {
14+
monitor.reloadConfig()
15+
monitor.postStatusChanged()
16+
}
17+
1318
Divider()
1419

1520
SettingsLink {

Sources/MicGuardCore/AudioMonitor.swift

Lines changed: 28 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -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() {

Sources/MicGuardCore/Notifications.swift

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,4 @@ public enum MicGuardNotification {
44
public static let statusChanged = NSNotification.Name("com.pszypowicz.MicGuard.statusChanged")
55
public static let appTerminated = NSNotification.Name("com.pszypowicz.MicGuard.appTerminated")
66
public static let requestStatus = NSNotification.Name("com.pszypowicz.MicGuard.requestStatus")
7-
public static let toggleMute = NSNotification.Name("com.pszypowicz.MicGuard.toggleMute")
8-
public static let setVolume = NSNotification.Name("com.pszypowicz.MicGuard.setVolume")
97
}

0 commit comments

Comments
 (0)