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:
Felipe M 2025-09-19 13:55:46 +02:00
parent 7ba5895406
commit 54c3b65d4a
Signed by: fmartingr
GPG key ID: CCFBC5637D4000A8
25 changed files with 3086 additions and 235 deletions

View file

@ -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)
}
}

View file

@ -1,6 +1,7 @@
import SwiftUI
import AppKit
import CoreUtils
import CoreSettings
public enum HUDState {
case hidden
@ -8,12 +9,21 @@ public enum HUDState {
case processing
}
public class HUDWindow: NSPanel {
public class HUDWindow: NSPanel, ObservableObject {
private var hostingView: NSHostingView<HUDContentView>?
private let settings: CoreSettings.Settings
@Published var hudState: HUDState = .hidden
public init(settings: CoreSettings.Settings) {
self.settings = settings
let baseWidth: CGFloat = 320
let baseHeight: CGFloat = 160
let scaledWidth = baseWidth * settings.hudSize
let scaledHeight = baseHeight * settings.hudSize
public init() {
super.init(
contentRect: NSRect(x: 0, y: 0, width: 320, height: 160),
contentRect: NSRect(x: 0, y: 0, width: scaledWidth, height: scaledHeight),
styleMask: [.nonactivatingPanel],
backing: .buffered,
defer: false
@ -33,7 +43,7 @@ public class HUDWindow: NSPanel {
}
private func setupContentView() {
let hudContentView = HUDContentView()
let hudContentView = HUDContentView(settings: settings, hudWindow: self)
hostingView = NSHostingView(rootView: hudContentView)
if let hostingView = hostingView {
@ -44,16 +54,16 @@ public class HUDWindow: NSPanel {
public func show(state: HUDState) {
centerOnScreen()
if let hostingView = hostingView {
hostingView.rootView.updateState(state)
}
// Update the published state
hudState = state
print("HUD showing with state: \(state)")
if !isVisible {
orderFront(nil)
alphaValue = 0
NSAnimationContext.runAnimationGroup({ context in
context.duration = 0.2
animator().alphaValue = 1.0
animator().alphaValue = settings.hudOpacity
})
}
}
@ -70,9 +80,7 @@ public class HUDWindow: NSPanel {
}
public func updateLevel(_ level: Float) {
if let hostingView = hostingView {
hostingView.rootView.updateState(.listening(level: level))
}
hudState = .listening(level: level)
}
private func centerOnScreen() {
@ -105,7 +113,8 @@ extension Notification.Name {
}
struct HUDContentView: View {
@State private var currentState: HUDState = .hidden
@ObservedObject var settings: CoreSettings.Settings
@ObservedObject var hudWindow: HUDWindow
var body: some View {
ZStack {
@ -117,7 +126,7 @@ struct HUDContentView: View {
)
VStack(spacing: 16) {
switch currentState {
switch hudWindow.hudState {
case .hidden:
EmptyView()
@ -130,7 +139,11 @@ struct HUDContentView: View {
}
.padding(24)
}
.frame(width: 320, height: 160)
.frame(width: 320 * settings.hudSize, height: 160 * settings.hudSize)
.scaleEffect(settings.hudSize)
.onAppear {
print("HUD Content View appeared with state: \(hudWindow.hudState)")
}
}
@ViewBuilder
@ -140,14 +153,14 @@ struct HUDContentView: View {
.font(.system(size: 32))
.foregroundColor(.blue)
Text("Listening...")
Text(NSLocalizedString("hud.listening", comment: "Listening..."))
.font(.headline)
.foregroundColor(.primary)
AudioLevelView(level: level)
.frame(height: 20)
Text("Press Esc to cancel")
Text(NSLocalizedString("hud.cancel", comment: "Press Esc to cancel"))
.font(.caption)
.foregroundColor(.secondary)
}
@ -159,21 +172,16 @@ struct HUDContentView: View {
ProgressView()
.scaleEffect(1.2)
Text("Processing...")
Text(NSLocalizedString("hud.processing", comment: "Processing..."))
.font(.headline)
.foregroundColor(.primary)
Text("Please wait")
Text(NSLocalizedString("hud.please_wait", comment: "Please wait"))
.font(.caption)
.foregroundColor(.secondary)
}
}
func updateState(_ state: HUDState) {
withAnimation(.easeInOut(duration: 0.3)) {
currentState = state
}
}
}
struct AudioLevelView: View {

View file

@ -0,0 +1,683 @@
import SwiftUI
class HelpWindowController: NSWindowController {
init() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 700, height: 600),
styleMask: [.titled, .closable, .resizable],
backing: .buffered,
defer: false
)
super.init(window: window)
window.title = "Tell me Help"
window.center()
let helpView = HelpView()
window.contentView = NSHostingView(rootView: helpView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
struct HelpView: View {
@State private var selectedSection = 0
var body: some View {
HSplitView {
// Sidebar
VStack(alignment: .leading, spacing: 0) {
Text("Help Topics")
.font(.headline)
.padding(.horizontal, 16)
.padding(.vertical, 12)
List(selection: $selectedSection) {
HelpSectionItem(title: "Getting Started", systemImage: "play.circle", tag: 0)
HelpSectionItem(title: "Permissions", systemImage: "lock.shield", tag: 1)
HelpSectionItem(title: "Speech Models", systemImage: "brain.head.profile", tag: 2)
HelpSectionItem(title: "Hotkeys & Usage", systemImage: "keyboard", tag: 3)
HelpSectionItem(title: "Troubleshooting", systemImage: "wrench.and.screwdriver", tag: 4)
HelpSectionItem(title: "Privacy & Security", systemImage: "shield.checkered", tag: 5)
}
.listStyle(.sidebar)
}
.frame(minWidth: 200, maxWidth: 300)
// Content area
ScrollView {
VStack(alignment: .leading, spacing: 20) {
switch selectedSection {
case 0:
GettingStartedHelp()
case 1:
PermissionsHelp()
case 2:
ModelsHelp()
case 3:
HotkeysHelp()
case 4:
TroubleshootingHelp()
case 5:
PrivacyHelp()
default:
GettingStartedHelp()
}
}
.padding(24)
}
}
.frame(width: 700, height: 600)
}
}
struct HelpSectionItem: View {
let title: String
let systemImage: String
let tag: Int
var body: some View {
Label(title, systemImage: systemImage)
.tag(tag)
}
}
// MARK: - Help Content Views
struct GettingStartedHelp: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Getting Started with Tell me")
.font(.largeTitle)
.fontWeight(.bold)
Text("Tell me is a privacy-focused speech-to-text application that works completely offline on your Mac.")
.font(.body)
VStack(alignment: .leading, spacing: 12) {
Text("Quick Setup")
.font(.headline)
HelpStep(
number: 1,
title: "Grant Permissions",
description: "Allow Microphone, Accessibility, and Input Monitoring access when prompted."
)
HelpStep(
number: 2,
title: "Download a Model",
description: "Go to Preferences → Models and download a speech recognition model (recommended: whisper-small)."
)
HelpStep(
number: 3,
title: "Start Dictating",
description: "Press ⌘⇧V anywhere to start dictation. The HUD will appear to show status."
)
HelpStep(
number: 4,
title: "Stop and Insert",
description: "Release the hotkey (push-to-talk mode) or press it again (toggle mode) to transcribe and insert text."
)
}
InfoBox(
title: "First Time Setup",
content: "If this is your first time, Tell me will guide you through the setup process automatically.",
type: .info
)
}
}
}
struct PermissionsHelp: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Permissions")
.font(.largeTitle)
.fontWeight(.bold)
Text("Tell me requires specific system permissions to function properly.")
.font(.body)
VStack(alignment: .leading, spacing: 16) {
PermissionHelpItem(
title: "Microphone",
description: "Required to capture your speech for transcription.",
required: true,
howToGrant: "Grant when prompted, or go to System Settings → Privacy & Security → Microphone."
)
PermissionHelpItem(
title: "Accessibility",
description: "Required to insert transcribed text into other applications.",
required: true,
howToGrant: "Go to System Settings → Privacy & Security → Accessibility and add Tell me."
)
PermissionHelpItem(
title: "Input Monitoring",
description: "Required to register global keyboard shortcuts and send text insertion events.",
required: true,
howToGrant: "Go to System Settings → Privacy & Security → Input Monitoring and add Tell me."
)
}
InfoBox(
title: "Permission Issues",
content: "If permissions aren't working, try restarting Tell me after granting them. Some permissions may require logging out and back in.",
type: .warning
)
}
}
}
struct ModelsHelp: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Speech Recognition Models")
.font(.largeTitle)
.fontWeight(.bold)
Text("Tell me uses OpenAI Whisper models for speech recognition. All models work entirely offline.")
.font(.body)
VStack(alignment: .leading, spacing: 12) {
Text("Available Models")
.font(.headline)
ModelHelpItem(
name: "whisper-tiny",
size: "39 MB",
ram: "~400 MB",
speed: "Very Fast",
accuracy: "Basic",
recommendation: "Good for testing or very low-end hardware"
)
ModelHelpItem(
name: "whisper-base",
size: "142 MB",
ram: "~500 MB",
speed: "Fast",
accuracy: "Good",
recommendation: "Recommended for most users"
)
ModelHelpItem(
name: "whisper-small",
size: "466 MB",
ram: "~1 GB",
speed: "Medium",
accuracy: "Very Good",
recommendation: "Best balance of speed and accuracy"
)
}
InfoBox(
title: "Model Storage",
content: "Models are stored in ~/Library/Application Support/Tell me/Models and can be deleted from Preferences to free up space.",
type: .info
)
InfoBox(
title: "No Internet Required",
content: "Once downloaded, models work completely offline. Your speech never leaves your device.",
type: .success
)
}
}
}
struct HotkeysHelp: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Hotkeys & Usage")
.font(.largeTitle)
.fontWeight(.bold)
Text("Tell me supports global hotkeys to start dictation from anywhere on your system.")
.font(.body)
VStack(alignment: .leading, spacing: 12) {
Text("Activation Modes")
.font(.headline)
HelpModeItem(
title: "Push-to-Talk (Default)",
description: "Hold down the hotkey to dictate, release to stop and transcribe."
)
HelpModeItem(
title: "Toggle Mode",
description: "Press once to start dictation, press again to stop and transcribe."
)
}
VStack(alignment: .leading, spacing: 12) {
Text("Default Hotkey")
.font(.headline)
HStack {
Text("⌘⇧V")
.font(.title2)
.fontWeight(.bold)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.gray.opacity(0.2))
.cornerRadius(8)
VStack(alignment: .leading) {
Text("Command + Shift + V")
.font(.body)
.fontWeight(.medium)
Text("Can be changed in Preferences → General")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
VStack(alignment: .leading, spacing: 8) {
Text("Other Controls")
.font(.headline)
HStack {
Text("⎋ Esc")
.font(.body)
.fontWeight(.bold)
.frame(width: 60, alignment: .leading)
Text("Cancel dictation at any time")
}
HStack {
Text("⌘↩ Cmd+Return")
.font(.body)
.fontWeight(.bold)
.frame(width: 120, alignment: .leading)
Text("Insert text (in preview mode)")
}
}
InfoBox(
title: "Hotkey Conflicts",
content: "If your hotkey conflicts with another app, change it in Preferences → General → Hotkey Combination.",
type: .warning
)
}
}
}
struct TroubleshootingHelp: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Troubleshooting")
.font(.largeTitle)
.fontWeight(.bold)
VStack(alignment: .leading, spacing: 16) {
TroubleshootingItem(
issue: "Hotkey doesn't work",
solutions: [
"Check that Input Monitoring permission is granted",
"Try changing the hotkey in Preferences",
"Restart Tell me after granting permissions",
"Make sure no other app is using the same hotkey"
]
)
TroubleshootingItem(
issue: "Text doesn't insert",
solutions: [
"Grant Accessibility permission in System Settings",
"Try switching insertion method in Preferences → Text Insertion",
"Check if you're in a secure input field (password, etc.)",
"Restart Tell me after granting permissions"
]
)
TroubleshootingItem(
issue: "Poor transcription quality",
solutions: [
"Try a larger model (whisper-small or whisper-base)",
"Ensure you're in a quiet environment",
"Speak clearly and at normal pace",
"Check your microphone input level",
"Set the correct language in Preferences → Models"
]
)
TroubleshootingItem(
issue: "App crashes or hangs",
solutions: [
"Check available RAM (models need 400MB-1GB+)",
"Try reducing processing threads in Preferences → Advanced",
"Restart Tell me",
"Try a smaller model if using whisper-medium or larger"
]
)
TroubleshootingItem(
issue: "Microphone not working",
solutions: [
"Grant Microphone permission when prompted",
"Check System Settings → Privacy & Security → Microphone",
"Test microphone in other apps",
"Check input level in System Settings → Sound"
]
)
}
InfoBox(
title: "Still Having Issues?",
content: "Enable logging in Preferences → Advanced and check Console.app for Tell me logs, or try restarting your Mac if permission issues persist.",
type: .info
)
}
}
}
struct PrivacyHelp: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Privacy & Security")
.font(.largeTitle)
.fontWeight(.bold)
VStack(alignment: .leading, spacing: 12) {
Text("100% Offline Operation")
.font(.headline)
Text("Tell me is designed with privacy as the top priority:")
.font(.body)
VStack(alignment: .leading, spacing: 8) {
PrivacyPoint(
icon: "shield.checkered",
text: "Your audio never leaves your device"
)
PrivacyPoint(
icon: "wifi.slash",
text: "No internet connection required for transcription"
)
PrivacyPoint(
icon: "eye.slash",
text: "No telemetry or usage tracking"
)
PrivacyPoint(
icon: "lock.doc",
text: "Transcribed text is not stored or logged"
)
}
}
VStack(alignment: .leading, spacing: 12) {
Text("Local Data Storage")
.font(.headline)
Text("Tell me only stores:")
.font(.body)
VStack(alignment: .leading, spacing: 6) {
Text("• Settings and preferences in UserDefaults")
.font(.body)
Text("• Downloaded models in ~/Library/Application Support/Tell me/")
.font(.body)
Text("• Optional local logs (if enabled, contains only timing and error data)")
.font(.body)
}
}
VStack(alignment: .leading, spacing: 12) {
Text("Secure Input Detection")
.font(.headline)
Text("Tell me automatically detects secure input contexts (like password fields) and disables text insertion to protect your security. In these cases, text is copied to clipboard instead.")
.font(.body)
}
InfoBox(
title: "Open Source",
content: "Tell me is open source software. You can review the code and build it yourself for complete transparency.",
type: .success
)
}
}
}
// MARK: - Helper Views
struct HelpStep: View {
let number: Int
let title: String
let description: String
var body: some View {
HStack(alignment: .top, spacing: 12) {
Text("\(number)")
.font(.title2)
.fontWeight(.bold)
.foregroundColor(.white)
.frame(width: 32, height: 32)
.background(Color.blue)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.body)
.fontWeight(.semibold)
Text(description)
.font(.body)
.foregroundColor(.secondary)
}
}
}
}
struct PermissionHelpItem: View {
let title: String
let description: String
let required: Bool
let howToGrant: String
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(title)
.font(.body)
.fontWeight(.semibold)
if required {
Text("REQUIRED")
.font(.caption)
.fontWeight(.bold)
.foregroundColor(.red)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.red.opacity(0.1))
.cornerRadius(4)
}
}
Text(description)
.font(.body)
.foregroundColor(.secondary)
Text("How to grant: \(howToGrant)")
.font(.caption)
.foregroundColor(.blue)
}
.padding(12)
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(8)
}
}
struct ModelHelpItem: View {
let name: String
let size: String
let ram: String
let speed: String
let accuracy: String
let recommendation: String
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(name)
.font(.body)
.fontWeight(.semibold)
Spacer()
Text(size)
.font(.caption)
.foregroundColor(.secondary)
}
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("RAM: \(ram)")
.font(.caption)
Text("Speed: \(speed)")
.font(.caption)
Text("Accuracy: \(accuracy)")
.font(.caption)
}
Spacer()
}
Text(recommendation)
.font(.caption)
.foregroundColor(.blue)
.italic()
}
.padding(12)
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(8)
}
}
struct HelpModeItem: View {
let title: String
let description: String
var body: some View {
HStack(alignment: .top, spacing: 12) {
Circle()
.fill(Color.blue)
.frame(width: 8, height: 8)
.padding(.top, 6)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.body)
.fontWeight(.semibold)
Text(description)
.font(.body)
.foregroundColor(.secondary)
}
}
}
}
struct TroubleshootingItem: View {
let issue: String
let solutions: [String]
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(issue)
.font(.body)
.fontWeight(.semibold)
VStack(alignment: .leading, spacing: 4) {
ForEach(solutions, id: \.self) { solution in
HStack(alignment: .top, spacing: 8) {
Text("")
.foregroundColor(.blue)
Text(solution)
.font(.body)
.foregroundColor(.secondary)
}
}
}
}
.padding(12)
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(8)
}
}
struct PrivacyPoint: View {
let icon: String
let text: String
var body: some View {
HStack(spacing: 12) {
Image(systemName: icon)
.foregroundColor(.green)
.frame(width: 20)
Text(text)
.font(.body)
}
}
}
struct InfoBox: View {
let title: String
let content: String
let type: InfoType
enum InfoType {
case info, warning, success, error
var color: Color {
switch self {
case .info: return .blue
case .warning: return .orange
case .success: return .green
case .error: return .red
}
}
var icon: String {
switch self {
case .info: return "info.circle"
case .warning: return "exclamationmark.triangle"
case .success: return "checkmark.circle"
case .error: return "xmark.circle"
}
}
}
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: type.icon)
.foregroundColor(type.color)
.font(.title3)
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.body)
.fontWeight(.semibold)
Text(content)
.font(.body)
.foregroundColor(.secondary)
}
}
.padding(16)
.background(type.color.opacity(0.1))
.cornerRadius(12)
}
}

