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"