Complete Phase 4: Comprehensive preferences, localization, and UX polish
- Rename application from MenuWhisper to Tell me with new domain com.fmartingr.tellme - Implement comprehensive preferences window with 6 tabs (General, Models, Text Insertion, Interface, Advanced, Permissions) - Add full English/Spanish localization for all UI elements - Create functional onboarding flow with model download capability - Implement preview dialog for transcription editing - Add settings export/import functionality - Fix HUD content display issues and add comprehensive permission checking - Enhance build scripts and app bundle creation for proper localization support
This commit is contained in:
parent
7ba5895406
commit
54c3b65d4a
25 changed files with 3086 additions and 235 deletions
|
|
@ -1,10 +1,11 @@
|
|||
import SwiftUI
|
||||
import CoreUtils
|
||||
import MenuWhisperAudio
|
||||
import TellMeAudio
|
||||
import CorePermissions
|
||||
import CoreSTT
|
||||
import CoreModels
|
||||
import CoreInjection
|
||||
import CoreSettings
|
||||
import AVFoundation
|
||||
|
||||
public class AppController: ObservableObject {
|
||||
|
|
@ -14,16 +15,21 @@ public class AppController: ObservableObject {
|
|||
private let hotkeyManager = HotkeyManager()
|
||||
private let audioEngine = AudioEngine()
|
||||
private let permissionManager = PermissionManager()
|
||||
private let soundManager = SoundManager()
|
||||
private let soundManager: SoundManager
|
||||
private let textInjector: TextInjector
|
||||
|
||||
// Settings
|
||||
public let settings = Settings()
|
||||
|
||||
// STT components
|
||||
public let whisperEngine = WhisperCPPEngine(numThreads: 4, useGPU: true)
|
||||
public let whisperEngine: WhisperCPPEngine
|
||||
public var modelManager: ModelManager!
|
||||
|
||||
// UI components
|
||||
private var hudWindow: HUDWindow?
|
||||
private var preferencesWindow: PreferencesWindowController?
|
||||
private var onboardingWindow: OnboardingWindowController?
|
||||
private var helpWindow: HelpWindowController?
|
||||
private var statusItem: NSStatusItem?
|
||||
|
||||
// State management
|
||||
|
|
@ -32,9 +38,13 @@ public class AppController: ObservableObject {
|
|||
|
||||
// Dictation timer
|
||||
private var dictationTimer: Timer?
|
||||
private let maxDictationDuration: TimeInterval = 600 // 10 minutes default
|
||||
|
||||
public init() {
|
||||
whisperEngine = WhisperCPPEngine(
|
||||
numThreads: settings.processingThreads,
|
||||
useGPU: true
|
||||
)
|
||||
soundManager = SoundManager(settings: settings)
|
||||
textInjector = TextInjector(permissionManager: permissionManager)
|
||||
setupDelegates()
|
||||
setupNotifications()
|
||||
|
|
@ -89,6 +99,10 @@ public class AppController: ObservableObject {
|
|||
public func start() {
|
||||
logger.info("Starting app controller")
|
||||
|
||||
// Debug: Check localization
|
||||
let testString = NSLocalizedString("preferences.title", comment: "Test")
|
||||
logger.info("Localization test - preferences.title: '\(testString)'")
|
||||
|
||||
// Setup status item menu on main actor
|
||||
Task { @MainActor in
|
||||
setupStatusItemMenu()
|
||||
|
|
@ -110,14 +124,14 @@ public class AppController: ObservableObject {
|
|||
@MainActor
|
||||
private func setupStatusItemMenu() {
|
||||
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
|
||||
statusItem?.button?.image = NSImage(systemSymbolName: "mic", accessibilityDescription: "MenuWhisper")
|
||||
statusItem?.button?.image = NSImage(systemSymbolName: "mic", accessibilityDescription: "Tell me")
|
||||
statusItem?.button?.imagePosition = .imageOnly
|
||||
|
||||
let menu = NSMenu()
|
||||
|
||||
// Status item
|
||||
let statusMenuItem = NSMenuItem()
|
||||
statusMenuItem.title = "MenuWhisper"
|
||||
statusMenuItem.title = NSLocalizedString("app.name", comment: "App name")
|
||||
statusMenuItem.isEnabled = false
|
||||
menu.addItem(statusMenuItem)
|
||||
|
||||
|
|
@ -125,19 +139,33 @@ public class AppController: ObservableObject {
|
|||
|
||||
// Model status
|
||||
let modelMenuItem = NSMenuItem()
|
||||
modelMenuItem.title = "Loading model..."
|
||||
modelMenuItem.title = NSLocalizedString("menubar.loading_model", comment: "Loading model...")
|
||||
modelMenuItem.isEnabled = false
|
||||
menu.addItem(modelMenuItem)
|
||||
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
|
||||
// Preferences
|
||||
let preferencesMenuItem = NSMenuItem(title: "Preferences...", action: #selector(openPreferences), keyEquivalent: ",")
|
||||
let preferencesMenuItem = NSMenuItem(title: NSLocalizedString("menubar.preferences", comment: "Preferences..."), action: #selector(openPreferences), keyEquivalent: ",")
|
||||
preferencesMenuItem.target = self
|
||||
menu.addItem(preferencesMenuItem)
|
||||
|
||||
// Help
|
||||
let helpMenuItem = NSMenuItem(title: NSLocalizedString("menubar.help", comment: "Help"), action: #selector(openHelp), keyEquivalent: "?")
|
||||
helpMenuItem.target = self
|
||||
menu.addItem(helpMenuItem)
|
||||
|
||||
// Debug: Reset Onboarding (only in development)
|
||||
#if DEBUG
|
||||
let resetOnboardingMenuItem = NSMenuItem(title: NSLocalizedString("menubar.reset_onboarding", comment: "Reset Onboarding"), action: #selector(resetOnboarding), keyEquivalent: "")
|
||||
resetOnboardingMenuItem.target = self
|
||||
menu.addItem(resetOnboardingMenuItem)
|
||||
#endif
|
||||
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
|
||||
// Quit
|
||||
let quitMenuItem = NSMenuItem(title: "Quit MenuWhisper", action: #selector(quitApp), keyEquivalent: "q")
|
||||
let quitMenuItem = NSMenuItem(title: NSLocalizedString("menubar.quit", comment: "Quit Tell me"), action: #selector(quitApp), keyEquivalent: "q")
|
||||
quitMenuItem.target = self
|
||||
menu.addItem(quitMenuItem)
|
||||
|
||||
|
|
@ -153,6 +181,24 @@ public class AppController: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
@objc private func openHelp() {
|
||||
Task { @MainActor in
|
||||
showHelp()
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
@objc private func resetOnboarding() {
|
||||
UserDefaults.standard.removeObject(forKey: "hasShownPermissionOnboarding")
|
||||
logger.info("Onboarding status reset - will show on next startup or permission check")
|
||||
|
||||
// Optionally show onboarding immediately
|
||||
Task { @MainActor in
|
||||
showPermissionOnboarding()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@objc private func quitApp() {
|
||||
NSApplication.shared.terminate(nil)
|
||||
}
|
||||
|
|
@ -165,11 +211,11 @@ public class AppController: ObservableObject {
|
|||
let modelMenuItem = menu.items[2] // Model status item
|
||||
|
||||
if let activeModel = modelManager?.activeModel, whisperEngine.isModelLoaded() {
|
||||
modelMenuItem.title = "Model: \(activeModel.name)"
|
||||
modelMenuItem.title = String(format: NSLocalizedString("menubar.model_status", comment: "Model status"), activeModel.name)
|
||||
} else if modelManager?.activeModel != nil {
|
||||
modelMenuItem.title = "Model: Loading..."
|
||||
modelMenuItem.title = NSLocalizedString("menubar.model_loading", comment: "Model loading")
|
||||
} else {
|
||||
modelMenuItem.title = "No model - click Preferences"
|
||||
modelMenuItem.title = NSLocalizedString("menubar.no_model", comment: "No model")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -222,21 +268,28 @@ public class AppController: ObservableObject {
|
|||
|
||||
@MainActor
|
||||
private func showPermissionOnboarding() {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Welcome to MenuWhisper"
|
||||
alert.informativeText = "MenuWhisper needs some permissions to work properly:\n\n• Microphone: To capture your speech\n• Accessibility: To insert transcribed text\n• Input Monitoring: To send keyboard events\n\nWould you like to set up permissions now?"
|
||||
alert.alertStyle = .informational
|
||||
alert.addButton(withTitle: "Set Up Permissions")
|
||||
alert.addButton(withTitle: "Later")
|
||||
|
||||
let response = alert.runModal()
|
||||
|
||||
// Mark that we've shown the onboarding
|
||||
UserDefaults.standard.set(true, forKey: "hasShownPermissionOnboarding")
|
||||
|
||||
if response == .alertFirstButtonReturn {
|
||||
showPreferences(initialTab: 1) // Open Permissions tab
|
||||
guard let modelManager = modelManager else {
|
||||
logger.error("ModelManager not initialized for onboarding")
|
||||
return
|
||||
}
|
||||
|
||||
onboardingWindow = OnboardingWindowController(
|
||||
permissionManager: permissionManager,
|
||||
modelManager: modelManager,
|
||||
whisperEngine: whisperEngine,
|
||||
onComplete: { [weak self] in
|
||||
// Mark that we've shown the onboarding
|
||||
UserDefaults.standard.set(true, forKey: "hasShownPermissionOnboarding")
|
||||
|
||||
// Clear the reference after a brief delay to allow window to close
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
self?.onboardingWindow = nil
|
||||
}
|
||||
}
|
||||
)
|
||||
onboardingWindow?.showWindow(nil)
|
||||
onboardingWindow?.window?.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
private func checkMicrophonePermission(completion: @escaping (Bool) -> Void) {
|
||||
|
|
@ -252,12 +305,53 @@ public class AppController: ObservableObject {
|
|||
cancelDictation()
|
||||
}
|
||||
|
||||
private func checkAllRequiredPermissions() -> Bool {
|
||||
// Refresh permission status first (they might have been granted since startup)
|
||||
permissionManager.checkAllPermissions()
|
||||
|
||||
// Check microphone permission (critical for audio capture)
|
||||
if permissionManager.microphoneStatus != .granted {
|
||||
logger.warning("Microphone permission not granted")
|
||||
Task { @MainActor in
|
||||
showMicrophonePermissionAlert()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Check accessibility permission (required for text insertion)
|
||||
if permissionManager.accessibilityStatus != .granted {
|
||||
logger.warning("Accessibility permission not granted")
|
||||
Task { @MainActor in
|
||||
showAccessibilityPermissionAlert()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Input monitoring (required for hotkeys and text insertion)
|
||||
if permissionManager.inputMonitoringStatus != .granted {
|
||||
logger.warning("Input monitoring permission not granted")
|
||||
Task { @MainActor in
|
||||
showInputMonitoringPermissionAlert()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
logger.info("All required permissions granted")
|
||||
return true
|
||||
}
|
||||
|
||||
private func startListening() {
|
||||
guard currentState == .idle else {
|
||||
logger.warning("Cannot start listening from state: \(currentState)")
|
||||
return
|
||||
}
|
||||
|
||||
// Check all required permissions before starting
|
||||
let allPermissionsGranted = checkAllRequiredPermissions()
|
||||
guard allPermissionsGranted else {
|
||||
return // Permission alerts will be shown by checkAllRequiredPermissions
|
||||
}
|
||||
|
||||
// Check if a model is loaded before starting
|
||||
guard whisperEngine.isModelLoaded() else {
|
||||
logger.warning("No model loaded - showing setup alert")
|
||||
|
|
@ -324,7 +418,7 @@ public class AppController: ObservableObject {
|
|||
}
|
||||
|
||||
let startTime = Date()
|
||||
let transcription = try await whisperEngine.transcribe(audioData: audioData, language: "auto")
|
||||
let transcription = try await whisperEngine.transcribe(audioData: audioData, language: settings.forcedLanguage ?? "auto")
|
||||
let duration = Date().timeIntervalSince(startTime)
|
||||
|
||||
logger.info("Transcription completed in \(String(format: "%.2f", duration))s: \"\(transcription)\"")
|
||||
|
|
@ -345,10 +439,37 @@ public class AppController: ObservableObject {
|
|||
private func injectTranscriptionResult(_ text: String) {
|
||||
logger.info("Attempting to inject transcription result: \(text)")
|
||||
|
||||
// Check if preview is enabled
|
||||
if settings.showPreview {
|
||||
showPreviewDialog(text: text)
|
||||
} else {
|
||||
performTextInjection(text)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func showPreviewDialog(text: String) {
|
||||
let previewDialog = PreviewDialogController(
|
||||
text: text,
|
||||
onInsert: { [weak self] editedText in
|
||||
self?.performTextInjection(editedText)
|
||||
},
|
||||
onCancel: { [weak self] in
|
||||
self?.finishProcessing()
|
||||
}
|
||||
)
|
||||
previewDialog.showWindow(nil)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func performTextInjection(_ text: String) {
|
||||
do {
|
||||
// Attempt to inject the text using paste method with fallback enabled
|
||||
try textInjector.injectText(text, method: .paste, enableFallback: true)
|
||||
logger.info("Text injection successful")
|
||||
// Determine injection method from settings
|
||||
let injectionMethod: InjectionMethod = settings.insertionMethod == .paste ? .paste : .typing
|
||||
|
||||
// Attempt to inject the text using the configured method
|
||||
try textInjector.injectText(text, method: injectionMethod, enableFallback: true)
|
||||
logger.info("Text injection successful using \(injectionMethod) method")
|
||||
|
||||
// Show success and finish processing
|
||||
finishProcessing()
|
||||
|
|
@ -403,7 +524,7 @@ public class AppController: ObservableObject {
|
|||
private func startDictationTimer() {
|
||||
stopDictationTimer() // Clean up any existing timer
|
||||
|
||||
dictationTimer = Timer.scheduledTimer(withTimeInterval: maxDictationDuration, repeats: false) { [weak self] _ in
|
||||
dictationTimer = Timer.scheduledTimer(withTimeInterval: settings.dictationTimeLimit, repeats: false) { [weak self] _ in
|
||||
self?.logger.info("Dictation timeout reached")
|
||||
self?.stopListening()
|
||||
}
|
||||
|
|
@ -416,7 +537,7 @@ public class AppController: ObservableObject {
|
|||
|
||||
private func showHUD(state: HUDState) {
|
||||
if hudWindow == nil {
|
||||
hudWindow = HUDWindow()
|
||||
hudWindow = HUDWindow(settings: settings)
|
||||
}
|
||||
hudWindow?.show(state: state)
|
||||
}
|
||||
|
|
@ -445,7 +566,7 @@ public class AppController: ObservableObject {
|
|||
private func showPermissionRequiredNotice() {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Permission Required"
|
||||
alert.informativeText = "MenuWhisper needs Accessibility and Input Monitoring permissions to insert text into other applications.\n\nWould you like to open System Settings to grant these permissions?"
|
||||
alert.informativeText = "Tell me needs Accessibility and Input Monitoring permissions to insert text into other applications.\n\nWould you like to open System Settings to grant these permissions?"
|
||||
alert.alertStyle = .warning
|
||||
alert.addButton(withTitle: "Open System Settings")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
|
|
@ -478,6 +599,7 @@ public class AppController: ObservableObject {
|
|||
modelManager: modelManager,
|
||||
whisperEngine: whisperEngine,
|
||||
permissionManager: permissionManager,
|
||||
settings: settings,
|
||||
initialTab: initialTab
|
||||
)
|
||||
} else {
|
||||
|
|
@ -490,11 +612,67 @@ public class AppController: ObservableObject {
|
|||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public func showHelp() {
|
||||
if helpWindow == nil {
|
||||
helpWindow = HelpWindowController()
|
||||
}
|
||||
|
||||
helpWindow?.showWindow(nil)
|
||||
helpWindow?.window?.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func showMicrophonePermissionAlert() {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = NSLocalizedString("permissions.microphone.title", comment: "Microphone Access Required")
|
||||
alert.informativeText = NSLocalizedString("permissions.microphone.message", comment: "Microphone permission message")
|
||||
alert.alertStyle = .warning
|
||||
alert.addButton(withTitle: NSLocalizedString("permissions.open_settings", comment: "Open System Settings"))
|
||||
alert.addButton(withTitle: NSLocalizedString("general.cancel", comment: "Cancel"))
|
||||
|
||||
let response = alert.runModal()
|
||||
if response == .alertFirstButtonReturn {
|
||||
permissionManager.openSystemSettings(for: .microphone)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func showAccessibilityPermissionAlert() {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = NSLocalizedString("permissions.accessibility.title", comment: "Accessibility Access Required")
|
||||
alert.informativeText = NSLocalizedString("permissions.accessibility.message", comment: "Accessibility permission message")
|
||||
alert.alertStyle = .warning
|
||||
alert.addButton(withTitle: NSLocalizedString("permissions.open_settings", comment: "Open System Settings"))
|
||||
alert.addButton(withTitle: NSLocalizedString("general.cancel", comment: "Cancel"))
|
||||
|
||||
let response = alert.runModal()
|
||||
if response == .alertFirstButtonReturn {
|
||||
permissionManager.openSystemSettings(for: .accessibility)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func showInputMonitoringPermissionAlert() {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = NSLocalizedString("permissions.input_monitoring.title", comment: "Input Monitoring Required")
|
||||
alert.informativeText = NSLocalizedString("permissions.input_monitoring.message", comment: "Input monitoring permission message")
|
||||
alert.alertStyle = .warning
|
||||
alert.addButton(withTitle: NSLocalizedString("permissions.open_settings", comment: "Open System Settings"))
|
||||
alert.addButton(withTitle: NSLocalizedString("general.cancel", comment: "Cancel"))
|
||||
|
||||
let response = alert.runModal()
|
||||
if response == .alertFirstButtonReturn {
|
||||
permissionManager.openSystemSettings(for: .inputMonitoring)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func showModelSetupAlert() {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "No Speech Recognition Model"
|
||||
alert.informativeText = "You need to download and select a speech recognition model before using MenuWhisper.\n\nWould you like to open Preferences to download a model?"
|
||||
alert.informativeText = "You need to download and select a speech recognition model before using Tell me.\n\nWould you like to open Preferences to download a model?"
|
||||
alert.alertStyle = .informational
|
||||
alert.addButton(withTitle: "Open Preferences")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
|
|
@ -511,6 +689,8 @@ public class AppController: ObservableObject {
|
|||
audioEngine.stopCapture()
|
||||
hotkeyManager.disableHotkey()
|
||||
preferencesWindow?.close()
|
||||
onboardingWindow?.close()
|
||||
helpWindow?.close()
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue