Skip to content

Commit d758bbf

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 d758bbf

16 files changed

Lines changed: 656 additions & 181 deletions

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+
<false/>
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: 73 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import MicGuardCore
22
import os
3+
import ServiceManagement
34
import SwiftUI
45

56
private func postTerminationNotification() {
@@ -54,26 +55,28 @@ struct MicGuardApp: App {
5455
exit(0)
5556
}
5657

57-
// Daemon mode — ensure single instance via fcntl lock file
58+
// Daemon mode
5859
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)
60+
61+
// Auto-register the LaunchAgent if running from a .app bundle.
62+
// This ensures the Mach service is advertised via launchd.
63+
// If the agent wasn't registered yet, registering it causes launchd
64+
// to start a new instance (RunAtLoad: true) — so we exit and let
65+
// launchd take over with the Mach service in the bootstrap.
66+
let agentPlist = Bundle.main.bundleURL
67+
.appending(path: "Contents/Library/LaunchAgents/com.pszypowicz.MicGuard.agent.plist")
68+
if FileManager.default.fileExists(atPath: agentPlist.path(percentEncoded: false)) {
69+
let agent = SMAppService.agent(plistName: "com.pszypowicz.MicGuard.agent.plist")
70+
if agent.status != .enabled {
71+
do {
72+
try agent.register()
73+
logger.info("LaunchAgent registered — exiting so launchd can relaunch with Mach service")
74+
exit(0)
75+
} catch {
76+
logger.error("Failed to register LaunchAgent: \(error, privacy: .public)")
77+
}
78+
}
7579
}
76-
// lockFD intentionally kept open — kernel releases lock on exit/crash
7780

7881
logger.info("MicGuard starting")
7982
installSignalHandlers()
@@ -111,11 +114,17 @@ struct MicGuardApp: App {
111114
args.contains("--help") || args.contains("-h")
112115
}
113116

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)
117+
/// Send an XPC request, exiting with error if the daemon is not running.
118+
private static func requireXPC(_ request: MicGuardRequest) -> MicGuardResponse {
119+
guard let response = sendDaemonRequest(request) else {
120+
fputs("MicGuard daemon is not running\n", stderr)
121+
exit(1)
122+
}
123+
if case .error(let message) = response {
124+
fputs("\(message)\n", stderr)
125+
exit(1)
126+
}
127+
return response
119128
}
120129

