Files
chanora/apps/chanora_flutter/macos/Runner/MainFlutterWindow.swift
T
Edison Jwa 8cb5a7a258 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)
2026-06-04 08:21:07 +09:00

310 lines
11 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())
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() {
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"))
super.awakeFromNib()
}
}