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).
408 lines
14 KiB
Swift
408 lines
14 KiB
Swift
import Cocoa
|
|
import FlutterMacOS
|
|
import CoreGraphics
|
|
import Network
|
|
import UserNotifications
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MacOSPermissionsHandler
|
|
//
|
|
// Native-side MethodChannel handler for macOS-specific permissions
|
|
// that the Flutter `permission_handler` plugin does not cover:
|
|
//
|
|
// • Input Monitoring (CGPreflightListenEventAccess /
|
|
// CGRequestListenEventAccess) for global PTT.
|
|
// • Local Network (NWBrowser trigger for _ts3._tcp) so macOS 15+
|
|
// shows the Local Network Privacy prompt.
|
|
// • Notifications (UNUserNotificationCenter authorization).
|
|
//
|
|
// Channel name: `app.chanora/macos_permissions`
|
|
//
|
|
// Trace: SRS-198, SRS-297, SRS-300, SysRS-166, SDD-091.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Polling interval for Input Monitoring state changes.
|
|
/// TCC does not emit a callback when the user toggles Input Monitoring
|
|
/// in System Settings, so we poll at a reasonable cadence.
|
|
private let kInputMonitoringPollInterval: TimeInterval = 2.0
|
|
|
|
class MacOSPermissionsHandler: NSObject, FlutterPlugin {
|
|
private var channel: FlutterMethodChannel?
|
|
private var inputMonitoringTimer: Timer?
|
|
private var lastInputMonitoringState: String = "Unknown"
|
|
|
|
// -- FlutterPlugin -------------------------------------------------------
|
|
|
|
static func register(with registrar: FlutterPluginRegistrar) {
|
|
let channel = FlutterMethodChannel(
|
|
name: "app.chanora/macos_permissions",
|
|
binaryMessenger: registrar.messenger
|
|
)
|
|
let instance = MacOSPermissionsHandler()
|
|
instance.channel = channel
|
|
registrar.addMethodCallDelegate(instance, channel: channel)
|
|
}
|
|
|
|
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
|
switch call.method {
|
|
// -- Input Monitoring ---------------------------------------------------
|
|
case "checkInputMonitoring":
|
|
result(inputMonitoringStateString())
|
|
startInputMonitoringPolling()
|
|
|
|
case "requestInputMonitoring":
|
|
requestInputMonitoring(result: result)
|
|
|
|
case "openInputMonitoringSettings":
|
|
openInputMonitoringSettings(result: result)
|
|
|
|
// -- Local Network ------------------------------------------------------
|
|
case "checkLocalNetwork":
|
|
checkLocalNetwork(result: result)
|
|
|
|
case "triggerLocalNetworkPrompt":
|
|
triggerLocalNetworkPrompt(result: result)
|
|
|
|
case "checkLocalNetworkAccess":
|
|
guard let args = call.arguments as? [String: Any],
|
|
let host = args["host"] as? String,
|
|
let port = args["port"] as? Int else {
|
|
result(FlutterError(
|
|
code: "INVALID_ARGS",
|
|
message: "checkLocalNetworkAccess requires host (String) and port (Int)",
|
|
details: nil))
|
|
return
|
|
}
|
|
checkLocalNetworkAccess(host: host, port: port, result: result)
|
|
|
|
// -- Notifications ------------------------------------------------------
|
|
case "checkNotifications":
|
|
checkNotifications(result: result)
|
|
|
|
case "requestNotifications":
|
|
requestNotifications(result: result)
|
|
|
|
default:
|
|
result(FlutterMethodNotImplemented)
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Input Monitoring
|
|
// =========================================================================
|
|
|
|
/// Returns the current Input Monitoring state as a string
|
|
/// consumable by the Dart side: "Granted", "Denied", "NotDetermined".
|
|
private func inputMonitoringStateString() -> String {
|
|
// CGPreflightListenEventAccess returns true when access is already
|
|
// granted. On macOS 10.15+ it returns false when denied or not yet
|
|
// determined — we cannot distinguish those two without attempting
|
|
// CGRequestListenEventAccess, so we conservatively report
|
|
// "NotDetermined" when preflight returns false. The Dart side
|
|
// treats both "Denied" and "NotDetermined" as L0Focused.
|
|
if CGPreflightListenEventAccess() {
|
|
return "Granted"
|
|
}
|
|
return "NotDetermined"
|
|
}
|
|
|
|
/// Request Input Monitoring permission via
|
|
/// `CGRequestListenEventAccess()`. On macOS 13+ this opens
|
|
/// System Settings → Privacy & Security → Input Monitoring.
|
|
private func requestInputMonitoring(result: @escaping FlutterResult) {
|
|
// CGRequestListenEventAccess shows the system prompt.
|
|
// It returns true if access was already granted or becomes
|
|
// granted synchronously (rare). Most of the time it returns
|
|
// false and the user must toggle the switch manually.
|
|
let granted = CGRequestListenEventAccess()
|
|
let state = granted ? "Granted" : "NotDetermined"
|
|
lastInputMonitoringState = state
|
|
result(state)
|
|
|
|
// Start polling so we detect when the user grants in Settings.
|
|
startInputMonitoringPolling()
|
|
}
|
|
|
|
/// Open System Settings → Privacy & Security → Input Monitoring
|
|
/// so the user can manually enable the app.
|
|
private func openInputMonitoringSettings(result: @escaping FlutterResult) {
|
|
if let url = URL(
|
|
string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent"
|
|
) {
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
result(nil)
|
|
}
|
|
|
|
/// Start a periodic timer that checks Input Monitoring state and
|
|
/// notifies the Dart side when it changes.
|
|
private func startInputMonitoringPolling() {
|
|
// Don't start a second timer if one is already running.
|
|
guard inputMonitoringTimer == nil else { return }
|
|
lastInputMonitoringState = inputMonitoringStateString()
|
|
|
|
inputMonitoringTimer = Timer.scheduledTimer(
|
|
withTimeInterval: kInputMonitoringPollInterval,
|
|
repeats: true
|
|
) { [weak self] _ in
|
|
self?.pollInputMonitoring()
|
|
}
|
|
}
|
|
|
|
private func pollInputMonitoring() {
|
|
let current = inputMonitoringStateString()
|
|
guard current != lastInputMonitoringState else { return }
|
|
lastInputMonitoringState = current
|
|
|
|
// Notify the Dart side via the inbound method.
|
|
channel?.invokeMethod("inputMonitoringStateChanged", arguments: [
|
|
"state": current,
|
|
])
|
|
}
|
|
|
|
// =========================================================================
|
|
// Local Network
|
|
// =========================================================================
|
|
|
|
/// Check whether Local Network access is available.
|
|
/// On macOS 14 and earlier, Local Network Privacy does not exist,
|
|
/// so we report "Unsupported". On macOS 15+, we attempt a brief
|
|
/// NWBrowser scan and report based on the result.
|
|
private func checkLocalNetwork(result: @escaping FlutterResult) {
|
|
if #available(macOS 15.0, *) {
|
|
// We cannot synchronously determine the Local Network state
|
|
// without actually using the network. Report "NotDetermined"
|
|
// and let triggerLocalNetworkPrompt resolve the actual state.
|
|
result("NotDetermined")
|
|
} else {
|
|
result("Unsupported")
|
|
}
|
|
}
|
|
|
|
/// Trigger the Local Network Privacy prompt by starting a brief
|
|
/// NWBrowser for `_ts3._tcp`. On macOS 15+, this causes the system
|
|
/// to show the Local Network permission dialog if not already
|
|
/// determined.
|
|
///
|
|
/// The browser is started and stopped after a short scan window.
|
|
/// State changes are reported back to Dart via
|
|
/// `localNetworkStateChanged`.
|
|
@available(macOS 15.0, *)
|
|
private func triggerLocalNetworkPromptImpl(result: @escaping FlutterResult) {
|
|
let bonjourType = "_ts3._tcp"
|
|
let browserDescriptor = NWBrowser.Descriptor.bonjourWithTXTRecord(
|
|
type: bonjourType, domain: nil)
|
|
let browser = NWBrowser(for: browserDescriptor, using: NWParameters.tcp)
|
|
|
|
var resolved = false
|
|
|
|
browser.stateUpdateHandler = { [weak self] (browserState: NWBrowser.State) in
|
|
switch browserState {
|
|
case .ready:
|
|
// Browser started successfully — local network is accessible.
|
|
if !resolved {
|
|
resolved = true
|
|
result("Granted")
|
|
self?.channel?.invokeMethod("localNetworkStateChanged", arguments: [
|
|
"state": "Granted",
|
|
])
|
|
}
|
|
browser.cancel()
|
|
case .failed(let error):
|
|
if !resolved {
|
|
resolved = true
|
|
// Check for DNS policy-denied error (kDNSServiceErr_PolicyDenied = -65570).
|
|
// This is the canonical signal that the user denied the Local Network prompt.
|
|
if case .dns(let dnsError) = error,
|
|
dnsError == DNSServiceErrorType(kDNSServiceErr_PolicyDenied) {
|
|
result("Denied")
|
|
self?.channel?.invokeMethod("localNetworkStateChanged", arguments: [
|
|
"state": "Denied",
|
|
])
|
|
} else {
|
|
// Network unreachable or other transient error —
|
|
// don't assume denied.
|
|
result("NotDetermined")
|
|
}
|
|
}
|
|
browser.cancel()
|
|
case .waiting(let error):
|
|
// The browser is waiting for network. If the specific DNS error
|
|
// is kDNSServiceErr_PolicyDenied, the user explicitly denied
|
|
// the Local Network prompt — report immediately.
|
|
if case .dns(let dnsError) = error,
|
|
dnsError == DNSServiceErrorType(kDNSServiceErr_PolicyDenied) {
|
|
if !resolved {
|
|
resolved = true
|
|
result("Denied")
|
|
self?.channel?.invokeMethod("localNetworkStateChanged", arguments: [
|
|
"state": "Denied",
|
|
])
|
|
}
|
|
browser.cancel()
|
|
}
|
|
// Otherwise the system dialog may be showing — wait for
|
|
// .ready, .failed, or the timeout.
|
|
case .setup, .cancelled:
|
|
break
|
|
@unknown default:
|
|
break
|
|
}
|
|
}
|
|
|
|
browser.start(queue: DispatchQueue.main)
|
|
|
|
// Timeout: if the browser doesn't resolve within 10 seconds,
|
|
// report NotDetermined so the Dart side doesn't hang forever.
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 10.0) {
|
|
if !resolved {
|
|
resolved = true
|
|
result("NotDetermined")
|
|
browser.cancel()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func triggerLocalNetworkPrompt(result: @escaping FlutterResult) {
|
|
if #available(macOS 15.0, *) {
|
|
triggerLocalNetworkPromptImpl(result: result)
|
|
} else {
|
|
result("Unsupported")
|
|
}
|
|
}
|
|
|
|
/// Probe whether Local Network access is currently denied for a specific
|
|
/// host:port by creating a short-lived NWConnection and checking
|
|
/// `unsatisfiedReason == .localNetworkDenied`. This does NOT trigger a
|
|
/// new system prompt — it is a read-only check.
|
|
private func checkLocalNetworkAccess(
|
|
host: String, port: Int, result: @escaping FlutterResult
|
|
) {
|
|
if #available(macOS 15.0, *) {
|
|
checkLocalNetworkAccessImpl(host: host, port: port, result: result)
|
|
} else {
|
|
result("Unsupported")
|
|
}
|
|
}
|
|
|
|
@available(macOS 15.0, *)
|
|
private func checkLocalNetworkAccessImpl(
|
|
host: String, port: Int, result: @escaping FlutterResult
|
|
) {
|
|
guard let endpointPort = NWEndpoint.Port(rawValue: UInt16(port)) else {
|
|
result("NotDetermined")
|
|
return
|
|
}
|
|
let endpointHost = NWEndpoint.Host(host)
|
|
let connection = NWConnection(
|
|
host: endpointHost, port: endpointPort, using: .tcp)
|
|
let queue = DispatchQueue(
|
|
label: "app.chanora.macos_permissions.local_network_check")
|
|
|
|
var didComplete = false
|
|
|
|
func finish(_ state: String) {
|
|
guard !didComplete else { return }
|
|
didComplete = true
|
|
connection.cancel()
|
|
result(state)
|
|
}
|
|
|
|
connection.stateUpdateHandler = { state in
|
|
switch state {
|
|
case .waiting, .failed:
|
|
if connection.currentPath?.unsatisfiedReason
|
|
== .localNetworkDenied {
|
|
finish("Denied")
|
|
} else {
|
|
finish("NotDetermined")
|
|
}
|
|
case .ready:
|
|
finish("Granted")
|
|
case .cancelled:
|
|
finish("NotDetermined")
|
|
case .setup, .preparing:
|
|
break
|
|
@unknown default:
|
|
break
|
|
}
|
|
}
|
|
|
|
connection.start(queue: queue)
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
|
if !didComplete {
|
|
if connection.currentPath?.unsatisfiedReason
|
|
== .localNetworkDenied {
|
|
finish("Denied")
|
|
} else {
|
|
finish("NotDetermined")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Notifications
|
|
// =========================================================================
|
|
|
|
private func checkNotifications(result: @escaping FlutterResult) {
|
|
UNUserNotificationCenter.current().getNotificationSettings { settings in
|
|
switch settings.authorizationStatus {
|
|
case .authorized, .provisional:
|
|
result("Granted")
|
|
case .denied:
|
|
result("Denied")
|
|
case .notDetermined:
|
|
result("NotDetermined")
|
|
@unknown default:
|
|
result("NotDetermined")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func requestNotifications(result: @escaping FlutterResult) {
|
|
UNUserNotificationCenter.current().requestAuthorization(options: [
|
|
.alert, .sound, .badge,
|
|
]) { granted, _ in
|
|
let state = granted ? "Granted" : "Denied"
|
|
result(state)
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Cleanup
|
|
// =========================================================================
|
|
|
|
deinit {
|
|
inputMonitoringTimer?.invalidate()
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MainFlutterWindow
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class MainFlutterWindow: NSWindow {
|
|
override func awakeFromNib() {
|
|
let flutterViewController = FlutterViewController()
|
|
let windowFrame = self.frame
|
|
self.contentViewController = flutterViewController
|
|
self.setFrame(windowFrame, display: true)
|
|
|
|
self.titlebarAppearsTransparent = true
|
|
self.titleVisibility = .hidden
|
|
self.styleMask.insert(.fullSizeContentView)
|
|
self.isMovableByWindowBackground = true
|
|
|
|
RegisterGeneratedPlugins(registry: flutterViewController)
|
|
|
|
// 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()
|
|
}
|
|
}
|