View file

@ -0,0 +1,258 @@
import SwiftUI
import Carbon
import CoreSettings
struct HotkeyRecorder: View {
@Binding var hotkey: HotkeyConfig
@State private var isRecording = false
@State private var recordedKeyCode: UInt32?
@State private var recordedModifiers: UInt32?
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Button(action: {
if isRecording {
stopRecording()
} else {
startRecording()
}
}) {
HStack {
if isRecording {
Text(NSLocalizedString("hotkey.press_keys", comment: "Press keys..."))
.foregroundColor(.primary)
} else {
Text(hotkeyDisplayString)
.foregroundColor(.primary)
}
}
.frame(minWidth: 150, minHeight: 30)
.background(isRecording ? Color.blue.opacity(0.2) : Color(NSColor.controlBackgroundColor))
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(isRecording ? Color.blue : Color.gray.opacity(0.3), lineWidth: 2)
)
.cornerRadius(6)
}
.buttonStyle(.plain)
.onKeyDown { event in
guard isRecording else { return false }
handleKeyEvent(event)
return true
}
if hotkey.keyCode != HotkeyConfig.default.keyCode || hotkey.modifiers != HotkeyConfig.default.modifiers {
Button(NSLocalizedString("hotkey.reset_default", comment: "Reset to Default")) {
hotkey = HotkeyConfig.default
}
.buttonStyle(.borderless)
.controlSize(.small)
.foregroundColor(.secondary)
}
}
Text(NSLocalizedString("hotkey.record_description", comment: "Click to record a new hotkey combination"))
.font(.caption)
.foregroundColor(.secondary)
}
.focusable()
}
private var hotkeyDisplayString: String {
return hotkeyToString(keyCode: hotkey.keyCode, modifiers: hotkey.modifiers)
}
private func startRecording() {
isRecording = true
recordedKeyCode = nil
recordedModifiers = nil
}
private func stopRecording() {
if let keyCode = recordedKeyCode, let modifiers = recordedModifiers {
hotkey = HotkeyConfig(keyCode: keyCode, modifiers: modifiers)
}
isRecording = false
recordedKeyCode = nil
recordedModifiers = nil
}
private func handleKeyEvent(_ event: NSEvent) {
let keyCode = event.keyCode
let modifierFlags = event.modifierFlags
// Convert NSEvent modifier flags to Carbon modifier flags
var carbonModifiers: UInt32 = 0
if modifierFlags.contains(.command) {
carbonModifiers |= UInt32(cmdKey)
}
if modifierFlags.contains(.shift) {
carbonModifiers |= UInt32(shiftKey)
}
if modifierFlags.contains(.option) {
carbonModifiers |= UInt32(optionKey)
}
if modifierFlags.contains(.control) {
carbonModifiers |= UInt32(controlKey)
}
// Only accept combinations with at least one modifier
guard carbonModifiers != 0 else { return }
recordedKeyCode = UInt32(keyCode)
recordedModifiers = carbonModifiers
// Auto-stop recording after a brief delay
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
if isRecording {
stopRecording()
}
}
}
}
// MARK: - Helper Functions
private func hotkeyToString(keyCode: UInt32, modifiers: UInt32) -> String {
var result = ""
// Add modifier symbols
if modifiers & UInt32(controlKey) != 0 {
result += ""
}
if modifiers & UInt32(optionKey) != 0 {
result += ""
}
if modifiers & UInt32(shiftKey) != 0 {
result += ""
}
if modifiers & UInt32(cmdKey) != 0 {
result += ""
}
// Add key name
result += keyCodeToString(keyCode)
return result
}
private func keyCodeToString(_ keyCode: UInt32) -> String {
// Map common key codes to their string representations
switch keyCode {
case 0: return "A"
case 1: return "S"
case 2: return "D"
case 3: return "F"
case 4: return "H"
case 5: return "G"
case 6: return "Z"
case 7: return "X"
case 8: return "C"
case 9: return "V"
case 10: return "§"
case 11: return "B"
case 12: return "Q"
case 13: return "W"
case 14: return "E"
case 15: return "R"
case 16: return "Y"
case 17: return "T"
case 18: return "1"
case 19: return "2"
case 20: return "3"
case 21: return "4"
case 22: return "6"
case 23: return "5"
case 24: return "="
case 25: return "9"
case 26: return "7"
case 27: return "-"
case 28: return "8"
case 29: return "0"
case 30: return "]"
case 31: return "O"
case 32: return "U"
case 33: return "["
case 34: return "I"
case 35: return "P"
case 36: return ""
case 37: return "L"
case 38: return "J"
case 39: return "'"
case 40: return "K"
case 41: return ";"
case 42: return "\\"
case 43: return ","
case 44: return "/"
case 45: return "N"
case 46: return "M"
case 47: return "."
case 48: return ""
case 49: return "Space"
case 50: return "`"
case 51: return ""
case 53: return ""
case 96: return "F5"
case 97: return "F6"
case 98: return "F7"
case 99: return "F3"
case 100: return "F8"
case 101: return "F9"
case 103: return "F11"
case 105: return "F13"
case 107: return "F14"
case 109: return "F10"
case 111: return "F12"
case 113: return "F15"
case 114: return "Help"
case 115: return "Home"
case 116: return ""
case 117: return ""
case 118: return "F4"
case 119: return "End"
case 120: return "F2"
case 121: return ""
case 122: return "F1"
case 123: return ""
case 124: return ""
case 125: return ""
case 126: return ""
default:
return "Key \(keyCode)"
}
}
// MARK: - Extensions
extension View {
func onKeyDown(perform action: @escaping (NSEvent) -> Bool) -> some View {
self.background(KeyEventHandlingView(onKeyDown: action))
}
}
struct KeyEventHandlingView: NSViewRepresentable {
let onKeyDown: (NSEvent) -> Bool
func makeNSView(context: Context) -> NSView {
let view = KeyHandlingNSView()
view.onKeyDown = onKeyDown
return view
}
func updateNSView(_ nsView: NSView, context: Context) {}
}
class KeyHandlingNSView: NSView {
var onKeyDown: ((NSEvent) -> Bool)?
override var acceptsFirstResponder: Bool { true }
override func keyDown(with event: NSEvent) {
if let handler = onKeyDown, handler(event) {
return
}
super.keyDown(with: event)
}
}

View file

