Skip to content

Commit 63a72d6

Browse files
committed
refactor(permissions): simplify accessibility onboarding with polling
Replaces the complex modal-based permission flow with a simpler polling mechanism and automatic TCC reset. This improves reliability for ad-hoc signed updates and reduces user friction by avoiding multiple dialogs.
1 parent ddcc29a commit 63a72d6

File tree

3 files changed

+70
-225
lines changed

3 files changed

+70
-225
lines changed

OpenDictation/App/AppDelegate.swift

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -44,16 +44,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
4444
// Check accessibility FIRST - before any other setup.
4545
// This prevents the escape key bug by ensuring event taps can be created.
4646
permissionsManager = PermissionsManager()
47-
48-
// Check for post-update launch (binary changed but accessibility lost)
49-
if permissionsManager?.isPostUpdateLaunch() == true && !AXIsProcessTrusted() {
50-
permissionsManager?.handlePostUpdateAccessibilityCheck()
51-
// If we get here, handlePostUpdateAccessibilityCheck either handled it
52-
// and we continue (unlikely but possible) or it triggered a quit/relaunch flow.
53-
} else {
54-
// Normal launch flow
55-
permissionsManager?.checkAccessibilityOnLaunch()
56-
}
47+
permissionsManager?.checkAccessibilityOnLaunch()
5748

5849
// MARK: - App Setup
5950
// Check if app should be moved to Applications (before other setup)

OpenDictation/App/Info.plist

Lines changed: 34 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,41 +2,39 @@
22
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
33
<plist version="1.0">
44
<dict>
5-
<key>CFBundleName</key>
6-
<string>$(PRODUCT_NAME)</string>
7-
<key>CFBundleDisplayName</key>
8-
<string>$(PRODUCT_NAME)</string>
9-
<key>CFBundleIdentifier</key>
10-
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
11-
<key>CFBundleVersion</key>
12-
<string>$(CURRENT_PROJECT_VERSION)</string>
13-
<key>CFBundleShortVersionString</key>
14-
<string>$(MARKETING_VERSION)</string>
15-
<key>CFBundlePackageType</key>
16-
<string>APPL</string>
17-
<key>CFBundleExecutable</key>
18-
<string>$(EXECUTABLE_NAME)</string>
19-
<key>LSMinimumSystemVersion</key>
20-
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
21-
<key>LSUIElement</key>
22-
<true/>
23-
<key>NSMicrophoneUsageDescription</key>
24-
<string>Open Dictation needs microphone access to record your voice for transcription.</string>
25-
<key>NSAppleEventsUsageDescription</key>
26-
<string>Open Dictation needs accessibility access to detect the text cursor position and insert transcribed text.</string>
27-
28-
<!-- Sparkle Auto-Update Configuration -->
29-
<key>SUFeedURL</key>
30-
<string>https://raw.githubusercontent.com/kdcokenny/OpenDictation/main/appcast.xml</string>
31-
<key>SUPublicEDKey</key>
32-
<string>rSBYJ/4XCkt7A2nS+gmB2K0Ti5Vum2ZjmsjwUkS2K9s=</string>
33-
<key>SUEnableAutomaticChecks</key>
34-
<true/>
35-
<key>SUAllowsAutomaticUpdates</key>
36-
<true/>
37-
<key>SUAutomaticallyUpdate</key>
38-
<true/>
39-
<key>SUScheduledCheckInterval</key>
40-
<integer>86400</integer>
5+
<key>CFBundleDisplayName</key>
6+
<string>$(PRODUCT_NAME)</string>
7+
<key>CFBundleExecutable</key>
8+
<string>$(EXECUTABLE_NAME)</string>
9+
<key>CFBundleIdentifier</key>
10+
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
11+
<key>CFBundleName</key>
12+
<string>$(PRODUCT_NAME)</string>
13+
<key>CFBundlePackageType</key>
14+
<string>APPL</string>
15+
<key>CFBundleShortVersionString</key>
16+
<string>$(MARKETING_VERSION)</string>
17+
<key>CFBundleVersion</key>
18+
<string>1000</string>
19+
<key>LSMinimumSystemVersion</key>
20+
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
21+
<key>LSUIElement</key>
22+
<true/>
23+
<key>NSAppleEventsUsageDescription</key>
24+
<string>Open Dictation needs accessibility access to detect the text cursor position and insert transcribed text.</string>
25+
<key>NSMicrophoneUsageDescription</key>
26+
<string>Open Dictation needs microphone access to record your voice for transcription.</string>
27+
<key>SUAllowsAutomaticUpdates</key>
28+
<true/>
29+
<key>SUAutomaticallyUpdate</key>
30+
<true/>
31+
<key>SUEnableAutomaticChecks</key>
32+
<true/>
33+
<key>SUFeedURL</key>
34+
<string>https://raw.githubusercontent.com/kdcokenny/OpenDictation/main/appcast.xml</string>
35+
<key>SUPublicEDKey</key>
36+
<string>rSBYJ/4XCkt7A2nS+gmB2K0Ti5Vum2ZjmsjwUkS2K9s=</string>
37+
<key>SUScheduledCheckInterval</key>
38+
<integer>86400</integer>
4139
</dict>
4240
</plist>

