Skip to content

Commit 3e67ce4

Browse files
committed
Replace DistributedNotificationCenter IPC with XPC (XPCListener/XPCSession)
CLI commands now communicate with the daemon over XPC (Mach service) instead of file writes + distributed notification pings. This provides reliable bidirectional communication, eliminates the duplicate mute logic race condition, and prepares for future sandboxing. Daemon: XPCListener with typed Codable handler (MicGuardRequest → MicGuardResponse), no @objc protocol needed. Client: XPCSession created as inactive, then activate() which throws XPCRichError gracefully if the Mach service is unavailable (avoids the _xpc_api_misuse trap on dealloc). cancel() before cleanup. Login: SMAppService.agent with embedded LaunchAgent plist replaces SMAppService.mainApp (provides Mach service registration via launchd). DistributedNotificationCenter kept for outbound statusChanged/appTerminated broadcasts to external consumers (SketchyBar). Zero integration impact. Adds make dev/dev-stop targets for local XPC testing via launchctl.
1 parent 8421080 commit 3e67ce4

14 files changed

Lines changed: 562 additions & 98 deletions

File tree

Makefile

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,26 @@ uninstall:
1212
zip: build
1313
cd .build && zip -ry MicGuard.zip MicGuard.app bin/mic-guard
1414

15-
.PHONY: build install uninstall zip
15+
# Development: build bundle, register LaunchAgent (Mach service), and launch daemon.
16+
# After this, `mic-guard` CLI commands work via XPC.
17+
dev: build
18+
-killall MicGuard 2>/dev/null
19+
-launchctl bootout gui/$$(id -u)/com.pszypowicz.MicGuard 2>/dev/null
20+
@# Generate dev plist with absolute ProgramArguments (launchctl needs it, SMAppService doesn't)
21+
/usr/libexec/PlistBuddy -c "Copy :. :" \
22+
-c "Add :ProgramArguments array" \
23+
-c "Add :ProgramArguments:0 string $$(pwd)/.build/MicGuard.app/Contents/MacOS/MicGuard" \
24+
.build/MicGuard.app/Contents/Library/LaunchAgents/com.pszypowicz.MicGuard.agent.plist 2>/dev/null || \
25+
/usr/libexec/PlistBuddy \
26+
-c "Set :ProgramArguments:0 $$(pwd)/.build/MicGuard.app/Contents/MacOS/MicGuard" \
27+
.build/MicGuard.app/Contents/Library/LaunchAgents/com.pszypowicz.MicGuard.agent.plist
28+
launchctl bootstrap gui/$$(id -u) .build/MicGuard.app/Contents/Library/LaunchAgents/com.pszypowicz.MicGuard.agent.plist
29+
@echo "Daemon running with XPC. Test with: .build/bin/mic-guard list"
30+
31+
# Stop the dev daemon and unregister the LaunchAgent.
32+
dev-stop:
33+
-killall MicGuard 2>/dev/null
34+
-launchctl bootout gui/$$(id -u)/com.pszypowicz.MicGuard 2>/dev/null
35+
@echo "Daemon stopped and LaunchAgent unregistered."
36+
37+
.PHONY: build install uninstall zip dev dev-stop

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ On first launch the current input device becomes your preferred mic. You can cha
3434

3535
- **Menubar daemon** — runs silently in the background with a shield+mic icon
3636
- **Auto-revert** — reverts unwanted input device switches caused by Bluetooth connections
37-
- **Mute / volume control** — toggle mute and set input volume via CLI or distributed notifications
37+
- **Mute / volume control** — toggle mute and set input volume via CLI
3838
- **CLI tool**`mic-guard` binary for scripting (`list`, `set`, `enable`, `toggle`, `mute`, `volume`, etc.)
3939
- **SketchyBar integration** — reference plugin with shield + mic items, device picker popup, and mute on click
4040
- **Distributed notifications** — real-time status broadcasts for custom integrations
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>Label</key>
6+
<string>com.pszypowicz.MicGuard</string>
7+
<key>BundleProgram</key>
8+
<string>Contents/MacOS/MicGuard</string>
9+
<key>MachServices</key>
10+
<dict>
11+
<key>com.pszypowicz.MicGuard.xpc</key>
12+
<true/>
13+
</dict>
14+
<key>RunAtLoad</key>
15+
<true/>
16+
<key>KeepAlive</key>
17+
<true/>
18+
<key>LimitLoadToSessionType</key>
19+
<string>Aqua</string>
20+
<key>AssociatedBundleIdentifiers</key>
21+
<array>
22+
<string>com.pszypowicz.MicGuard</string>
23+
</array>
24+
</dict>
25+
</plist>