@ -0,0 +1,557 @@
import SwiftUI
import CorePermissions
import CoreModels
import CoreSTT
class OnboardingWindowController: NSWindowController {
private let permissionManager: PermissionManager
private let modelManager: ModelManager
private let whisperEngine: WhisperCPPEngine
private var onboardingView: OnboardingView?
init(permissionManager: PermissionManager, modelManager: ModelManager, whisperEngine: WhisperCPPEngine, onComplete: @escaping () -> Void) {
self.permissionManager = permissionManager
self.modelManager = modelManager
self.whisperEngine = whisperEngine
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 650, height: 550),
styleMask: [.titled, .closable, .miniaturizable],
backing: .buffered,
defer: false
)
super.init(window: window)
window.title = NSLocalizedString("onboarding.title", comment: "Welcome to Tell me")
window.center()
window.level = .floating
onboardingView = OnboardingView(
permissionManager: permissionManager,
modelManager: modelManager,
whisperEngine: whisperEngine,
onComplete: { [weak self] in
print("Onboarding complete, closing window...")
onComplete()
self?.close()
}
)
window.contentView = NSHostingView(rootView: onboardingView!)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
struct OnboardingView: View {
@ObservedObject var permissionManager: PermissionManager
@ObservedObject var modelManager: ModelManager
let whisperEngine: WhisperCPPEngine
let onComplete: () -> Void
@State private var currentStep = 0
@State private var isDownloading = false
@State private var downloadError: String?
private let totalSteps = 4
var body: some View {
VStack(spacing: 0) {
// Header
VStack(spacing: 12) {
Image(systemName: "mic.circle.fill")
.foregroundColor(.blue)
.font(.system(size: 50))
Text(NSLocalizedString("onboarding.title", comment: "Welcome to Tell me"))
.font(.title)
.fontWeight(.bold)
Text(NSLocalizedString("onboarding.subtitle", comment: "Offline speech-to-text for macOS"))
.font(.title3)
.foregroundColor(.secondary)
}
.padding(.top, 50)
.padding(.bottom, 30)
.padding(.horizontal, 50)
// Progress indicator
HStack(spacing: 8) {
ForEach(0..<totalSteps, id: \.self) { step in
Circle()
.fill(step <= currentStep ? Color.blue : Color.gray.opacity(0.3))
.frame(width: 8, height: 8)
}
}
.padding(.bottom, 24)
// Content area
ScrollView {
VStack(spacing: 16) {
switch currentStep {
case 0:
WelcomeStep()
case 1:
PermissionsStep(permissionManager: permissionManager)
case 2:
ModelsStep(
modelManager: modelManager,
whisperEngine: whisperEngine,
isDownloading: $isDownloading,
downloadError: $downloadError
)
case 3:
CompletionStep(hasModel: modelManager.activeModel != nil)
default:
WelcomeStep()
}
}
.padding(.horizontal, 50)
}
.frame(height: 280)
// Navigation buttons (fixed at bottom)
HStack(spacing: 12) {
Button(NSLocalizedString("onboarding.buttons.skip", comment: "Skip Setup")) {
onComplete()
}
.buttonStyle(.borderless)
.controlSize(.regular)
.foregroundColor(.secondary)
if currentStep > 0 {
Button(NSLocalizedString("onboarding.buttons.back", comment: "Back")) {
currentStep = max(0, currentStep - 1)
}
.buttonStyle(.bordered)
.controlSize(.regular)
}
Spacer()
if currentStep < totalSteps - 1 {
Button(NSLocalizedString("onboarding.buttons.next", comment: "Next")) {
currentStep = min(totalSteps - 1, currentStep + 1)
}
.buttonStyle(.borderedProminent)
.controlSize(.regular)
} else {
Button(NSLocalizedString("onboarding.buttons.get_started", comment: "Get Started")) {
onComplete()
}
.buttonStyle(.borderedProminent)
.controlSize(.regular)
}
}
.padding(.horizontal, 50)
.padding(.top, 30)
.padding(.bottom, 50)
.background(Color(NSColor.windowBackgroundColor))
}
.frame(width: 650, height: 550)
}
}
struct WelcomeStep: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text(NSLocalizedString("onboarding.what_is", comment: "What is Tell me?"))
.font(.title3)
.fontWeight(.semibold)
VStack(alignment: .leading, spacing: 16) {
FeatureRow(
icon: "mic.fill",
title: NSLocalizedString("onboarding.feature.offline", comment: "Offline Speech Recognition"),
description: NSLocalizedString("onboarding.feature.offline_desc", comment: "Offline description")
)
FeatureRow(
icon: "keyboard",
title: NSLocalizedString("onboarding.feature.hotkey", comment: "Global Hotkey"),
description: NSLocalizedString("onboarding.feature.hotkey_desc", comment: "Hotkey description")
)
FeatureRow(
icon: "lock.shield",
title: NSLocalizedString("onboarding.feature.privacy", comment: "Privacy First"),
description: NSLocalizedString("onboarding.feature.privacy_desc", comment: "Privacy description")
)
}
}
}
}
struct FeatureRow: View {
let icon: String
let title: String
let description: String
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: icon)
.foregroundColor(.blue)
.frame(width: 20)
.font(.title3)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.body)
.fontWeight(.medium)
Text(description)
.font(.caption)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
}
struct PermissionsStep: View {
@ObservedObject var permissionManager: PermissionManager
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text(NSLocalizedString("onboarding.permissions.title", comment: "Permissions Required"))
.font(.headline)
Text(NSLocalizedString("onboarding.permissions.description", comment: "Permissions description"))
.font(.body)
.foregroundColor(.secondary)
VStack(alignment: .leading, spacing: 12) {
PermissionStepRow(
title: "Microphone",
description: "To capture your speech for transcription",
status: permissionManager.microphoneStatus,
onGrant: {
permissionManager.requestMicrophonePermission { _ in }
}
)
PermissionStepRow(
title: "Accessibility",
description: "To insert transcribed text into applications",
status: permissionManager.accessibilityStatus,
onGrant: {
permissionManager.openSystemSettings(for: .accessibility)
}
)
PermissionStepRow(
title: "Input Monitoring",
description: "To register global keyboard shortcuts",
status: permissionManager.inputMonitoringStatus,
onGrant: {
permissionManager.openSystemSettings(for: .inputMonitoring)
}
)
}
Button("Refresh Permission Status") {
permissionManager.checkAllPermissions()
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
}
struct PermissionStepRow: View {
let title: String
let description: String
let status: PermissionStatus
let onGrant: () -> Void
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.body)
.fontWeight(.medium)
Text(description)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
HStack(spacing: 8) {
Circle()
.fill(statusColor)
.frame(width: 8, height: 8)
Text(statusText)
.font(.caption)
.foregroundColor(statusColor)
if status != .granted {
Button("Grant") {
onGrant()
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
}
.padding(12)
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(8)
}
private var statusColor: Color {
switch status {
case .granted: return .green
case .denied: return .red
case .notDetermined, .restricted: return .orange
}
}
private var statusText: String {
switch status {
case .granted: return "Granted"
case .denied: return "Denied"
case .notDetermined: return "Not Set"
case .restricted: return "Restricted"
}
}
}
struct ModelsStep: View {
@ObservedObject var modelManager: ModelManager
let whisperEngine: WhisperCPPEngine
@Binding var isDownloading: Bool
@Binding var downloadError: String?
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text(NSLocalizedString("onboarding.models.title", comment: "Download a Speech Model"))
.font(.headline)
Text(NSLocalizedString("onboarding.models.description", comment: "Models description"))
.font(.body)
.foregroundColor(.secondary)
// Model recommendation
VStack(alignment: .leading, spacing: 12) {
Text(NSLocalizedString("onboarding.models.recommended", comment: "Recommended: Whisper Tiny"))
.font(.body)
.fontWeight(.semibold)
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("whisper-tiny")
.font(.body)
.fontWeight(.medium)
Text(NSLocalizedString("onboarding.models.tiny_description", comment: "Tiny model description"))
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
if let tinyModel = getTinyModel() {
if tinyModel.isDownloaded {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text(NSLocalizedString("onboarding.models.downloaded", comment: "Downloaded"))
.font(.caption)
.foregroundColor(.green)
}
} else if isDownloading {
HStack {
ProgressView()
.scaleEffect(0.8)
Text(NSLocalizedString("onboarding.models.downloading", comment: "Downloading..."))
.font(.caption)
.foregroundColor(.secondary)
}
} else {
Button("Download") {
downloadTinyModel()
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
}
}
}
.padding(16)
.background(Color.blue.opacity(0.1))
.cornerRadius(12)
// Error display
if let error = downloadError {
HStack {
Image(systemName: "exclamationmark.triangle")
.foregroundColor(.red)
Text(String(format: NSLocalizedString("onboarding.models.download_failed", comment: "Download failed"), error))
.font(.caption)
.foregroundColor(.red)
}
.padding(12)
.background(Color.red.opacity(0.1))
.cornerRadius(8)
}
// Additional info
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "info.circle")
.foregroundColor(.blue)
Text(NSLocalizedString("onboarding.models.info1", comment: "Models info 1"))
.font(.caption)
.foregroundColor(.secondary)
}
HStack {
Image(systemName: "wifi.slash")
.foregroundColor(.green)
Text(NSLocalizedString("onboarding.models.info2", comment: "Models info 2"))
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
private func getTinyModel() -> ModelInfo? {
return modelManager.availableModels.first { $0.name == "whisper-tiny" }
}
private func downloadTinyModel() {
guard let tinyModel = getTinyModel() else { return }
isDownloading = true
downloadError = nil
Task {
do {
try await modelManager.downloadModel(tinyModel) { progress in
// We don't need to track progress value for spinner
print("Download progress: \(progress.progress * 100)%")
}
DispatchQueue.main.async {
isDownloading = false
// Set as active model
modelManager.setActiveModel(tinyModel)
// Load the model
Task {
do {
if let modelPath = modelManager.getModelPath(for: tinyModel) {
try await whisperEngine.loadModel(at: modelPath)
}
} catch {
print("Failed to load model: \(error)")
}
}
}
} catch {
DispatchQueue.main.async {
isDownloading = false
downloadError = error.localizedDescription
}
}
}
}
}
struct CompletionStep: View {
let hasModel: Bool
var body: some View {
VStack(alignment: .leading, spacing: 16) {
if hasModel {
Text(NSLocalizedString("onboarding.completion.ready_title", comment: "You're All Set!"))
.font(.headline)
.foregroundColor(.green)
Text(NSLocalizedString("onboarding.completion.ready_desc", comment: "Ready description"))
.font(.body)
.foregroundColor(.secondary)
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top) {
Image(systemName: "1.circle.fill")
.foregroundColor(.blue)
VStack(alignment: .leading, spacing: 2) {
Text(NSLocalizedString("onboarding.completion.step1", comment: "Step 1"))
.fontWeight(.medium)
Text(NSLocalizedString("onboarding.completion.step1_desc", comment: "Step 1 description"))
.font(.caption)
.foregroundColor(.secondary)
}
}
HStack(alignment: .top) {
Image(systemName: "2.circle.fill")
.foregroundColor(.blue)
VStack(alignment: .leading, spacing: 2) {
Text(NSLocalizedString("onboarding.completion.step2", comment: "Step 2"))
.fontWeight(.medium)
Text(NSLocalizedString("onboarding.completion.step2_desc", comment: "Step 2 description"))
.font(.caption)
.foregroundColor(.secondary)
}
}
HStack(alignment: .top) {
Image(systemName: "3.circle.fill")
.foregroundColor(.blue)
VStack(alignment: .leading, spacing: 2) {
Text(NSLocalizedString("onboarding.completion.step3", comment: "Step 3"))
.fontWeight(.medium)
Text(NSLocalizedString("onboarding.completion.step3_desc", comment: "Step 3 description"))
.font(.caption)
.foregroundColor(.secondary)
}
}
}
} else {
Text(NSLocalizedString("onboarding.completion.incomplete_title", comment: "Setup Not Complete"))
.font(.headline)
.foregroundColor(.orange)
Text(NSLocalizedString("onboarding.completion.incomplete_desc", comment: "Incomplete description"))
.font(.body)
.foregroundColor(.secondary)
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
Text(NSLocalizedString("onboarding.completion.no_model_warning", comment: "No model warning"))
.font(.body)
.fontWeight(.medium)
}
.padding(12)
.background(Color.orange.opacity(0.1))
.cornerRadius(8)
Text(NSLocalizedString("onboarding.completion.what_next", comment: "What to do next:"))
.font(.body)
.fontWeight(.semibold)
VStack(alignment: .leading, spacing: 6) {
Text(NSLocalizedString("onboarding.completion.next_step1", comment: "Next step 1"))
Text(NSLocalizedString("onboarding.completion.next_step2", comment: "Next step 2"))
Text(NSLocalizedString("onboarding.completion.next_step3", comment: "Next step 3"))
}
.font(.body)
.foregroundColor(.secondary)
}
}
Text(NSLocalizedString("onboarding.completion.footer", comment: "Footer text"))
.font(.caption)
.foregroundColor(.secondary)
.padding(.top, 8)
}
}
}

View file

