feat(macos): add macOS permissions service for Input Monitoring, Local Network, and Notifications (#21)
* feat(macos): add macOS permissions service for Input Monitoring, Local Network, and Notifications Add MacOSPermissionsService (Dart) + native MethodChannel handler (Swift) for macOS-specific permissions not covered by permission_handler: - Input Monitoring (CGPreflightListenEventAccess / CGRequestListenEventAccess) for global PTT via Event Tap - Local Network Privacy prompt (NWBrowser for _ts3._tcp, macOS 15+) - Notifications (UNUserNotificationCenter authorization) Trace: SRS-198, SRS-297, SRS-300, SysRS-166, SDD-091 Changes: - Info.plist: add NSBonjourServices array with _ts3._tcp - macos_permissions_service.dart: Dart service with MethodChannel, ValueNotifier states, PTT capability derivation (L0Focused / L1MacOSEventTap), non-macOS short-circuit - MainFlutterWindow.swift: native handler registered as FlutterPlugin, Input Monitoring check/request/polling, NWBrowser trigger with denial detection, UNUserNotificationCenter request - main.dart: wire service into bootstrap lifecycle, listen for PTT capability changes from Input Monitoring state - macos_permissions_service_test.dart: 17 unit tests covering inbound state changes, outbound calls, lifecycle, error handling, platform behavior (179/179 full suite pass) * fix(macos): keep permissions capability state live
This commit is contained in:
@@ -40,5 +40,9 @@
|
||||
<string>Chanora uses Input Monitoring so push-to-talk keys work even when other apps are focused. Chanora never records what you type — only the key you bound for talking.</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Chanora needs local network access to connect to TeamSpeak-compatible voice servers.</string>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_ts3._tcp</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,5 +1,292 @@
|
||||
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)
|
||||
|
||||
// -- 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
|
||||
let code = error.errorCode
|
||||
// POSIX permission-denied or network-down signals that
|
||||
// the user denied the Local Network prompt.
|
||||
if code == ENETDOWN || code == EACCES || code == EPERM {
|
||||
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:
|
||||
// The browser is waiting for network — this is normal and
|
||||
// may mean the permission dialog is showing. Don't resolve
|
||||
// yet; wait for .ready or .failed or the timeout.
|
||||
break
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 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() {
|
||||
@@ -15,6 +302,9 @@ class MainFlutterWindow: NSWindow {
|
||||
|
||||
RegisterGeneratedPlugins(registry: flutterViewController)
|
||||
|
||||
// Register the macOS permissions MethodChannel handler.
|
||||
MacOSPermissionsHandler.register(with: flutterViewController.registrar(forPlugin: "MacOSPermissionsHandler"))
|
||||
|
||||
super.awakeFromNib()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user