OpenDictation/Core/Services/PermissionsManager.swift

Lines changed: 35 additions & 179 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ final class PermissionsManager: ObservableObject {
2222

2323
private enum Keys {
2424
static let accessibilityPromptedVersion = "PermissionsManager_AccessibilityPromptedVersion"
25-
static let lastLaunchedBuildNumber = "PermissionsManager_LastLaunchedBuildNumber"
2625
}
2726

2827
// MARK: - Published Properties
@@ -37,6 +36,7 @@ final class PermissionsManager: ObservableObject {
3736

3837
private let logger = Logger.app(category: "PermissionsManager")
3938
private var accessibilityObserver: Task<Void, Never>?
39+
private var pollingTask: Task<Void, Never>?
4040

4141
// MARK: - Initialization
4242

@@ -46,6 +46,7 @@ final class PermissionsManager: ObservableObject {
4646

4747
deinit {
4848
accessibilityObserver?.cancel()
49+
pollingTask?.cancel()
4950
}
5051

5152
// MARK: - Public Methods
@@ -96,208 +97,63 @@ final class PermissionsManager: ObservableObject {
9697
/// This prevents the escape key bug by ensuring accessibility is granted
9798
/// before any event taps are created.
9899
func checkAccessibilityOnLaunch() {
99-
// We intentionally don't use the system prompt as our dialog explains it better.
100100
// Use the raw string value to avoid Swift 6 concurrency issues with the global constant
101101
// kAXTrustedCheckOptionPrompt's value is "AXTrustedCheckOptionPrompt"
102-
let options = ["AXTrustedCheckOptionPrompt": false] as CFDictionary
103-
if AXIsProcessTrustedWithOptions(options) {
102+
let checkOptions = ["AXTrustedCheckOptionPrompt": false] as CFDictionary
103+
if AXIsProcessTrustedWithOptions(checkOptions) {
104104
// Already granted - update status and continue
105105
isAccessibilityGranted = true
106106
return
107107
}
108108

109109
// Reset any stale TCC entries before requesting permission.
110-
// This is critical for ad-hoc signed apps: after an update, the app's code signature
111-
// changes, but the old TCC entry remains. The user sees "Open Dictation" checked in
112-
// System Settings, but AXIsProcessTrusted() returns false because the signature
113-
// doesn't match. Resetting clears this stale state.
110+
// This is critical for ad-hoc signed apps (like Open Dictation distributed via GitHub).
114111
// Pattern from Loop (github.com/MrKai77/Loop).
115112
logger.info("Accessibility not granted - resetting TCC to clear any stale entries")
116113
resetAccessibility()
117114

118-
// Open System Settings to Accessibility pane
115+
// Call with prompt:true to REGISTER the app with TCC.
116+
// This is what actually makes the app appear in the Accessibility list.
117+
// We let the system show its native prompt instead of showing our own custom dialog first,
118+
// which prevents "triple popup" fatigue (System Prompt + Our Dialog + System Settings).
119+
let promptOptions = ["AXTrustedCheckOptionPrompt": true] as CFDictionary
120+
_ = AXIsProcessTrustedWithOptions(promptOptions)
121+
122+
// Also open System Settings to Accessibility pane for convenience
119123
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") {
120124
NSWorkspace.shared.open(url)
121125
}
122126

123-
// Force our app to front so alert appears on top (Clipy pattern)
127+
// Force our app to front so symbols appear on top
124128
NSApp.activate(ignoringOtherApps: true)
125129

126-
// Show custom alert explaining what's needed
127-
let alert = NSAlert()
128-
alert.messageText = "Open Dictation needs accessibility access."
129-
alert.informativeText = """
130-
Open Dictation needs accessibility to paste transcribed text directly into other apps.
131-
132-
In the System Settings window that just opened, find "Open Dictation" in the list and check its checkbox. Then click the "Continue" button here.
133-
"""
134-
alert.alertStyle = .informational
135-
alert.addButton(withTitle: "Continue")
136-
alert.addButton(withTitle: "Quit")
137-
138-
guard alert.runModal() == .alertFirstButtonReturn else {
139-
// User clicked "Quit"
140-
NSApp.terminate(nil)
141-
return
142-
}
143-
144-
// Verify user actually granted permission before relaunching
145-
// This prevents infinite loop if user clicks "Continue" without granting permission
146-
if !AXIsProcessTrustedWithOptions(options) {
147-
// Still not granted - show error and quit
148-
let errorAlert = NSAlert()
149-
errorAlert.messageText = "Permission not granted"
150-
errorAlert.informativeText = "Open Dictation cannot continue without accessibility permission. Please enable it in System Settings and relaunch the app."
151-
errorAlert.alertStyle = .critical
152-
errorAlert.addButton(withTitle: "Quit")
153-
errorAlert.runModal()
154-
NSApp.terminate(nil)
155-
return
156-
}
157-
158-
// Permission granted - relaunch to ensure clean state
159-
let configuration = NSWorkspace.OpenConfiguration()
160-
configuration.createsNewApplicationInstance = true
161-
162-
NSWorkspace.shared.openApplication(at: Bundle.main.bundleURL, configuration: configuration) { _, error in
163-
DispatchQueue.main.async {
164-
if let error = error {
165-
NSApp.presentError(error)
166-
return
167-
}
168-
169-
NSApp.terminate(nil)
170-
}
171-
}
172-
}
173-
174-
// MARK: - Post-Update Handling
175-
176-
/// Detects if this launch is after an app update that changed the binary.
177-
/// Uses version comparison as primary signal (reliable) with delegate flag as optimization.
178-
func isPostUpdateLaunch() -> Bool {
179-
let currentBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String
180-
let lastBuild = UserDefaults.standard.string(forKey: Keys.lastLaunchedBuildNumber)
181-
182-
// Always update for next launch
183-
UserDefaults.standard.set(currentBuild, forKey: Keys.lastLaunchedBuildNumber)
184-
185-
// Post-update if: (1) build changed, OR (2) Sparkle delegate set flag
186-
let buildChanged = (lastBuild != nil) && (lastBuild != currentBuild)
187-
let delegateFlag = UpdateService.consumePostUpdateFlag()
188-
189-
if buildChanged || delegateFlag {
190-
logger.info("Detected post-update launch (buildChanged: \(buildChanged), delegateFlag: \(delegateFlag))")
191-
return true
192-
}
193-
194-
return false
195-
}
196-
197-
/// Handles accessibility check specifically after an auto-update.
198-
/// Terminates app so user can manually relaunch for proper TCC registration.
199-
func handlePostUpdateAccessibilityCheck() {
200-
// Already granted? Continue normally (user may have manually fixed or relaunch worked)
201-
if AXIsProcessTrusted() {
202-
logger.info("Accessibility already granted on post-update launch")
203-
isAccessibilityGranted = true
204-
return
205-
}
206-
207-
logger.info("Post-update launch detected - accessibility not granted")
208-
209-
// Force app to front
210-
NSApp.activate(ignoringOtherApps: true)
211-
212-
// Reset stale TCC entries
213-
let resetSucceeded = resetAccessibilityAndReturnStatus()
214-
215-
if resetSucceeded {
216-
showPostUpdateQuitDialog()
217-
} else {
218-
showManualRemovalDialog()
219-
}
220-
221-
NSApp.terminate(nil)
222-
}
223-
224-
/// Shows dialog when tccutil reset succeeded.
225-
private func showPostUpdateQuitDialog() {
226-
let alert = NSAlert()
227-
alert.messageText = "One More Step After Update"
228-
alert.informativeText = """
229-
Open Dictation was just updated. macOS security requires you to:
230-
231-
1. Click "Quit" below
232-
2. Reopen Open Dictation from the Dock or Applications folder
233-
3. Enable accessibility permission when prompted
234-
235-
This is a one-time step after updates.
236-
"""
237-
alert.alertStyle = .informational
238-
alert.addButton(withTitle: "Quit")
239-
alert.addButton(withTitle: "Open Applications Folder")
240-
241-
if alert.runModal() == .alertSecondButtonReturn {
242-
NSWorkspace.shared.selectFile(Bundle.main.bundlePath, inFileViewerRootedAtPath: "/Applications")
243-
}
130+
// Start polling until permission is granted (Pattern from Rectangle)
131+
pollForAccessibilityPermission()
244132
}
245133

246-
/// Shows dialog when tccutil reset FAILED - user must manually remove.
247-
private func showManualRemovalDialog() {
248-
let alert = NSAlert()
249-
alert.messageText = "Manual Step Required"
250-
alert.informativeText = """
251-
Open Dictation was just updated, but automatic permission reset failed.
252-
253-
Please follow these steps:
254-
255-
1. Click "Open System Settings" below
256-
2. Find "Open Dictation" in the list
257-
3. Click the minus (-) button to REMOVE it
258-
4. Close this app and reopen it
259-
5. Re-add Open Dictation when prompted
260-
261-
Toggling the checkbox off/on will NOT work—you must fully remove it.
262-
"""
263-
alert.alertStyle = .warning
264-
alert.addButton(withTitle: "Open System Settings")
265-
alert.addButton(withTitle: "Quit")
266-
267-
if alert.runModal() == .alertFirstButtonReturn {
268-
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") {
269-
NSWorkspace.shared.open(url)
134+
/// Polls until accessibility permission is granted.
135+
private func pollForAccessibilityPermission() {
136+
pollingTask?.cancel()
137+
pollingTask = Task { [weak self] in
138+
while !Task.isCancelled {
139+
// Check every 500ms (Pattern from Rectangle)
140+
try? await Task.sleep(for: .milliseconds(500))
141+
142+
guard let self = self else { return }
143+
144+
if await MainActor.run(body: { AXIsProcessTrusted() }) {
145+
await MainActor.run {
146+
self.isAccessibilityGranted = true
147+
self.accessibilityDidUpdate.send()
148+
self.pollingTask = nil
149+
}
150+
return
151+
}
270152
}
271153
}
272154
}
273155

274-
/// Resets accessibility and returns whether it succeeded.
275-
private nonisolated func resetAccessibilityAndReturnStatus() -> Bool {
276-
guard let bundleID = Bundle.main.bundleIdentifier else { return false }
277-
278-
do {
279-
let process = Process()
280-
process.executableURL = URL(fileURLWithPath: "/usr/bin/tccutil")
281-
process.arguments = ["reset", "Accessibility", bundleID]
282-
process.standardOutput = FileHandle.nullDevice
283-
process.standardError = FileHandle.nullDevice
284-
try process.run()
285-
process.waitUntilExit()
286-
287-
let log = OSLog.app(category: "PermissionsManager")
288-
if process.terminationStatus == 0 {
289-
os_log("Reset accessibility permissions for %{public}@", log: log, type: .info, bundleID)
290-
return true
291-
} else {
292-
os_log("tccutil reset failed with status %d", log: log, type: .error, process.terminationStatus)
293-
return false
294-
}
295-
} catch {
296-
let log = OSLog.app(category: "PermissionsManager")
297-
os_log("Failed to run tccutil: %{public}@", log: log, type: .error, error.localizedDescription)
298-
return false
299-
}
300-
}
156+
301157

302158
// MARK: - Accessibility Permission
303159

0 commit comments

Comments
 (0)