diff --git a/oAI/Services/AppleMailService.swift b/oAI/Services/AppleMailService.swift index edb1343..2761cb8 100644 --- a/oAI/Services/AppleMailService.swift +++ b/oAI/Services/AppleMailService.swift @@ -76,34 +76,63 @@ final class AppleMailService { } } + /// Outcome of `requestAccess()`. `.suspectedPlatformBug` is distinct from `.denied` because a + /// genuine user denial requires the system dialog to actually be shown and answered, which + /// takes real human time — see `classifyRequestOutcome` for the timing heuristic that tells + /// the two apart. + enum MailAccessOutcome: Equatable { + case granted + case denied + case suspectedPlatformBug + } + + /// Below this, a failure almost certainly means no dialog was ever shown — no human reads, + /// decides, and clicks a permission dialog in under this long. Generous on purpose: the goal + /// is zero false positives (never mistake a real, if fast, denial for the platform bug), not a + /// tight bound. + nonisolated static let suspectedBugThreseholdSeconds: TimeInterval = 0.3 + + /// Pure classification, kept separate from the live call for testability. + nonisolated static func classifyRequestOutcome(succeeded: Bool, elapsedSeconds: TimeInterval) -> MailAccessOutcome { + if succeeded { return .granted } + return elapsedSeconds < suspectedBugThreseholdSeconds ? .suspectedPlatformBug : .denied + } + /// 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). /// - /// Two earlier attempts used `AEDeterminePermissionToAutomateTarget(askUserIfNeeded: true)` — - /// first called directly (relying on implicit MainActor isolation), then wrapped in - /// `DispatchQueue.main.async`. Neither worked live: logs showed it returning -1743 in ~9ms, - /// far too fast for any real human response, immediately followed by the passive status check - /// (`askUserIfNeeded: false`) still reporting -1744 ("would need to ask") — i.e. the OS never - /// actually recorded a decision despite the "denied" return. That points at - /// `AEDeterminePermissionToAutomateTarget` itself misbehaving in this context (possibly a - /// macOS 27 beta issue — this project has hit other real TCC/permission regressions on this - /// beta before, see CLAUDE.md's Personal Data Tools Apple Feedback note), not a threading bug. + /// Live-verified (2026-08-19, several rounds with Rune): the prompt never appears on this + /// machine (macOS 27 beta) for Confab specifically — not a threading bug (tried direct call, + /// `DispatchQueue.main.async`), not the wrong API (tried `AEDeterminePermissionToAutomateTarget` + /// AND a real Apple Event via `runHandler`, both fail identically), not a build/signing issue + /// (verified the installed app's signature, team ID, Info.plist key, and even a notarized + /// build — all correct), and not an OS-wide Automation outage (Terminal → Mail works fine). + /// Every failure returns in single-digit milliseconds — far too fast for a real dialog to have + /// been shown and answered. This matches an already-documented macOS 27 beta bug in this same + /// project (`EKEventStore.requestFullAccessToEvents()`/`CNContactStore.requestAccess()` failing + /// identically for Calendar/Contacts while Reminders/Location work fine) — a category of + /// TCC-gated permission requests that this OS beta just never prompts for from third-party + /// apps, regardless of what the app does. /// - /// Switched strategy entirely: attempt a real, harmless Apple Event via the same `runHandler` - /// path `mail_list_accounts`/`testConnection()` already use — sending an actual Apple Event is - /// the standard, well-proven way macOS shows the first-time "X wants to control Y" Automation - /// prompt, and this exact path is already confirmed working (it's what correctly reported - /// "not authorized" in the very first live test). + /// Given that, this still attempts the real thing first (the way it *should* work, and will + /// once the OS bug is fixed) via the same `runHandler` path `mail_list_accounts`/ + /// `testConnection()` use, but times the attempt and treats an implausibly-fast failure as the + /// known platform bug rather than a real denial — callers can then fall back to sending the + /// user straight to System Settings' Automation pane instead of a dead-end "nothing happened." @discardableResult - func requestAccess() async -> Bool { + func requestAccess() async -> MailAccessOutcome { Log.mail.info("requestAccess: called — attempting a real Apple Event (listAccounts) to trigger the OS consent prompt") - switch await runHandler("listAccounts", arguments: []) { + let start = Date() + let result = await runHandler("listAccounts", arguments: []) + let elapsed = Date().timeIntervalSince(start) + switch result { case .success: - Log.mail.info("requestAccess: real Apple Event succeeded — access granted") - return true + Log.mail.info("requestAccess: real Apple Event succeeded after \(String(format: "%.3f", elapsed))s — access granted") + return .granted case .failure(let error): - Log.mail.info("requestAccess: real Apple Event failed — \(String(describing: error))") - return false + let outcome = Self.classifyRequestOutcome(succeeded: false, elapsedSeconds: elapsed) + Log.mail.info("requestAccess: real Apple Event failed after \(String(format: "%.3f", elapsed))s — \(String(describing: error)) — classified as \(String(describing: outcome))") + return outcome } } diff --git a/oAI/Views/Screens/SettingsView.swift b/oAI/Views/Screens/SettingsView.swift index d6774c6..010b846 100644 --- a/oAI/Views/Screens/SettingsView.swift +++ b/oAI/Views/Screens/SettingsView.swift @@ -134,6 +134,7 @@ struct SettingsView: View { @State private var isTestingMail = false @State private var mailTestResult: String? @State private var mailAccessState: PersonalDataAccessState = AppleMailService.shared.accessState + @State private var mailAccessNote: String? private let labelWidth: CGFloat = 160 @@ -1060,8 +1061,34 @@ It's better to admit "I need more information" or "I cannot do that" than to fak isEnabled: $settingsService.mailEnabled, state: mailAccessState, systemSettingsAnchor: "Privacy_Automation", - requestAccess: { mailAccessState = await AppleMailService.shared.requestAccess() ? .granted : AppleMailService.shared.accessState } + requestAccess: { + switch await AppleMailService.shared.requestAccess() { + case .granted: + mailAccessState = .granted + mailAccessNote = nil + case .denied: + mailAccessState = .denied + mailAccessNote = nil + case .suspectedPlatformBug: + // Known macOS 27 beta issue (see AppleMailService.requestAccess doc + // comment) — the OS never shows the consent dialog for this app, so + // there's nothing more to try in-app. Send the user to System Settings + // directly rather than leaving the button looking like it did nothing. + mailAccessState = .denied + mailAccessNote = "macOS isn't showing the permission prompt (a known macOS 27 beta issue) — opened System Settings instead. If Confab isn't listed there under Automation, this can't be granted until Apple fixes it." + openPrivacySettings(anchor: "Privacy_Automation") + } + } ) + if let mailAccessNote { + rowDivider() + Text(mailAccessNote) + .font(.system(size: 12)) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 16) + .padding(.vertical, 8) + } if settingsService.mailEnabled { rowDivider() row("Require Approval for Every Action") { diff --git a/oAITests/AppleMailServiceTests.swift b/oAITests/AppleMailServiceTests.swift index 74fe39c..3c2690e 100644 --- a/oAITests/AppleMailServiceTests.swift +++ b/oAITests/AppleMailServiceTests.swift @@ -188,4 +188,29 @@ struct AppleMailServiceTests { #expect(AppleMailService.mapAutomationPermissionStatus(-1744) == .notDetermined) #expect(AppleMailService.mapAutomationPermissionStatus(-9999) == .notDetermined) } + + // MARK: - classifyRequestOutcome + + @Test("classifyRequestOutcome maps success to granted regardless of elapsed time") + func classifiesSuccessAsGranted() { + #expect(AppleMailService.classifyRequestOutcome(succeeded: true, elapsedSeconds: 0.001) == .granted) + #expect(AppleMailService.classifyRequestOutcome(succeeded: true, elapsedSeconds: 5.0) == .granted) + } + + @Test("classifyRequestOutcome treats an implausibly fast failure as the suspected platform bug") + func classifiesFastFailureAsSuspectedBug() { + #expect(AppleMailService.classifyRequestOutcome(succeeded: false, elapsedSeconds: 0.009) == .suspectedPlatformBug) + #expect(AppleMailService.classifyRequestOutcome(succeeded: false, elapsedSeconds: 0.0) == .suspectedPlatformBug) + } + + @Test("classifyRequestOutcome treats a slower failure as a real denial") + func classifiesSlowFailureAsDenied() { + #expect(AppleMailService.classifyRequestOutcome(succeeded: false, elapsedSeconds: 1.5) == .denied) + } + + @Test("classifyRequestOutcome threshold is right at the boundary") + func classifiesThresholdBoundary() { + #expect(AppleMailService.classifyRequestOutcome(succeeded: false, elapsedSeconds: AppleMailService.suspectedBugThreseholdSeconds) == .denied) + #expect(AppleMailService.classifyRequestOutcome(succeeded: false, elapsedSeconds: AppleMailService.suspectedBugThreseholdSeconds - 0.001) == .suspectedPlatformBug) + } }