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