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:
2026-08-19 14:55:24 +02:00
parent 56a5d1d56f
commit 1dc485badb
3 changed files with 102 additions and 21 deletions
+49 -20
View File
@@ -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 /// 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). /// the user hasn't been asked yet (a no-op if already granted or denied).
/// ///
/// Two earlier attempts used `AEDeterminePermissionToAutomateTarget(askUserIfNeeded: true)` /// Live-verified (2026-08-19, several rounds with Rune): the prompt never appears on this
/// first called directly (relying on implicit MainActor isolation), then wrapped in /// machine (macOS 27 beta) for Confab specifically not a threading bug (tried direct call,
/// `DispatchQueue.main.async`. Neither worked live: logs showed it returning -1743 in ~9ms, /// `DispatchQueue.main.async`), not the wrong API (tried `AEDeterminePermissionToAutomateTarget`
/// far too fast for any real human response, immediately followed by the passive status check /// AND a real Apple Event via `runHandler`, both fail identically), not a build/signing issue
/// (`askUserIfNeeded: false`) still reporting -1744 ("would need to ask") i.e. the OS never /// (verified the installed app's signature, team ID, Info.plist key, and even a notarized
/// actually recorded a decision despite the "denied" return. That points at /// build all correct), and not an OS-wide Automation outage (Terminal Mail works fine).
/// `AEDeterminePermissionToAutomateTarget` itself misbehaving in this context (possibly a /// Every failure returns in single-digit milliseconds far too fast for a real dialog to have
/// macOS 27 beta issue this project has hit other real TCC/permission regressions on this /// been shown and answered. This matches an already-documented macOS 27 beta bug in this same
/// beta before, see CLAUDE.md's Personal Data Tools Apple Feedback note), not a threading bug. /// 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` /// Given that, this still attempts the real thing first (the way it *should* work, and will
/// path `mail_list_accounts`/`testConnection()` already use sending an actual Apple Event is /// once the OS bug is fixed) via the same `runHandler` path `mail_list_accounts`/
/// the standard, well-proven way macOS shows the first-time "X wants to control Y" Automation /// `testConnection()` use, but times the attempt and treats an implausibly-fast failure as the
/// prompt, and this exact path is already confirmed working (it's what correctly reported /// known platform bug rather than a real denial callers can then fall back to sending the
/// "not authorized" in the very first live test). /// user straight to System Settings' Automation pane instead of a dead-end "nothing happened."
@discardableResult @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") 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: case .success:
Log.mail.info("requestAccess: real Apple Event succeeded — access granted") Log.mail.info("requestAccess: real Apple Event succeeded after \(String(format: "%.3f", elapsed))s — access granted")
return true return .granted
case .failure(let error): case .failure(let error):
Log.mail.info("requestAccess: real Apple Event failed — \(String(describing: error))") let outcome = Self.classifyRequestOutcome(succeeded: false, elapsedSeconds: elapsed)
return false Log.mail.info("requestAccess: real Apple Event failed after \(String(format: "%.3f", elapsed))s — \(String(describing: error)) — classified as \(String(describing: outcome))")
return outcome
} }
} }
+28 -1
View File
@@ -134,6 +134,7 @@ struct SettingsView: View {
@State private var isTestingMail = false @State private var isTestingMail = false
@State private var mailTestResult: String? @State private var mailTestResult: String?
@State private var mailAccessState: PersonalDataAccessState = AppleMailService.shared.accessState @State private var mailAccessState: PersonalDataAccessState = AppleMailService.shared.accessState
@State private var mailAccessNote: String?
private let labelWidth: CGFloat = 160 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, isEnabled: $settingsService.mailEnabled,
state: mailAccessState, state: mailAccessState,
systemSettingsAnchor: "Privacy_Automation", 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 { if settingsService.mailEnabled {
rowDivider() rowDivider()
row("Require Approval for Every Action") { row("Require Approval for Every Action") {
+25
View File
@@ -188,4 +188,29 @@ struct AppleMailServiceTests {
#expect(AppleMailService.mapAutomationPermissionStatus(-1744) == .notDetermined) #expect(AppleMailService.mapAutomationPermissionStatus(-1744) == .notDetermined)
#expect(AppleMailService.mapAutomationPermissionStatus(-9999) == .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)
}
} }