fix: sync native agent permission states

This commit is contained in:
AI Bot
2026-05-13 00:18:19 +08:00
parent 2ff75087b3
commit 73327be8b0
4 changed files with 191 additions and 16 deletions

View File

@@ -14,9 +14,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate {
private var webView: WKWebView?
private var inputMonitoringTap: CFMachPort?
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
)
let webConfiguration = WKWebViewConfiguration()
let webView = WKWebView(frame: .zero, configuration: webConfiguration)
@@ -38,18 +45,29 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate {
window.makeKeyAndOrderFront(nil)
self.window = window
loadAgentPanel()
loadAgentPanel(tab: activeTab)
NSApp.activate(ignoringOtherApps: true)
}
private func loadAgentPanel() {
guard let url = URL(string: "http://127.0.0.1:4317/boss-agent") else {
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)
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
loadFallback()
}
@@ -74,6 +92,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate {
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)
}
@@ -81,12 +109,38 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate {
url.path == "/api/v1/boss-agent/permissions/open"
}
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) {
let target =
URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?
.first(where: { $0.name == "target" })?
.value ?? "all"
queryValue("target", in: url) ?? "all"
let returnTab = normalizedTab(queryValue("returnTab", in: url) ?? activeTab)
activeTab = returnTab
requestNativePermission(for: target)
if let settingsUrl = systemSettingsUrl(for: target) {
@@ -94,7 +148,49 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate {
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in
self?.loadAgentPanel()
self?.loadAgentPanel(tab: returnTab)
}
}
private func nativePermissionQueryItems() -> [URLQueryItem] {
[
URLQueryItem(name: "nativeAccessibility", value: AXIsProcessTrusted() ? "granted" : "missing"),
URLQueryItem(name: "nativeScreenRecording", value: screenRecordingStatus()),
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"
}
}
@@ -133,9 +229,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate {
case "notifications":
requestNotificationPermission()
case "microphone":
AVCaptureDevice.requestAccess(for: .audio) { _ in }
requestMicrophonePermission()
case "camera":
AVCaptureDevice.requestAccess(for: .video) { _ in }
requestCameraPermission()
case "localNetwork":
requestLocalNetworkPermission()
default:
@@ -149,6 +245,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate {
_ = 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()

View File

@@ -78,6 +78,13 @@ const MACOS_PERMISSION_SETTINGS = {
const AUTO_PREFLIGHT_PERMISSION_KEYS = new Set(["accessibility", "screenRecording", "automation"]);
const NATIVE_PERMISSION_QUERY_PARAMS = {
accessibility: "nativeAccessibility",
screenRecording: "nativeScreenRecording",
microphone: "nativeMicrophone",
camera: "nativeCamera",
};
function nonEmpty(value) {
const text = String(value ?? "").trim();
return text || undefined;
@@ -103,6 +110,10 @@ function normalizePermissionStatus(value) {
return value === "granted" || value === "missing" || value === "unknown" ? value : "unknown";
}
function isPermissionStatus(value) {
return value === "granted" || value === "missing" || value === "unknown";
}
function statusTone(status) {
if (status === "granted" || status === "valid" || status === "connected" || status === "bound") {
return "good";
@@ -220,7 +231,7 @@ function buildPermissionSetupPlan(coreItems, extendedItems, readiness) {
requiredForSilentControl: true,
canPreflight: AUTO_PREFLIGHT_PERMISSION_KEYS.has(item.key),
settingsUrl: MACOS_PERMISSION_SETTINGS[item.key] ?? MACOS_PERMISSION_SETTINGS.all,
openUrl: `/api/v1/boss-agent/permissions/open?target=${encodeURIComponent(item.key)}`,
openUrl: `/api/v1/boss-agent/permissions/open?target=${encodeURIComponent(item.key)}&returnTab=permissions`,
owner: "boss-agent.app",
}));
const missingActions = actions.filter((action) => action.status !== "granted");
@@ -232,7 +243,7 @@ function buildPermissionSetupPlan(coreItems, extendedItems, readiness) {
silentUseReady: missingActions.length === 0,
primaryAction: {
label: "打开完整授权向导",
href: "/api/v1/boss-agent/permissions/open?target=all",
href: "/api/v1/boss-agent/permissions/open?target=all&returnTab=permissions",
settingsUrl: MACOS_PERMISSION_SETTINGS.all,
},
actions,
@@ -245,6 +256,21 @@ function buildPermissionSetupPlan(coreItems, extendedItems, readiness) {
};
}
export function mergeBossAgentNativePermissionOverrides(permissions = {}, queryParams = {}) {
const getQueryValue = (name) => {
if (typeof queryParams.get === "function") return queryParams.get(name);
return queryParams[name];
};
const merged = { ...permissions };
for (const [permissionKey, queryKey] of Object.entries(NATIVE_PERMISSION_QUERY_PARAMS)) {
const value = getQueryValue(queryKey);
if (isPermissionStatus(value)) {
merged[permissionKey] = value;
}
}
return merged;
}
function resolveSkills(runtime) {
const rawSkills = Array.isArray(runtime.lastSkills) ? runtime.lastSkills : [];
const items = rawSkills

View File

@@ -41,6 +41,7 @@ import {
import {
buildBossAgentStatus,
detectLocalComputerPermissions,
mergeBossAgentNativePermissionOverrides,
normalizeBossAgentTab,
openBossAgentPermissionSettings,
renderBossAgentHtmlWithQr,
@@ -1010,7 +1011,10 @@ const server = createServer(async (request, response) => {
const requestUrl = new URL(request.url || "/", `http://${config.bindHost || "127.0.0.1"}`);
if (requestUrl.pathname === "/" || requestUrl.pathname === "/boss-agent") {
const permissions = await detectLocalComputerPermissions();
const permissions = mergeBossAgentNativePermissionOverrides(
await detectLocalComputerPermissions(),
requestUrl.searchParams,
);
const status = buildBossAgentStatus(config, runtime, { permissions });
const activeTab = normalizeBossAgentTab(requestUrl.searchParams.get("tab") ?? "overview");
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
@@ -1034,6 +1038,7 @@ const server = createServer(async (request, response) => {
if (requestUrl.pathname === "/api/v1/boss-agent/permissions/open") {
const target = requestUrl.searchParams.get("target") || "all";
const returnTab = normalizeBossAgentTab(requestUrl.searchParams.get("returnTab") ?? "permissions");
const result = await openBossAgentPermissionSettings(target);
const wantsJson = String(request.headers.accept || "").includes("application/json");
if (wantsJson) {
@@ -1041,7 +1046,7 @@ const server = createServer(async (request, response) => {
response.end(JSON.stringify(result));
return;
}
response.writeHead(302, { Location: "/boss-agent" });
response.writeHead(302, { Location: `/boss-agent?tab=${encodeURIComponent(returnTab)}` });
response.end();
return;
}

View File

@@ -4,6 +4,7 @@ import { readFileSync } from "node:fs";
import {
buildBossAgentStatus,
mergeBossAgentNativePermissionOverrides,
renderBossAgentHtml,
renderBossAgentHtmlWithQr,
} from "../local-agent/boss-agent-status.mjs";
@@ -169,8 +170,8 @@ test("boss-agent permission and skill menu entries render as separate tab pages"
assert.match(permissionsHtml, /<h2>一次完整授权<\/h2>/);
assert.match(permissionsHtml, /完整接管待补齐/);
assert.match(permissionsHtml, /后续静默使用/);
assert.match(permissionsHtml, /api\/v1\/boss-agent\/permissions\/open\?target=all/);
assert.match(permissionsHtml, /api\/v1\/boss-agent\/permissions\/open\?target=fullDiskAccess/);
assert.match(permissionsHtml, /api\/v1\/boss-agent\/permissions\/open\?target=all&amp;returnTab=permissions/);
assert.match(permissionsHtml, /api\/v1\/boss-agent\/permissions\/open\?target=fullDiskAccess&amp;returnTab=permissions/);
assert.doesNotMatch(permissionsHtml, /<h2>Skill 部署情况<\/h2>/);
const skillsHtml = renderBossAgentHtml(status, { activeTab: "skills" });
@@ -180,6 +181,33 @@ test("boss-agent permission and skill menu entries render as separate tab pages"
assert.doesNotMatch(skillsHtml, /<h2>一次完整授权<\/h2>/);
});
test("boss-agent native permission overrides update app-owned camera and microphone state", () => {
const merged = mergeBossAgentNativePermissionOverrides(
{
accessibility: "missing",
screenRecording: "missing",
automation: "granted",
camera: "unknown",
microphone: "unknown",
},
new URLSearchParams({
nativeAccessibility: "granted",
nativeCamera: "granted",
nativeMicrophone: "missing",
nativeScreenRecording: "granted",
nativeInputMonitoring: "invalid",
}),
);
assert.deepEqual(merged, {
accessibility: "granted",
screenRecording: "granted",
automation: "granted",
camera: "granted",
microphone: "missing",
});
});
test("boss-agent unbound HTML renders a real scannable QR image when qrcode is available", async () => {
const status = buildBossAgentStatus(
{
@@ -215,6 +243,14 @@ test("boss-agent mac app intercepts permission links and triggers native app per
assert.match(swiftSource, /UNUserNotificationCenter\.current\(\)\.requestAuthorization/);
assert.match(swiftSource, /CGEvent\.tapCreate/);
assert.match(swiftSource, /NSWorkspace\.shared\.open/);
assert.match(swiftSource, /loadAgentPanel\(tab:/);
assert.match(swiftSource, /returnTab/);
assert.match(swiftSource, /nativePermissionQueryItems/);
assert.match(swiftSource, /nativeCamera/);
assert.match(swiftSource, /nativeMicrophone/);
assert.match(swiftSource, /AVCaptureDevice\.authorizationStatus\(for: \.video/);
assert.match(swiftSource, /AVCaptureDevice\.authorizationStatus\(for: \.audio/);
assert.match(swiftSource, /NSApplication\.didBecomeActiveNotification/);
assert.match(buildScript, /NSMicrophoneUsageDescription/);
assert.match(buildScript, /NSCameraUsageDescription/);
assert.match(buildScript, /NSAppleEventsUsageDescription/);