@@ -2,6 +2,7 @@ import CoreAudio
22import Foundation
33import Observation
44import 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+
0 commit comments