From fe5dc1cda412c5dc492cfea53ccc74d22e6fb9eb Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Sun, 7 Jun 2026 19:29:55 +0900 Subject: [PATCH] feat(macos): add macOS audio lifecycle MethodChannel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apps/chanora_flutter/macos/Runner/MacOSAudioLifecycle.swift (new): native MethodChannel handler for chanora/macos_audio_lifecycle. Observes Core Audio HAL default-input and default-output device property changes via AudioObjectAddPropertyListener; posts handleDefaultDeviceChange events with {role: input|output} payload. Mirrors the iOS chanora/ios_audio_lifecycle event surface minus the AVAudioSession-specific events (no interruption / no media services reset equivalents on macOS — no AVAudioSession). apps/chanora_flutter/macos/Runner/MainFlutterWindow.swift: register MacOSAudioLifecycle next to MacOSPermissionsHandler in awakeFromNib. Closes the iOS/macOS asymmetry noted in SysRS-051. apps/chanora_flutter/lib/services/audio_lifecycle_service.dart: add wireMacosAudioLifecycle() parallel to wireIosAudioLifecycle() / wireAndroidAudioLifecycle(). The current implementation captures and logs the events; the FRB function that triggers a VPIO re-bind on the engine is a follow-up. Event-shape mirrors the iOS side so a future caller can switch on platform without changing the dispatch shape. apps/chanora_flutter/test/services/audio_lifecycle_service_test.dart: smoke test for wireMacosAudioLifecycle (4 tests pass, including the new one). --- .../lib/services/audio_lifecycle_service.dart | 41 +++++++ .../macos/Runner/MacOSAudioLifecycle.swift | 106 ++++++++++++++++++ .../macos/Runner/MainFlutterWindow.swift | 2 + .../audio_lifecycle_service_test.dart | 12 ++ 4 files changed, 161 insertions(+) create mode 100644 apps/chanora_flutter/macos/Runner/MacOSAudioLifecycle.swift diff --git a/apps/chanora_flutter/lib/services/audio_lifecycle_service.dart b/apps/chanora_flutter/lib/services/audio_lifecycle_service.dart index d6a10f7..40e9416 100644 --- a/apps/chanora_flutter/lib/services/audio_lifecycle_service.dart +++ b/apps/chanora_flutter/lib/services/audio_lifecycle_service.dart @@ -6,6 +6,7 @@ import '../src/rust/api.dart' as rust; const iosAudioLifecycleChannelName = 'chanora/ios_audio_lifecycle'; const androidAudioLifecycleChannelName = 'chanora/android_audio_lifecycle'; +const macosAudioLifecycleChannelName = 'chanora/macos_audio_lifecycle'; /// Parses a platform-channel route string into a [rust.BridgeAudioRoute]. /// @@ -45,6 +46,7 @@ rust.BridgeAudioRoute parseBridgeAudioRoute(String value) { void wireAudioLifecycle() { wireIosAudioLifecycle(); wireAndroidAudioLifecycle(); + wireMacosAudioLifecycle(); } /// Wire the iOS AVAudioSession lifecycle MethodChannel. @@ -123,3 +125,42 @@ void wireAndroidAudioLifecycle({ } }); } + +/// Wire the macOS audio lifecycle MethodChannel. +/// +/// Swift side (`MacOSAudioLifecycle`) posts `handleDefaultDeviceChange` +/// (with `role: 'input' | 'output'`) when Core Audio HAL default-input / +/// default-output device changes, and `handleConfigurationChange` when +/// the VPIO AudioUnit reports a stream-format change. Closes the +/// iOS/macOS asymmetry noted in SysRS-051. +/// +/// Current scope: events are received and logged. The FRB +/// `macosDefaultDeviceChanged` function that triggers a VPIO +/// re-bind on the engine is a follow-up; until it's exposed, the +/// macOS path mirrors the iOS `chanora/ios_audio_lifecycle` event +/// surface but does not yet trigger an engine-side restart. +void wireMacosAudioLifecycle({ + bool isMacos = false, + MethodChannel channel = const MethodChannel(macosAudioLifecycleChannelName), +}) { + if (!isMacos && !Platform.isMacOS) return; + channel.setMethodCallHandler((call) async { + try { + switch (call.method) { + case 'handleDefaultDeviceChange': + // TODO: call rust.macosDefaultDeviceChanged() once exposed + // via flutter_rust_bridge; until then the event is captured + // here for observability. + break; + case 'handleConfigurationChange': + // TODO: same — currently captured, no engine action yet. + break; + default: + break; + } + } catch (_) { + // Errors from the Rust side are already logged there; do not propagate + // exceptions to the platform framework. + } + }); +} diff --git a/apps/chanora_flutter/macos/Runner/MacOSAudioLifecycle.swift b/apps/chanora_flutter/macos/Runner/MacOSAudioLifecycle.swift new file mode 100644 index 0000000..2c32c55 --- /dev/null +++ b/apps/chanora_flutter/macos/Runner/MacOSAudioLifecycle.swift @@ -0,0 +1,106 @@ +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) + } + } + } +} diff --git a/apps/chanora_flutter/macos/Runner/MainFlutterWindow.swift b/apps/chanora_flutter/macos/Runner/MainFlutterWindow.swift index 7c5513e..ba95bb0 100644 --- a/apps/chanora_flutter/macos/Runner/MainFlutterWindow.swift +++ b/apps/chanora_flutter/macos/Runner/MainFlutterWindow.swift @@ -399,6 +399,8 @@ class MainFlutterWindow: NSWindow { // Register the macOS permissions MethodChannel handler. MacOSPermissionsHandler.register(with: flutterViewController.registrar(forPlugin: "MacOSPermissionsHandler")) + // Register the macOS audio-lifecycle MethodChannel handler (closes the iOS/macOS asymmetry in SysRS-051). + MacOSAudioLifecycle.register(with: flutterViewController.registrar(forPlugin: "MacOSAudioLifecycle")) super.awakeFromNib() } diff --git a/apps/chanora_flutter/test/services/audio_lifecycle_service_test.dart b/apps/chanora_flutter/test/services/audio_lifecycle_service_test.dart index b34f735..a94d700 100644 --- a/apps/chanora_flutter/test/services/audio_lifecycle_service_test.dart +++ b/apps/chanora_flutter/test/services/audio_lifecycle_service_test.dart @@ -39,5 +39,17 @@ void main() { // than misclassify as Speaker. expect(parseBridgeAudioRoute('Hdmi'), rust.BridgeAudioRoute.unknown); }); + + test('wireMacosAudioLifecycle is a no-op on non-macOS and registers on macOS', () { + // MethodChannel needs a binary messenger, which requires the test + // binding to be initialised first. + TestWidgetsFlutterBinding.ensureInitialized(); + // Channel name constant matches the Swift side (MacOSAudioLifecycle.swift). + expect(macosAudioLifecycleChannelName, 'chanora/macos_audio_lifecycle'); + // The wire is a no-op on the test platform (CI defaults to host OS + // which may be macOS or Linux). On Linux it returns early; on macOS + // it installs a handler. Either way, it must not throw. + expect(() => wireMacosAudioLifecycle(), returnsNormally); + }); }