Sources/MicGuard/MicGuardApp.swift

Lines changed: 7 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -54,31 +54,18 @@ struct MicGuardApp: App {
5454
exit(0)
5555
}
5656

57-
// Daemon mode — ensure single instance via fcntl lock file
57+
// Daemon mode
5858
Config.ensureConfigDir()
59-
let lockPath = Config.configDir.appending(component: "lock").path(percentEncoded: false)
60-
let lockFD = open(lockPath, O_CREAT | O_WRONLY, 0o600)
61-
guard lockFD != -1 else {
62-
logger.error("Could not create lock file — exiting")
63-
exit(1)
64-
}
65-
var lock = flock(
66-
l_start: 0, l_len: 0, l_pid: getpid(),
67-
l_type: Int16(F_WRLCK), l_whence: Int16(SEEK_SET)
68-
)
69-
if fcntl(lockFD, F_SETLK, &lock) == -1 {
70-
// Query which process holds the lock
71-
var info = lock
72-
_ = fcntl(lockFD, F_GETLK, &info)
73-
logger.info("Another instance is already running (PID \(info.l_pid, privacy: .public)) — exiting")
74-
exit(0)
75-
}
76-
// lockFD intentionally kept open — kernel releases lock on exit/crash
77-
7859
logger.info("MicGuard starting")
7960
installSignalHandlers()
8061

8162
AudioMonitor.shared.start()
63+
64+
// Try to start the XPC listener. This only succeeds when the process
65+
// was launched by launchd with the Mach service registered (make dev).
66+
// Ad-hoc signed Homebrew builds can't use SMAppService.agent() due to
67+
// Launch Constraint Violations — see GitHub issue #1 for details.
68+
AudioMonitor.shared.startXPCListener()
8269
}
8370

