Files
boss/apps/boss-agent-mac/Sources/BossAgentApp.swift

369 lines
13 KiB
Swift

import Cocoa
import WebKit
import ApplicationServices
final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate {
private var window: NSWindow?
private var webView: WKWebView?
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) ?? "core",
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) ?? "core",
returnTab: queryValue("returnTab", in: url) ?? activeTab
)
}
private func handlePermissionTarget(_ target: String, returnTab rawReturnTab: String) {
let permissionTarget = normalizedPermissionTarget(target)
let returnTab = normalizedTab(rawReturnTab)
activeTab = returnTab
UserDefaults.standard.set(permissionTarget, forKey: "lastPermissionRequestTarget")
UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: "lastPermissionRequestAt")
NSLog("boss-agent permission request target=%@ returnTab=%@", permissionTarget, returnTab)
NSApp.activate(ignoringOtherApps: true)
requestNativePermission(for: permissionTarget)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { [weak self] in
if let settingsUrl = self?.systemSettingsUrl(for: permissionTarget) {
NSWorkspace.shared.open(settingsUrl)
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in
self?.loadAgentPanel(tab: returnTab)
}
}
private func nativePermissionQueryItems() -> [URLQueryItem] {
let accessibility = AXIsProcessTrusted() ? "granted" : "missing"
let screenRecording = screenRecordingStatus()
UserDefaults.standard.set(accessibility, forKey: "native.accessibility")
UserDefaults.standard.set(screenRecording, forKey: "native.screenRecording")
return [
URLQueryItem(name: "nativeAccessibility", value: accessibility),
URLQueryItem(name: "nativeScreenRecording", value: screenRecording),
]
}
private func screenRecordingStatus() -> String {
if #available(macOS 10.15, *) {
if CGPreflightScreenCaptureAccess() {
return "granted"
}
if CGRequestScreenCaptureAccess() {
return "granted"
}
return tccPermissionStatus(service: "kTCCServiceScreenCapture") ?? "missing"
}
return "unknown"
}
private func tccPermissionStatus(service: String) -> String? {
let clients = service == "kTCCServiceScreenCapture"
? "'com.hyzq.boss.agent','site.hyzq.boss.computer-use-helper'"
: "'com.hyzq.boss.agent'"
let query = "select auth_value from access where client in (\(clients)) and service='\(service)' order by auth_value desc limit 1;"
let databasePaths = [
"/Library/Application Support/com.apple.TCC/TCC.db",
"\(NSHomeDirectory())/Library/Application Support/com.apple.TCC/TCC.db",
]
for databasePath in databasePaths where FileManager.default.fileExists(atPath: databasePath) {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/sqlite3")
process.arguments = [databasePath, query]
let output = Pipe()
process.standardOutput = output
process.standardError = Pipe()
do {
try process.run()
process.waitUntilExit()
} catch {
continue
}
let data = output.fileHandleForReading.readDataToEndOfFile()
let value = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
if value == "2" {
return "granted"
}
if value == "0" {
return "missing"
}
}
return nil
}
private func requestNativePermission(for target: String) {
let targets: [String]
if target == "core" {
targets = ["accessibility", "screenRecording"]
} else {
targets = [target]
}
for permission in targets {
requestSingleNativePermission(permission)
}
}
private func requestSingleNativePermission(_ permission: String) {
switch permission {
case "accessibility":
requestAccessibilityPermission()
case "screenRecording":
requestScreenRecordingPermission()
default:
break
}
}
private func requestAccessibilityPermission() {
let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String
let options = [promptKey: true] as CFDictionary
_ = AXIsProcessTrustedWithOptions(options)
}
private func requestScreenRecordingPermission() {
if #available(macOS 10.15, *) {
_ = CGRequestScreenCaptureAccess()
} else {
_ = CGPreflightScreenCaptureAccess()
}
}
private func systemSettingsUrl(for target: String) -> URL? {
let mapping = [
"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",
]
return URL(string: mapping[normalizedPermissionTarget(target)] ?? mapping["core"]!)
}
private func normalizedPermissionTarget(_ target: String) -> String {
switch target {
case "accessibility", "screenRecording", "core":
return target
default:
return "core"
}
}
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()