From 2d8b5e599cbbcfb8e2067a00d99fad6d695e9f4f Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Tue, 19 May 2026 11:35:13 +0530 Subject: [PATCH] fix(v2.4.1): recover from cdhash mismatch, menu bar binding label, tap-failure UX Five fixes bundled into one patch release. Motivated by silent breakage when v2.4.0 binary's ad-hoc signature doesn't match the Accessibility TCC entry from a prior install: the hotkey listener never starts and nothing tells the user why. - Menu bar 'Hotkey: Hold fn' is now driven by HotKeyBindingStore and updates live when the user rebinds in Settings. - Onboarding polls permission state at 2s instead of 0.5s and stops hammering TCC. As soon as every requirement lands it auto-completes and starts the hotkey listener, so the user doesn't have to click 'Get Started' for fn to begin working. - New 'onboardingCompleted' UserDefaults flag. When set and only AX is missing on launch, show a slim ReapproveAccessibilityView (open the pane, auto-dismiss when access returns) instead of the full first-run flow. Common path on every release because each ad-hoc-signed build has a new cdhash. - HotKeyManager now posts hotKeyTapCreationFailed when CGEvent.tapCreate returns nil. MenuBarController catches that, switches to the error icon, sets the status item to 'Accessibility permission required', and turns the hotkey menu row into a clickable 'Open Accessibility Settings\u2026' shortcut. - Bump version to 2.4.1. --- AudioType/App/AudioTypeApp.swift | 80 +++++++++++++++++++++----- AudioType/App/MenuBarController.swift | 61 +++++++++++++++++++- AudioType/Core/HotKeyManager.swift | 9 +++ AudioType/UI/OnboardingView.swift | 83 ++++++++++++++++++++++++--- AudioType/UI/SettingsView.swift | 2 +- Resources/Info.plist | 4 +- 6 files changed, 212 insertions(+), 27 deletions(-) diff --git a/AudioType/App/AudioTypeApp.swift b/AudioType/App/AudioTypeApp.swift index 3054019..dbe8f6d 100644 --- a/AudioType/App/AudioTypeApp.swift +++ b/AudioType/App/AudioTypeApp.swift @@ -14,6 +14,8 @@ struct AudioTypeApp: App { @MainActor class AppDelegate: NSObject, NSApplicationDelegate { + static let onboardingCompletedKey = "onboardingCompleted" + private var statusItem: NSStatusItem! private var menuBarController: MenuBarController! private var transcriptionManager: TranscriptionManager! @@ -47,15 +49,47 @@ class AppDelegate: NSObject, NSApplicationDelegate { private func checkPermissions() async { let micPermission = await Permissions.checkMicrophone() let accessibilityPermission = Permissions.checkAccessibility() + let engineAvailable = EngineResolver.anyEngineAvailable + let allGood = micPermission && accessibilityPermission && engineAvailable + + if allGood { + markOnboardingCompleted() + await transcriptionManager.initialize() + return + } - // Show onboarding if permissions are missing or no engine is usable - if !micPermission || !accessibilityPermission || !EngineResolver.anyEngineAvailable { + // If the user already completed onboarding before but accessibility is + // now failing, it's almost always a cdhash mismatch after a fresh release + // (TCC.db still says granted, but AXIsProcessTrusted returns false because + // the saved cdhash no longer matches the ad-hoc-signed binary). Show a + // narrow re-approval prompt instead of the full first-run onboarding. + let onboardingDone = UserDefaults.standard.bool(forKey: Self.onboardingCompletedKey) + if onboardingDone && micPermission && engineAvailable && !accessibilityPermission { await MainActor.run { - showOnboarding() + showReapprovePrompt() } - } else { - // All set — start listening for hotkey - await transcriptionManager.initialize() + return + } + + await MainActor.run { + showOnboarding() + } + } + + fileprivate func markOnboardingCompleted() { + UserDefaults.standard.set(true, forKey: Self.onboardingCompletedKey) + } + + fileprivate func startTranscriptionIfNeeded() { + Task { + await self.transcriptionManager.initialize() + } + } + + fileprivate func dismissOnboarding() { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.onboardingWindow?.orderOut(nil) + self.onboardingWindow = nil } } @@ -75,14 +109,32 @@ class AppDelegate: NSObject, NSApplicationDelegate { window.contentView = NSHostingView( rootView: OnboardingView { [weak self] in - // Delay to let animations complete before releasing - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self?.onboardingWindow?.orderOut(nil) - self?.onboardingWindow = nil - } - Task { - await self?.transcriptionManager.initialize() - } + guard let self = self else { return } + self.markOnboardingCompleted() + self.dismissOnboarding() + self.startTranscriptionIfNeeded() + }) + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + private func showReapprovePrompt() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 280), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.title = "AudioType needs re-approval" + window.center() + window.isReleasedWhenClosed = false + self.onboardingWindow = window + + window.contentView = NSHostingView( + rootView: ReapproveAccessibilityView { [weak self] in + guard let self = self else { return } + self.dismissOnboarding() + self.startTranscriptionIfNeeded() }) window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) diff --git a/AudioType/App/MenuBarController.swift b/AudioType/App/MenuBarController.swift index 57f99f4..87bb45e 100644 --- a/AudioType/App/MenuBarController.swift +++ b/AudioType/App/MenuBarController.swift @@ -75,6 +75,23 @@ class MenuBarController: NSObject, NSWindowDelegate { name: .audioLevelChanged, object: nil ) + + // Refresh the hotkey menu item when the binding changes from Settings. + NotificationCenter.default.addObserver( + self, + selector: #selector(hotKeyBindingDidChange), + name: HotKeyBindingStore.didChangeNotification, + object: nil + ) + + // Surface a clear error in the menu bar when the event tap can't be + // created — almost always an Accessibility permission problem. + NotificationCenter.default.addObserver( + self, + selector: #selector(hotKeyTapFailed), + name: .hotKeyTapCreationFailed, + object: nil + ) } deinit { @@ -96,8 +113,13 @@ class MenuBarController: NSObject, NSWindowDelegate { statusMenuItem.tag = 100 // Tag to identify status item menu.addItem(statusMenuItem) - // Hotkey info - let hotkeyItem = NSMenuItem(title: "Hotkey: Hold fn", action: nil, keyEquivalent: "") + // Hotkey info (live-updated when the binding changes in Settings) + let hotkeyItem = NSMenuItem( + title: hotkeyMenuTitle(), + action: nil, + keyEquivalent: "" + ) + hotkeyItem.tag = 101 hotkeyItem.isEnabled = false menu.addItem(hotkeyItem) @@ -167,6 +189,41 @@ class MenuBarController: NSObject, NSWindowDelegate { } } + private func hotkeyMenuTitle() -> String { + "Hotkey: Hold \(HotKeyBindingStore.current.displayName)" + } + + @objc private func hotKeyBindingDidChange() { + DispatchQueue.main.async { [weak self] in + guard let self = self, + let menu = self.statusItem?.menu, + let item = menu.item(withTag: 101) + else { return } + item.title = self.hotkeyMenuTitle() + } + } + + @objc private func hotKeyTapFailed() { + DispatchQueue.main.async { [weak self] in + guard let self = self, let button = self.statusItem?.button else { return } + button.image = self.errorIcon + self.updateStatusMenuItem("Accessibility permission required") + self.replaceHotkeyItemWithAccessibilityFix() + } + } + + private func replaceHotkeyItemWithAccessibilityFix() { + guard let menu = statusItem?.menu, let item = menu.item(withTag: 101) else { return } + item.title = "Open Accessibility Settings…" + item.action = #selector(openAccessibilitySettingsFromMenu) + item.target = self + item.isEnabled = true + } + + @objc private func openAccessibilitySettingsFromMenu() { + Permissions.openAccessibilitySettings() + } + private func showRecordingIndicator() { if recordingWindow == nil { let window = NSWindow( diff --git a/AudioType/Core/HotKeyManager.swift b/AudioType/Core/HotKeyManager.swift index 1a2564c..210283d 100644 --- a/AudioType/Core/HotKeyManager.swift +++ b/AudioType/Core/HotKeyManager.swift @@ -8,6 +8,12 @@ enum HotKeyEvent { case keyUp } +extension Notification.Name { + /// Posted when `CGEvent.tapCreate` fails, almost always because Accessibility + /// permission isn't actually trusted for the running binary. + static let hotKeyTapCreationFailed = Notification.Name("hotKeyTapCreationFailed") +} + class HotKeyManager { private var eventTap: CFMachPort? private var runLoopSource: CFRunLoopSource? @@ -79,6 +85,9 @@ class HotKeyManager { retained.release() refconRetained = nil logger.error("Failed to create event tap. Accessibility permission may be required.") + // Surface the failure so the menu bar can show an error state and the + // user understands the hotkey isn't going to work. + NotificationCenter.default.post(name: .hotKeyTapCreationFailed, object: nil) return } diff --git a/AudioType/UI/OnboardingView.swift b/AudioType/UI/OnboardingView.swift index 8155343..1d39300 100644 --- a/AudioType/UI/OnboardingView.swift +++ b/AudioType/UI/OnboardingView.swift @@ -9,8 +9,12 @@ struct OnboardingView: View { @State private var anyCloudKeyConfigured = GroqEngine.isConfigured || OpenAIEngine.isConfigured @State private var apiKeyText = "" @State private var apiKeySaveError: String? + @State private var didAutoComplete = false - let timer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect() + // Poll every 2 seconds to pick up permission changes the user made in + // System Settings. Cheaper than the previous 0.5s cadence and we stop + // polling entirely once everything's ready (see onReceive below). + let timer = Timer.publish(every: 2.0, on: .main, in: .common).autoconnect() let onComplete: () -> Void @@ -164,16 +168,26 @@ struct OnboardingView: View { checkPermissions() } .onReceive(timer) { _ in - // Continuously refresh permission state so the UI reflects changes made - // in System Settings. The user closes the window themselves via the - // "Get Started" button once everything is ready. - microphoneGranted = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized - accessibilityGranted = Permissions.checkAccessibility() - speechRecognitionGranted = Permissions.isSpeechRecognitionAuthorized - anyCloudKeyConfigured = GroqEngine.isConfigured || OpenAIEngine.isConfigured + refreshPermissionState() + + // Once every required permission lands, finish onboarding automatically. + // This avoids the trap where the user grants AX in System Settings, + // returns to AudioType, but the hotkey listener never starts because + // they haven't clicked "Get Started" yet. + if canContinue && !didAutoComplete { + didAutoComplete = true + onComplete() + } } } + private func refreshPermissionState() { + microphoneGranted = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized + accessibilityGranted = Permissions.checkAccessibility() + speechRecognitionGranted = Permissions.isSpeechRecognitionAuthorized + anyCloudKeyConfigured = GroqEngine.isConfigured || OpenAIEngine.isConfigured + } + /// The user can proceed once mic + accessibility are granted AND at least one engine is usable. private var canContinue: Bool { microphoneGranted && accessibilityGranted @@ -257,3 +271,56 @@ struct PermissionRow: View { .padding(.vertical, 8) } } + +/// Slim follow-up shown after a re-install/update when the user has already +/// granted Accessibility before, but the new binary's cdhash doesn't match +/// the saved TCC entry so `AXIsProcessTrusted` returns false. Recovering +/// only requires removing + re-adding AudioType in Accessibility settings. +struct ReapproveAccessibilityView: View { + let onApproved: () -> Void + + // Poll every 2 seconds and dismiss as soon as AX trust comes back. + let timer = Timer.publish(every: 2.0, on: .main, in: .common).autoconnect() + @State private var didFinish = false + + var body: some View { + VStack(spacing: 20) { + Image(systemName: "lock.shield") + .font(.system(size: 40)) + .foregroundColor(AudioTypeTheme.coral) + + Text("AudioType needs re-approval") + .font(.title3) + .fontWeight(.semibold) + + Text( + "After updating AudioType, macOS needs you to re-approve " + + "Accessibility access. In System Settings, remove AudioType from " + + "the Accessibility list and add it back from /Applications." + ) + .font(.callout) + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + .padding(.horizontal) + .fixedSize(horizontal: false, vertical: true) + + Button("Open Accessibility Settings") { + Permissions.openAccessibilitySettings() + } + .buttonStyle(.borderedProminent) + .tint(AudioTypeTheme.coral) + + Text("This window closes itself when access is restored.") + .font(.caption) + .foregroundColor(.secondary) + } + .padding(24) + .frame(width: 420) + .onReceive(timer) { _ in + if Permissions.checkAccessibility() && !didFinish { + didFinish = true + onApproved() + } + } + } +} diff --git a/AudioType/UI/SettingsView.swift b/AudioType/UI/SettingsView.swift index 3ca2c5c..0f5e812 100644 --- a/AudioType/UI/SettingsView.swift +++ b/AudioType/UI/SettingsView.swift @@ -238,7 +238,7 @@ struct SettingsView: View { HStack { Text("Version") Spacer() - Text("2.4.0") + Text("2.4.1") .foregroundColor(.secondary) } diff --git a/Resources/Info.plist b/Resources/Info.plist index 09701e6..afb86a3 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -11,9 +11,9 @@ CFBundleDisplayName AudioType CFBundleVersion - 2.4.0 + 2.4.1 CFBundleShortVersionString - 2.4.0 + 2.4.1 CFBundlePackageType APPL LSMinimumSystemVersion