8471
var body: some Scene {
@@ -252,24 +239,15 @@ struct MicGuardApp: App {
252239
fputs("No input device found\n", stderr)
253240
exit(1)
254241
}
255-
// Determine current mute state from hardware. The daemon tracks
256-
// isMuted in software, but the CLI is short-lived so we read the
257-
// hardware directly: volume == 0 OR hw-mute flag set → muted.
258242
let hwMuted = AudioDevices.isInputMuted(for: device.id) == true
259243
let hwVol = AudioDevices.inputVolume(for: device.id) ?? 0
260244
let currentlyMuted = hwMuted || hwVol == 0
261-
262245
if currentlyMuted {
263-
// Unmute: clear hw mute and set a fallback volume. If the daemon
264-
// is running, its mute listener will detect the hw flag change and
265-
// restore the real pre-mute volume, overriding this fallback.
266246
_ = AudioDevices.setInputMuted(for: device.id, muted: false)
267247
if hwVol == 0 {
268248
_ = AudioDevices.setInputVolume(for: device.id, volume: 50)
269249
}
270250
} else {
271-
// Mute: zero volume and set hw mute flag. The daemon's volume
272-
// listener will detect vol → 0 and save the pre-mute volume.
273251
_ = AudioDevices.setInputVolume(for: device.id, volume: 0)
274252
_ = AudioDevices.setInputMuted(for: device.id, muted: true)
275253
}

Sources/MicGuardCore/AudioMonitor.swift

Lines changed: 153 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import CoreAudio
22
import Foundation
33
import Observation
44
import os
5+
@preconcurrency import XPC
56

67
@Observable
78
@MainActor
@@ -38,6 +39,8 @@ public final class AudioMonitor {
3839

3940

4041
private var statusDebounceWork: DispatchWorkItem?
42+
@ObservationIgnored
43+
private var xpcListener: XPCListener?
4144
var preMuteVolume: Int = 100
4245

4346
let audio: any AudioDeviceProviding
@@ -528,6 +531,146 @@ public final class AudioMonitor {
528531
}
529532
}
530533

534+
// MARK: - XPC
535+
536+
/// Start the XPC listener. Returns true if the Mach service is available
537+
/// (process was launched by launchd with the service registered).
538+
@discardableResult
539+
public func startXPCListener() -> Bool {
540+
do {
541+
xpcListener = try XPCListener(service: micGuardMachService, targetQueue: .main) { request in
542+
request.accept { [weak self] (req: MicGuardRequest) -> MicGuardResponse in
543+
guard let self else { return .error(message: "Daemon shutting down") }
544+
return MainActor.assumeIsolated {
545+
self.handleRequest(req)
546+
}
547+
}
548+
}
549+
logger.info("XPC listener started on '\(micGuardMachService, privacy: .public)'")
550+
return true
551+
} catch {
552+
logger.error("Failed to start XPC listener: \(error, privacy: .public)")
553+
return false
554+
}
555+
}
556+
557+
/// Handle an XPC request from the CLI and return a response.
558+
public func handleRequest(_ request: MicGuardRequest) -> MicGuardResponse {
559+
switch request {
560+
case .ping:
561+
postStatusChanged()
562+
return .ok
563+
564+
case .enable:
565+
isEnabled = true
566+
return .ok
567+
568+
case .disable:
569+
isEnabled = false
570+
return .ok
571+
572+
case .toggle:
573+
isEnabled = !isEnabled
574+
return .statusInfo(enabled: isEnabled, mode: mode)
575+
576+
case .status:
577+
return .statusInfo(enabled: isEnabled, mode: mode)
578+
579+
case .setDevice(let name):
580+
guard inputDevices.contains(where: { $0.name == name }) else {
581+
return .error(message: "Device '\(name)' not found")
582+
}
583+
setMode("manual")
584+
setPreferredDevice(name: name)
585+
return .ok
586+
587+
case .setVolume(let volume):
588+
guard let device = audio.currentInputDevice() else {
589+
return .error(message: "No input device found")
590+
}
591+
guard audio.setInputVolume(for: device.id, volume: volume) else {
592+
return .error(message: "Failed to set volume")
593+
}
594+
inputVolume = volume
595+
if volume > 0 && isMuted {
596+
isMuted = false
597+
} else if volume == 0 && !isMuted {
598+
preMuteVolume = max(inputVolume, 1)
599+
isMuted = true
600+
}
601+
debouncedPostStatusChanged()
602+
return .ok
603+
604+
case .mute:
605+
return toggleMute()
606+
607+
case .list:
608+
return .deviceList(buildDeviceInfoList())
609+
610+
case .current:
611+
return .device(name: currentDevice.isEmpty ? nil : currentDevice)
612+
}
613+
}
614+
615+
/// Toggle mute on the current input device using the daemon's mute state.
616+
@discardableResult
617+
public func toggleMute() -> MicGuardResponse {
618+
guard let device = audio.currentInputDevice() else {
619+
return .error(message: "No input device found")
620+
}
621+
if isMuted {
622+
_ = audio.setInputMuted(for: device.id, muted: false)
623+
_ = audio.setInputVolume(for: device.id, volume: preMuteVolume)
624+
isMuted = false
625+
inputVolume = preMuteVolume
626+
logger.info("toggleMute: unmuted '\(device.name, privacy: .public)' vol=\(self.preMuteVolume, privacy: .public)")
627+
} else {
628+
preMuteVolume = max(inputVolume, 1)
629+
_ = audio.setInputVolume(for: device.id, volume: 0)
630+
_ = audio.setInputMuted(for: device.id, muted: true)
631+
isMuted = true
632+
inputVolume = 0
633+
logger.info("toggleMute: muted '\(device.name, privacy: .public)' saved vol=\(self.preMuteVolume, privacy: .public)")
634+
}
635+
postStatusChanged()
636+
return .ok
637+
}
638+
639+
/// Build a sorted list of DeviceInfo for XPC and status broadcasting.
640+
public func buildDeviceInfoList() -> [DeviceInfo] {
641+
var devices: [DeviceInfo] = inputDevices
642+
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
643+
.map { device in
644+
let isCurrent = device.name == currentDevice
645+
let vol = (isCurrent && isMuted) ? 0 : (audio.inputVolume(for: device.id) ?? 0)
646+
let muted = (isCurrent && isMuted) || (audio.isInputMuted(for: device.id) ?? false)
647+
return DeviceInfo(
648+
name: device.name,
649+
current: isCurrent,
650+
volume: vol,
651+
muted: muted,
652+
available: true,
653+
preferred: device.name == preferredDevice
654+
)
655+
}
656+
657+
// If the preferred device is disconnected, append it as unavailable
658+
if !preferredDevice.isEmpty,
659+
!devices.contains(where: { $0.name == preferredDevice }) {
660+
devices.append(DeviceInfo(
661+
name: preferredDevice,
662+
current: false,
663+
volume: 0,
664+
muted: false,
665+
available: false,
666+
preferred: true
667+
))
668+
devices.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
669+
}
670+
671+
return devices
672+
}
673+
531674
// MARK: - Status Notifications
532675

533676
private func debouncedPostStatusChanged() {
@@ -545,39 +688,15 @@ public final class AudioMonitor {
545688
statusDebounceWork?.cancel()
546689
logger.debug("postStatusChanged: isMuted=\(self.isMuted, privacy: .public) inputVolume=\(self.inputVolume, privacy: .public) currentDevice='\(self.currentDevice, privacy: .public)'")
547690

548-
// Build per-device status, sorted alphabetically
549-
var devices: [[String: Any]] = inputDevices
550-
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
551-
.map { device in
552-
let isCurrent = device.name == currentDevice
553-
let vol = (isCurrent && isMuted) ? 0 : (audio.inputVolume(for: device.id) ?? 0)
554-
let muted = (isCurrent && isMuted) || (audio.isInputMuted(for: device.id) ?? false)
555-
return [
556-
"name": device.name,
557-
"current": isCurrent,
558-
"volume": vol,
559-
"muted": muted,
560-
"available": true,
561-
"preferred": device.name == preferredDevice,
562-
]
563-
}
564-
565-
// If the preferred device is disconnected, append it as unavailable
566-
if !preferredDevice.isEmpty,
567-
!devices.contains(where: { ($0["name"] as? String) == preferredDevice }) {
568-
devices.append([
569-
"name": preferredDevice,
570-
"current": false,
571-
"volume": 0,
572-
"muted": false,
573-
"available": false,
574-
"preferred": true,
575-
])
576-
devices.sort {
577-
let a = ($0["name"] as? String) ?? ""
578-
let b = ($1["name"] as? String) ?? ""
579-
return a.localizedCaseInsensitiveCompare(b) == .orderedAscending
580-
}
691+
let devices: [[String: Any]] = buildDeviceInfoList().map { d in
692+
[
693+
"name": d.name,
694+
"current": d.current,
695+
"volume": d.volume,
696+
"muted": d.muted,
697+
"available": d.available,
698+
"preferred": d.preferred,
699+
]
581700
}
582701

583702
let payload: [String: Any] = [
@@ -602,3 +721,4 @@ public final class AudioMonitor {
602721
)
603722
}
604723
}
724+
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
@preconcurrency import XPC
2+
3+
/// Send a request to the MicGuard daemon over XPC and return the response.
4+
///
5+
/// Creates the session as inactive, then activates manually — `activate()` throws
6+
/// a `XPCRichError` (instead of trapping) if the Mach service is unavailable.
7+
/// Returns `nil` if the daemon is not running or the request fails.
8+
public func sendDaemonRequest(_ request: MicGuardRequest) -> MicGuardResponse? {
9+
do {
10+
let session = try XPCSession(machService: micGuardMachService, options: .inactive)
11+
try session.activate()
12+
defer { session.cancel(reason: "CLI request complete") }
13+
let reply: MicGuardResponse = try session.sendSync(request)
14+
return reply
15+
} catch {
16+
return nil
17+
}
18+
}

0 commit comments

Comments
 (0)