11import MicGuardCore
22import os
3+ import ServiceManagement
34import SwiftUI
45
56private 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 ( " \n Print 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 ( " \n Enable 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 ( " \n Disable 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 ( " \n Toggle 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 ( " \n Print 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 ( " \n Toggle 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 ( " \n Ask 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 " :
0 commit comments