Skip to content

Commit 85b9350

Browse files
committed
fix(permissions): handle accessibility loss after auto-update
1 parent d920885 commit 85b9350

File tree

3 files changed

+171
-3
lines changed

3 files changed

+171
-3
lines changed

OpenDictation/App/AppDelegate.swift

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
4343
// MARK: - Accessibility Permission Check
4444
// Check accessibility FIRST - before any other setup.
4545
// This prevents the escape key bug by ensuring event taps can be created.
46-
// Pattern from Touch Bar Simulator by Sindre Sorhus.
4746
permissionsManager = PermissionsManager()
48-
permissionsManager?.checkAccessibilityOnLaunch()
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+
}
4957

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

OpenDictation/Core/Services/PermissionsManager.swift

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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.

OpenDictation/Core/Services/UpdateService.swift

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ final class UpdateService: ObservableObject {
1212
static let shared = UpdateService()
1313

1414
private let controller: SPUStandardUpdaterController
15+
private let delegate = UpdateServiceDelegate()
1516

1617
/// The underlying SPUUpdater for bindings and state observation
1718
var updater: SPUUpdater { controller.updater }
@@ -22,7 +23,7 @@ final class UpdateService: ObservableObject {
2223
private init() {
2324
controller = SPUStandardUpdaterController(
2425
startingUpdater: true,
25-
updaterDelegate: nil,
26+
updaterDelegate: delegate,
2627
userDriverDelegate: nil
2728
)
2829

@@ -42,3 +43,33 @@ final class UpdateService: ObservableObject {
4243
controller.updater.checkForUpdates()
4344
}
4445
}
46+
47+
// MARK: - Update Delegate
48+
49+
/// Separate delegate class to handle Sparkle callbacks.
50+
/// This avoids Swift init order issues (can't pass self to constructor before init).
51+
private final class UpdateServiceDelegate: NSObject, SPUUpdaterDelegate {
52+
53+
func updaterWillRelaunchApplication(_ updater: SPUUpdater) {
54+
// Set flag so post-update launch can detect this was an auto-update relaunch
55+
// Using synchronize() to ensure it's written before the process terminates
56+
UserDefaults.standard.set(true, forKey: kPostUpdateRelaunchKey)
57+
UserDefaults.standard.synchronize()
58+
}
59+
}
60+
61+
// MARK: - Post-Update Detection
62+
63+
/// File-level constant to avoid Swift concurrency issues with accessing MainActor-isolated properties
64+
private let kPostUpdateRelaunchKey = "OpenDictation_PostUpdateRelaunch"
65+
66+
extension UpdateService {
67+
/// Checks and clears the post-update relaunch flag
68+
static func consumePostUpdateFlag() -> Bool {
69+
let value = UserDefaults.standard.bool(forKey: kPostUpdateRelaunchKey)
70+
if value {
71+
UserDefaults.standard.removeObject(forKey: kPostUpdateRelaunchKey)
72+
}
73+
return value
74+
}
75+
}

0 commit comments

Comments
 (0)