@@ -22,6 +22,7 @@ final class PermissionsManager: ObservableObject {
2222
2323 private enum Keys {
2424 static let accessibilityPromptedVersion = " PermissionsManager_AccessibilityPromptedVersion "
25+ static let lastLaunchedBuildNumber = " PermissionsManager_LastLaunchedBuildNumber "
2526 }
2627
2728 // MARK: - Published Properties
@@ -170,6 +171,134 @@ final class PermissionsManager: ObservableObject {
170171 }
171172 }
172173
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+ }
244+ }
245+
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)
270+ }
271+ }
272+ }
273+
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+ }
301+
173302 // MARK: - Accessibility Permission
174303
175304 /// Requests Accessibility permission if not already prompted this app version.
0 commit comments