Fall back to System Settings when the Mail consent prompt is the known beta bug
Confirmed via multiple live rounds with Rune this is a macOS 27 beta issue, not a Confab bug — matches an already-documented pattern in this project (Calendar/Contacts requestAccess failing identically). Every failure returns in single-digit milliseconds, ruled out threading, wrong API, build/signing, and notarization; even a fresh notarized build fails identically, while Terminal->Mail (first-party) works with no prompt at all, and the same-style bug already has a drafted Apple Feedback report for a different permission category. Kept the real Apple Event attempt as the primary path (the way this should work once the OS bug is fixed), but now time it: a failure faster than any human could plausibly answer a real dialog (default 300ms) is classified as the platform bug rather than a genuine denial, and the UI falls back to opening System Settings' Automation pane directly with an explanatory note, instead of the button silently doing nothing.
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user