@@ -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