diff --git a/apps/boss-agent-mac/Sources/BossAgentApp.swift b/apps/boss-agent-mac/Sources/BossAgentApp.swift index 00f5bcd..2d809fd 100644 --- a/apps/boss-agent-mac/Sources/BossAgentApp.swift +++ b/apps/boss-agent-mac/Sources/BossAgentApp.swift @@ -2,6 +2,7 @@ import Cocoa import WebKit import ApplicationServices import AVFoundation +import IOKit.hid import Network import UserNotifications @@ -13,6 +14,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate { private var window: NSWindow? private var webView: WKWebView? private var inputMonitoringTap: CFMachPort? + private var globalKeyMonitor: Any? + private var inputMonitoringManager: IOHIDManager? private var localNetworkBrowser: NWBrowser? private var activeTab = "overview" @@ -24,6 +27,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate { name: NSApplication.didBecomeActiveNotification, object: nil ) + NSAppleEventManager.shared().setEventHandler( + self, + andSelector: #selector(handleGetUrlEvent(_:withReplyEvent:)), + forEventClass: AEEventClass(kInternetEventClass), + andEventID: AEEventID(kAEGetURL) + ) let webConfiguration = WKWebViewConfiguration() let webView = WKWebView(frame: .zero, configuration: webConfiguration) @@ -47,6 +56,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate { loadAgentPanel(tab: activeTab) NSApp.activate(ignoringOtherApps: true) + handleLaunchPermissionRequestIfNeeded() } private func loadAgentPanel(tab: String? = nil) { @@ -68,6 +78,40 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate { loadAgentPanel(tab: activeTab) } + private func handleLaunchPermissionRequestIfNeeded() { + let arguments = CommandLine.arguments + guard + let targetIndex = arguments.firstIndex(of: "--request-permission"), + arguments.indices.contains(targetIndex + 1) + else { + return + } + + let target = arguments[targetIndex + 1] + let returnTab = commandLineValue(after: "--return-tab", in: arguments) ?? "permissions" + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + self?.handlePermissionTarget(target, returnTab: returnTab) + } + } + + private func commandLineValue(after flag: String, in arguments: [String]) -> String? { + guard let index = arguments.firstIndex(of: flag), arguments.indices.contains(index + 1) else { + return nil + } + return arguments[index + 1] + } + + @objc private func handleGetUrlEvent(_ event: NSAppleEventDescriptor, withReplyEvent replyEvent: NSAppleEventDescriptor) { + guard + let urlString = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue, + let url = URL(string: urlString), + isBossAgentDeepLink(url) + else { + return + } + handleBossAgentDeepLink(url) + } + func application(_ application: NSApplication, open urls: [URL]) { for url in urls where isBossAgentDeepLink(url) { handleBossAgentDeepLink(url) @@ -171,9 +215,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate { let returnTab = normalizedTab(rawReturnTab) activeTab = returnTab + UserDefaults.standard.set(target, forKey: "lastPermissionRequestTarget") + UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: "lastPermissionRequestAt") + NSLog("boss-agent permission request target=%@ returnTab=%@", target, returnTab) + NSApp.activate(ignoringOtherApps: true) requestNativePermission(for: target) - if let settingsUrl = systemSettingsUrl(for: target) { - NSWorkspace.shared.open(settingsUrl) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { [weak self] in + if let settingsUrl = self?.systemSettingsUrl(for: target) { + NSWorkspace.shared.open(settingsUrl) + } } DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in @@ -185,6 +235,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate { [ URLQueryItem(name: "nativeAccessibility", value: AXIsProcessTrusted() ? "granted" : "missing"), URLQueryItem(name: "nativeScreenRecording", value: screenRecordingStatus()), + URLQueryItem(name: "nativeInputMonitoring", value: inputMonitoringStatus()), URLQueryItem(name: "nativeMicrophone", value: microphonePermissionStatus()), URLQueryItem(name: "nativeCamera", value: cameraPermissionStatus()), ] @@ -223,6 +274,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate { } } + private func inputMonitoringStatus() -> String { + if #available(macOS 10.15, *) { + return CGPreflightListenEventAccess() ? "granted" : "missing" + } + + switch IOHIDCheckAccess(kIOHIDRequestTypeListenEvent) { + case kIOHIDAccessTypeGranted: + return "granted" + case kIOHIDAccessTypeDenied: + return "missing" + default: + return "unknown" + } + } + private func requestNativePermission(for target: String) { let targets = target == "all" ? [ @@ -314,6 +380,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate { } private func requestInputMonitoringPermission() { + var listenRequestResult = false + if #available(macOS 10.15, *) { + listenRequestResult = CGRequestListenEventAccess() + } else { + listenRequestResult = IOHIDRequestAccess(kIOHIDRequestTypeListenEvent) + } + NSLog("boss-agent input monitoring listen request result=%@", listenRequestResult ? "true" : "false") + + if globalKeyMonitor == nil { + globalKeyMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.keyDown]) { _ in } + } + preflightKeyboardStateRead() + primeInputMonitoringHidPath() + let eventMask = CGEventMask(1 << CGEventType.keyDown.rawValue) inputMonitoringTap = CGEvent.tapCreate( tap: .cgSessionEventTap, @@ -329,6 +409,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate { } } + private func preflightKeyboardStateRead() { + for keyCode in [CGKeyCode(0), CGKeyCode(1), CGKeyCode(49), CGKeyCode(53)] { + _ = CGEventSource.keyState(.combinedSessionState, key: keyCode) + } + } + + private func primeInputMonitoringHidPath() { + let manager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone)) + let keyboardMatching = [ + kIOHIDDeviceUsagePageKey as String: kHIDPage_GenericDesktop, + kIOHIDDeviceUsageKey as String: kHIDUsage_GD_Keyboard, + ] as CFDictionary + IOHIDManagerSetDeviceMatching(manager, keyboardMatching) + let openResult = IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone)) + NSLog("boss-agent input monitoring hid open result=%d", openResult) + inputMonitoringManager = manager + } + private func requestNotificationPermission() { UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { _, _ in } } @@ -348,15 +446,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate { private func systemSettingsUrl(for target: String) -> URL? { let mapping = [ - "all": "x-apple.systempreferences:com.apple.preference.security?Privacy", - "accessibility": "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility", - "screenRecording": "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture", - "automation": "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation", - "fullDiskAccess": "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles", - "inputMonitoring": "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent", + "all": "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Security", + "accessibility": "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_Accessibility", + "screenRecording": "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_ScreenCapture", + "automation": "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_Automation", + "fullDiskAccess": "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles", + "inputMonitoring": "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_ListenEvent", "notifications": "x-apple.systempreferences:com.apple.Notifications-Settings.extension", - "microphone": "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone", - "camera": "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera", + "microphone": "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_Microphone", + "camera": "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_Camera", "localNetwork": "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?privacy-localnetwork", ] return URL(string: mapping[target] ?? mapping["all"]!) diff --git a/local-agent/boss-agent-status.mjs b/local-agent/boss-agent-status.mjs index e1ee12d..4f42a78 100644 --- a/local-agent/boss-agent-status.mjs +++ b/local-agent/boss-agent-status.mjs @@ -64,15 +64,15 @@ const EXTENDED_PERMISSION_DEFS = [ ]; const MACOS_PERMISSION_SETTINGS = { - all: "x-apple.systempreferences:com.apple.preference.security?Privacy", - accessibility: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility", - screenRecording: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture", - automation: "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation", - fullDiskAccess: "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles", - inputMonitoring: "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent", + all: "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Security", + accessibility: "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_Accessibility", + screenRecording: "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_ScreenCapture", + automation: "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_Automation", + fullDiskAccess: "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles", + inputMonitoring: "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_ListenEvent", notifications: "x-apple.systempreferences:com.apple.Notifications-Settings.extension", - microphone: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone", - camera: "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera", + microphone: "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_Microphone", + camera: "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_Camera", localNetwork: "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?privacy-localnetwork", }; @@ -81,6 +81,7 @@ const AUTO_PREFLIGHT_PERMISSION_KEYS = new Set(["accessibility", "screenRecordin const NATIVE_PERMISSION_QUERY_PARAMS = { accessibility: "nativeAccessibility", screenRecording: "nativeScreenRecording", + inputMonitoring: "nativeInputMonitoring", microphone: "nativeMicrophone", camera: "nativeCamera", }; @@ -885,12 +886,59 @@ export async function openBossAgentPermissionSettings(target = "all", platform = }; } + const nativeUrl = `boss-agent://permissions/open?target=${encodeURIComponent(target)}&returnTab=permissions`; + const nativeDeepLink = await runCommand("open", ["-b", "com.hyzq.boss.agent", nativeUrl], 2500); + if (nativeDeepLink.ok) { + return { + ok: true, + target, + settingsUrl, + message: "已通过 boss-agent 发起系统权限申请。", + nativeRequest: true, + nativeUrl, + }; + } + + const nativeLaunch = await runCommand( + "open", + [ + "-a", + "/Applications/boss-agent.app", + "--args", + "--request-permission", + target, + "--return-tab", + "permissions", + ], + 2500, + ); + if (nativeLaunch.ok) { + return { + ok: true, + target, + settingsUrl, + message: "已通过 boss-agent 发起系统权限申请。", + nativeRequest: true, + nativeUrl, + }; + } + const result = await runCommand("open", [settingsUrl], 2500); return { ok: result.ok, target, settingsUrl, - message: result.ok ? "已打开系统权限设置。" : result.stderr || result.stdout || "打开系统权限设置失败。", + message: result.ok + ? "已打开系统权限设置。" + : nativeDeepLink.stderr + || nativeDeepLink.stdout + || nativeLaunch.stderr + || nativeLaunch.stdout + || result.stderr + || result.stdout + || "打开系统权限设置失败。", + nativeRequest: false, + nativeUrl, }; } diff --git a/scripts/build-boss-agent-mac-app.sh b/scripts/build-boss-agent-mac-app.sh index fd9bb4e..7f0b238 100644 --- a/scripts/build-boss-agent-mac-app.sh +++ b/scripts/build-boss-agent-mac-app.sh @@ -30,6 +30,7 @@ swiftc "$SOURCE_FILE" \ -framework WebKit \ -framework ApplicationServices \ -framework AVFoundation \ + -framework IOKit \ -framework Network \ -framework UserNotifications @@ -157,6 +158,8 @@ cat > "$CONTENTS_DIR/Info.plist" <<'PLIST' boss-agent 需要通过自动化控制 Finder、浏览器和授权的企业应用,以完成远程桌面级任务。 NSScreenCaptureUsageDescription boss-agent 需要读取屏幕画面,用于识别桌面状态、系统弹窗和远程控制结果。 + NSInputMonitoringUsageDescription + boss-agent 需要输入监控权限,用于低层热键、复杂输入和部分不可访问控件兜底。 NSMicrophoneUsageDescription boss-agent 需要麦克风权限,用于语音协作和远程指令输入。 NSCameraUsageDescription diff --git a/tests/boss-agent-status.test.mjs b/tests/boss-agent-status.test.mjs index 62c712c..fc4a65c 100644 --- a/tests/boss-agent-status.test.mjs +++ b/tests/boss-agent-status.test.mjs @@ -67,6 +67,14 @@ test("boss-agent status exposes unbound QR binding and local permission states", status.permissionSetup.actions.find((action) => action.key === "localNetwork")?.settingsUrl, "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?privacy-localnetwork", ); + assert.equal( + status.permissionSetup.actions.find((action) => action.key === "inputMonitoring")?.settingsUrl, + "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_ListenEvent", + ); + assert.equal( + status.permissionSetup.actions.find((action) => action.key === "screenRecording")?.settingsUrl, + "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_ScreenCapture", + ); assert.deepEqual( status.permissions.items.map((item) => [item.key, item.status]), [ @@ -199,7 +207,7 @@ test("boss-agent native permission overrides update app-owned camera and microph nativeCamera: "granted", nativeMicrophone: "missing", nativeScreenRecording: "granted", - nativeInputMonitoring: "invalid", + nativeInputMonitoring: "granted", }), ); @@ -209,6 +217,7 @@ test("boss-agent native permission overrides update app-owned camera and microph automation: "granted", camera: "granted", microphone: "missing", + inputMonitoring: "granted", }); }); @@ -240,19 +249,39 @@ test("boss-agent mac app intercepts permission links and triggers native app per assert.match(swiftSource, /decidePolicyFor navigationAction/); assert.match(swiftSource, /api\/v1\/boss-agent\/permissions\/open/); + assert.match(swiftSource, /NSApp\.activate\(ignoringOtherApps: true\)/); + assert.match(swiftSource, /NSAppleEventManager\.shared\(\)\.setEventHandler/); + assert.match(swiftSource, /kAEGetURL/); + assert.match(swiftSource, /handleGetUrlEvent/); + assert.match(swiftSource, /--request-permission/); + assert.match(swiftSource, /--return-tab/); + assert.match(swiftSource, /lastPermissionRequestTarget/); assert.match(swiftSource, /AXIsProcessTrustedWithOptions/); assert.match(swiftSource, /CGRequestScreenCaptureAccess/); assert.match(swiftSource, /AVCaptureDevice\.requestAccess\(for: \.audio/); assert.match(swiftSource, /AVCaptureDevice\.requestAccess\(for: \.video/); assert.match(swiftSource, /UNUserNotificationCenter\.current\(\)\.requestAuthorization/); + assert.match(swiftSource, /import IOKit\.hid/); + assert.match(swiftSource, /CGRequestListenEventAccess\(\)/); + assert.match(swiftSource, /CGPreflightListenEventAccess\(\)/); + assert.match(swiftSource, /NSEvent\.addGlobalMonitorForEvents\(matching: \[\.keyDown\]\)/); + assert.match(swiftSource, /CGEventSource\.keyState\(\.combinedSessionState/); + assert.match(swiftSource, /IOHIDManagerOpen\(manager/); + assert.match(swiftSource, /kHIDUsage_GD_Keyboard/); + assert.match(swiftSource, /IOHIDRequestAccess\(kIOHIDRequestTypeListenEvent\)/); + assert.match(swiftSource, /IOHIDCheckAccess\(kIOHIDRequestTypeListenEvent\)/); assert.match(swiftSource, /CGEvent\.tapCreate/); assert.match(swiftSource, /NSWorkspace\.shared\.open/); + assert.match(swiftSource, /deadline: \.now\(\) \+ 0\.45/); assert.match(swiftSource, /loadAgentPanel\(tab:/); assert.match(swiftSource, /returnTab/); assert.match(swiftSource, /nativePermissionQueryItems/); assert.match(swiftSource, /nativeCamera/); assert.match(swiftSource, /nativeMicrophone/); + assert.match(swiftSource, /nativeInputMonitoring/); assert.match(swiftSource, /privacy-localnetwork/); + assert.match(swiftSource, /com\.apple\.settings\.PrivacySecurity\.extension\?Privacy_ListenEvent/); + assert.doesNotMatch(swiftSource, /com\.apple\.preference\.security\?Privacy_ListenEvent/); assert.match(swiftSource, /func application\(_ application: NSApplication, open urls: \[URL\]\)/); assert.match(swiftSource, /isBossAgentDeepLink/); assert.match(swiftSource, /AVCaptureDevice\.authorizationStatus\(for: \.video/); @@ -262,10 +291,19 @@ test("boss-agent mac app intercepts permission links and triggers native app per assert.match(buildScript, /NSCameraUsageDescription/); assert.match(buildScript, /NSAppleEventsUsageDescription/); assert.match(buildScript, /NSLocalNetworkUsageDescription/); + assert.match(buildScript, /NSInputMonitoringUsageDescription/); + assert.match(buildScript, /framework IOKit/); assert.match(buildScript, /CFBundleURLTypes/); assert.match(buildScript, /boss-agent/); assert.match(buildScript, /CFBundleIconFile/); assert.match(buildScript, /BossAgent\.icns/); assert.match(buildScript, /iconutil -c icns/); assert.match(buildScript, /codesign --force --deep --sign - "\$APP_DIR"/); + + const statusSource = readFileSync("local-agent/boss-agent-status.mjs", "utf8"); + assert.match(statusSource, /boss-agent:\/\/permissions\/open/); + assert.match(statusSource, /com\.hyzq\.boss\.agent/); + assert.match(statusSource, /--request-permission/); + assert.match(statusSource, /\/Applications\/boss-agent\.app/); + assert.match(statusSource, /nativeRequest/); });