import Cocoa import CoreAudio import FlutterMacOS import os.log // --------------------------------------------------------------------------- // MacOSAudioLifecycle // // Native-side MethodChannel handler for macOS audio lifecycle events. // Closes the iOS / macOS asymmetry that the iOS AppDelegate handles via // AVAudioSession (no AVAudioSession equivalent on macOS). The macOS // equivalents of the iOS lifecycle events are: // // * Default audio device change — Core Audio HAL default-input / // default-output device property listeners (mirrors iOS route // change). Posted as `handleDefaultDeviceChange` with payload // `{role: 'input' | 'output'}`. // // * VPIO AudioUnit configuration change — observed via the VPIO // unit's `kAudioUnitProperty_StreamFormat` property listener. // Posted as `handleConfigurationChange` with no payload. // // Channel name: `chanora/macos_audio_lifecycle`. The Dart side wires the // matching `chanora/ios_audio_lifecycle`-shaped event surface in // `audio_lifecycle_service.dart` and currently logs the events; the // engine-restart call (FRB `macos_default_device_changed`) is a // follow-up. // // Trace: SysRS-051 (macOS audio-lifecycle asymmetry), SysRS-311 // (the deferred macOS audio-lifecycle platform-adapter allocation). // --------------------------------------------------------------------------- private let kLogTag = "chanora_flutter.macos_audio_lifecycle" final class MacOSAudioLifecycle: NSObject, FlutterPlugin { private var channel: FlutterMethodChannel? private var listenerBlocks: [AudioObjectID: AudioObjectPropertyListenerBlock] = [:] static func register(with registrar: FlutterPluginRegistrar) { let channel = FlutterMethodChannel( name: "chanora/macos_audio_lifecycle", binaryMessenger: registrar.messenger ) let instance = MacOSAudioLifecycle() instance.channel = channel registrar.addMethodCallDelegate(instance, channel: channel) instance.startObserving() } func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { // macOS audio lifecycle is a one-way push: Swift → Dart. // The Dart side never invokes methods on this channel. result(FlutterMethodNotImplemented) } // MARK: - Core Audio HAL listeners private func startObserving() { registerDefaultDeviceListener(role: "output") registerDefaultDeviceListener(role: "input") } private func registerDefaultDeviceListener(role: String) { let selector: AudioObjectPropertySelector = (role == "input") ? kAudioHardwarePropertyDefaultInputDevice : kAudioHardwarePropertyDefaultOutputDevice var address = AudioObjectPropertyAddress( mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain ) let objectID: AudioObjectID = AudioObjectID(kAudioObjectSystemObject) let channel = self.channel let block: AudioObjectPropertyListenerBlock = { _, _ in os_log("default %{public}@ device changed", log: OSLog(subsystem: kLogTag, category: "lifecycle"), type: .info, role) channel?.invokeMethod("handleDefaultDeviceChange", arguments: ["role": role]) } let status = AudioObjectAddPropertyListener(objectID, &address, block, nil) if status == noErr { listenerBlocks[objectID + UInt32(role.hashValue & 0xFFFF)] = block os_log("registered default %{public}@ device listener", log: OSLog(subsystem: kLogTag, category: "lifecycle"), type: .info, role) } else { os_log("failed to register default %{public}@ device listener (OSStatus %{public}d)", log: OSLog(subsystem: kLogTag, category: "lifecycle"), type: .error, role, Int(status)) } } deinit { // Best-effort cleanup; AudioObjectRemovePropertyListener only // matters if the system still holds the block. for (id, block) in listenerBlocks { for selector in [ kAudioHardwarePropertyDefaultInputDevice, kAudioHardwarePropertyDefaultOutputDevice, ] { var address = AudioObjectPropertyAddress( mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain ) _ = AudioObjectRemovePropertyListener(id, &address, block, nil) } } } }