496 lines
18 KiB
Swift
496 lines
18 KiB
Swift
import Cocoa
|
|
import WebKit
|
|
import ApplicationServices
|
|
import AVFoundation
|
|
import IOKit.hid
|
|
import Network
|
|
import UserNotifications
|
|
|
|
private let bossInputMonitoringTapCallback: CGEventTapCallBack = { _, _, event, _ in
|
|
Unmanaged.passUnretained(event)
|
|
}
|
|
|
|
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"
|
|
|
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
NSApp.setActivationPolicy(.regular)
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(handleApplicationDidBecomeActive),
|
|
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)
|
|
webView.setValue(false, forKey: "drawsBackground")
|
|
webView.navigationDelegate = self
|
|
self.webView = webView
|
|
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 1180, height: 780),
|
|
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
window.title = "boss-agent"
|
|
window.titlebarAppearsTransparent = true
|
|
window.isMovableByWindowBackground = true
|
|
window.contentView = webView
|
|
window.center()
|
|
window.makeKeyAndOrderFront(nil)
|
|
self.window = window
|
|
|
|
loadAgentPanel(tab: activeTab)
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
handleLaunchPermissionRequestIfNeeded()
|
|
}
|
|
|
|
private func loadAgentPanel(tab: String? = nil) {
|
|
activeTab = normalizedTab(tab ?? activeTab)
|
|
var components = URLComponents()
|
|
components.scheme = "http"
|
|
components.host = "127.0.0.1"
|
|
components.port = 4317
|
|
components.path = "/boss-agent"
|
|
components.queryItems = [URLQueryItem(name: "tab", value: activeTab)] + nativePermissionQueryItems()
|
|
guard let url = components.url else {
|
|
loadFallback()
|
|
return
|
|
}
|
|
webView?.load(URLRequest(url: url, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData))
|
|
}
|
|
|
|
@objc private func handleApplicationDidBecomeActive() {
|
|
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)
|
|
}
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
|
loadFallback()
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
|
loadFallback()
|
|
}
|
|
|
|
func webView(
|
|
_ webView: WKWebView,
|
|
decidePolicyFor navigationAction: WKNavigationAction,
|
|
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
|
|
) {
|
|
guard let url = navigationAction.request.url else {
|
|
decisionHandler(.allow)
|
|
return
|
|
}
|
|
|
|
if isPermissionSetupUrl(url) {
|
|
decisionHandler(.cancel)
|
|
handlePermissionSetupNavigation(url)
|
|
return
|
|
}
|
|
|
|
if isAgentPanelUrl(url) && !hasNativePermissionQuery(url) {
|
|
decisionHandler(.cancel)
|
|
loadAgentPanel(tab: queryValue("tab", in: url))
|
|
return
|
|
}
|
|
|
|
if isAgentPanelUrl(url), let tab = queryValue("tab", in: url) {
|
|
activeTab = normalizedTab(tab)
|
|
}
|
|
|
|
decisionHandler(.allow)
|
|
}
|
|
|
|
private func isPermissionSetupUrl(_ url: URL) -> Bool {
|
|
url.path == "/api/v1/boss-agent/permissions/open"
|
|
}
|
|
|
|
private func isBossAgentDeepLink(_ url: URL) -> Bool {
|
|
url.scheme == "boss-agent"
|
|
}
|
|
|
|
private func handleBossAgentDeepLink(_ url: URL) {
|
|
if url.host == "permissions" && url.path == "/open" {
|
|
handlePermissionTarget(
|
|
queryValue("target", in: url) ?? "all",
|
|
returnTab: queryValue("returnTab", in: url) ?? "permissions"
|
|
)
|
|
return
|
|
}
|
|
|
|
if url.host == "tab" {
|
|
loadAgentPanel(tab: String(url.path.dropFirst()))
|
|
}
|
|
}
|
|
|
|
private func isAgentPanelUrl(_ url: URL) -> Bool {
|
|
let host = url.host ?? ""
|
|
return (host == "127.0.0.1" || host == "localhost") && url.port == 4317 && (url.path == "/boss-agent" || url.path == "/")
|
|
}
|
|
|
|
private func hasNativePermissionQuery(_ url: URL) -> Bool {
|
|
URLComponents(url: url, resolvingAgainstBaseURL: false)?
|
|
.queryItems?
|
|
.contains(where: { $0.name.hasPrefix("native") }) == true
|
|
}
|
|
|
|
private func queryValue(_ name: String, in url: URL) -> String? {
|
|
URLComponents(url: url, resolvingAgainstBaseURL: false)?
|
|
.queryItems?
|
|
.first(where: { $0.name == name })?
|
|
.value
|
|
}
|
|
|
|
private func normalizedTab(_ value: String?) -> String {
|
|
switch value {
|
|
case "permissions", "skills", "license", "logs", "overview":
|
|
return value ?? "overview"
|
|
default:
|
|
return "overview"
|
|
}
|
|
}
|
|
|
|
private func handlePermissionSetupNavigation(_ url: URL) {
|
|
handlePermissionTarget(
|
|
queryValue("target", in: url) ?? "all",
|
|
returnTab: queryValue("returnTab", in: url) ?? activeTab
|
|
)
|
|
}
|
|
|
|
private func handlePermissionTarget(_ target: String, returnTab rawReturnTab: String) {
|
|
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)
|
|
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
|
|
self?.loadAgentPanel(tab: returnTab)
|
|
}
|
|
}
|
|
|
|
private func nativePermissionQueryItems() -> [URLQueryItem] {
|
|
[
|
|
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()),
|
|
]
|
|
}
|
|
|
|
private func screenRecordingStatus() -> String {
|
|
if #available(macOS 10.15, *) {
|
|
return CGPreflightScreenCaptureAccess() ? "granted" : "missing"
|
|
}
|
|
return "unknown"
|
|
}
|
|
|
|
private func microphonePermissionStatus() -> String {
|
|
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
|
case .authorized:
|
|
return "granted"
|
|
case .denied, .restricted:
|
|
return "missing"
|
|
case .notDetermined:
|
|
return "unknown"
|
|
@unknown default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
private func cameraPermissionStatus() -> String {
|
|
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
|
case .authorized:
|
|
return "granted"
|
|
case .denied, .restricted:
|
|
return "missing"
|
|
case .notDetermined:
|
|
return "unknown"
|
|
@unknown default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
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: [String]
|
|
if target == "core" {
|
|
targets = ["accessibility", "screenRecording"]
|
|
} else if target == "all" {
|
|
targets = [
|
|
"accessibility",
|
|
"screenRecording",
|
|
"automation",
|
|
"fullDiskAccess",
|
|
"inputMonitoring",
|
|
"notifications",
|
|
"microphone",
|
|
"camera",
|
|
"localNetwork",
|
|
]
|
|
} else {
|
|
targets = [target]
|
|
}
|
|
|
|
for permission in targets {
|
|
requestSingleNativePermission(permission)
|
|
}
|
|
}
|
|
|
|
private func requestSingleNativePermission(_ permission: String) {
|
|
switch permission {
|
|
case "accessibility":
|
|
requestAccessibilityPermission()
|
|
case "screenRecording":
|
|
requestScreenRecordingPermission()
|
|
case "automation":
|
|
requestAutomationPermission()
|
|
case "fullDiskAccess":
|
|
preflightFullDiskAccess()
|
|
case "inputMonitoring":
|
|
requestInputMonitoringPermission()
|
|
case "notifications":
|
|
requestNotificationPermission()
|
|
case "microphone":
|
|
requestMicrophonePermission()
|
|
case "camera":
|
|
requestCameraPermission()
|
|
case "localNetwork":
|
|
requestLocalNetworkPermission()
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func requestAccessibilityPermission() {
|
|
let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String
|
|
let options = [promptKey: true] as CFDictionary
|
|
_ = AXIsProcessTrustedWithOptions(options)
|
|
}
|
|
|
|
private func requestMicrophonePermission() {
|
|
if AVCaptureDevice.authorizationStatus(for: .audio) == .notDetermined {
|
|
AVCaptureDevice.requestAccess(for: .audio) { _ in }
|
|
}
|
|
}
|
|
|
|
private func requestCameraPermission() {
|
|
if AVCaptureDevice.authorizationStatus(for: .video) == .notDetermined {
|
|
AVCaptureDevice.requestAccess(for: .video) { _ in }
|
|
}
|
|
}
|
|
|
|
private func requestScreenRecordingPermission() {
|
|
if #available(macOS 10.15, *) {
|
|
_ = CGRequestScreenCaptureAccess()
|
|
} else {
|
|
_ = CGPreflightScreenCaptureAccess()
|
|
}
|
|
}
|
|
|
|
private func requestAutomationPermission() {
|
|
let script = NSAppleScript(source: "tell application \"Finder\" to get name")
|
|
var error: NSDictionary?
|
|
_ = script?.executeAndReturnError(&error)
|
|
}
|
|
|
|
private func preflightFullDiskAccess() {
|
|
let candidatePaths = [
|
|
"\(NSHomeDirectory())/Library/Safari/Bookmarks.plist",
|
|
"\(NSHomeDirectory())/Library/Mail",
|
|
"/Library/Application Support/com.apple.TCC/TCC.db",
|
|
]
|
|
for path in candidatePaths {
|
|
if FileManager.default.fileExists(atPath: path) {
|
|
_ = try? Data(contentsOf: URL(fileURLWithPath: path), options: .mappedIfSafe)
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
place: .headInsertEventTap,
|
|
options: .listenOnly,
|
|
eventsOfInterest: eventMask,
|
|
callback: bossInputMonitoringTapCallback,
|
|
userInfo: nil
|
|
)
|
|
if let tap = inputMonitoringTap {
|
|
CFMachPortInvalidate(tap)
|
|
inputMonitoringTap = nil
|
|
}
|
|
}
|
|
|
|
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 }
|
|
}
|
|
|
|
private func requestLocalNetworkPermission() {
|
|
let parameters = NWParameters.tcp
|
|
parameters.includePeerToPeer = true
|
|
let browser = NWBrowser(for: .bonjour(type: "_http._tcp", domain: nil), using: parameters)
|
|
browser.stateUpdateHandler = { _ in }
|
|
localNetworkBrowser = browser
|
|
browser.start(queue: .main)
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { [weak self] in
|
|
self?.localNetworkBrowser?.cancel()
|
|
self?.localNetworkBrowser = nil
|
|
}
|
|
}
|
|
|
|
private func systemSettingsUrl(for target: String) -> URL? {
|
|
let mapping = [
|
|
"all": "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Security",
|
|
"core": "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_Accessibility",
|
|
"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.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"]!)
|
|
}
|
|
|
|
private func loadFallback() {
|
|
let html = """
|
|
<!doctype html>
|
|
<html lang="zh-CN">
|
|
<meta charset="utf-8">
|
|
<style>
|
|
body { margin:0; min-height:100vh; display:grid; place-items:center; background:#f6f8f5; font-family:-apple-system,BlinkMacSystemFont,'PingFang SC',sans-serif; color:#111418; }
|
|
.card { width:520px; padding:32px; border-radius:24px; background:white; border:1px solid #e8ece9; box-shadow:0 24px 70px rgba(22,38,29,.12); }
|
|
h1 { margin:0 0 10px; font-size:28px; letter-spacing:-.04em; }
|
|
p { color:#707982; line-height:1.7; margin:0; }
|
|
</style>
|
|
<body>
|
|
<section class="card">
|
|
<h1>boss-agent 未启动</h1>
|
|
<p>请先启动本机 local-agent 服务,然后重新打开 boss-agent。</p>
|
|
</section>
|
|
</body>
|
|
</html>
|
|
"""
|
|
webView?.loadHTMLString(html, baseURL: nil)
|
|
}
|
|
}
|
|
|
|
let app = NSApplication.shared
|
|
let delegate = AppDelegate()
|
|
app.delegate = delegate
|
|
app.run()
|