diff --git a/oAI/Services/AppleMailService.swift b/oAI/Services/AppleMailService.swift index a266f03..0db045b 100644 --- a/oAI/Services/AppleMailService.swift +++ b/oAI/Services/AppleMailService.swift @@ -53,6 +53,51 @@ final class AppleMailService { // through one queue, mirroring MCPService.runBashCommand's off-main-actor pattern. private let queue = DispatchQueue(label: "com.oai.applemail", qos: .userInitiated) + // MARK: - Automation Permission (Settings UI status badge + Request Access button) + + /// Live tri-state permission check via `AEDeterminePermissionToAutomateTarget`, which — unlike + /// sending an actual Apple Event — can query Automation permission for Mail.app without + /// triggering any script execution or the system prompt (`askUserIfNeeded: false`). This is + /// the Apple-Events equivalent of `EKEventStore.authorizationStatus(for:)`, reusing the same + /// `PersonalDataAccessState` enum EventKitService/ContactsService/LocationMapsService use. + var accessState: PersonalDataAccessState { + Self.mapAutomationPermissionStatus(Self.checkMailAutomationPermission(askUserIfNeeded: false)) + } + + /// Pure mapping from an `AEDeterminePermissionToAutomateTarget` status code to the shared + /// tri-state enum, kept separate from the live AE call for testability. + nonisolated static func mapAutomationPermissionStatus(_ status: OSStatus) -> PersonalDataAccessState { + switch status { + case 0: return .granted // noErr + case -1743: return .denied // errAEEventNotPermitted + default: return .notDetermined // errAEEventWouldRequireUserConsent (-1744) and others + } + } + + /// Triggers the real macOS "Confab wants to control Mail" Automation permission prompt if + /// the user hasn't been asked yet (a no-op if already granted or denied). Blocks until the + /// user responds, so this must run off the main thread. + @discardableResult + func requestAccess() async -> Bool { + await withCheckedContinuation { continuation in + queue.async { + let status = Self.checkMailAutomationPermission(askUserIfNeeded: true) + continuation.resume(returning: status == 0) + } + } + } + + private static func checkMailAutomationPermission(askUserIfNeeded: Bool) -> OSStatus { + let bundleID = "com.apple.mail" + var target = AEAddressDesc() + let createStatus = bundleID.withCString { cString in + AECreateDesc(typeApplicationBundleID, cString, bundleID.utf8.count, &target) + } + guard createStatus == 0 else { return OSStatus(createStatus) } + defer { AEDisposeDesc(&target) } + return AEDeterminePermissionToAutomateTarget(&target, typeWildCard, typeWildCard, askUserIfNeeded) + } + @ObservationIgnored private lazy var compiledScript: NSAppleScript? = { guard let script = NSAppleScript(source: Self.scriptSource) else { return nil } diff --git a/oAI/Views/Screens/SettingsView.swift b/oAI/Views/Screens/SettingsView.swift index 06fca41..d6774c6 100644 --- a/oAI/Views/Screens/SettingsView.swift +++ b/oAI/Views/Screens/SettingsView.swift @@ -133,6 +133,7 @@ struct SettingsView: View { // Mail state @State private var isTestingMail = false @State private var mailTestResult: String? + @State private var mailAccessState: PersonalDataAccessState = AppleMailService.shared.accessState private let labelWidth: CGFloat = 160 @@ -1041,20 +1042,26 @@ It's better to admit "I need more information" or "I cannot do that" than to fak Text("Mail") .font(.system(size: 18, weight: .semibold)) } - Text("Let the AI search your Apple Mail inbox, read messages, and save attachments to disk (e.g. to hand off to Paperless). Uses AppleScript to talk to Mail.app — no separate credentials needed. The first use triggers a one-time macOS Automation permission prompt.") + Text("Let the AI search your Apple Mail inbox, read messages, and save attachments to disk (e.g. to hand off to Paperless). Uses AppleScript to talk to Mail.app — no separate credentials needed.") .font(.system(size: 14)) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } .padding(.bottom, 4) + .onAppear { + mailAccessState = AppleMailService.shared.accessState + } VStack(alignment: .leading, spacing: 6) { sectionHeader("Apple Mail") formSection { - row("Enable Mail Access") { - Toggle("", isOn: $settingsService.mailEnabled) - .toggleStyle(.switch) - } + personalDataRow( + title: "Mail", + isEnabled: $settingsService.mailEnabled, + state: mailAccessState, + systemSettingsAnchor: "Privacy_Automation", + requestAccess: { mailAccessState = await AppleMailService.shared.requestAccess() ? .granted : AppleMailService.shared.accessState } + ) if settingsService.mailEnabled { rowDivider() row("Require Approval for Every Action") { diff --git a/oAITests/AppleMailServiceTests.swift b/oAITests/AppleMailServiceTests.swift index 90510bc..74fe39c 100644 --- a/oAITests/AppleMailServiceTests.swift +++ b/oAITests/AppleMailServiceTests.swift @@ -170,4 +170,22 @@ struct AppleMailServiceTests { let summary = AppleMailService.shared.approvalSummary(forTool: "mail_bogus", arguments: "{}") #expect(summary == "Perform action: mail_bogus") } + + // MARK: - mapAutomationPermissionStatus + + @Test("mapAutomationPermissionStatus maps noErr (0) to granted") + func mapsGrantedStatus() { + #expect(AppleMailService.mapAutomationPermissionStatus(0) == .granted) + } + + @Test("mapAutomationPermissionStatus maps -1743 to denied") + func mapsDeniedStatus() { + #expect(AppleMailService.mapAutomationPermissionStatus(-1743) == .denied) + } + + @Test("mapAutomationPermissionStatus maps -1744 (would require consent) and other codes to notDetermined") + func mapsNotDeterminedStatus() { + #expect(AppleMailService.mapAutomationPermissionStatus(-1744) == .notDetermined) + #expect(AppleMailService.mapAutomationPermissionStatus(-9999) == .notDetermined) + } }