feat(macos): add macOS audio lifecycle MethodChannel
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).
This commit is contained in:
@@ -6,6 +6,7 @@ import '../src/rust/api.dart' as rust;
|
|||||||
|
|
||||||
const iosAudioLifecycleChannelName = 'chanora/ios_audio_lifecycle';
|
const iosAudioLifecycleChannelName = 'chanora/ios_audio_lifecycle';
|
||||||
const androidAudioLifecycleChannelName = 'chanora/android_audio_lifecycle';
|
const androidAudioLifecycleChannelName = 'chanora/android_audio_lifecycle';
|
||||||
|
const macosAudioLifecycleChannelName = 'chanora/macos_audio_lifecycle';
|
||||||
|
|
||||||
/// Parses a platform-channel route string into a [rust.BridgeAudioRoute].
|
/// Parses a platform-channel route string into a [rust.BridgeAudioRoute].
|
||||||
///
|
///
|
||||||
@@ -45,6 +46,7 @@ rust.BridgeAudioRoute parseBridgeAudioRoute(String value) {
|
|||||||
void wireAudioLifecycle() {
|
void wireAudioLifecycle() {
|
||||||
wireIosAudioLifecycle();
|
wireIosAudioLifecycle();
|
||||||
wireAndroidAudioLifecycle();
|
wireAndroidAudioLifecycle();
|
||||||
|
wireMacosAudioLifecycle();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wire the iOS AVAudioSession lifecycle MethodChannel.
|
/// 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.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -399,6 +399,8 @@ class MainFlutterWindow: NSWindow {
|
|||||||
|
|
||||||
// Register the macOS permissions MethodChannel handler.
|
// Register the macOS permissions MethodChannel handler.
|
||||||
MacOSPermissionsHandler.register(with: flutterViewController.registrar(forPlugin: "MacOSPermissionsHandler"))
|
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()
|
super.awakeFromNib()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,5 +39,17 @@ void main() {
|
|||||||
// than misclassify as Speaker.
|
// than misclassify as Speaker.
|
||||||
expect(parseBridgeAudioRoute('Hdmi'), rust.BridgeAudioRoute.unknown);
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user