/* * Atoll (DynamicIsland) * Copyright (C) 2024-2026 Atoll Contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ import Foundation import CoreAudio import SwiftUI import Combine // MARK: - CoreAudio Callback Function // C function pointer for CoreAudio property listener private func microphonePropertyListener( inObjectID: AudioObjectID, inNumberAddresses: UInt32, inAddresses: UnsafePointer, inClientData: UnsafeMutableRawPointer? ) -> OSStatus { guard let context = inClientData else { return noErr } let monitor = Unmanaged.fromOpaque(context).takeUnretainedValue() DispatchQueue.main.async { print("MicrophoneMonitor: 📢 Microphone property changed") monitor.checkMicrophoneStatus() } return noErr } @MainActor class MicrophoneMonitor: ObservableObject { // MARK: - Published Properties @Published var isMicActive: Bool = false @Published var activeApp: String? = nil @Published var isMonitoring: Bool = false // MARK: - Private Properties private var defaultInputDevice: AudioDeviceID = 0 private var isListenerRegistered: Bool = false private var cancellables = Set() // MARK: - Configuration // Pure event-driven - no polling // MARK: - Initialization init() { // No initial setup needed } deinit { // Clean up listener synchronously // Remove property listener if isListenerRegistered, defaultInputDevice != 0 { var address = AudioObjectPropertyAddress( mSelector: kAudioDevicePropertyDeviceIsRunningSomewhere, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMaster ) let context = Unmanaged.passUnretained(self).toOpaque() AudioObjectRemovePropertyListener( defaultInputDevice, &address, microphonePropertyListener, context ) } } // MARK: - Public Methods /// Start monitoring microphone usage func startMonitoring() { guard !isMonitoring else { print("MicrophoneMonitor: Already monitoring, skipping start") return } print("MicrophoneMonitor: 🟢 Starting microphone monitoring...") isMonitoring = true // Get default input device defaultInputDevice = getDefaultInputDevice() guard defaultInputDevice != 0 else { print("MicrophoneMonitor: ⚠️ No input device found") return } print("MicrophoneMonitor: 🎤 Found input device ID: \(defaultInputDevice)") // Check if property exists let propertyExists = checkPropertyExists() print("MicrophoneMonitor: Property exists: \(propertyExists)") // Setup event listener setupPropertyListener() // Check initial state checkMicrophoneStatus() print("MicrophoneMonitor: ✅ Started monitoring (event-driven only)") } /// Stop monitoring microphone usage func stopMonitoring() { guard isMonitoring else { print("MicrophoneMonitor: Not monitoring, skipping stop") return } print("MicrophoneMonitor: 🛑 Stopping monitoring...") isMonitoring = false // Remove property listener if isListenerRegistered { removePropertyListener() } // Reset state if isMicActive { isMicActive = false } activeApp = nil print("MicrophoneMonitor: ✅ Stopped monitoring") } /// Toggle monitoring state func toggleMonitoring() { if isMonitoring { stopMonitoring() } else { startMonitoring() } } // MARK: - Private Methods /// Get default input device ID private func getDefaultInputDevice() -> AudioDeviceID { var deviceID: AudioDeviceID = 0 var size = UInt32(MemoryLayout.size) var address = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyDefaultInputDevice, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMaster ) let status = AudioObjectGetPropertyData( AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &deviceID ) if status != noErr { print("MicrophoneMonitor: ⚠️ Failed to get default input device (status: \(status))") return 0 } return deviceID } /// Check if the property exists on the device private func checkPropertyExists() -> Bool { guard defaultInputDevice != 0 else { return false } var address = AudioObjectPropertyAddress( mSelector: kAudioDevicePropertyDeviceIsRunningSomewhere, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMaster ) let hasProperty = AudioObjectHasProperty(defaultInputDevice, &address) print("MicrophoneMonitor: Device \(defaultInputDevice) has property: \(hasProperty)") return hasProperty } /// Setup CoreAudio property listener private func setupPropertyListener() { guard defaultInputDevice != 0 else { return } // Use kAudioDevicePropertyDeviceIsRunningSomewhere (tracks when device is in use anywhere) var address = AudioObjectPropertyAddress( mSelector: kAudioDevicePropertyDeviceIsRunningSomewhere, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMaster ) // Pass self as context let context = Unmanaged.passUnretained(self).toOpaque() let status = AudioObjectAddPropertyListener( defaultInputDevice, &address, microphonePropertyListener, context ) if status == noErr { isListenerRegistered = true print("MicrophoneMonitor: ✅ Property listener registered") } else { print("MicrophoneMonitor: ⚠️ Failed to register property listener (status: \(status))") } } /// Remove CoreAudio property listener private func removePropertyListener() { guard defaultInputDevice != 0, isListenerRegistered else { return } var address = AudioObjectPropertyAddress( mSelector: kAudioDevicePropertyDeviceIsRunningSomewhere, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMaster ) let context = Unmanaged.passUnretained(self).toOpaque() let status = AudioObjectRemovePropertyListener( defaultInputDevice, &address, microphonePropertyListener, context ) if status == noErr { isListenerRegistered = false print("MicrophoneMonitor: ✅ Property listener removed") } else { print("MicrophoneMonitor: ⚠️ Failed to remove property listener (status: \(status))") } } /// Check current microphone status func checkMicrophoneStatus() { guard defaultInputDevice != 0 else { return } let isRunning = isDeviceRunning(defaultInputDevice) // Debug logging print("MicrophoneMonitor: 🔍 Checking... current=\(isMicActive), detected=\(isRunning)") // Update state if changed if isRunning != isMicActive { print("MicrophoneMonitor: 🔄 State change detected (\(isMicActive) -> \(isRunning))") withAnimation(.smooth) { isMicActive = isRunning } if isRunning { print("MicrophoneMonitor: 🎤 Microphone ACTIVE") // Could try to identify app here (TODO: investigate) activeApp = "Unknown App" } else { print("MicrophoneMonitor: ⚪ Microphone INACTIVE") activeApp = nil } } } /// Check if audio device is running private func isDeviceRunning(_ deviceID: AudioDeviceID) -> Bool { var address = AudioObjectPropertyAddress( mSelector: kAudioDevicePropertyDeviceIsRunningSomewhere, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMaster ) var isRunning: UInt32 = 0 var size = UInt32(MemoryLayout.size) let status = AudioObjectGetPropertyData( deviceID, &address, 0, nil, &size, &isRunning ) if status != noErr { print("MicrophoneMonitor: ⚠️ Failed to check device running status (status: \(status))") return false } return isRunning != 0 } } // MARK: - Extensions extension MicrophoneMonitor { /// Get current microphone status without async var currentMicStatus: Bool { return isMicActive } /// Check if monitoring is available var isMonitoringAvailable: Bool { return getDefaultInputDevice() != 0 } }