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