import AppKit import Foundation @preconcurrency import IOKit.hid import OSLog import SwiftUI import MacToolsPluginKit public final class MouseEnhancerPluginFactory: NSObject, MacToolsPluginBundleFactory { public static func makeProvider(context: PluginRuntimeContext) throws -> any PluginProvider { MouseEnhancerPluginProvider(context: context) } } @MainActor private struct MouseEnhancerPluginProvider: PluginProvider { let context: PluginRuntimeContext func makePlugins() -> [any MacToolsPlugin] { [ MouseEnhancerPlugin( context: context, localization: PluginLocalization(bundle: context.resourceBundle) ), ] } } enum MouseEnhancerInputMonitoringAuthorizationStatus { case granted case denied case unknown } @MainActor final class MouseEnhancerPlugin: MacToolsPlugin, PluginPrimaryPanel, AccessibilityPermissionRefreshing, PluginConfigurationPresenting { private enum PermissionID { static let accessibility = "accessibility" static let inputMonitoring = "input-monitoring" } let metadata: PluginMetadata let primaryPanelDescriptor: PluginPrimaryPanelDescriptor var onStateChange: (() -> Void)? var requestPermissionGuidance: ((String) -> Void)? var shortcutBindingResolver: ((String) -> ShortcutBinding?)? var requestConfigurationPresentation: (() -> Void)? let store: MouseEnhancerStore private let localization: PluginLocalization private let session: any MouseEnhancerSessionManaging private let makeMiddleClickSession: @MainActor () -> any MouseEnhancerMiddleClickSessionManaging private var middleClickSession: (any MouseEnhancerMiddleClickSessionManaging)? private let accessibilityTrusted: @MainActor () -> Bool private let requestAccessibilityTrust: @MainActor (Bool) -> Bool private let inputMonitoringAuthorizationStatus: @MainActor () -> MouseEnhancerInputMonitoringAuthorizationStatus private let openURL: (URL) -> Void private let logger = Logger( subsystem: Bundle.main.bundleIdentifier ?? "cc.ggbond.mactools", category: "MouseEnhancerPlugin" ) private var isAccessibilityGranted: Bool private var lastErrorMessage: String? init( context: PluginRuntimeContext = PluginRuntimeContext(pluginID: "mouse-enhancer"), session: (any MouseEnhancerSessionManaging)? = nil, makeMiddleClickSession: @escaping @MainActor () -> any MouseEnhancerMiddleClickSessionManaging = { MouseEnhancerMiddleClickSession() }, localization: PluginLocalization = PluginLocalization(bundle: .main), accessibilityTrusted: @escaping @MainActor () -> Bool = MouseEnhancerAccessibilityCheck.isTrusted, requestAccessibilityTrust: @escaping @MainActor (Bool) -> Bool = MouseEnhancerAccessibilityCheck.requestTrust(prompt:), inputMonitoringAuthorizationStatus: @escaping @MainActor () -> MouseEnhancerInputMonitoringAuthorizationStatus = MouseEnhancerPlugin.currentInputMonitoringAuthorizationStatus, openURL: @escaping (URL) -> Void = { NSWorkspace.shared.open($0) } ) { self.localization = localization self.store = MouseEnhancerStore(storage: context.storage) self.session = session ?? MouseEnhancerSession() self.makeMiddleClickSession = makeMiddleClickSession self.accessibilityTrusted = accessibilityTrusted self.requestAccessibilityTrust = requestAccessibilityTrust self.inputMonitoringAuthorizationStatus = inputMonitoringAuthorizationStatus self.openURL = openURL self.isAccessibilityGranted = accessibilityTrusted() self.primaryPanelDescriptor = PluginPrimaryPanelDescriptor( controlStyle: .button, menuActionBehavior: .keepPresented, buttonTitleProvider: { localization.string("panel.button.settings", defaultValue: "设置") } ) self.metadata = PluginMetadata( id: "mouse-enhancer", title: localization.string("metadata.title", defaultValue: "鼠标增强"), iconName: "computermouse", iconTint: Color(nsColor: .systemTeal), order: 56, defaultDescription: localization.string( "metadata.description", defaultValue: "增强鼠标与触控板滚动控制" ) ) } func activate(context: PluginRuntimeContext) { refreshAccessibilityPermission() applyCurrentConfiguration() applyMiddleClickConfiguration() } func deactivate(reason: PluginDeactivationReason) { guard reason.requiresStateCleanup else { return } session.deactivate() stopMiddleClickSession() onStateChange?() } func refresh() { refreshAccessibilityPermission() applyCurrentConfiguration() applyMiddleClickConfiguration() onStateChange?() } var primaryPanelState: PluginPanelState { PluginPanelState( subtitle: panelSubtitle, isOn: false, isExpanded: false, isEnabled: true, isVisible: true, detail: nil, errorMessage: lastErrorMessage ) } var permissionRequirements: [PluginPermissionRequirement] { [ PluginPermissionRequirement( id: PermissionID.accessibility, kind: .accessibility, title: localization.string("permission.accessibility.title", defaultValue: "辅助功能"), description: localization.string( "permission.accessibility.description", defaultValue: "用于监听滚动事件和发送中键点击。" ) ), PluginPermissionRequirement( id: PermissionID.inputMonitoring, kind: .inputMonitoring, title: localization.string("permission.inputMonitoring.title", defaultValue: "输入监控"), description: localization.string( "permission.inputMonitoring.description", defaultValue: "用于区分鼠标滚轮和触控板手势。" ) ), ] } var settingsSections: [PluginSettingsSection] { [] } var shortcutDefinitions: [PluginShortcutDefinition] { [] } var configuration: PluginConfiguration? { PluginConfiguration(description: metadata.defaultDescription) { [weak self] _ in guard let self else { return AnyView(EmptyView()) } return AnyView( MouseEnhancerSettingsView( store: self.store, localization: self.localization, onChange: { [weak self] in self?.configurationDidChange() } ) ) } } func handleAction(_ action: PluginPanelAction) { switch action { case .invokeAction(controlID: _): requestConfigurationPresentation?() default: return } } func permissionState(for permissionID: String) -> PluginPermissionState { switch permissionID { case PermissionID.accessibility: return PluginPermissionState( isGranted: isAccessibilityGranted, footnote: isAccessibilityGranted ? nil : localization.string( "permission.accessibility.footnote", defaultValue: "系统设置 → 隐私与安全性 → 辅助功能,允许 MacTools。" ) ) case PermissionID.inputMonitoring: return inputMonitoringPermissionState default: return PluginPermissionState(isGranted: true, footnote: nil) } } func handlePermissionAction(id: String) { switch id { case PermissionID.accessibility: handleAccessibilityPermissionAction() case PermissionID.inputMonitoring: openInputMonitoringSettings() default: return } } func handleSettingsAction(id: String) {} func handleShortcutAction(id: String) {} func refreshAccessibilityPermission() { let previous = isAccessibilityGranted isAccessibilityGranted = accessibilityTrusted() if previous && !isAccessibilityGranted { session.deactivate() stopMiddleClickSession() lastErrorMessage = localization.string( "error.accessibilityRevoked", defaultValue: "辅助功能权限已关闭,鼠标增强已暂停。" ) } else if !previous && isAccessibilityGranted { lastErrorMessage = nil applyCurrentConfiguration() applyMiddleClickConfiguration() } if previous != isAccessibilityGranted { onStateChange?() } } private func ensureAccessibilityPermissionForActiveConfiguration() -> Bool { lastErrorMessage = nil let configuration = store.configuration guard configuration.shouldInstallEventTap || configuration.middleClickEnabled else { return true } isAccessibilityGranted = accessibilityTrusted() if !isAccessibilityGranted { isAccessibilityGranted = requestAccessibilityTrust(true) } guard isAccessibilityGranted else { lastErrorMessage = localization.string( "error.accessibilityRequired", defaultValue: "鼠标增强需要辅助功能权限,请先前往设置完成授权。" ) requestPermissionGuidance?(PermissionID.accessibility) return false } return true } func configurationDidChange() { lastErrorMessage = nil guard ensureAccessibilityPermissionForActiveConfiguration() else { session.deactivate() stopMiddleClickSession() onStateChange?() return } applyCurrentConfiguration() applyMiddleClickConfiguration() onStateChange?() } private func applyCurrentConfiguration() { let configuration = store.configuration guard configuration.shouldInstallEventTap else { session.deactivate() return } guard isAccessibilityGranted else { session.deactivate() lastErrorMessage = localization.string( "error.accessibilityRequired", defaultValue: "鼠标增强需要辅助功能权限,请先前往设置完成授权。" ) return } if session.state.scrollTapInstalled { session.update(configuration: configuration) return } guard session.activate(configuration: configuration) else { lastErrorMessage = localization.string( "error.tapUnavailable", defaultValue: "无法启动滚动事件监听,请确认辅助功能授权后重试。" ) logger.error("failed to install scroll event tap") return } } private func applyMiddleClickConfiguration() { let configuration = store.configuration guard configuration.middleClickEnabled else { stopMiddleClickSession() return } guard isAccessibilityGranted else { stopMiddleClickSession() lastErrorMessage = localization.string( "error.accessibilityRequired", defaultValue: "鼠标增强需要辅助功能权限,请先前往设置完成授权。" ) return } if let middleClickSession { middleClickSession.requiredFingerCount = configuration.middleClickFingerCount return } let newSession = makeMiddleClickSession() newSession.requiredFingerCount = configuration.middleClickFingerCount newSession.activate() middleClickSession = newSession logger.info("middle click enabled requiredFingerCount=\(configuration.middleClickFingerCount, privacy: .public)") } private func stopMiddleClickSession() { middleClickSession?.deactivate() middleClickSession = nil } private func handleAccessibilityPermissionAction() { if isAccessibilityGranted { refreshAccessibilityPermission() return } isAccessibilityGranted = requestAccessibilityTrust(true) if isAccessibilityGranted { lastErrorMessage = nil applyCurrentConfiguration() applyMiddleClickConfiguration() } else { lastErrorMessage = localization.string( "error.accessibilityRequired", defaultValue: "鼠标增强需要辅助功能权限,请先前往设置完成授权。" ) } onStateChange?() } private var panelSubtitle: String { let configuration = store.configuration if session.state.scrollTapInstalled { return localization.format( "panel.subtitle.enabledFormat", defaultValue: "已开启 · %@ · %@", deviceSummary(configuration), axisSummary(configuration) ) } if activeConfigurationNeedsAccessibility(configuration), !isAccessibilityGranted { return localization.string("panel.subtitle.needsAccessibility", defaultValue: "启用前需要辅助功能授权") } if !configuration.shouldInstallEventTap, !configuration.middleClickEnabled { return localization.string("panel.subtitle.off", defaultValue: "未启用增强功能") } if configuration.middleClickEnabled, !configuration.shouldInstallEventTap { return localization.format( "panel.subtitle.middleClickEnabledFormat", defaultValue: "模拟中键 · %d指", configuration.middleClickFingerCount ) } return metadata.defaultDescription } private func deviceSummary(_ configuration: MouseEnhancerConfiguration) -> String { switch (configuration.hasMouseReversing, configuration.hasTrackpadReversing) { case (true, true): return localization.string("summary.device.all", defaultValue: "鼠标和触控板") case (true, false): return localization.string("summary.device.mouse", defaultValue: "鼠标") case (false, true): return localization.string("summary.device.trackpad", defaultValue: "触控板") case (false, false): return localization.string("summary.device.none", defaultValue: "未选设备") } } private func axisSummary(_ configuration: MouseEnhancerConfiguration) -> String { let hasVertical = configuration.reverseMouseVertical || configuration.reverseTrackpadVertical let hasHorizontal = configuration.reverseMouseHorizontal || configuration.reverseTrackpadHorizontal switch (hasVertical, hasHorizontal) { case (true, true): return localization.string("summary.axis.all", defaultValue: "水平和垂直") case (true, false): return localization.string("summary.axis.vertical", defaultValue: "垂直") case (false, true): return localization.string("summary.axis.horizontal", defaultValue: "水平") case (false, false): return localization.string("summary.axis.none", defaultValue: "未选方向") } } private func activeConfigurationNeedsAccessibility(_ configuration: MouseEnhancerConfiguration) -> Bool { configuration.shouldInstallEventTap || configuration.middleClickEnabled } private var inputMonitoringPermissionState: PluginPermissionState { switch inputMonitoringAuthorizationStatus() { case .granted: return PluginPermissionState( isGranted: true, footnote: localization.string( "permission.inputMonitoring.granted", defaultValue: "已允许,可提升设备识别准确性。" ) ) case .denied, .unknown: return PluginPermissionState( isGranted: false, footnote: localization.string( "permission.inputMonitoring.footnote", defaultValue: "系统设置 → 隐私与安全性 → 输入监控,允许 MacTools。" ) ) } } private static func currentInputMonitoringAuthorizationStatus() -> MouseEnhancerInputMonitoringAuthorizationStatus { switch IOHIDCheckAccess(kIOHIDRequestTypeListenEvent) { case kIOHIDAccessTypeGranted: return .granted case kIOHIDAccessTypeDenied: return .denied case kIOHIDAccessTypeUnknown: return .unknown default: return .unknown } } private func openInputMonitoringSettings() { guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent") else { return } openURL(url) } }