diff --git a/Package.swift b/Package.swift index c937265..764200c 100644 --- a/Package.swift +++ b/Package.swift @@ -16,6 +16,7 @@ let package = Package( linkerSettings: [ .linkedFramework("ScreenCaptureKit"), .linkedFramework("CoreGraphics"), + .linkedFramework("ApplicationServices"), .linkedFramework("Network"), ] ), diff --git a/README.md b/README.md index 364000f..413e441 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ flowchart LR input["Input
CGEvent mouse / keyboard"] apps["Apps
osascript / JXA"] end - tcc{{"TCC grants, once, on the Mac's own screen
Screen Recording · Accessibility · Automation
bound to bundle id dev.openab.instance-mcp"}} + tcc{{"TCC grants, once, on the Mac's own screen
Screen Recording · Accessibility · Full Disk Access
bound to bundle id dev.openab.instance-mcp"}} end cli -- "HTTPS · MCP Streamable HTTP" --> s8444 @@ -174,10 +174,12 @@ Tagged releases publish a universal, Developer-ID-signed and Apple-notarized ins 3. Double-click the package. It auto-detects your Tailscale login/name, preserves or creates the bearer token, installs the LaunchAgent, detects the Playwright upstream, and configures `tailscale serve :8444`. -4. Once, enable Full Disk Access, Screen & System Audio Recording, and Accessibility for - **oab-instance-mcp** in System Settings → Privacy & Security. Future releases keep the same - Developer ID + bundle id, so these grants survive updates. -5. Use the menu bar item to copy the MCP URL and bearer token into OpenAB Connect/Remote. +4. On first launch, the **Set Up Mac Permissions** window opens once if anything is missing. Use + each row's Open Settings button, return to the wizard, then Test Again. Grant only what you need: + Full Disk Access, Screen & System Audio Recording, and/or Accessibility. Browser tools work + without any of them. Future releases keep the same Developer ID + bundle id, so grants survive. +5. The same wizard remains available from the menu bar as **Set Up Permissions…**; use the menu + item to copy the MCP URL and bearer token into OpenAB Connect/Remote. The `.app.zip` beside the package is an advanced/manual artifact. After unzipping: @@ -249,10 +251,13 @@ kiro-cli mcp add --name macmini-mcp --url https://..ts.net:8444/m ## Menu bar With `--menu-bar` (deploy.sh sets it) the agent shows a status item: version, the public MCP URL -(click to copy), a masked bearer token line (click to copy the full token), ✓/✗ for Screen -Recording and Accessibility (click ✗ to open the pane), session / call counters with the last tool -call, Open Log, Restart, and Quit (which boots the launchd job out so KeepAlive does not bring it -back). The icon fills briefly on each tool call. +(click to copy), a masked bearer token line (click to copy the full token), live ✓/✗/? rows for +Screen Recording, Accessibility, and Full Disk Access, **Set Up Permissions…** (the same window +that auto-shows once on first launch when anything is missing), session / call counters with the +last tool call, Open Log, Restart, and Quit. The setup window opens the exact System Settings pane +for each permission and re-tests when the app becomes active or the user clicks Test Again; it +never polls screenshot or triggers permission prompts by itself. The icon fills briefly on each +tool call. ## Operate diff --git a/Sources/InstanceMCPCore/PermissionStatus.swift b/Sources/InstanceMCPCore/PermissionStatus.swift new file mode 100644 index 0000000..2b646c7 --- /dev/null +++ b/Sources/InstanceMCPCore/PermissionStatus.swift @@ -0,0 +1,114 @@ +import ApplicationServices +import CoreGraphics +import Darwin +import Foundation + +/// The three macOS TCC capabilities this daemon can use. Browser-only operation +/// needs none; each row in the setup window explains which tools it unlocks. +public enum PermissionKind: String, CaseIterable, Sendable { + case screenRecording + case accessibility + case fullDiskAccess +} + +/// `unknown` is meaningful for FDA: macOS has no public preflight API, and a Mac +/// with none of our protected probe files gives us no honest measurement. +public enum PermissionState: Equatable, Sendable { + case granted + case denied + case unknown + + public var isGranted: Bool { self == .granted } +} + +public struct PermissionSnapshot: Equatable, Sendable { + public var screenRecording: PermissionState + public var accessibility: PermissionState + public var fullDiskAccess: PermissionState + + public init(screenRecording: PermissionState, accessibility: PermissionState, + fullDiskAccess: PermissionState) { + self.screenRecording = screenRecording + self.accessibility = accessibility + self.fullDiskAccess = fullDiskAccess + } + + public subscript(_ kind: PermissionKind) -> PermissionState { + switch kind { + case .screenRecording: return screenRecording + case .accessibility: return accessibility + case .fullDiskAccess: return fullDiskAccess + } + } + + public var allGranted: Bool { + PermissionKind.allCases.allSatisfy { self[$0].isGranted } + } + + public var grantedCount: Int { + PermissionKind.allCases.filter { self[$0].isGranted }.count + } +} + +/// Testable probes for the shipping permission rules. +public enum PermissionProbe { + /// Snapshot using Apple's public preflight APIs plus an actual protected-file + /// open for Full Disk Access. + public static func current(homeDirectory: String = NSHomeDirectory()) -> PermissionSnapshot { + PermissionSnapshot( + screenRecording: CGPreflightScreenCaptureAccess() ? .granted : .denied, + accessibility: AXIsProcessTrusted() ? .granted : .denied, + fullDiskAccess: fullDiskAccess(homeDirectory: homeDirectory) + ) + } + + /// Paths protected by `kTCCServiceSystemPolicyAllFiles`. We do not read or + /// inspect any content: a successful `open` is immediately followed by + /// `close`. The user's own TCC.db exists on every normal desktop account; + /// Safari/Messages are fallbacks for unusual layouts. + public static func fullDiskAccessCandidatePaths(homeDirectory: String) -> [String] { + let home = URL(fileURLWithPath: homeDirectory, isDirectory: true) + return [ + "Library/Application Support/com.apple.TCC/TCC.db", + "Library/Safari/History.db", + "Library/Messages/chat.db", + ].map { home.appendingPathComponent($0).path } + } + + public static func fullDiskAccess(homeDirectory: String = NSHomeDirectory()) -> PermissionState { + fullDiskAccess( + paths: fullDiskAccessCandidatePaths(homeDirectory: homeDirectory), + exists: { FileManager.default.fileExists(atPath: $0) }, + openForRead: { path in + let fd = Darwin.open(path, O_RDONLY | O_CLOEXEC) + guard fd >= 0 else { return false } + Darwin.close(fd) + return true + } + ) + } + + /// Injection seam: tests cover granted, denied and no-probe-file without + /// depending on the CI runner's own TCC database. + public static func fullDiskAccess( + paths: [String], + exists: (String) -> Bool, + openForRead: (String) -> Bool + ) -> PermissionState { + var found = false + for path in paths where exists(path) { + found = true + if openForRead(path) { return .granted } + } + return found ? .denied : .unknown + } +} + +/// One-time auto-show policy. "Not Now" is respected across launches and +/// versions; the menu item remains available forever. A fully granted machine +/// never gets an onboarding window just because it upgraded to 0.6.3. +public enum PermissionSetupPolicy { + public static func shouldAutoShow(hasShown: Bool, snapshot: PermissionSnapshot) -> Bool { + !hasShown && !snapshot.allGranted + } +} diff --git a/Sources/InstanceMCPCore/Tools/SysInfoTool.swift b/Sources/InstanceMCPCore/Tools/SysInfoTool.swift index c321109..2e97a80 100644 --- a/Sources/InstanceMCPCore/Tools/SysInfoTool.swift +++ b/Sources/InstanceMCPCore/Tools/SysInfoTool.swift @@ -8,8 +8,8 @@ public struct SysInfoTool: Tool { public let name = "sys_info" public let description = """ Describe this Mac: hostname, macOS version, hardware, logged-in GUI user, displays, \ - Tailscale addresses, and which TCC permissions (Screen Recording, Accessibility) the \ - agent currently holds. Call this first to learn what the other tools can do here. + Tailscale addresses, and which TCC permissions (Screen Recording, Accessibility, Full Disk \ + Access) the agent currently holds. Call this first to learn what the other tools can do here. """ public let inputSchema: JSONValue = ["type": "object", "properties": [:]] @@ -42,9 +42,18 @@ public struct SysInfoTool: Tool { ] } - // TCC. CGPreflightScreenCaptureAccess is the non-prompting check. - let screenRecording = CGPreflightScreenCaptureAccess() - let accessibility = AXIsProcessTrusted() + // TCC. Public non-prompting checks for screen/AX; FDA is an actual + // open+close of a protected database (no content read). + let permissions = PermissionProbe.current() + let screenRecording = permissions.screenRecording.isGranted + let accessibility = permissions.accessibility.isGranted + let fullDiskAccess = permissions.fullDiskAccess.isGranted + let fullDiskAccessState: String + switch permissions.fullDiskAccess { + case .granted: fullDiskAccessState = "granted" + case .denied: fullDiskAccessState = "denied" + case .unknown: fullDiskAccessState = "unknown" + } let console = consoleUser() let tail = tailnetAddresses() @@ -62,6 +71,8 @@ public struct SysInfoTool: Tool { "permissions": [ "screen_recording": .bool(screenRecording), "accessibility": .bool(accessibility), + "full_disk_access": .bool(fullDiskAccess), + "full_disk_access_state": .string(fullDiskAccessState), ], "uptime_secs": .number(pi.systemUptime.rounded()), ] @@ -73,9 +84,10 @@ public struct SysInfoTool: Tool { "\(Int(d["points"]?["width"]?.doubleValue ?? 0))×\(Int(d["points"]?["height"]?.doubleValue ?? 0))pt\(d["main"]?.boolValue == true ? " (main)" : "")" }.joined(separator: ", ")) lines.append("tailscale: \(tail.isEmpty ? "none" : tail.joined(separator: ", "))") - lines.append("permissions: screen_recording=\(screenRecording) accessibility=\(accessibility)") + lines.append("permissions: screen_recording=\(screenRecording) accessibility=\(accessibility) full_disk_access=\(fullDiskAccessState)") if !screenRecording { lines.append("→ screenshot will fail until Screen Recording is granted to oab-instance-mcp") } if !accessibility { lines.append("→ mouse/key will fail until Accessibility is granted to oab-instance-mcp") } + if !fullDiskAccess { lines.append("→ protected Mail/Messages/Safari files will fail until Full Disk Access is granted to oab-instance-mcp") } lines.append("agent \(agentVersion)") return ToolResult(content: [.text(lines.joined(separator: "\n"))], structured: structured) } diff --git a/Sources/oab-instance-mcp/PermissionSetupWindowController.swift b/Sources/oab-instance-mcp/PermissionSetupWindowController.swift new file mode 100644 index 0000000..e8fb88e --- /dev/null +++ b/Sources/oab-instance-mcp/PermissionSetupWindowController.swift @@ -0,0 +1,265 @@ +import AppKit +import InstanceMCPCore + +/// First-run guidance for the three optional macOS TCC capabilities. This window +/// can open settings and test; it cannot and must not grant a permission itself. +/// +/// No timer polls TCC and no screenshot is taken automatically. That is +/// deliberate: Connect's old screenshot polling caused one macOS permission +/// dialog per call. Here every re-test is either the user's explicit click or the +/// single refresh when they return from System Settings. +@MainActor +final class PermissionSetupWindowController: NSWindowController, NSWindowDelegate { + private var statusLabels: [PermissionKind: NSTextField] = [:] + private var actionButtons: [PermissionKind: NSButton] = [:] + private let summaryLabel = NSTextField(labelWithString: "") + private let finishButton = NSButton(title: "Not Now", target: nil, action: nil) + private let testButton = NSButton(title: "Test Again", target: nil, action: nil) + private let onFinish: () -> Void + private var activationObserver: NSObjectProtocol? + private(set) var snapshot = PermissionProbe.current() + + init(onFinish: @escaping () -> Void) { + self.onFinish = onFinish + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 650, height: 510), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.title = "Set Up Mac Permissions" + window.isReleasedWhenClosed = false + window.center() + super.init(window: window) + window.delegate = self + buildUI(in: window) + activationObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didBecomeActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.refresh() } + } + refresh() + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + deinit { + if let activationObserver { NotificationCenter.default.removeObserver(activationObserver) } + } + + func show() { + refresh() + NSApp.activate(ignoringOtherApps: true) + window?.center() + window?.makeKeyAndOrderFront(nil) + } + + func refresh() { + snapshot = PermissionProbe.current() + for kind in PermissionKind.allCases { + let state = snapshot[kind] + let label = statusLabels[kind] + label?.stringValue = state.statusText + label?.textColor = state.statusColor + let button = actionButtons[kind] + button?.title = state.isGranted ? "Granted" : "Open Settings" + button?.isEnabled = !state.isGranted + } + summaryLabel.stringValue = snapshot.allGranted + ? "All three permissions are ready. Agents can see, control, and access protected files on this Mac." + : "\(snapshot.grantedCount) of 3 ready. Grant only the capabilities you want this Mac to lend." + summaryLabel.textColor = snapshot.allGranted ? .systemGreen : .secondaryLabelColor + finishButton.title = snapshot.allGranted ? "Done" : "Not Now" + } + + func windowWillClose(_ notification: Notification) { onFinish() } + + // MARK: UI + + private func buildUI(in window: NSWindow) { + guard let content = window.contentView else { return } + let stack = NSStackView() + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 14 + stack.translatesAutoresizingMaskIntoConstraints = false + content.addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 28), + stack.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -28), + stack.topAnchor.constraint(equalTo: content.topAnchor, constant: 24), + stack.bottomAnchor.constraint(lessThanOrEqualTo: content.bottomAnchor, constant: -22), + ]) + + let title = NSTextField(labelWithString: "Give agents only the capabilities you choose") + title.font = .systemFont(ofSize: 20, weight: .semibold) + stack.addArrangedSubview(title) + + let intro = wrappingLabel("oab-instance-mcp runs in your logged-in desktop session. macOS requires you to approve each sensitive capability in System Settings. The app cannot approve them for you, and it never bypasses TCC.") + stack.addArrangedSubview(intro) + + summaryLabel.font = .systemFont(ofSize: 12, weight: .medium) + summaryLabel.maximumNumberOfLines = 2 + stack.addArrangedSubview(summaryLabel) + + for kind in PermissionKind.allCases { stack.addArrangedSubview(row(for: kind)) } + + let optional = wrappingLabel("Browser tools (navigate, read the DOM, click, type) work without these permissions. Choose Not Now if this Mac will only lend its browser.") + optional.font = .systemFont(ofSize: 11) + optional.textColor = .secondaryLabelColor + stack.addArrangedSubview(optional) + + let footer = NSStackView() + footer.orientation = .horizontal + footer.alignment = .centerY + footer.spacing = 10 + let spacer = NSView() + footer.addArrangedSubview(spacer) + testButton.target = self + testButton.action = #selector(testAgain) + footer.addArrangedSubview(testButton) + finishButton.target = self + finishButton.action = #selector(finish) + finishButton.keyEquivalent = "\r" + footer.addArrangedSubview(finishButton) + footer.translatesAutoresizingMaskIntoConstraints = false + stack.addArrangedSubview(footer) + footer.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true + } + + private func row(for kind: PermissionKind) -> NSView { + let box = NSBox() + box.boxType = .custom + box.cornerRadius = 8 + box.borderColor = .separatorColor + box.borderWidth = 1 + box.fillColor = .controlBackgroundColor + box.contentViewMargins = NSSize(width: 14, height: 10) + box.translatesAutoresizingMaskIntoConstraints = false + // NSBox does not derive an intrinsic height from an assigned contentView; + // without this an NSStackView collapses all three cards onto one row. + box.widthAnchor.constraint(equalToConstant: 594).isActive = true + box.heightAnchor.constraint(equalToConstant: 72).isActive = true + + let icon = NSImageView() + icon.image = NSImage(systemSymbolName: kind.symbolName, accessibilityDescription: kind.title) + icon.symbolConfiguration = .init(pointSize: 20, weight: .regular) + icon.contentTintColor = .secondaryLabelColor + icon.widthAnchor.constraint(equalToConstant: 30).isActive = true + + let title = NSTextField(labelWithString: kind.title) + title.font = .systemFont(ofSize: 13, weight: .semibold) + let detail = wrappingLabel(kind.detail) + detail.font = .systemFont(ofSize: 11) + detail.textColor = .secondaryLabelColor + let labels = NSStackView(views: [title, detail]) + labels.orientation = .vertical + labels.alignment = .leading + labels.spacing = 2 + + let status = NSTextField(labelWithString: "Testing…") + status.font = .systemFont(ofSize: 12, weight: .medium) + status.alignment = .right + status.widthAnchor.constraint(equalToConstant: 82).isActive = true + statusLabels[kind] = status + + let action = NSButton(title: "Open Settings", target: self, action: #selector(openSettings(_:))) + action.tag = PermissionKind.allCases.firstIndex(of: kind) ?? 0 + action.widthAnchor.constraint(equalToConstant: 105).isActive = true + actionButtons[kind] = action + + let row = NSStackView(views: [icon, labels, status, action]) + row.orientation = .horizontal + row.alignment = .centerY + row.spacing = 10 + labels.setContentHuggingPriority(.defaultLow, for: .horizontal) + labels.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + row.translatesAutoresizingMaskIntoConstraints = false + row.widthAnchor.constraint(equalToConstant: 566).isActive = true + box.contentView = row + return box + } + + private func wrappingLabel(_ text: String) -> NSTextField { + let label = NSTextField(wrappingLabelWithString: text) + label.maximumNumberOfLines = 0 + label.lineBreakMode = .byWordWrapping + return label + } + + // MARK: actions + + @objc private func testAgain() { refresh() } + + @objc private func finish() { + onFinish() + // Avoid a duplicate callback from windowWillClose. `onFinish` only sets + // one boolean today, so duplicate would be harmless, but the ownership is + // clearer if the close delegate is temporarily detached. + window?.delegate = nil + close() + window?.delegate = self + } + + @objc private func openSettings(_ sender: NSButton) { + guard PermissionKind.allCases.indices.contains(sender.tag) else { return } + let kind = PermissionKind.allCases[sender.tag] + NSWorkspace.shared.open(kind.settingsURL) + } +} + +private extension PermissionKind { + var title: String { + switch self { + case .screenRecording: return "Screen & System Audio Recording" + case .accessibility: return "Accessibility" + case .fullDiskAccess: return "Full Disk Access" + } + } + + var detail: String { + switch self { + case .screenRecording: return "Lets screenshot and OpenAB Connect's Screens pane see this desktop." + case .accessibility: return "Lets mouse and key tools control the pointer and keyboard." + case .fullDiskAccess: return "Lets exec/osascript open protected Mail, Messages, Safari, and other user data." + } + } + + var symbolName: String { + switch self { + case .screenRecording: return "rectangle.inset.filled.and.person.filled" + case .accessibility: return "accessibility" + case .fullDiskAccess: return "internaldrive" + } + } + + var settingsURL: URL { + let pane: String + switch self { + case .screenRecording: pane = "Privacy_ScreenCapture" + case .accessibility: pane = "Privacy_Accessibility" + case .fullDiskAccess: pane = "Privacy_AllFiles" + } + return URL(string: "x-apple.systempreferences:com.apple.preference.security?\(pane)")! + } +} + +private extension PermissionState { + var statusText: String { + switch self { + case .granted: return "✓ Granted" + case .denied: return "✗ Not granted" + case .unknown: return "? Can't test" + } + } + + var statusColor: NSColor { + switch self { + case .granted: return .systemGreen + case .denied: return .systemRed + case .unknown: return .systemOrange + } + } +} diff --git a/Sources/oab-instance-mcp/StatusItemController.swift b/Sources/oab-instance-mcp/StatusItemController.swift index 14be1e3..e8e7d13 100644 --- a/Sources/oab-instance-mcp/StatusItemController.swift +++ b/Sources/oab-instance-mcp/StatusItemController.swift @@ -2,6 +2,7 @@ import AppKit import ApplicationServices import CoreGraphics import Foundation +import InstanceMCPCore /// Menu bar presence for the daemon: at-a-glance health (permissions, activity) and the /// handful of actions a human at the Mac actually needs. Everything else stays CLI/MCP. @@ -14,6 +15,9 @@ final class StatusItemController: NSObject, NSMenuDelegate { private let launchdLabel = "dev.openab.instance-mcp" private let logPath: String private let token: String? + private let defaults: UserDefaults + private static let permissionSetupShownKey = "permissionSetupWizard.hasShown.v1" + private var permissionSetupWindow: PermissionSetupWindowController? private var sessions = 0 private var calls = 0 @@ -21,11 +25,13 @@ final class StatusItemController: NSObject, NSMenuDelegate { private var lastCall: (tool: String, who: String, at: Date)? private var flashWork: DispatchWorkItem? - init(version: String, url: String, logPath: String, token: String? = nil) { + init(version: String, url: String, logPath: String, token: String? = nil, + defaults: UserDefaults = .standard) { self.version = version self.url = url self.logPath = logPath self.token = token + self.defaults = defaults self.item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) super.init() item.button?.image = Self.icon(active: false) @@ -35,6 +41,31 @@ final class StatusItemController: NSObject, NSMenuDelegate { item.menu = menu } + + /// Show once on the first launch that has anything missing. A machine that + /// upgraded with all grants already present is marked complete without a + /// window; revoking later remains visible in the menu but is never nagged. + func showPermissionSetupIfNeeded() { + let snapshot = PermissionProbe.current() + let shown = defaults.bool(forKey: Self.permissionSetupShownKey) + if snapshot.allGranted { + defaults.set(true, forKey: Self.permissionSetupShownKey) + return + } + if PermissionSetupPolicy.shouldAutoShow(hasShown: shown, snapshot: snapshot) { + showPermissionSetup() + } + } + + @objc func showPermissionSetup() { + if permissionSetupWindow == nil { + permissionSetupWindow = PermissionSetupWindowController { [weak self] in + guard let self else { return } + self.defaults.set(true, forKey: Self.permissionSetupShownKey) + } + } + permissionSetupWindow?.show() + } private static func icon(active: Bool) -> NSImage? { let name = active ? "desktopcomputer.and.arrow.down.fill" : "desktopcomputer.and.arrow.down" let cfg = NSImage.SymbolConfiguration(pointSize: 15, weight: .regular) @@ -70,8 +101,7 @@ final class StatusItemController: NSObject, NSMenuDelegate { func menuWillOpen(_ menu: NSMenu) { menu.removeAllItems() - let screen = CGPreflightScreenCaptureAccess() - let ax = AXIsProcessTrusted() + let permissions = PermissionProbe.current() menu.addItem(label("oab-instance-mcp \(version)")) menu.addItem(label(url, action: #selector(copyURL), tip: "Click to copy")) @@ -80,8 +110,13 @@ final class StatusItemController: NSObject, NSMenuDelegate { } menu.addItem(.separator()) - menu.addItem(label("\(screen ? "✓" : "✗") Screen Recording", action: screen ? nil : #selector(openScreenPane))) - menu.addItem(label("\(ax ? "✓" : "✗") Accessibility", action: ax ? nil : #selector(openAXPane))) + menu.addItem(label("Set Up Permissions…", action: #selector(showPermissionSetup))) + menu.addItem(permissionMenuItem("Screen Recording", kind: .screenRecording, + state: permissions.screenRecording, action: #selector(openScreenPane))) + menu.addItem(permissionMenuItem("Accessibility", kind: .accessibility, + state: permissions.accessibility, action: #selector(openAXPane))) + menu.addItem(permissionMenuItem("Full Disk Access", kind: .fullDiskAccess, + state: permissions.fullDiskAccess, action: #selector(openFDAPane))) menu.addItem(.separator()) menu.addItem(label("Sessions \(sessions) · Calls \(calls)" + (denies > 0 ? " · Denied \(denies)" : ""))) @@ -106,6 +141,21 @@ final class StatusItemController: NSObject, NSMenuDelegate { return m } + private func permissionMenuItem(_ title: String, kind: PermissionKind, + state: PermissionState, action: Selector) -> NSMenuItem { + let mark: String + switch state { + case .granted: mark = "✓" + case .denied: mark = "✗" + case .unknown: mark = "?" + } + let item = label("\(mark) \(title)", action: state.isGranted ? nil : action) + if kind == .fullDiskAccess && state == .unknown { + item.toolTip = "No protected probe database was found; open settings to verify manually" + } + return item + } + @objc private func copyURL() { NSPasteboard.general.clearContents() NSPasteboard.general.setString(url, forType: .string) @@ -126,6 +176,9 @@ final class StatusItemController: NSObject, NSMenuDelegate { @objc private func openAXPane() { NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")!) } + @objc private func openFDAPane() { + NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles")!) + } @objc private func openLog() { NSWorkspace.shared.open(URL(fileURLWithPath: logPath)) } diff --git a/Sources/oab-instance-mcp/main.swift b/Sources/oab-instance-mcp/main.swift index b0631bd..ab6bac3 100644 --- a/Sources/oab-instance-mcp/main.swift +++ b/Sources/oab-instance-mcp/main.swift @@ -4,7 +4,7 @@ import CoreGraphics import Foundation import InstanceMCPCore -let version = "0.6.2" +let version = "0.6.3" struct Options { var host = "127.0.0.1" @@ -145,10 +145,13 @@ do { } catch { fputs("failed to start listener: \(error)\n", stderr); exit(2) } +let startupPermissions = PermissionProbe.current() log("oab-instance-mcp \(version) starting on http://\(opts.host):\(opts.port)\(opts.path) " + "auth=[logins:\(opts.allowLogins.sorted().joined(separator: ",")) token:\(opts.token != nil) insecure-local:\(opts.insecureLocal)] " + "attach=\(opts.attach) upstreams=[\(opts.upstreams.map { $0.0 }.joined(separator: ","))] " + - "screen_recording=\(CGPreflightScreenCaptureAccess()) accessibility=\(AXIsProcessTrusted())") + "screen_recording=\(startupPermissions.screenRecording.isGranted) " + + "accessibility=\(startupPermissions.accessibility.isGranted) " + + "full_disk_access=\(startupPermissions.fullDiskAccess.isGranted)") http.start() signal(SIGPIPE, SIG_IGN) @@ -165,6 +168,9 @@ if opts.menuBar { statusItem = MainActor.assumeIsolated { StatusItemController(version: version, url: publicURL, logPath: logPath, token: opts.token) } + DispatchQueue.main.async { + MainActor.assumeIsolated { statusItem?.showPermissionSetupIfNeeded() } + } app.run() } else { RunLoop.main.run() diff --git a/Tests/InstanceMCPCoreTests/PermissionStatusTests.swift b/Tests/InstanceMCPCoreTests/PermissionStatusTests.swift new file mode 100644 index 0000000..5cf5047 --- /dev/null +++ b/Tests/InstanceMCPCoreTests/PermissionStatusTests.swift @@ -0,0 +1,82 @@ +import XCTest +@testable import InstanceMCPCore + +final class PermissionProbeTests: XCTestCase { + func testFullDiskAccessGrantedWhenAnExistingProtectedFileOpens() { + var opened: [String] = [] + let state = PermissionProbe.fullDiskAccess( + paths: ["missing", "denied", "readable", "after"], + exists: { $0 != "missing" }, + openForRead: { path in opened.append(path); return path == "readable" } + ) + XCTAssertEqual(state, .granted) + XCTAssertEqual(opened, ["denied", "readable"], "stop after the first successful open") + } + + func testFullDiskAccessDeniedWhenProbeFilesExistButNoneOpen() { + let state = PermissionProbe.fullDiskAccess( + paths: ["tcc", "safari", "messages"], + exists: { _ in true }, + openForRead: { _ in false } + ) + XCTAssertEqual(state, .denied) + } + + func testFullDiskAccessUnknownWhenNoProbeFileExists() { + var opens = 0 + let state = PermissionProbe.fullDiskAccess( + paths: ["tcc", "safari"], + exists: { _ in false }, + openForRead: { _ in opens += 1; return true } + ) + XCTAssertEqual(state, .unknown) + XCTAssertEqual(opens, 0) + } + + func testCandidatePathsStayInsideTheHomeAndUseProtectedDatabases() { + let paths = PermissionProbe.fullDiskAccessCandidatePaths(homeDirectory: "/Users/tester") + XCTAssertEqual(paths, [ + "/Users/tester/Library/Application Support/com.apple.TCC/TCC.db", + "/Users/tester/Library/Safari/History.db", + "/Users/tester/Library/Messages/chat.db", + ]) + } + + func testSnapshotSummary() { + let partial = PermissionSnapshot(screenRecording: .granted, accessibility: .denied, + fullDiskAccess: .unknown) + XCTAssertFalse(partial.allGranted) + XCTAssertEqual(partial.grantedCount, 1) + XCTAssertEqual(partial[.accessibility], .denied) + let all = PermissionSnapshot(screenRecording: .granted, accessibility: .granted, + fullDiskAccess: .granted) + XCTAssertTrue(all.allGranted) + XCTAssertEqual(all.grantedCount, 3) + } +} + +final class PermissionSetupPolicyTests: XCTestCase { + let complete = PermissionSnapshot(screenRecording: .granted, accessibility: .granted, + fullDiskAccess: .granted) + let missing = PermissionSnapshot(screenRecording: .denied, accessibility: .granted, + fullDiskAccess: .granted) + + func testFirstRunWithMissingPermissionAutoShows() { + XCTAssertTrue(PermissionSetupPolicy.shouldAutoShow(hasShown: false, snapshot: missing)) + } + + func testNotNowIsRespectedAcrossLaterLaunches() { + XCTAssertFalse(PermissionSetupPolicy.shouldAutoShow(hasShown: true, snapshot: missing)) + } + + func testFullyGrantedMachineNeverAutoShows() { + XCTAssertFalse(PermissionSetupPolicy.shouldAutoShow(hasShown: false, snapshot: complete)) + XCTAssertFalse(PermissionSetupPolicy.shouldAutoShow(hasShown: true, snapshot: complete)) + } + + func testUnknownFDAIsNotReportedAsComplete() { + let unknown = PermissionSnapshot(screenRecording: .granted, accessibility: .granted, + fullDiskAccess: .unknown) + XCTAssertTrue(PermissionSetupPolicy.shouldAutoShow(hasShown: false, snapshot: unknown)) + } +} diff --git a/docs/releasing.md b/docs/releasing.md index d855b43..dd8c41a 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -120,8 +120,8 @@ lipo -create /tmp/imcp-arm64/release/oab-instance-mcp \ /tmp/imcp-x86_64/release/oab-instance-mcp \ -output /tmp/oab-instance-mcp chmod +x /tmp/oab-instance-mcp -scripts/assemble-app.sh /tmp/oab-instance-mcp /tmp/oab-instance-mcp.app 0.6.2 -ALLOW_UNSIGNED=1 scripts/package-pkg.sh /tmp/oab-instance-mcp.app /tmp/oab-instance-mcp.pkg 0.6.2 +scripts/assemble-app.sh /tmp/oab-instance-mcp /tmp/oab-instance-mcp.app 0.6.3 +ALLOW_UNSIGNED=1 scripts/package-pkg.sh /tmp/oab-instance-mcp.app /tmp/oab-instance-mcp.pkg 0.6.3 ``` Unsigned artifacts are testing inputs only; do not install or publish them. diff --git a/scripts/install-prebuilt.sh b/scripts/install-prebuilt.sh index ca113a8..1c2ebf6 100755 --- a/scripts/install-prebuilt.sh +++ b/scripts/install-prebuilt.sh @@ -218,4 +218,4 @@ echo " app: $APP" echo " login: $LOGIN" echo " MCP: https://$DNSNAME:$HTTPS_PORT/mcp" echo " token: $TOKEN_FILE (copy it from the menu bar; not printed)" -echo " one-time: enable Full Disk Access, Screen Recording and Accessibility in System Settings" +echo " first launch: use the Set Up Mac Permissions window (also in the menu bar) for optional TCC grants"