121130
private static func handleCLI(command: String, args: [String], quiet: Bool) {
@@ -127,23 +136,32 @@ struct MicGuardApp: App {
127136
return
128137
}
129138
let outputFormat = Self.parseOutputFlag(args: args)
130-
let devices = AudioDevices.listInputDevices()
139+
let response = sendDaemonRequest(.list)
140+
// Fall back to direct CoreAudio if daemon is not running (read-only)
141+
let devices: [DeviceInfo]
142+
if case .deviceList(let list) = response {
143+
devices = list
144+
} else {
145+
devices = AudioDevices.listInputDevices().map { device in
146+
DeviceInfo(
147+
name: device.name,
148+
current: device.id == AudioDevices.currentInputDevice()?.id,
149+
volume: AudioDevices.inputVolume(for: device.id) ?? 0,
150+
muted: AudioDevices.isInputMuted(for: device.id) ?? false,
151+
available: true,
152+
preferred: false
153+
)
154+
}
155+
}
131156
if outputFormat == "json" {
132-
let currentDevice = AudioDevices.currentInputDevice()
133-
let preferred = Config.readPreferredDevice()
134-
let entries: [[String: Any]] = devices.map { device in
135-
var entry: [String: Any] = [
136-
"name": device.name,
137-
"current": device.id == currentDevice?.id,
138-
"preferred": device.name == preferred,
157+
let entries: [[String: Any]] = devices.map { d in
158+
[
159+
"name": d.name,
160+
"current": d.current,
161+
"volume": d.volume,
162+
"muted": d.muted,
163+
"preferred": d.preferred,
139164
]
140-
if let vol = AudioDevices.inputVolume(for: device.id) {
141-
entry["volume"] = vol
142-
}
143-
if let muted = AudioDevices.isInputMuted(for: device.id) {
144-
entry["muted"] = muted
145-
}
146-
return entry
147165
}
148166
if let data = try? JSONSerialization.data(withJSONObject: entries, options: [.prettyPrinted, .sortedKeys]),
149167
let json = String(data: data, encoding: .utf8) {
@@ -160,7 +178,10 @@ struct MicGuardApp: App {
160178
print("\nPrint the current default input device.")
161179
return
162180
}
163-
if let device = AudioDevices.currentInputDevice() {
181+
// Fall back to direct CoreAudio if daemon is not running (read-only)
182+
if let response = sendDaemonRequest(.current) {
183+
if case .device(let name) = response, let name { print(name) }
184+
} else if let device = AudioDevices.currentInputDevice() {
164185
print(device.name)
165186
}
166187
case "set":
@@ -174,53 +195,42 @@ struct MicGuardApp: App {
174195
fputs("Usage: mic-guard set <device name>\n", stderr)
175196
exit(1)
176197
}
177-
Config.writePreferredDevice(name)
178-
Config.writeMode("manual")
179-
if !AudioDevices.setInputDevice(name: name) {
180-
fputs("Failed to set input device to '\(name)'\n", stderr)
181-
exit(1)
182-
}
183-
notifyDaemon()
198+
_ = requireXPC(.setDevice(name: name))
184199
case "enable":
185200
if wantsHelp(args) {
186201
print("Usage: mic-guard enable")
187202
print("\nEnable MicGuard.")
188203
return
189204
}
190-
Config.writeEnabled(true)
191-
notifyDaemon()
205+
_ = requireXPC(.enable)
192206
if !quiet { print("enabled") }
193207
case "disable":
194208
if wantsHelp(args) {
195209
print("Usage: mic-guard disable")
196210
print("\nDisable MicGuard.")
197211
return
198212
}
199-
Config.writeEnabled(false)
200-
notifyDaemon()
213+
_ = requireXPC(.disable)
201214
if !quiet { print("disabled") }
202215
case "toggle":
203216
if wantsHelp(args) {
204217
print("Usage: mic-guard toggle")
205218
print("\nToggle MicGuard on/off.")
206219
return
207220
}
208-
let current = Config.readEnabled()
209-
Config.writeEnabled(!current)
210-
notifyDaemon()
211-
if !quiet { print(current ? "disabled" : "enabled") }
221+
let response = requireXPC(.toggle)
222+
if !quiet, case .statusInfo(let enabled, _) = response {
223+
print(enabled ? "enabled" : "disabled")
224+
}
212225
case "status":
213226
if wantsHelp(args) {
214227
print("Usage: mic-guard status")
215228
print("\nPrint whether MicGuard is enabled or disabled.")
216229
return
217230
}
218-
let enabled = Config.readEnabled()
219-
let mode = Config.readMode()
220-
if enabled {
221-
print("enabled (\(mode))")
222-
} else {
223-
print("disabled")
231+
let response = requireXPC(.status)
232+
if case .statusInfo(let enabled, let mode) = response {
233+
print(enabled ? "enabled (\(mode))" : "disabled")
224234
}
225235
case "volume":
226236
if wantsHelp(args) {
@@ -233,54 +243,21 @@ struct MicGuardApp: App {
233243
fputs("Usage: mic-guard volume <0-100>\n", stderr)
234244
exit(1)
235245
}
236-
guard let device = AudioDevices.currentInputDevice() else {
237-
fputs("No input device found\n", stderr)
238-
exit(1)
239-
}
240-
if !AudioDevices.setInputVolume(for: device.id, volume: volume) {
241-
fputs("Failed to set volume\n", stderr)
242-
exit(1)
243-
}
244-
notifyDaemon()
246+
_ = requireXPC(.setVolume(volume: volume))
245247
case "mute":
246248
if wantsHelp(args) {
247249
print("Usage: mic-guard mute")
248250
print("\nToggle mute on the current input device.")
249251
return
250252
}
251-
guard let device = AudioDevices.currentInputDevice() else {
252-
fputs("No input device found\n", stderr)
253-
exit(1)
254-
}
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.
258-
let hwMuted = AudioDevices.isInputMuted(for: device.id) == true
259-
let hwVol = AudioDevices.inputVolume(for: device.id) ?? 0
260-
let currentlyMuted = hwMuted || hwVol == 0
261-
262-
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.
266-
_ = AudioDevices.setInputMuted(for: device.id, muted: false)
267-
if hwVol == 0 {
268-
_ = AudioDevices.setInputVolume(for: device.id, volume: 50)
269-
}
270-
} 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.
273-
_ = AudioDevices.setInputVolume(for: device.id, volume: 0)
274-
_ = AudioDevices.setInputMuted(for: device.id, muted: true)
275-
}
276-
notifyDaemon()
253+
_ = requireXPC(.mute)
277254
case "ping":
278255
if wantsHelp(args) {
279256
print("Usage: mic-guard ping")
280257
print("\nAsk the running daemon to re-broadcast its status.")
281258
return
282259
}
283-
notifyDaemon()
260+
_ = requireXPC(.ping)
284261
case "version", "--version", "-v":
285262
print("mic-guard \(version)")
286263
case "help", "--help", "-h":

Sources/MicGuard/SettingsView.swift

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,17 @@ private struct DisplayDevice: Equatable, Identifiable {
1010

1111
struct SettingsView: View {
1212
private var monitor = AudioMonitor.shared
13-
@State private var isLoginEnabled = SMAppService.mainApp.status == .enabled
13+
private static let loginService: SMAppService = {
14+
// Use the LaunchAgent plist when running from a .app bundle (provides Mach service).
15+
// Fall back to mainApp login item for development builds without a .app bundle.
16+
let bundlePath = Bundle.main.bundleURL
17+
.appending(path: "Contents/Library/LaunchAgents/com.pszypowicz.MicGuard.agent.plist")
18+
if FileManager.default.fileExists(atPath: bundlePath.path(percentEncoded: false)) {
19+
return SMAppService.agent(plistName: "com.pszypowicz.MicGuard.agent.plist")
20+
}
21+
return .mainApp
22+
}()
23+
@State private var isLoginEnabled = SettingsView.loginService.status == .enabled
1424
@State private var showAdvanced = false
1525

1626
var body: some View {
@@ -85,13 +95,13 @@ struct SettingsView: View {
8595
private func toggleLoginItem(_ enable: Bool) {
8696
do {
8797
if enable {
88-
try SMAppService.mainApp.register()
98+
try Self.loginService.register()
8999
} else {
90-
try SMAppService.mainApp.unregister()
100+
try Self.loginService.unregister()
91101
}
92102
} catch {
93103
logger.error("Login item toggle failed: \(error, privacy: .public)")
94104
}
95-
isLoginEnabled = SMAppService.mainApp.status == .enabled
105+
isLoginEnabled = Self.loginService.status == .enabled
96106
}
97107
}

0 commit comments

Comments
 (0)