Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 66 additions & 14 deletions AudioType/App/AudioTypeApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down Expand Up @@ -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
}
}

Expand All @@ -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)
Expand Down
61 changes: 59 additions & 2 deletions AudioType/App/MenuBarController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)

Expand Down Expand Up @@ -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(
Expand Down
9 changes: 9 additions & 0 deletions AudioType/Core/HotKeyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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
}

Expand Down
83 changes: 75 additions & 8 deletions AudioType/UI/OnboardingView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
}
}
}
2 changes: 1 addition & 1 deletion AudioType/UI/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ struct SettingsView: View {
HStack {
Text("Version")
Spacer()
Text("2.4.0")
Text("2.4.1")
.foregroundColor(.secondary)
}

Expand Down
4 changes: 2 additions & 2 deletions Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@
<key>CFBundleDisplayName</key>
<string>AudioType</string>
<key>CFBundleVersion</key>
<string>2.4.0</string>
<string>2.4.1</string>
<key>CFBundleShortVersionString</key>
<string>2.4.0</string>
<string>2.4.1</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>LSMinimumSystemVersion</key>
Expand Down
Loading