@ -3,20 +3,23 @@ import CoreModels
import CoreSTT
import CoreUtils
import CorePermissions
import CoreSettings
class PreferencesWindowController: NSWindowController {
private let modelManager: ModelManager
private let whisperEngine: WhisperCPPEngine
private let permissionManager: PermissionManager
private let settings: CoreSettings.Settings
private var preferencesView: PreferencesView?
init(modelManager: ModelManager, whisperEngine: WhisperCPPEngine, permissionManager: PermissionManager, initialTab: Int = 0) {
init(modelManager: ModelManager, whisperEngine: WhisperCPPEngine, permissionManager: PermissionManager, settings: CoreSettings.Settings, initialTab: Int = 0) {
self.modelManager = modelManager
self.whisperEngine = whisperEngine
self.permissionManager = permissionManager
self.settings = settings
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 600, height: 500),
contentRect: NSRect(x: 0, y: 0, width: 800, height: 600),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false
@ -24,13 +27,16 @@ class PreferencesWindowController: NSWindowController {
super.init(window: window)
window.title = "MenuWhisper Preferences"
window.title = NSLocalizedString("preferences.title", comment: "Tell me Preferences")
window.center()
window.minSize = NSSize(width: 750, height: 600)
window.maxSize = NSSize(width: 1200, height: 800)
preferencesView = PreferencesView(
modelManager: modelManager,
whisperEngine: whisperEngine,
permissionManager: permissionManager,
settings: settings,
initialTab: initialTab,
onClose: { [weak self] in
self?.close()
@ -53,6 +59,7 @@ struct PreferencesView: View {
@ObservedObject var modelManager: ModelManager
let whisperEngine: WhisperCPPEngine
@ObservedObject var permissionManager: PermissionManager
@ObservedObject var settings: CoreSettings.Settings
let onClose: () -> Void
@State private var selectedTab: Int
@ -61,10 +68,11 @@ struct PreferencesView: View {
@State private var showingDeleteAlert = false
@State private var modelToDelete: ModelInfo?
init(modelManager: ModelManager, whisperEngine: WhisperCPPEngine, permissionManager: PermissionManager, initialTab: Int = 0, onClose: @escaping () -> Void) {
init(modelManager: ModelManager, whisperEngine: WhisperCPPEngine, permissionManager: PermissionManager, settings: CoreSettings.Settings, initialTab: Int = 0, onClose: @escaping () -> Void) {
self.modelManager = modelManager
self.whisperEngine = whisperEngine
self.permissionManager = permissionManager
self.settings = settings
self.onClose = onClose
self._selectedTab = State(initialValue: initialTab)
}
@ -75,37 +83,56 @@ struct PreferencesView: View {
var body: some View {
TabView(selection: $selectedTab) {
GeneralTab(settings: settings)
.tabItem {
Label(NSLocalizedString("preferences.general", comment: "General"), systemImage: "gearshape")
}
.tag(0)
ModelsTab(
modelManager: modelManager,
whisperEngine: whisperEngine,
settings: settings,
isDownloading: $isDownloading,
downloadProgress: $downloadProgress,
showingDeleteAlert: $showingDeleteAlert,
modelToDelete: $modelToDelete
)
.tabItem {
Label("Models", systemImage: "brain.head.profile")
Label(NSLocalizedString("preferences.models", comment: "Models"), systemImage: "brain.head.profile")
}
.tag(0)
.tag(1)
InsertionTab(settings: settings)
.tabItem {
Label(NSLocalizedString("preferences.insertion", comment: "Text Insertion"), systemImage: "text.cursor")
}
.tag(2)
HUDTab(settings: settings)
.tabItem {
Label(NSLocalizedString("preferences.interface", comment: "Interface"), systemImage: "rectangle.on.rectangle")
}
.tag(3)
AdvancedTab(settings: settings)
.tabItem {
Label(NSLocalizedString("preferences.advanced", comment: "Advanced"), systemImage: "slider.horizontal.3")
}
.tag(4)
PermissionsTab(permissionManager: permissionManager)
.tabItem {
Label("Permissions", systemImage: "lock.shield")
Label(NSLocalizedString("preferences.permissions", comment: "Permissions"), systemImage: "lock.shield")
}
.tag(1)
GeneralTab()
.tabItem {
Label("General", systemImage: "gearshape")
}
.tag(2)
.tag(5)
}
.frame(width: 600, height: 500)
.alert("Delete Model", isPresented: $showingDeleteAlert) {
Button("Cancel", role: .cancel) {
.frame(minWidth: 750, idealWidth: 800, maxWidth: 1200, minHeight: 600, idealHeight: 600, maxHeight: 800)
.alert(NSLocalizedString("alert.delete_model", comment: "Delete Model"), isPresented: $showingDeleteAlert) {
Button(NSLocalizedString("general.cancel", comment: "Cancel"), role: .cancel) {
modelToDelete = nil
}
Button("Delete", role: .destructive) {
Button(NSLocalizedString("preferences.models.delete", comment: "Delete"), role: .destructive) {
if let model = modelToDelete {
deleteModel(model)
}
@ -113,7 +140,7 @@ struct PreferencesView: View {
}
} message: {
if let model = modelToDelete {
Text("Are you sure you want to delete '\(model.name)'? This action cannot be undone.")
Text(String(format: NSLocalizedString("preferences.models.delete_confirm", comment: "Delete confirmation"), model.name))
}
}
}
@ -130,6 +157,7 @@ struct PreferencesView: View {
struct ModelsTab: View {
@ObservedObject var modelManager: ModelManager
let whisperEngine: WhisperCPPEngine
@ObservedObject var settings: CoreSettings.Settings
@Binding var isDownloading: [String: Bool]
@Binding var downloadProgress: [String: Double]
@ -138,17 +166,17 @@ struct ModelsTab: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Speech Recognition Models")
Text(NSLocalizedString("preferences.models.title", comment: "Speech Recognition Models"))
.font(.title2)
.fontWeight(.semibold)
Text("Download and manage speech recognition models. Larger models provide better accuracy but use more memory and processing time.")
Text(NSLocalizedString("preferences.models.description", comment: "Model description"))
.font(.caption)
.foregroundColor(.secondary)
// Current Model Status
VStack(alignment: .leading, spacing: 8) {
Text("Current Model")
Text(NSLocalizedString("preferences.models.current_model", comment: "Current Model"))
.font(.headline)
if let activeModel = modelManager.activeModel {
@ -168,7 +196,7 @@ struct ModelsTab: View {
.fill(whisperEngine.isModelLoaded() ? Color.green : Color.orange)
.frame(width: 8, height: 8)
Text(whisperEngine.isModelLoaded() ? "Loaded" : "Loading...")
Text(whisperEngine.isModelLoaded() ? NSLocalizedString("status.loaded", comment: "Loaded") : NSLocalizedString("status.loading", comment: "Loading..."))
.font(.caption)
.foregroundColor(whisperEngine.isModelLoaded() ? .green : .orange)
}
@ -176,7 +204,7 @@ struct ModelsTab: View {
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(8)
} else {
Text("No model selected")
Text(NSLocalizedString("preferences.models.no_model", comment: "No model selected"))
.foregroundColor(.secondary)
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
@ -185,9 +213,47 @@ struct ModelsTab: View {
}
}
// Language Settings
VStack(alignment: .leading, spacing: 8) {
Text(NSLocalizedString("preferences.models.language", comment: "Language"))
.font(.headline)
HStack {
Text(NSLocalizedString("preferences.models.recognition_language", comment: "Recognition Language:"))
.font(.body)
Picker(NSLocalizedString("general.language", comment: "Language"), selection: Binding(
get: { settings.forcedLanguage ?? "auto" },
set: { newValue in
settings.forcedLanguage = newValue == "auto" ? nil : newValue
}
)) {
Text(NSLocalizedString("language.auto_detect", comment: "Auto-detect")).tag("auto")
Divider()
Text(NSLocalizedString("language.english", comment: "English")).tag("en")
Text(NSLocalizedString("language.spanish", comment: "Spanish")).tag("es")
Text(NSLocalizedString("language.french", comment: "French")).tag("fr")
Text(NSLocalizedString("language.german", comment: "German")).tag("de")
Text(NSLocalizedString("language.italian", comment: "Italian")).tag("it")
Text(NSLocalizedString("language.portuguese", comment: "Portuguese")).tag("pt")
Text(NSLocalizedString("language.russian", comment: "Russian")).tag("ru")
Text(NSLocalizedString("language.chinese", comment: "Chinese")).tag("zh")
Text(NSLocalizedString("language.japanese", comment: "Japanese")).tag("ja")
Text(NSLocalizedString("language.korean", comment: "Korean")).tag("ko")
}
.pickerStyle(.menu)
.frame(width: 150)
Spacer()
}
.padding(12)
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(8)
}
// Available Models
VStack(alignment: .leading, spacing: 8) {
Text("Available Models")
Text(NSLocalizedString("preferences.models.available_models", comment: "Available Models"))
.font(.headline)
ScrollView {
@ -286,7 +352,7 @@ struct ModelRow: View {
.fontWeight(.medium)
if isActive {
Text("ACTIVE")
Text(NSLocalizedString("preferences.models.active_badge", comment: "ACTIVE"))
.font(.caption)
.fontWeight(.semibold)
.foregroundColor(.white)
@ -315,13 +381,13 @@ struct ModelRow: View {
if model.isDownloaded {
HStack(spacing: 8) {
if !isActive {
Button("Select") {
Button(NSLocalizedString("general.select", comment: "Select")) {
onSelect()
}
.buttonStyle(.bordered)
}
Button("Delete") {
Button(NSLocalizedString("preferences.models.delete", comment: "Delete")) {
onDelete()
}
.buttonStyle(.bordered)
@ -329,17 +395,20 @@ struct ModelRow: View {
}
} else {
if isDownloading {
VStack {
ProgressView(value: downloadProgress)
.frame(width: 80)
Text("\(Int(downloadProgress * 100))%")
HStack {
ProgressView()
.scaleEffect(0.8)
Text(NSLocalizedString("preferences.models.downloading", comment: "Downloading..."))
.font(.caption)
.foregroundColor(.secondary)
}
} else {
Button("Download") {
Button(NSLocalizedString("preferences.models.download", comment: "Download")) {
onDownload()
}
.buttonStyle(.bordered)
.accessibilityLabel("Download \(model.name) model")
.accessibilityHint("Downloads the speech recognition model for offline use")
}
}
}
@ -359,19 +428,19 @@ struct PermissionsTab: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Permissions")
Text(NSLocalizedString("preferences.permissions", comment: "Permissions"))
.font(.title2)
.fontWeight(.semibold)
Text("MenuWhisper requires certain system permissions to function properly. Click the buttons below to grant permissions in System Settings.")
Text(NSLocalizedString("preferences.permissions.description", comment: "Permissions description"))
.font(.caption)
.foregroundColor(.secondary)
VStack(alignment: .leading, spacing: 12) {
// Microphone Permission
PermissionRow(
title: "Microphone",
description: "Required to capture speech for transcription",
title: NSLocalizedString("permissions.microphone.title_short", comment: "Microphone"),
description: NSLocalizedString("permissions.microphone.description_short", comment: "Microphone description"),
status: permissionManager.microphoneStatus,
onOpenSettings: {
permissionManager.openSystemSettings(for: .microphone)
@ -385,8 +454,8 @@ struct PermissionsTab: View {
// Accessibility Permission
PermissionRow(
title: "Accessibility",
description: "Required to insert transcribed text into other applications",
title: NSLocalizedString("permissions.accessibility.title_short", comment: "Accessibility"),
description: NSLocalizedString("permissions.accessibility.description_short", comment: "Accessibility description"),
status: permissionManager.accessibilityStatus,
onOpenSettings: {
permissionManager.openSystemSettings(for: .accessibility)
@ -400,8 +469,8 @@ struct PermissionsTab: View {
// Input Monitoring Permission
PermissionRow(
title: "Input Monitoring",
description: "Required to send keyboard events for text insertion",
title: NSLocalizedString("permissions.input_monitoring.title_short", comment: "Input Monitoring"),
description: NSLocalizedString("permissions.input_monitoring.description_short", comment: "Input Monitoring description"),
status: permissionManager.inputMonitoringStatus,
onOpenSettings: {
permissionManager.openSystemSettings(for: .inputMonitoring)
@ -417,17 +486,17 @@ struct PermissionsTab: View {
// Help text
VStack(alignment: .leading, spacing: 8) {
Text("Need Help?")
Text(NSLocalizedString("preferences.permissions.need_help", comment: "Need Help?"))
.font(.headline)
Text("After granting permissions in System Settings:")
Text(NSLocalizedString("preferences.permissions.after_granting", comment: "After granting permissions"))
.font(.body)
.foregroundColor(.secondary)
VStack(alignment: .leading, spacing: 4) {
Text("1. Close System Settings")
Text("2. Click 'Refresh Status' to update permission status")
Text("3. Some permissions may require restarting MenuWhisper")
Text(NSLocalizedString("preferences.permissions.step1", comment: "Step 1"))
Text(NSLocalizedString("preferences.permissions.step2", comment: "Step 2"))
Text(NSLocalizedString("preferences.permissions.step3", comment: "Step 3"))
}
.font(.caption)
.foregroundColor(.secondary)
@ -479,14 +548,14 @@ struct PermissionRow: View {
VStack(spacing: 6) {
if status != .granted {
Button("Open System Settings") {
Button(NSLocalizedString("permissions.open_settings", comment: "Open System Settings")) {
onOpenSettings()
}
.buttonStyle(.bordered)
.controlSize(.small)
}
Button("Refresh Status") {
Button(NSLocalizedString("permissions.refresh_status", comment: "Refresh Status")) {
onRefresh()
}
.buttonStyle(.borderless)
@ -510,30 +579,415 @@ struct PermissionRow: View {
private var statusText: String {
switch status {
case .granted:
return "Granted"
return NSLocalizedString("status.granted", comment: "Granted")
case .denied:
return "Denied"
return NSLocalizedString("status.denied", comment: "Denied")
case .notDetermined:
return "Not Set"
return NSLocalizedString("status.not_set", comment: "Not Set")
case .restricted:
return "Restricted"
return NSLocalizedString("status.restricted", comment: "Restricted")
}
}
}
struct GeneralTab: View {
@ObservedObject var settings: CoreSettings.Settings
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("General Settings")
VStack(alignment: .leading, spacing: 20) {
Text(NSLocalizedString("preferences.general.title", comment: "General Settings"))
.font(.title2)
.fontWeight(.semibold)
Text("Additional settings will be available in Phase 4.")
.font(.body)
.foregroundColor(.secondary)
// Hotkey Configuration
VStack(alignment: .leading, spacing: 12) {
Text(NSLocalizedString("preferences.general.global_hotkey", comment: "Global Hotkey"))
.font(.headline)
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(NSLocalizedString("preferences.general.hotkey_combination", comment: "Hotkey Combination:"))
.frame(width: 140, alignment: .leading)
HotkeyRecorder(hotkey: $settings.hotkey)
Spacer()
}
HStack {
Text(NSLocalizedString("preferences.general.mode", comment: "Activation Mode:"))
.frame(width: 140, alignment: .leading)
Picker(NSLocalizedString("general.mode", comment: "Mode"), selection: $settings.hotkeyMode) {
ForEach(HotkeyMode.allCases, id: \.self) { mode in
Text(mode.displayName).tag(mode)
}
}
.pickerStyle(.segmented)
.frame(width: 200)
Spacer()
}
}
.padding(16)
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(12)
}
// Audio and Timing
VStack(alignment: .leading, spacing: 12) {
Text(NSLocalizedString("preferences.general.audio_timing", comment: "Audio & Timing"))
.font(.headline)
VStack(alignment: .leading, spacing: 12) {
HStack {
Toggle(NSLocalizedString("preferences.general.sounds", comment: "Play sounds"), isOn: $settings.playSounds)
Spacer()
}
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(NSLocalizedString("preferences.general.limit", comment: "Dictation time limit:"))
Spacer()
Text("\(Int(settings.dictationTimeLimit / 60)) \(NSLocalizedString("preferences.general.minutes", comment: "minutes"))")
.foregroundColor(.secondary)
}
Slider(
value: Binding(
get: { settings.dictationTimeLimit / 60 },
set: { settings.dictationTimeLimit = $0 * 60 }
),
in: 1...30,
step: 1
) {
Text(NSLocalizedString("preferences.general.time_limit_slider", comment: "Time Limit"))
}
}
}
.padding(16)
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(12)
}
// Settings Management
VStack(alignment: .leading, spacing: 12) {
Text(NSLocalizedString("preferences.general.settings_management", comment: "Settings Management"))
.font(.headline)
HStack(spacing: 12) {
Button(NSLocalizedString("preferences.general.export_settings", comment: "Export Settings...")) {
exportSettings()
}
.buttonStyle(.bordered)
Button(NSLocalizedString("preferences.general.import_settings", comment: "Import Settings...")) {
importSettings()
}
.buttonStyle(.bordered)
Spacer()
}
.padding(16)
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(12)
}
Spacer()
}
.padding(20)
}
private func exportSettings() {
let panel = NSSavePanel()
panel.allowedContentTypes = [.json]
panel.nameFieldStringValue = "\(NSLocalizedString("app.name", comment: "App name")) Settings.json"
if panel.runModal() == .OK, let url = panel.url {
do {
let data = try settings.exportSettings()
try data.write(to: url)
// Show success message
showAlert(title: NSLocalizedString("alert.success", comment: "Success"), message: NSLocalizedString("success.settings.exported", comment: "Settings exported"))
} catch {
showAlert(title: NSLocalizedString("alert.error", comment: "Error"), message: "Failed to export settings: \(error.localizedDescription)")
}
}
}
private func importSettings() {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.json]
panel.allowsMultipleSelection = false
if panel.runModal() == .OK, let url = panel.url {
do {
let data = try Data(contentsOf: url)
try settings.importSettings(from: data)
showAlert(title: NSLocalizedString("alert.success", comment: "Success"), message: NSLocalizedString("success.settings.imported", comment: "Settings imported"))
} catch {
showAlert(title: NSLocalizedString("alert.error", comment: "Error"), message: "Failed to import settings: \(error.localizedDescription)")
}
}
}
private func showAlert(title: String, message: String) {
let alert = NSAlert()
alert.messageText = title
alert.informativeText = message
alert.runModal()
}
}
// MARK: - New Tab Views
struct InsertionTab: View {
@ObservedObject var settings: CoreSettings.Settings
var body: some View {
VStack(alignment: .leading, spacing: 20) {
Text(NSLocalizedString("preferences.insertion.title", comment: "Text Insertion"))
.font(.title2)
.fontWeight(.semibold)
// Insertion Method
VStack(alignment: .leading, spacing: 12) {
Text(NSLocalizedString("preferences.insertion.method", comment: "Insertion Method:"))
.font(.headline)
VStack(alignment: .leading, spacing: 8) {
Picker(NSLocalizedString("general.method", comment: "Method"), selection: $settings.insertionMethod) {
ForEach(InsertionMethod.allCases, id: \.self) { method in
Text(method.displayName).tag(method)
}
}
.pickerStyle(.segmented)
Text(NSLocalizedString("preferences.insertion.method_description", comment: "Insertion method description"))
.font(.caption)
.foregroundColor(.secondary)
}
.padding(16)
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(12)
}
// Preview Settings
VStack(alignment: .leading, spacing: 12) {
Text(NSLocalizedString("preferences.insertion.preview", comment: "Preview"))
.font(.headline)
VStack(alignment: .leading, spacing: 8) {
Toggle(NSLocalizedString("preferences.insertion.preview", comment: "Show preview"), isOn: $settings.showPreview)
Text(NSLocalizedString("preferences.insertion.preview_description", comment: "Preview description"))
.font(.caption)
.foregroundColor(.secondary)
}
.padding(16)
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(12)
}
// Secure Input Information
VStack(alignment: .leading, spacing: 12) {
Text(NSLocalizedString("preferences.insertion.secure_input_title", comment: "Secure Input Handling"))
.font(.headline)
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "info.circle")
.foregroundColor(.blue)
Text(NSLocalizedString("preferences.insertion.secure_input_description", comment: "Secure input description"))
.font(.body)
}
}
.padding(16)
.background(Color.blue.opacity(0.1))
.cornerRadius(12)
}
Spacer()
}
.padding(20)
}
}
struct HUDTab: View {
@ObservedObject var settings: CoreSettings.Settings
var body: some View {
VStack(alignment: .leading, spacing: 20) {
Text(NSLocalizedString("preferences.hud.title", comment: "Interface Settings"))
.font(.title2)
.fontWeight(.semibold)
// HUD Appearance
VStack(alignment: .leading, spacing: 12) {
Text(NSLocalizedString("preferences.hud.appearance", comment: "HUD Appearance"))
.font(.headline)
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(NSLocalizedString("preferences.hud.opacity", comment: "Opacity:"))
Spacer()
Text("\(Int(settings.hudOpacity * 100))%")
.foregroundColor(.secondary)
}
Slider(value: $settings.hudOpacity, in: 0.3...1.0, step: 0.1) {
Text(NSLocalizedString("preferences.hud.opacity_slider", comment: "HUD Opacity"))
}
}
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(NSLocalizedString("preferences.hud.size", comment: "Size:"))
Spacer()
Text("\(Int(settings.hudSize * 100))%")
.foregroundColor(.secondary)
}
Slider(value: $settings.hudSize, in: 0.8...1.5, step: 0.1) {
Text(NSLocalizedString("preferences.hud.size_slider", comment: "HUD Size"))
}
}
}
.padding(16)
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(12)
}
// Audio Feedback
VStack(alignment: .leading, spacing: 12) {
Text(NSLocalizedString("preferences.hud.audio_feedback", comment: "Audio Feedback"))
.font(.headline)
VStack(alignment: .leading, spacing: 8) {
Toggle(NSLocalizedString("preferences.general.sounds", comment: "Play sounds for dictation"), isOn: $settings.playSounds)
Text(NSLocalizedString("preferences.hud.sounds_description", comment: "Sounds description"))
.font(.caption)
.foregroundColor(.secondary)
}
.padding(16)
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(12)
}
Spacer()
}
.padding(20)
}
}
struct AdvancedTab: View {
@ObservedObject var settings: CoreSettings.Settings
@State private var showingResetAlert = false
var body: some View {
VStack(alignment: .leading, spacing: 20) {
Text(NSLocalizedString("preferences.advanced.title", comment: "Advanced Settings"))
.font(.title2)
.fontWeight(.semibold)
// Processing Settings
VStack(alignment: .leading, spacing: 12) {
Text(NSLocalizedString("preferences.advanced.processing", comment: "Processing"))
.font(.headline)
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(NSLocalizedString("preferences.advanced.threads", comment: "Processing threads:"))
Spacer()
Text("\(settings.processingThreads)")
.foregroundColor(.secondary)
}
Slider(
value: Binding(
get: { Double(settings.processingThreads) },
set: { settings.processingThreads = Int($0) }
),
in: 1...8,
step: 1
) {
Text(NSLocalizedString("preferences.advanced.threads_slider", comment: "Processing Threads"))
}
Text(NSLocalizedString("preferences.advanced.threads_description", comment: "Threads description"))
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding(16)
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(12)
}
// Logging Settings
VStack(alignment: .leading, spacing: 12) {
Text(NSLocalizedString("preferences.advanced.logging", comment: "Logging"))
.font(.headline)
VStack(alignment: .leading, spacing: 8) {
Toggle(NSLocalizedString("preferences.advanced.enable_logging", comment: "Enable logging"), isOn: $settings.enableLogging)
Text(NSLocalizedString("preferences.advanced.logging_description", comment: "Logging description"))
.font(.caption)
.foregroundColor(.secondary)
}
.padding(16)
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(12)
}
// Reset Settings
VStack(alignment: .leading, spacing: 12) {
Text(NSLocalizedString("preferences.advanced.reset", comment: "Reset"))
.font(.headline)
HStack {
Button(NSLocalizedString("preferences.advanced.reset_button", comment: "Reset All Settings")) {
showingResetAlert = true
}
.buttonStyle(.bordered)
.foregroundColor(.red)
Spacer()
}
.padding(16)
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(12)
}
Spacer()
}
.padding(20)
.alert(NSLocalizedString("preferences.advanced.reset_title", comment: "Reset Settings"), isPresented: $showingResetAlert) {
Button(NSLocalizedString("general.cancel", comment: "Cancel"), role: .cancel) { }
Button(NSLocalizedString("general.reset", comment: "Reset"), role: .destructive) {
resetSettings()
}
} message: {
Text(NSLocalizedString("preferences.advanced.reset_message", comment: "Reset confirmation"))
}
}
private func resetSettings() {
// Reset all settings to defaults
settings.hotkey = HotkeyConfig.default
settings.hotkeyMode = .pushToTalk
settings.playSounds = false
settings.dictationTimeLimit = 600
settings.hudOpacity = 0.9
settings.hudSize = 1.0
settings.forcedLanguage = nil
settings.insertionMethod = .paste
settings.showPreview = false
settings.enableLogging = false
settings.processingThreads = 4
}
}

View file

@ -0,0 +1,117 @@
import SwiftUI
class PreviewDialogController: NSWindowController {
private var previewView: PreviewDialogView?
init(text: String, onInsert: @escaping (String) -> Void, onCancel: @escaping () -> Void) {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
styleMask: [.titled, .closable, .resizable],
backing: .buffered,
defer: false
)
super.init(window: window)
window.title = NSLocalizedString("preview.title", comment: "Preview Transcription")
window.center()
window.level = .floating
previewView = PreviewDialogView(
text: text,
onInsert: { [weak self] editedText in
onInsert(editedText)
self?.close()
},
onCancel: { [weak self] in
onCancel()
self?.close()
}
)
window.contentView = NSHostingView(rootView: previewView!)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
struct PreviewDialogView: View {
@State private var text: String
let onInsert: (String) -> Void
let onCancel: () -> Void
@FocusState private var isTextFocused: Bool
init(text: String, onInsert: @escaping (String) -> Void, onCancel: @escaping () -> Void) {
self._text = State(initialValue: text)
self.onInsert = onInsert
self.onCancel = onCancel
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
// Header
VStack(alignment: .leading, spacing: 4) {
Text(NSLocalizedString("preview.title", comment: "Preview Transcription"))
.font(.title2)
.fontWeight(.semibold)
Text(NSLocalizedString("preview.description", comment: "Review and edit the transcribed text before insertion."))
.font(.caption)
.foregroundColor(.secondary)
}
// Text Editor
VStack(alignment: .leading, spacing: 8) {
Text(NSLocalizedString("preview.transcribed_text", comment: "Transcribed Text:"))
.font(.headline)
ScrollView {
TextEditor(text: $text)
.focused($isTextFocused)
.font(.body)
.padding(8)
.background(Color(NSColor.textBackgroundColor))
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
)
}
.frame(minHeight: 120)
}
// Actions
HStack {
// Character count
Text(String(format: NSLocalizedString("preview.character_count", comment: "%d characters"), text.count))
.font(.caption)
.foregroundColor(.secondary)
Spacer()
// Buttons
HStack(spacing: 12) {
Button(NSLocalizedString("general.cancel", comment: "Cancel")) {
onCancel()
}
.buttonStyle(.bordered)
.keyboardShortcut(.escape)
Button(NSLocalizedString("preview.insert", comment: "Insert")) {
onInsert(text)
}
.buttonStyle(.borderedProminent)
.keyboardShortcut(.return, modifiers: [.command])
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
.padding(20)
.frame(width: 500, height: 300)
.onAppear {
isTextFocused = true
}
}
}

View file

@ -4,16 +4,21 @@
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>es</string>
</array>
<key>CFBundleDisplayName</key>
<string>Menu-Whisper</string>
<string>Tell me</string>
<key>CFBundleExecutable</key>
<string>MenuWhisper</string>
<string>TellMe</string>
<key>CFBundleIdentifier</key>
<string>com.menuwhisper.app</string>
<string>com.fmartingr.tellme</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Menu-Whisper</string>
<string>Tell me</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
@ -27,7 +32,7 @@
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2025. All rights reserved.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Menu-Whisper needs access to your microphone to capture speech for offline transcription. Your audio data never leaves your device.</string>
<string>Tell me needs access to your microphone to capture speech for offline transcription. Your audio data never leaves your device.</string>
<key>NSSupportsAutomaticTermination</key>
<true/>
<key>NSSupportsSuddenTermination</key>

View file

@ -1,7 +1,7 @@
/* Menu-Whisper - English Localization */
/* Tell me - English Localization */
/* General */
"app.name" = "Menu-Whisper";
"app.name" = "Tell me";
"general.ok" = "OK";
"general.cancel" = "Cancel";
"general.continue" = "Continue";
@ -13,29 +13,54 @@
"menubar.listening" = "Listening";
"menubar.processing" = "Processing";
"menubar.preferences" = "Preferences...";
"menubar.quit" = "Quit Menu-Whisper";
"menubar.help" = "Help";
"menubar.quit" = "Quit Tell me";
"menubar.loading_model" = "Loading model...";
"menubar.model_status" = "Model: %@";
"menubar.model_loading" = "Model: Loading...";
"menubar.no_model" = "No model - click Preferences";
"menubar.reset_onboarding" = "Reset Onboarding";
/* HUD States */
"hud.listening" = "Listening...";
"hud.processing" = "Transcribing...";
"hud.cancel" = "Press Esc to cancel";
"hud.please_wait" = "Please wait";
/* Permissions */
"permissions.microphone.title" = "Microphone Access Required";
"permissions.microphone.message" = "Menu-Whisper needs access to your microphone to perform speech-to-text transcription.";
"permissions.microphone.message" = "Tell me needs access to your microphone to capture speech for transcription. Please grant microphone access in System Settings.";
"permissions.accessibility.title" = "Accessibility Access Required";
"permissions.accessibility.message" = "Menu-Whisper needs Accessibility access to insert transcribed text into applications.";
"permissions.accessibility.message" = "Tell me needs Accessibility access to insert transcribed text into other applications. Please grant accessibility access in System Settings.";
"permissions.input_monitoring.title" = "Input Monitoring Required";
"permissions.input_monitoring.message" = "Menu-Whisper needs Input Monitoring access to register global hotkeys.";
"permissions.input_monitoring.message" = "Tell me needs Input Monitoring access to register global hotkeys and insert text. Please grant input monitoring access in System Settings.";
"permissions.open_settings" = "Open System Settings";
"permissions.refresh_status" = "Refresh Status";
/* Button Labels */
"general.select" = "Select";
"general.reset" = "Reset";
/* Picker Labels */
"general.language" = "Language";
"general.mode" = "Mode";
"general.method" = "Method";
/* Slider and Control Labels */
"preferences.general.time_limit_slider" = "Time Limit";
"preferences.hud.opacity_slider" = "HUD Opacity";
"preferences.hud.size_slider" = "HUD Size";
"preferences.advanced.threads_slider" = "Processing Threads";
/* Preferences Window */
"preferences.title" = "Menu-Whisper Preferences";
"preferences.title" = "Tell me Preferences";
"preferences.general" = "General";
"preferences.models" = "Models";
"preferences.hotkeys" = "Hotkeys";
"preferences.insertion" = "Text Insertion";
"preferences.interface" = "Interface";
"preferences.advanced" = "Advanced";
"preferences.permissions" = "Permissions";
/* General Preferences */
"preferences.general.hotkey" = "Global Hotkey:";
@ -51,6 +76,7 @@
"preferences.models.language" = "Language:";
"preferences.models.language.auto" = "Auto-detect";
"preferences.models.download" = "Download";
"preferences.models.downloading" = "Downloading...";
"preferences.models.delete" = "Delete";
"preferences.models.size" = "Size:";
"preferences.models.languages" = "Languages:";
@ -74,4 +100,161 @@
/* Success Messages */
"success.model.downloaded" = "Model downloaded successfully";
"success.settings.exported" = "Settings exported successfully";
"success.settings.imported" = "Settings imported successfully";
"success.settings.imported" = "Settings imported successfully";
/* Status Labels */
"status.loaded" = "Loaded";
"status.loading" = "Loading...";
"status.granted" = "Granted";
"status.denied" = "Denied";
"status.not_set" = "Not Set";
"status.restricted" = "Restricted";
/* Alert Titles */
"alert.delete_model" = "Delete Model";
"alert.success" = "Success";
"alert.error" = "Error";
/* Permissions */
"permissions.microphone.title_short" = "Microphone";
"permissions.microphone.description_short" = "Required to capture speech for transcription";
"permissions.accessibility.title_short" = "Accessibility";
"permissions.accessibility.description_short" = "Required to insert transcribed text into other applications";
"permissions.input_monitoring.title_short" = "Input Monitoring";
"permissions.input_monitoring.description_short" = "Required to send keyboard events for text insertion";
/* Preview Dialog */
"preview.title" = "Preview Transcription";
"preview.description" = "Review and edit the transcribed text before insertion.";
"preview.transcribed_text" = "Transcribed Text:";
"preview.character_count" = "%d characters";
"preview.insert" = "Insert";
/* HUD Settings */
"preferences.hud.title" = "Interface Settings";
"preferences.hud.appearance" = "HUD Appearance";
"preferences.hud.opacity" = "Opacity:";
"preferences.hud.size" = "Size:";
"preferences.hud.audio_feedback" = "Audio Feedback";
"preferences.hud.sounds_description" = "Enable audio feedback to know when dictation starts and stops.";
/* Advanced Settings */
"preferences.advanced.title" = "Advanced Settings";
"preferences.advanced.processing" = "Processing";
"preferences.advanced.threads" = "Processing threads:";
"preferences.advanced.threads_description" = "More threads can improve performance on multi-core systems but use more CPU.";
"preferences.advanced.logging" = "Logging";
"preferences.advanced.logging_description" = "Logs are stored locally only and contain timing and error information. No audio or text is ever logged.";
"preferences.advanced.enable_logging" = "Enable local logging";
"preferences.advanced.reset" = "Reset";
"preferences.advanced.reset_button" = "Reset All Settings to Default";
"preferences.advanced.reset_title" = "Reset Settings";
"preferences.advanced.reset_message" = "This will reset all settings to their default values. This action cannot be undone.";
/* General Settings */
"preferences.general.global_hotkey" = "Global Hotkey";
"preferences.general.hotkey_combination" = "Hotkey Combination:";
"preferences.general.audio_timing" = "Audio & Timing";
"preferences.general.settings_management" = "Settings Management";
"preferences.general.export_settings" = "Export Settings...";
"preferences.general.import_settings" = "Import Settings...";
/* Insertion Settings */
"preferences.insertion.title" = "Text Insertion";
"preferences.insertion.method_description" = "Paste is faster and more reliable, but may not work in all applications. Type method works everywhere but is slower.";
"preferences.insertion.preview_description" = "When enabled, transcribed text will be shown in a dialog before insertion, allowing you to review and edit it.";
"preferences.insertion.secure_input_title" = "Secure Input Handling";
"preferences.insertion.secure_input_description" = "Text insertion is automatically disabled in secure contexts (password fields, etc.). Text is copied to clipboard instead.";
/* Languages */
"language.auto_detect" = "Auto-detect";
"language.english" = "English";
"language.spanish" = "Spanish";
"language.french" = "French";
"language.german" = "German";
"language.italian" = "Italian";
"language.portuguese" = "Portuguese";
"language.russian" = "Russian";
"language.chinese" = "Chinese";
"language.japanese" = "Japanese";
"language.korean" = "Korean";
/* Hotkey Recorder */
"hotkey.press_keys" = "Press keys...";
"hotkey.reset_default" = "Reset to Default";
"hotkey.record_description" = "Click to record a new hotkey combination";
/* Onboarding */
"onboarding.title" = "Welcome to Tell me";
"onboarding.subtitle" = "Offline speech-to-text for macOS";
"onboarding.what_is" = "What is Tell me?";
"onboarding.feature.offline" = "Offline Speech Recognition";
"onboarding.feature.offline_desc" = "Convert your speech to text without internet connection";
"onboarding.feature.hotkey" = "Global Hotkey";
"onboarding.feature.hotkey_desc" = "Use ⌘⇧V from anywhere to start dictation";
"onboarding.feature.privacy" = "Privacy First";
"onboarding.feature.privacy_desc" = "Your audio never leaves your device";
"onboarding.permissions.title" = "Permissions Required";
"onboarding.permissions.description" = "Tell me needs the following permissions to work properly:";
"onboarding.models.title" = "Download a Speech Model";
"onboarding.models.description" = "Tell me requires a speech recognition model to work. We recommend starting with the tiny model for quick setup.";
"onboarding.models.recommended" = "Recommended: Whisper Tiny";
"onboarding.models.tiny_description" = "39 MB • Fast • Good for quick setup";
"onboarding.models.downloaded" = "Downloaded";
"onboarding.models.downloading" = "Downloading...";
"onboarding.models.download_failed" = "Download failed: %@";
"onboarding.models.info1" = "You can download larger, more accurate models later from Preferences";
"onboarding.models.info2" = "Models work completely offline once downloaded";
"onboarding.completion.ready_title" = "You're All Set!";
"onboarding.completion.ready_desc" = "Tell me is ready to use with your downloaded model!";
"onboarding.completion.incomplete_title" = "Setup Not Complete";
"onboarding.completion.incomplete_desc" = "Tell me won't work properly without a speech recognition model.";
"onboarding.completion.no_model_warning" = "No model downloaded - dictation will not work";
"onboarding.completion.what_next" = "What to do next:";
"onboarding.completion.step1" = "Press ⌘⇧V to start dictation";
"onboarding.completion.step1_desc" = "Or change the hotkey in Preferences";
"onboarding.completion.step2" = "Speak clearly when the HUD appears";
"onboarding.completion.step2_desc" = "Release the hotkey when done speaking";
"onboarding.completion.step3" = "Press Esc to cancel anytime";
"onboarding.completion.step3_desc" = "Works in all apps and contexts";
"onboarding.completion.next_step1" = "• Go back to step 3 and download the tiny model";
"onboarding.completion.next_step2" = "• Or download models later from Preferences → Models";
"onboarding.completion.next_step3" = "• You'll see an alert when you try to use dictation without a model";
"onboarding.completion.footer" = "Access Preferences from the menu bar icon at any time.";
"onboarding.buttons.skip" = "Skip Setup";
"onboarding.buttons.back" = "Back";
"onboarding.buttons.next" = "Next";
"onboarding.buttons.get_started" = "Get Started";
/* Missing Preferences Strings */
"preferences.models.speech_recognition" = "Speech Recognition Models";
"preferences.models.description" = "Download and manage speech recognition models. Larger models provide better accuracy but use more memory and processing time.";
"preferences.models.current_model" = "Current Model";
"preferences.models.no_model" = "No model selected";
"preferences.models.recognition_language" = "Recognition Language:";
"preferences.models.available_models" = "Available Models";
"preferences.models.active_badge" = "ACTIVE";
"preferences.models.delete_confirm" = "Are you sure you want to delete '%@'? This action cannot be undone.";
/* Permissions Strings */
"preferences.permissions.description" = "Tell me requires certain system permissions to function properly. Click the buttons below to grant permissions in System Settings.";
"preferences.permissions.need_help" = "Need Help?";
"preferences.permissions.after_granting" = "After granting permissions in System Settings:";
"preferences.permissions.step1" = "1. Close System Settings";
"preferences.permissions.step2" = "2. Click 'Refresh Status' to update permission status";
"preferences.permissions.step3" = "3. Some permissions may require restarting Tell me";
/* General Tab Strings */
"preferences.general.title" = "General Settings";
"preferences.general.dictation_limit" = "Dictation time limit:";
"preferences.general.minutes" = "minutes";
/* Advanced Tab Strings */
"preferences.advanced.threads_description" = "More threads can improve performance on multi-core systems but use more CPU.";
"preferences.advanced.logging_description" = "Logs are stored locally only and contain timing and error information. No audio or text is ever logged.";
"preferences.advanced.reset_confirmation" = "This will reset all settings to their default values. This action cannot be undone.";

View file

@ -1,7 +1,7 @@
/* Menu-Whisper - Spanish Localization */
/* Tell me - Spanish Localization */
/* General */
"app.name" = "Menu-Whisper";
"app.name" = "Tell me";
"general.ok" = "Aceptar";
"general.cancel" = "Cancelar";
"general.continue" = "Continuar";
@ -13,29 +13,54 @@
"menubar.listening" = "Escuchando";
"menubar.processing" = "Procesando";
"menubar.preferences" = "Preferencias...";
"menubar.quit" = "Salir de Menu-Whisper";
"menubar.help" = "Ayuda";
"menubar.quit" = "Salir de Tell me";
"menubar.loading_model" = "Cargando modelo...";
"menubar.model_status" = "Modelo: %@";
"menubar.model_loading" = "Modelo: Cargando...";
"menubar.no_model" = "Sin modelo - haz clic en Preferencias";
"menubar.reset_onboarding" = "Restablecer Configuración Inicial";
/* HUD States */
"hud.listening" = "Escuchando...";
"hud.processing" = "Transcribiendo...";
"hud.cancel" = "Presiona Esc para cancelar";
"hud.please_wait" = "Por favor espera";
/* Permissions */
"permissions.microphone.title" = "Acceso al Micrófono Requerido";
"permissions.microphone.message" = "Menu-Whisper necesita acceso a tu micrófono para realizar la transcripción de voz a texto.";
"permissions.microphone.message" = "Tell me necesita acceso a tu micrófono para capturar voz para transcripción. Por favor otorga acceso al micrófono en Configuración del Sistema.";
"permissions.accessibility.title" = "Acceso de Accesibilidad Requerido";
"permissions.accessibility.message" = "Menu-Whisper necesita acceso de Accesibilidad para insertar texto transcrito en aplicaciones.";
"permissions.accessibility.message" = "Tell me necesita acceso de Accesibilidad para insertar texto transcrito en otras aplicaciones. Por favor otorga acceso de accesibilidad en Configuración del Sistema.";
"permissions.input_monitoring.title" = "Monitoreo de Entrada Requerido";
"permissions.input_monitoring.message" = "Menu-Whisper necesita acceso de Monitoreo de Entrada para registrar atajos de teclado globales.";
"permissions.input_monitoring.message" = "Tell me necesita acceso de Monitoreo de Entrada para registrar atajos globales e insertar texto. Por favor otorga acceso de monitoreo de entrada en Configuración del Sistema.";
"permissions.open_settings" = "Abrir Configuración del Sistema";
"permissions.refresh_status" = "Actualizar Estado";
/* Button Labels */
"general.select" = "Seleccionar";
"general.reset" = "Restablecer";
/* Picker Labels */
"general.language" = "Idioma";
"general.mode" = "Modo";
"general.method" = "Método";
/* Slider and Control Labels */
"preferences.general.time_limit_slider" = "Límite de Tiempo";
"preferences.hud.opacity_slider" = "Opacidad del HUD";
"preferences.hud.size_slider" = "Tamaño del HUD";
"preferences.advanced.threads_slider" = "Hilos de Procesamiento";
/* Preferences Window */
"preferences.title" = "Preferencias de Menu-Whisper";
"preferences.title" = "Preferencias de Tell me";
"preferences.general" = "General";
"preferences.models" = "Modelos";
"preferences.hotkeys" = "Atajos";
"preferences.insertion" = "Inserción de Texto";
"preferences.interface" = "Interfaz";
"preferences.advanced" = "Avanzado";
"preferences.permissions" = "Permisos";
/* General Preferences */
"preferences.general.hotkey" = "Atajo Global:";
@ -51,6 +76,7 @@
"preferences.models.language" = "Idioma:";
"preferences.models.language.auto" = "Detección automática";
"preferences.models.download" = "Descargar";
"preferences.models.downloading" = "Descargando...";
"preferences.models.delete" = "Eliminar";
"preferences.models.size" = "Tamaño:";
"preferences.models.languages" = "Idiomas:";
@ -74,4 +100,161 @@
/* Success Messages */
"success.model.downloaded" = "Modelo descargado exitosamente";
"success.settings.exported" = "Configuración exportada exitosamente";
"success.settings.imported" = "Configuración importada exitosamente";
"success.settings.imported" = "Configuración importada exitosamente";
/* Status Labels */
"status.loaded" = "Cargado";
"status.loading" = "Cargando...";
"status.granted" = "Otorgado";
"status.denied" = "Denegado";
"status.not_set" = "No Configurado";
"status.restricted" = "Restringido";
/* Alert Titles */
"alert.delete_model" = "Eliminar Modelo";
"alert.success" = "Éxito";
"alert.error" = "Error";
/* Permissions */
"permissions.microphone.title_short" = "Micrófono";
"permissions.microphone.description_short" = "Requerido para capturar voz para transcripción";
"permissions.accessibility.title_short" = "Accesibilidad";
"permissions.accessibility.description_short" = "Requerido para insertar texto transcrito en otras aplicaciones";
"permissions.input_monitoring.title_short" = "Monitoreo de Entrada";
"permissions.input_monitoring.description_short" = "Requerido para enviar eventos de teclado para inserción de texto";
/* Preview Dialog */
"preview.title" = "Vista Previa de Transcripción";
"preview.description" = "Revisa y edita el texto transcrito antes de insertarlo.";
"preview.transcribed_text" = "Texto Transcrito:";
"preview.character_count" = "%d caracteres";
"preview.insert" = "Insertar";
/* HUD Settings */
"preferences.hud.title" = "Configuración de Interfaz";
"preferences.hud.appearance" = "Apariencia del HUD";
"preferences.hud.opacity" = "Opacidad:";
"preferences.hud.size" = "Tamaño:";
"preferences.hud.audio_feedback" = "Retroalimentación de Audio";
"preferences.hud.sounds_description" = "Habilita retroalimentación de audio para saber cuándo empieza y termina el dictado.";
/* Advanced Settings */
"preferences.advanced.title" = "Configuración Avanzada";
"preferences.advanced.processing" = "Procesamiento";
"preferences.advanced.threads" = "Hilos de procesamiento:";
"preferences.advanced.threads_description" = "Más hilos pueden mejorar el rendimiento en sistemas multi-núcleo pero usan más CPU.";
"preferences.advanced.logging" = "Registro";
"preferences.advanced.logging_description" = "Los registros se almacenan solo localmente y contienen información de tiempo y errores. Nunca se registra audio o texto.";
"preferences.advanced.enable_logging" = "Habilitar registro local";
"preferences.advanced.reset" = "Restablecer";
"preferences.advanced.reset_button" = "Restablecer Toda la Configuración por Defecto";
"preferences.advanced.reset_title" = "Restablecer Configuración";
"preferences.advanced.reset_message" = "Esto restablecerá toda la configuración a sus valores por defecto. Esta acción no se puede deshacer.";
/* General Settings */
"preferences.general.global_hotkey" = "Atajo Global";
"preferences.general.hotkey_combination" = "Combinación de Teclas:";
"preferences.general.audio_timing" = "Audio y Temporización";
"preferences.general.settings_management" = "Gestión de Configuración";
"preferences.general.export_settings" = "Exportar Configuración...";
"preferences.general.import_settings" = "Importar Configuración...";
/* Insertion Settings */
"preferences.insertion.title" = "Inserción de Texto";
"preferences.insertion.method_description" = "Pegar es más rápido y confiable, pero puede no funcionar en todas las aplicaciones. El método de escritura funciona en todas partes pero es más lento.";
"preferences.insertion.preview_description" = "Cuando está habilitado, el texto transcrito se mostrará en un diálogo antes de la inserción, permitiéndote revisarlo y editarlo.";
"preferences.insertion.secure_input_title" = "Manejo de Entrada Segura";
"preferences.insertion.secure_input_description" = "La inserción de texto se desactiva automáticamente en contextos seguros (campos de contraseña, etc.). El texto se copia al portapapeles en su lugar.";
/* Languages */
"language.auto_detect" = "Detección automática";
"language.english" = "Inglés";
"language.spanish" = "Español";
"language.french" = "Francés";
"language.german" = "Alemán";
"language.italian" = "Italiano";
"language.portuguese" = "Portugués";
"language.russian" = "Ruso";
"language.chinese" = "Chino";
"language.japanese" = "Japonés";
"language.korean" = "Coreano";
/* Hotkey Recorder */
"hotkey.press_keys" = "Presiona teclas...";
"hotkey.reset_default" = "Restablecer por Defecto";
"hotkey.record_description" = "Haz clic para grabar una nueva combinación de teclas";
/* Onboarding */
"onboarding.title" = "Bienvenido a Tell me";
"onboarding.subtitle" = "Reconocimiento de voz offline para macOS";
"onboarding.what_is" = "¿Qué es Tell me?";
"onboarding.feature.offline" = "Reconocimiento de Voz Offline";
"onboarding.feature.offline_desc" = "Convierte tu voz a texto sin conexión a internet";
"onboarding.feature.hotkey" = "Atajo Global";
"onboarding.feature.hotkey_desc" = "Usa ⌘⇧V desde cualquier lugar para iniciar dictado";
"onboarding.feature.privacy" = "Privacidad Primero";
"onboarding.feature.privacy_desc" = "Tu audio nunca sale de tu dispositivo";
"onboarding.permissions.title" = "Permisos Requeridos";
"onboarding.permissions.description" = "Tell me necesita los siguientes permisos para funcionar correctamente:";
"onboarding.models.title" = "Descargar un Modelo de Voz";
"onboarding.models.description" = "Tell me requiere un modelo de reconocimiento de voz para funcionar. Recomendamos empezar con el modelo tiny para configuración rápida.";
"onboarding.models.recommended" = "Recomendado: Whisper Tiny";
"onboarding.models.tiny_description" = "39 MB • Rápido • Bueno para configuración inicial";
"onboarding.models.downloaded" = "Descargado";
"onboarding.models.downloading" = "Descargando...";
"onboarding.models.download_failed" = "Descarga falló: %@";
"onboarding.models.info1" = "Puedes descargar modelos más grandes y precisos después desde Preferencias";
"onboarding.models.info2" = "Los modelos funcionan completamente offline una vez descargados";
"onboarding.completion.ready_title" = "¡Todo Listo!";
"onboarding.completion.ready_desc" = "¡Tell me está listo para usar con tu modelo descargado!";
"onboarding.completion.incomplete_title" = "Configuración Incompleta";
"onboarding.completion.incomplete_desc" = "Tell me no funcionará correctamente sin un modelo de reconocimiento de voz.";
"onboarding.completion.no_model_warning" = "Sin modelo descargado - el dictado no funcionará";
"onboarding.completion.what_next" = "Qué hacer a continuación:";
"onboarding.completion.step1" = "Presiona ⌘⇧V para iniciar dictado";
"onboarding.completion.step1_desc" = "O cambia el atajo en Preferencias";
"onboarding.completion.step2" = "Habla claramente cuando aparezca el HUD";
"onboarding.completion.step2_desc" = "Suelta el atajo cuando termines de hablar";
"onboarding.completion.step3" = "Presiona Esc para cancelar en cualquier momento";
"onboarding.completion.step3_desc" = "Funciona en todas las aplicaciones y contextos";
"onboarding.completion.next_step1" = "• Regresa al paso 3 y descarga el modelo tiny";
"onboarding.completion.next_step2" = "• O descarga modelos después desde Preferencias → Modelos";
"onboarding.completion.next_step3" = "• Verás una alerta cuando trates de usar dictado sin modelo";
"onboarding.completion.footer" = "Accede a Preferencias desde el ícono de la barra de menú en cualquier momento.";
"onboarding.buttons.skip" = "Saltar Configuración";
"onboarding.buttons.back" = "Atrás";
"onboarding.buttons.next" = "Siguiente";
"onboarding.buttons.get_started" = "Empezar";
/* Missing Preferences Strings */
"preferences.models.speech_recognition" = "Modelos de Reconocimiento de Voz";
"preferences.models.description" = "Descarga y gestiona modelos de reconocimiento de voz. Los modelos más grandes proporcionan mejor precisión pero usan más memoria y tiempo de procesamiento.";
"preferences.models.current_model" = "Modelo Actual";
"preferences.models.no_model" = "Ningún modelo seleccionado";
"preferences.models.recognition_language" = "Idioma de Reconocimiento:";
"preferences.models.available_models" = "Modelos Disponibles";
"preferences.models.active_badge" = "ACTIVO";
"preferences.models.delete_confirm" = "¿Estás seguro de que quieres eliminar '%@'? Esta acción no se puede deshacer.";
/* Permissions Strings */
"preferences.permissions.description" = "Tell me requiere ciertos permisos del sistema para funcionar correctamente. Haz clic en los botones de abajo para otorgar permisos en Configuración del Sistema.";
"preferences.permissions.need_help" = "¿Necesitas Ayuda?";
"preferences.permissions.after_granting" = "Después de otorgar permisos en Configuración del Sistema:";
"preferences.permissions.step1" = "1. Cierra Configuración del Sistema";
"preferences.permissions.step2" = "2. Haz clic en 'Actualizar Estado' para actualizar el estado de permisos";
"preferences.permissions.step3" = "3. Algunos permisos pueden requerir reiniciar Tell me";
/* General Tab Strings */
"preferences.general.title" = "Configuración General";
"preferences.general.dictation_limit" = "Límite de tiempo de dictado:";
"preferences.general.minutes" = "minutos";
/* Advanced Tab Strings */
"preferences.advanced.threads_description" = "Más hilos pueden mejorar el rendimiento en sistemas multi-núcleo pero usan más CPU.";
"preferences.advanced.logging_description" = "Los registros se almacenan solo localmente y contienen información de tiempo y errores. Nunca se registra audio o texto.";
"preferences.advanced.reset_confirmation" = "Esto restablecerá toda la configuración a sus valores por defecto. Esta acción no se puede deshacer.";

View file

@ -2,16 +2,17 @@ import Foundation
import AVFoundation
import AppKit
import CoreUtils
import CoreSettings
public class SoundManager: ObservableObject {
private let logger = Logger(category: "SoundManager")
@Published public var soundsEnabled: Bool = true
private let settings: CoreSettings.Settings
private var startSound: AVAudioPlayer?
private var stopSound: AVAudioPlayer?
public init() {
public init(settings: CoreSettings.Settings) {
self.settings = settings
setupSounds()
}
@ -28,7 +29,7 @@ public class SoundManager: ObservableObject {
}
public func playStartSound() {
guard soundsEnabled else { return }
guard settings.playSounds else { return }
logger.debug("Playing start sound")
// Use a subtle system sound for start
@ -36,7 +37,7 @@ public class SoundManager: ObservableObject {
}
public func playStopSound() {
guard soundsEnabled else { return }
guard settings.playSounds else { return }
logger.debug("Playing stop sound")
// Use a different system sound for stop

View file

@ -10,7 +10,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
@main
struct MenuWhisperApp: App {
struct TellMeApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {

View file

@ -26,7 +26,7 @@ public struct ModelInfo: Codable, Identifiable {
public var fileURL: URL {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let modelsDirectory = appSupport.appendingPathComponent("MenuWhisper/Models")
let modelsDirectory = appSupport.appendingPathComponent("TellMe/Models")
return modelsDirectory.appendingPathComponent(filename)
}
@ -102,7 +102,7 @@ public enum ModelError: Error, LocalizedError {
}
@MainActor
public class ModelManager: ObservableObject {
public class ModelManager: NSObject, ObservableObject {
private let logger = Logger(category: "ModelManager")
@Published public private(set) var availableModels: [ModelInfo] = []
@ -111,19 +111,22 @@ public class ModelManager: ObservableObject {
@Published public private(set) var downloadProgress: [String: DownloadProgress] = [:]
private let modelsDirectory: URL
private let urlSession: URLSession
private var urlSession: URLSession
private var downloadTasks: [String: URLSessionDownloadTask] = [:]
private var progressCallbacks: [String: (DownloadProgress) -> Void] = [:]
public init() {
public override init() {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
modelsDirectory = appSupport.appendingPathComponent("MenuWhisper/Models")
modelsDirectory = appSupport.appendingPathComponent("TellMe/Models")
// Configure URLSession for downloads
// Configure URLSession for downloads (simple session, delegates created per download)
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 30
config.timeoutIntervalForResource = 3600 // 1 hour for large model downloads
urlSession = URLSession(configuration: config)
super.init()
try? FileManager.default.createDirectory(at: modelsDirectory, withIntermediateDirectories: true)
// Ensure we have models available - use fallback approach first
@ -172,35 +175,34 @@ public class ModelManager: ObservableObject {
throw ModelError.downloadFailed("Invalid download URL")
}
// Create temporary file for download
let tempURL = modelsDirectory.appendingPathComponent("\(model.name).tmp")
let modelName = model.name
let modelSHA256 = model.sha256
let modelFileURL = model.fileURL
do {
let (tempFileURL, response) = try await urlSession.download(from: url)
print("Starting download for \(modelName) from \(url)")
guard let httpResponse = response as? HTTPURLResponse,
(200..<300).contains(httpResponse.statusCode) else {
throw ModelError.downloadFailed("HTTP error: \(String(describing: (response as? HTTPURLResponse)?.statusCode))")
}
// Use simple URLSession download for reliability (progress spinners don't need exact progress)
let (tempURL, response) = try await urlSession.download(from: url)
// Verify SHA256 checksum if provided
if !model.sha256.isEmpty {
try await verifyChecksum(fileURL: tempFileURL, expectedSHA256: model.sha256)
}
// Move to final location
if FileManager.default.fileExists(atPath: model.fileURL.path) {
try FileManager.default.removeItem(at: model.fileURL)
}
try FileManager.default.moveItem(at: tempFileURL, to: model.fileURL)
logger.info("Model file \(model.name).bin downloaded successfully")
} catch {
// Clean up temp files on error
try? FileManager.default.removeItem(at: tempURL)
throw ModelError.downloadFailed(error.localizedDescription)
guard let httpResponse = response as? HTTPURLResponse,
(200..<300).contains(httpResponse.statusCode) else {
throw ModelError.downloadFailed("HTTP error: \(String(describing: (response as? HTTPURLResponse)?.statusCode))")
}
print("Download completed for \(modelName)")
// Verify SHA256 checksum if provided
if !modelSHA256.isEmpty {
try await verifyChecksum(fileURL: tempURL, expectedSHA256: modelSHA256)
}
// Move to final location
if FileManager.default.fileExists(atPath: modelFileURL.path) {
try FileManager.default.removeItem(at: modelFileURL)
}
try FileManager.default.moveItem(at: tempURL, to: modelFileURL)
logger.info("Model file \(modelName).bin downloaded successfully")
}
private func downloadCoreMlEncoder(_ model: ModelInfo) async throws {
@ -402,14 +404,14 @@ public class ModelManager: ObservableObject {
private func saveActiveModelPreference() {
if let activeModel = activeModel {
UserDefaults.standard.set(activeModel.name, forKey: "MenuWhisper.ActiveModel")
UserDefaults.standard.set(activeModel.name, forKey: "TellMe.ActiveModel")
} else {
UserDefaults.standard.removeObject(forKey: "MenuWhisper.ActiveModel")
UserDefaults.standard.removeObject(forKey: "TellMe.ActiveModel")
}
}
private func loadActiveModelPreference() {
guard let modelName = UserDefaults.standard.string(forKey: "MenuWhisper.ActiveModel") else {
guard let modelName = UserDefaults.standard.string(forKey: "TellMe.ActiveModel") else {
return
}
@ -417,7 +419,8 @@ public class ModelManager: ObservableObject {
if activeModel == nil {
// Clear preference if model is no longer available or downloaded
UserDefaults.standard.removeObject(forKey: "MenuWhisper.ActiveModel")
UserDefaults.standard.removeObject(forKey: "TellMe.ActiveModel")
}
}
}
}

View file

@ -28,6 +28,20 @@ public struct HotkeyConfig: Codable {
public static let `default` = HotkeyConfig(keyCode: 9, modifiers: 768) // V key with Cmd+Shift
}
public enum InsertionMethod: String, CaseIterable, Codable {
case paste = "paste"
case typing = "typing"
public var displayName: String {
switch self {
case .paste:
return NSLocalizedString("preferences.insertion.method.paste", comment: "Paste method")
case .typing:
return NSLocalizedString("preferences.insertion.method.type", comment: "Type method")
}
}
}
public class Settings: ObservableObject {
private let logger = Logger(category: "Settings")
private let userDefaults = UserDefaults.standard
@ -49,6 +63,15 @@ public class Settings: ObservableObject {
didSet { userDefaults.set(dictationTimeLimit, forKey: "dictationTimeLimit") }
}
// HUD Settings
@Published public var hudOpacity: Double {
didSet { userDefaults.set(hudOpacity, forKey: "hudOpacity") }
}
@Published public var hudSize: Double {
didSet { userDefaults.set(hudSize, forKey: "hudSize") }
}
// Model Settings
@Published public var activeModelName: String? {
didSet { userDefaults.set(activeModelName, forKey: "activeModelName") }
@ -59,25 +82,47 @@ public class Settings: ObservableObject {
}
// Insertion Settings
@Published public var insertionMethod: String {
didSet { userDefaults.set(insertionMethod, forKey: "insertionMethod") }
@Published public var insertionMethod: InsertionMethod {
didSet { userDefaults.set(insertionMethod.rawValue, forKey: "insertionMethod") }
}
@Published public var showPreview: Bool {
didSet { userDefaults.set(showPreview, forKey: "showPreview") }
}
// Advanced Settings
@Published public var enableLogging: Bool {
didSet { userDefaults.set(enableLogging, forKey: "enableLogging") }
}
@Published public var processingThreads: Int {
didSet { userDefaults.set(processingThreads, forKey: "processingThreads") }
}
public init() {
// Load settings from UserDefaults
self.hotkey = Settings.loadHotkey()
self.hotkeyMode = HotkeyMode(rawValue: userDefaults.string(forKey: "hotkeyMode") ?? "") ?? .pushToTalk
self.playSounds = userDefaults.object(forKey: "playSounds") as? Bool ?? false
self.dictationTimeLimit = userDefaults.object(forKey: "dictationTimeLimit") as? TimeInterval ?? 600 // 10 minutes
// HUD Settings
self.hudOpacity = userDefaults.object(forKey: "hudOpacity") as? Double ?? 0.9
self.hudSize = userDefaults.object(forKey: "hudSize") as? Double ?? 1.0
// Model Settings
self.activeModelName = userDefaults.string(forKey: "activeModelName")
self.forcedLanguage = userDefaults.string(forKey: "forcedLanguage")
self.insertionMethod = userDefaults.string(forKey: "insertionMethod") ?? "paste"
// Insertion Settings
let insertionMethodString = userDefaults.string(forKey: "insertionMethod") ?? "paste"
self.insertionMethod = InsertionMethod(rawValue: insertionMethodString) ?? .paste
self.showPreview = userDefaults.object(forKey: "showPreview") as? Bool ?? false
// Advanced Settings
self.enableLogging = userDefaults.object(forKey: "enableLogging") as? Bool ?? false
self.processingThreads = userDefaults.object(forKey: "processingThreads") as? Int ?? 4
logger.info("Settings initialized")
}
@ -88,10 +133,14 @@ public class Settings: ObservableObject {
"hotkeyMode": hotkeyMode.rawValue,
"playSounds": playSounds,
"dictationTimeLimit": dictationTimeLimit,
"hudOpacity": hudOpacity,
"hudSize": hudSize,
"activeModelName": activeModelName as Any,
"forcedLanguage": forcedLanguage as Any,
"insertionMethod": insertionMethod,
"showPreview": showPreview
"insertionMethod": insertionMethod.rawValue,
"showPreview": showPreview,
"enableLogging": enableLogging,
"processingThreads": processingThreads
]
return try JSONSerialization.data(withJSONObject: settingsDict, options: .prettyPrinted)
@ -118,10 +167,19 @@ public class Settings: ObservableObject {
dictationTimeLimit = timeLimit
}
if let opacity = settingsDict["hudOpacity"] as? Double {
hudOpacity = opacity
}
if let size = settingsDict["hudSize"] as? Double {
hudSize = size
}
activeModelName = settingsDict["activeModelName"] as? String
forcedLanguage = settingsDict["forcedLanguage"] as? String
if let method = settingsDict["insertionMethod"] as? String {
if let methodString = settingsDict["insertionMethod"] as? String,
let method = InsertionMethod(rawValue: methodString) {
insertionMethod = method
}
@ -129,6 +187,14 @@ public class Settings: ObservableObject {
showPreview = preview
}
if let logging = settingsDict["enableLogging"] as? Bool {
enableLogging = logging
}
if let threads = settingsDict["processingThreads"] as? Int {
processingThreads = threads
}
logger.info("Settings imported successfully")
}

View file

@ -0,0 +1,119 @@
import Foundation
public class LocalizationManager {
public static let shared = LocalizationManager()
private var currentLanguage: String
private var strings: [String: String] = [:]
private init() {
// Detect system language
let preferredLanguages = Locale.preferredLanguages
let systemLanguage = preferredLanguages.first ?? "en"
if systemLanguage.hasPrefix("es") {
currentLanguage = "es"
} else {
currentLanguage = "en"
}
loadStrings()
}
public func localizedString(_ key: String, comment: String = "") -> String {
return strings[key] ?? key
}
private func loadStrings() {
// Load strings for current language
if let path = Bundle.main.path(forResource: "Localizable", ofType: "strings", inDirectory: "Localizations/\(currentLanguage).lproj"),
let data = FileManager.default.contents(atPath: path),
let content = String(data: data, encoding: .utf8) {
parseStringsFile(content)
} else {
// Fallback to embedded strings
loadEmbeddedStrings()
}
}
private func parseStringsFile(_ content: String) {
let lines = content.components(separatedBy: .newlines)
for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.hasPrefix("\"") && trimmed.contains("\" = \"") && trimmed.hasSuffix("\";") {
// Parse: "key" = "value";
let parts = trimmed.dropFirst().dropLast() // Remove leading " and trailing ";
if let equalIndex = parts.firstIndex(of: "=") {
let keyPart = String(parts[..<equalIndex]).trimmingCharacters(in: .whitespacesAndNewlines)
let valuePart = String(parts[parts.index(after: equalIndex)...]).trimmingCharacters(in: .whitespacesAndNewlines)
if keyPart.hasPrefix("\"") && keyPart.hasSuffix("\"") &&
valuePart.hasPrefix("\"") && valuePart.hasSuffix("\"") {
let key = String(keyPart.dropFirst().dropLast())
let value = String(valuePart.dropFirst().dropLast())
strings[key] = value
}
}
}
}
}
private func loadEmbeddedStrings() {
// Fallback embedded strings for key functionality
if currentLanguage == "es" {
strings = [
"app.name": "Tell me",
"preferences.title": "Preferencias de Tell me",
"preferences.general": "General",
"preferences.models": "Modelos",
"preferences.insertion": "Inserción de Texto",
"preferences.interface": "Interfaz",
"preferences.advanced": "Avanzado",
"preferences.permissions": "Permisos",
"preferences.general.title": "Configuración General",
"preferences.models.title": "Modelos de Reconocimiento de Voz",
"preferences.insertion.title": "Inserción de Texto",
"preferences.hud.title": "Configuración de Interfaz",
"preferences.advanced.title": "Configuración Avanzada",
"general.cancel": "Cancelar",
"general.ok": "Aceptar",
"preferences.models.download": "Descargar",
"preferences.models.delete": "Eliminar",
"preferences.models.downloading": "Descargando...",
"status.loaded": "Cargado",
"status.loading": "Cargando...",
"alert.delete_model": "Eliminar Modelo"
]
} else {
strings = [
"app.name": "Tell me",
"preferences.title": "Tell me Preferences",
"preferences.general": "General",
"preferences.models": "Models",
"preferences.insertion": "Text Insertion",
"preferences.interface": "Interface",
"preferences.advanced": "Advanced",
"preferences.permissions": "Permissions",
"preferences.general.title": "General Settings",
"preferences.models.title": "Speech Recognition Models",
"preferences.insertion.title": "Text Insertion",
"preferences.hud.title": "Interface Settings",
"preferences.advanced.title": "Advanced Settings",
"general.cancel": "Cancel",
"general.ok": "OK",
"preferences.models.download": "Download",
"preferences.models.delete": "Delete",
"preferences.models.downloading": "Downloading...",
"status.loaded": "Loaded",
"status.loading": "Loading...",
"alert.delete_model": "Delete Model"
]
}
}
}
// Global convenience function that doesn't conflict with system NSLocalizedString
public func L(_ key: String) -> String {
return LocalizationManager.shared.localizedString(key)
}