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

@ -8,7 +8,7 @@ on:
jobs: jobs:
build: build:
name: Build Menu-Whisper name: Build Tell me
runs-on: macos-13 runs-on: macos-13
steps: steps:

View file

@ -71,7 +71,7 @@ Menu-Whisper follows a modular architecture with clear separation of concerns be
- Curated model catalog (JSON-based) - Curated model catalog (JSON-based)
- Download management with progress tracking - Download management with progress tracking
- SHA256 verification and integrity checks - SHA256 verification and integrity checks
- Local storage in `~/Library/Application Support/MenuWhisper/Models` - Local storage in `~/Library/Application Support/TellMe/Models`
- Model selection and metadata management - Model selection and metadata management
#### Core/Injection #### Core/Injection
@ -185,7 +185,7 @@ The application follows a finite state machine pattern:
The project uses Swift Package Manager with modular targets: The project uses Swift Package Manager with modular targets:
``` ```
MenuWhisper/ TellMe/
├── Package.swift # SPM configuration ├── Package.swift # SPM configuration
├── Sources/ ├── Sources/
│ ├── App/ # Main application target │ ├── App/ # Main application target

View file

@ -2,13 +2,13 @@
import PackageDescription import PackageDescription
let package = Package( let package = Package(
name: "MenuWhisper", name: "TellMe",
platforms: [ platforms: [
.macOS(.v13) .macOS(.v13)
], ],
products: [ products: [
.executable( .executable(
name: "MenuWhisper", name: "TellMe",
targets: ["App"] targets: ["App"]
) )
], ],
@ -20,7 +20,7 @@ let package = Package(
.executableTarget( .executableTarget(
name: "App", name: "App",
dependencies: [ dependencies: [
"MenuWhisperAudio", "TellMeAudio",
"CoreSTT", "CoreSTT",
"CoreModels", "CoreModels",
"CoreInjection", "CoreInjection",
@ -36,7 +36,7 @@ let package = Package(
// Core Module Targets // Core Module Targets
.target( .target(
name: "MenuWhisperAudio", name: "TellMeAudio",
dependencies: ["CoreUtils"], dependencies: ["CoreUtils"],
path: "Sources/CoreAudio" path: "Sources/CoreAudio"
), ),
@ -46,7 +46,7 @@ let package = Package(
dependencies: [ dependencies: [
"CoreUtils", "CoreUtils",
"CoreModels", "CoreModels",
"MenuWhisperAudio", "TellMeAudio",
.product(name: "SwiftWhisper", package: "SwiftWhisper") .product(name: "SwiftWhisper", package: "SwiftWhisper")
], ],
path: "Sources/CoreSTT" path: "Sources/CoreSTT"
@ -83,8 +83,8 @@ let package = Package(
// Test Targets // Test Targets
.testTarget( .testTarget(
name: "MenuWhisperAudioTests", name: "TellMeAudioTests",
dependencies: ["MenuWhisperAudio"], dependencies: ["TellMeAudio"],
path: "Tests/CoreAudioTests" path: "Tests/CoreAudioTests"
), ),
@ -126,7 +126,7 @@ let package = Package(
.testTarget( .testTarget(
name: "IntegrationTests", name: "IntegrationTests",
dependencies: ["CoreSTT", "CoreModels", "MenuWhisperAudio"], dependencies: ["CoreSTT", "CoreModels", "TellMeAudio"],
path: "Tests/IntegrationTests" path: "Tests/IntegrationTests"
) )
] ]

View file

@ -1,10 +1,10 @@
# Menu-Whisper # Tell me
A macOS menu bar application that provides offline speech-to-text transcription using Whisper-family models and automatically inserts the transcribed text into the currently focused application. A macOS menu bar application that provides offline speech-to-text transcription using Whisper-family models and automatically inserts the transcribed text into the currently focused application.
## Overview ## Overview
Menu-Whisper is designed to be a privacy-focused, offline-first speech recognition tool for macOS. It runs entirely locally on Apple Silicon machines, requiring no internet connection during normal operation (only for initial model downloads). Tell me is designed to be a privacy-focused, offline-first speech recognition tool for macOS. It runs entirely locally on Apple Silicon machines, requiring no internet connection during normal operation (only for initial model downloads).
### Key Features ### Key Features

View file

@ -1,11 +1,11 @@
#!/bin/bash #!/bin/bash
# Build script for Menu-Whisper # Build script for Tell me
# This script builds the project using Swift Package Manager # This script builds the project using Swift Package Manager
set -e set -e
echo "🔨 Building Menu-Whisper..." echo "🔨 Building Tell me..."
# Clean previous build # Clean previous build
echo "🧹 Cleaning previous build..." echo "🧹 Cleaning previous build..."

View file

@ -6,9 +6,11 @@ set -e
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BUILD_DIR="$PROJECT_DIR/.build" BUILD_DIR="$PROJECT_DIR/.build"
DEV_APP_DIR="$BUILD_DIR/MenuWhisper-Dev.app" DEV_APP_DIR="$BUILD_DIR/TellMe-Dev.app"
echo "🔨 Building MenuWhisper for development..." echo "🔨 Building Tell me for development..."
defaults delete com.fmartingr.tellme hasShownPermissionOnboarding 2>/dev/null || echo "Onboarding status reset for new domain"
# Clean previous dev build # Clean previous dev build
rm -rf "$DEV_APP_DIR" rm -rf "$DEV_APP_DIR"
@ -21,7 +23,7 @@ mkdir -p "$DEV_APP_DIR/Contents/MacOS"
mkdir -p "$DEV_APP_DIR/Contents/Resources" mkdir -p "$DEV_APP_DIR/Contents/Resources"
# Copy executable # Copy executable
cp "$BUILD_DIR/arm64-apple-macosx/debug/MenuWhisper" "$DEV_APP_DIR/Contents/MacOS/MenuWhisper" cp "$BUILD_DIR/arm64-apple-macosx/debug/TellMe" "$DEV_APP_DIR/Contents/MacOS/TellMe"
# Create Info.plist # Create Info.plist
cat > "$DEV_APP_DIR/Contents/Info.plist" << EOF cat > "$DEV_APP_DIR/Contents/Info.plist" << EOF
@ -30,11 +32,11 @@ cat > "$DEV_APP_DIR/Contents/Info.plist" << EOF
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>MenuWhisper</string> <string>TellMe</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
<string>com.menuwhisper.dev</string> <string>com.fmartingr.tellme</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>MenuWhisper Dev</string> <string>TellMe Dev</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
@ -46,14 +48,20 @@ cat > "$DEV_APP_DIR/Contents/Info.plist" << EOF
<key>LSUIElement</key> <key>LSUIElement</key>
<true/> <true/>
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
<string>MenuWhisper needs microphone access to capture speech for transcription.</string> <string>Tell me needs microphone access to capture speech for transcription.</string>
</dict> </dict>
</plist> </plist>
EOF EOF
# Copy resources if they exist # Copy resources if they exist
if [ -d "$PROJECT_DIR/Sources/App/Resources" ]; then if [ -d "$PROJECT_DIR/Sources/App/Resources" ]; then
cp -r "$PROJECT_DIR/Sources/App/Resources/"* "$DEV_APP_DIR/Contents/Resources/" 2>/dev/null || true # Copy non-localization resources
find "$PROJECT_DIR/Sources/App/Resources" -maxdepth 1 -type f -exec cp {} "$DEV_APP_DIR/Contents/Resources/" \;
# Copy localization files to correct location (directly in Resources, not in Localizations subfolder)
if [ -d "$PROJECT_DIR/Sources/App/Resources/Localizations" ]; then
cp -r "$PROJECT_DIR/Sources/App/Resources/Localizations/"*.lproj "$DEV_APP_DIR/Contents/Resources/" 2>/dev/null || true
fi
fi fi
echo "✅ Development app bundle created at: $DEV_APP_DIR" echo "✅ Development app bundle created at: $DEV_APP_DIR"
@ -61,6 +69,6 @@ echo ""
echo "To run with proper permissions:" echo "To run with proper permissions:"
echo "1. open '$DEV_APP_DIR'" echo "1. open '$DEV_APP_DIR'"
echo "2. Grant permissions in System Settings" echo "2. Grant permissions in System Settings"
echo "3. Or run: '$DEV_APP_DIR/Contents/MacOS/MenuWhisper'" echo "3. Or run: '$DEV_APP_DIR/Contents/MacOS/TellMe'"
echo "" echo ""
echo "The app bundle makes it easier to grant permissions in System Settings." echo "The app bundle makes it easier to grant permissions in System Settings."

View file

@ -1,11 +1,11 @@
#!/bin/bash #!/bin/bash
# Notarization script for Menu-Whisper # Notarization script for Tell me
# This is a placeholder script that will be completed in Phase 5 # This is a placeholder script that will be completed in Phase 5
set -e set -e
echo "🍎 Menu-Whisper Notarization Script" echo "🍎 Tell me Notarization Script"
echo "📋 This script will handle code signing and notarization for distribution" echo "📋 This script will handle code signing and notarization for distribution"
echo "" echo ""
echo "⚠️ This is a placeholder script - implementation pending Phase 5" echo "⚠️ This is a placeholder script - implementation pending Phase 5"

View file

@ -1,10 +1,11 @@
import SwiftUI import SwiftUI
import CoreUtils import CoreUtils
import MenuWhisperAudio import TellMeAudio
import CorePermissions import CorePermissions
import CoreSTT import CoreSTT
import CoreModels import CoreModels
import CoreInjection import CoreInjection
import CoreSettings
import AVFoundation import AVFoundation
public class AppController: ObservableObject { public class AppController: ObservableObject {
@ -14,16 +15,21 @@ public class AppController: ObservableObject {
private let hotkeyManager = HotkeyManager() private let hotkeyManager = HotkeyManager()
private let audioEngine = AudioEngine() private let audioEngine = AudioEngine()
private let permissionManager = PermissionManager() private let permissionManager = PermissionManager()
private let soundManager = SoundManager() private let soundManager: SoundManager
private let textInjector: TextInjector private let textInjector: TextInjector
// Settings
public let settings = Settings()
// STT components // STT components
public let whisperEngine = WhisperCPPEngine(numThreads: 4, useGPU: true) public let whisperEngine: WhisperCPPEngine
public var modelManager: ModelManager! public var modelManager: ModelManager!
// UI components // UI components
private var hudWindow: HUDWindow? private var hudWindow: HUDWindow?
private var preferencesWindow: PreferencesWindowController? private var preferencesWindow: PreferencesWindowController?
private var onboardingWindow: OnboardingWindowController?
private var helpWindow: HelpWindowController?
private var statusItem: NSStatusItem? private var statusItem: NSStatusItem?
// State management // State management
@ -32,9 +38,13 @@ public class AppController: ObservableObject {
// Dictation timer // Dictation timer
private var dictationTimer: Timer? private var dictationTimer: Timer?
private let maxDictationDuration: TimeInterval = 600 // 10 minutes default
public init() { public init() {
whisperEngine = WhisperCPPEngine(
numThreads: settings.processingThreads,
useGPU: true
)
soundManager = SoundManager(settings: settings)
textInjector = TextInjector(permissionManager: permissionManager) textInjector = TextInjector(permissionManager: permissionManager)
setupDelegates() setupDelegates()
setupNotifications() setupNotifications()
@ -89,6 +99,10 @@ public class AppController: ObservableObject {
public func start() { public func start() {
logger.info("Starting app controller") 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 // Setup status item menu on main actor
Task { @MainActor in Task { @MainActor in
setupStatusItemMenu() setupStatusItemMenu()
@ -110,14 +124,14 @@ public class AppController: ObservableObject {
@MainActor @MainActor
private func setupStatusItemMenu() { private func setupStatusItemMenu() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) 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 statusItem?.button?.imagePosition = .imageOnly
let menu = NSMenu() let menu = NSMenu()
// Status item // Status item
let statusMenuItem = NSMenuItem() let statusMenuItem = NSMenuItem()
statusMenuItem.title = "MenuWhisper" statusMenuItem.title = NSLocalizedString("app.name", comment: "App name")
statusMenuItem.isEnabled = false statusMenuItem.isEnabled = false
menu.addItem(statusMenuItem) menu.addItem(statusMenuItem)
@ -125,19 +139,33 @@ public class AppController: ObservableObject {
// Model status // Model status
let modelMenuItem = NSMenuItem() let modelMenuItem = NSMenuItem()
modelMenuItem.title = "Loading model..." modelMenuItem.title = NSLocalizedString("menubar.loading_model", comment: "Loading model...")
modelMenuItem.isEnabled = false modelMenuItem.isEnabled = false
menu.addItem(modelMenuItem) menu.addItem(modelMenuItem)
menu.addItem(NSMenuItem.separator()) menu.addItem(NSMenuItem.separator())
// Preferences // 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 preferencesMenuItem.target = self
menu.addItem(preferencesMenuItem) 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 // 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 quitMenuItem.target = self
menu.addItem(quitMenuItem) 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() { @objc private func quitApp() {
NSApplication.shared.terminate(nil) NSApplication.shared.terminate(nil)
} }
@ -165,11 +211,11 @@ public class AppController: ObservableObject {
let modelMenuItem = menu.items[2] // Model status item let modelMenuItem = menu.items[2] // Model status item
if let activeModel = modelManager?.activeModel, whisperEngine.isModelLoaded() { 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 { } else if modelManager?.activeModel != nil {
modelMenuItem.title = "Model: Loading..." modelMenuItem.title = NSLocalizedString("menubar.model_loading", comment: "Model loading")
} else { } 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 @MainActor
private func showPermissionOnboarding() { private func showPermissionOnboarding() {
let alert = NSAlert() guard let modelManager = modelManager else {
alert.messageText = "Welcome to MenuWhisper" logger.error("ModelManager not initialized for onboarding")
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?" return
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
} }
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) { private func checkMicrophonePermission(completion: @escaping (Bool) -> Void) {
@ -252,12 +305,53 @@ public class AppController: ObservableObject {
cancelDictation() 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() { private func startListening() {
guard currentState == .idle else { guard currentState == .idle else {
logger.warning("Cannot start listening from state: \(currentState)") logger.warning("Cannot start listening from state: \(currentState)")
return 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 // Check if a model is loaded before starting
guard whisperEngine.isModelLoaded() else { guard whisperEngine.isModelLoaded() else {
logger.warning("No model loaded - showing setup alert") logger.warning("No model loaded - showing setup alert")
@ -324,7 +418,7 @@ public class AppController: ObservableObject {
} }
let startTime = Date() 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) let duration = Date().timeIntervalSince(startTime)
logger.info("Transcription completed in \(String(format: "%.2f", duration))s: \"\(transcription)\"") logger.info("Transcription completed in \(String(format: "%.2f", duration))s: \"\(transcription)\"")
@ -345,10 +439,37 @@ public class AppController: ObservableObject {
private func injectTranscriptionResult(_ text: String) { private func injectTranscriptionResult(_ text: String) {
logger.info("Attempting to inject transcription result: \(text)") 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 { do {
// Attempt to inject the text using paste method with fallback enabled // Determine injection method from settings
try textInjector.injectText(text, method: .paste, enableFallback: true) let injectionMethod: InjectionMethod = settings.insertionMethod == .paste ? .paste : .typing
logger.info("Text injection successful")
// 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 // Show success and finish processing
finishProcessing() finishProcessing()
@ -403,7 +524,7 @@ public class AppController: ObservableObject {
private func startDictationTimer() { private func startDictationTimer() {
stopDictationTimer() // Clean up any existing timer 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?.logger.info("Dictation timeout reached")
self?.stopListening() self?.stopListening()
} }
@ -416,7 +537,7 @@ public class AppController: ObservableObject {
private func showHUD(state: HUDState) { private func showHUD(state: HUDState) {
if hudWindow == nil { if hudWindow == nil {
hudWindow = HUDWindow() hudWindow = HUDWindow(settings: settings)
} }
hudWindow?.show(state: state) hudWindow?.show(state: state)
} }
@ -445,7 +566,7 @@ public class AppController: ObservableObject {
private func showPermissionRequiredNotice() { private func showPermissionRequiredNotice() {
let alert = NSAlert() let alert = NSAlert()
alert.messageText = "Permission Required" 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.alertStyle = .warning
alert.addButton(withTitle: "Open System Settings") alert.addButton(withTitle: "Open System Settings")
alert.addButton(withTitle: "Cancel") alert.addButton(withTitle: "Cancel")
@ -478,6 +599,7 @@ public class AppController: ObservableObject {
modelManager: modelManager, modelManager: modelManager,
whisperEngine: whisperEngine, whisperEngine: whisperEngine,
permissionManager: permissionManager, permissionManager: permissionManager,
settings: settings,
initialTab: initialTab initialTab: initialTab
) )
} else { } else {
@ -490,11 +612,67 @@ public class AppController: ObservableObject {
NSApp.activate(ignoringOtherApps: true) 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 @MainActor
private func showModelSetupAlert() { private func showModelSetupAlert() {
let alert = NSAlert() let alert = NSAlert()
alert.messageText = "No Speech Recognition Model" 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.alertStyle = .informational
alert.addButton(withTitle: "Open Preferences") alert.addButton(withTitle: "Open Preferences")
alert.addButton(withTitle: "Cancel") alert.addButton(withTitle: "Cancel")
@ -511,6 +689,8 @@ public class AppController: ObservableObject {
audioEngine.stopCapture() audioEngine.stopCapture()
hotkeyManager.disableHotkey() hotkeyManager.disableHotkey()
preferencesWindow?.close() preferencesWindow?.close()
onboardingWindow?.close()
helpWindow?.close()
NotificationCenter.default.removeObserver(self) NotificationCenter.default.removeObserver(self)
} }
} }

View file

@ -1,6 +1,7 @@
import SwiftUI import SwiftUI
import AppKit import AppKit
import CoreUtils import CoreUtils
import CoreSettings
public enum HUDState { public enum HUDState {
case hidden case hidden
@ -8,12 +9,21 @@ public enum HUDState {
case processing case processing
} }
public class HUDWindow: NSPanel { public class HUDWindow: NSPanel, ObservableObject {
private var hostingView: NSHostingView<HUDContentView>? 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( super.init(
contentRect: NSRect(x: 0, y: 0, width: 320, height: 160), contentRect: NSRect(x: 0, y: 0, width: scaledWidth, height: scaledHeight),
styleMask: [.nonactivatingPanel], styleMask: [.nonactivatingPanel],
backing: .buffered, backing: .buffered,
defer: false defer: false
@ -33,7 +43,7 @@ public class HUDWindow: NSPanel {
} }
private func setupContentView() { private func setupContentView() {
let hudContentView = HUDContentView() let hudContentView = HUDContentView(settings: settings, hudWindow: self)
hostingView = NSHostingView(rootView: hudContentView) hostingView = NSHostingView(rootView: hudContentView)
if let hostingView = hostingView { if let hostingView = hostingView {
@ -44,16 +54,16 @@ public class HUDWindow: NSPanel {
public func show(state: HUDState) { public func show(state: HUDState) {
centerOnScreen() centerOnScreen()
if let hostingView = hostingView { // Update the published state
hostingView.rootView.updateState(state) hudState = state
} print("HUD showing with state: \(state)")
if !isVisible { if !isVisible {
orderFront(nil) orderFront(nil)
alphaValue = 0 alphaValue = 0
NSAnimationContext.runAnimationGroup({ context in NSAnimationContext.runAnimationGroup({ context in
context.duration = 0.2 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) { public func updateLevel(_ level: Float) {
if let hostingView = hostingView { hudState = .listening(level: level)
hostingView.rootView.updateState(.listening(level: level))
}
} }
private func centerOnScreen() { private func centerOnScreen() {
@ -105,7 +113,8 @@ extension Notification.Name {
} }
struct HUDContentView: View { struct HUDContentView: View {
@State private var currentState: HUDState = .hidden @ObservedObject var settings: CoreSettings.Settings
@ObservedObject var hudWindow: HUDWindow
var body: some View { var body: some View {
ZStack { ZStack {
@ -117,7 +126,7 @@ struct HUDContentView: View {
) )
VStack(spacing: 16) { VStack(spacing: 16) {
switch currentState { switch hudWindow.hudState {
case .hidden: case .hidden:
EmptyView() EmptyView()
@ -130,7 +139,11 @@ struct HUDContentView: View {
} }
.padding(24) .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 @ViewBuilder
@ -140,14 +153,14 @@ struct HUDContentView: View {
.font(.system(size: 32)) .font(.system(size: 32))
.foregroundColor(.blue) .foregroundColor(.blue)
Text("Listening...") Text(NSLocalizedString("hud.listening", comment: "Listening..."))
.font(.headline) .font(.headline)
.foregroundColor(.primary) .foregroundColor(.primary)
AudioLevelView(level: level) AudioLevelView(level: level)
.frame(height: 20) .frame(height: 20)
Text("Press Esc to cancel") Text(NSLocalizedString("hud.cancel", comment: "Press Esc to cancel"))
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
@ -159,21 +172,16 @@ struct HUDContentView: View {
ProgressView() ProgressView()
.scaleEffect(1.2) .scaleEffect(1.2)
Text("Processing...") Text(NSLocalizedString("hud.processing", comment: "Processing..."))
.font(.headline) .font(.headline)
.foregroundColor(.primary) .foregroundColor(.primary)
Text("Please wait") Text(NSLocalizedString("hud.please_wait", comment: "Please wait"))
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
} }
func updateState(_ state: HUDState) {
withAnimation(.easeInOut(duration: 0.3)) {
currentState = state
}
}
} }
struct AudioLevelView: View { 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 CoreSTT
import CoreUtils import CoreUtils
import CorePermissions import CorePermissions
import CoreSettings
class PreferencesWindowController: NSWindowController { class PreferencesWindowController: NSWindowController {
private let modelManager: ModelManager private let modelManager: ModelManager
private let whisperEngine: WhisperCPPEngine private let whisperEngine: WhisperCPPEngine
private let permissionManager: PermissionManager private let permissionManager: PermissionManager
private let settings: CoreSettings.Settings
private var preferencesView: PreferencesView? 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.modelManager = modelManager
self.whisperEngine = whisperEngine self.whisperEngine = whisperEngine
self.permissionManager = permissionManager self.permissionManager = permissionManager
self.settings = settings
let window = NSWindow( 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], styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered, backing: .buffered,
defer: false defer: false
@ -24,13 +27,16 @@ class PreferencesWindowController: NSWindowController {
super.init(window: window) super.init(window: window)
window.title = "MenuWhisper Preferences" window.title = NSLocalizedString("preferences.title", comment: "Tell me Preferences")
window.center() window.center()
window.minSize = NSSize(width: 750, height: 600)
window.maxSize = NSSize(width: 1200, height: 800)
preferencesView = PreferencesView( preferencesView = PreferencesView(
modelManager: modelManager, modelManager: modelManager,
whisperEngine: whisperEngine, whisperEngine: whisperEngine,
permissionManager: permissionManager, permissionManager: permissionManager,
settings: settings,
initialTab: initialTab, initialTab: initialTab,
onClose: { [weak self] in onClose: { [weak self] in
self?.close() self?.close()
@ -53,6 +59,7 @@ struct PreferencesView: View {
@ObservedObject var modelManager: ModelManager @ObservedObject var modelManager: ModelManager
let whisperEngine: WhisperCPPEngine let whisperEngine: WhisperCPPEngine
@ObservedObject var permissionManager: PermissionManager @ObservedObject var permissionManager: PermissionManager
@ObservedObject var settings: CoreSettings.Settings
let onClose: () -> Void let onClose: () -> Void
@State private var selectedTab: Int @State private var selectedTab: Int
@ -61,10 +68,11 @@ struct PreferencesView: View {
@State private var showingDeleteAlert = false @State private var showingDeleteAlert = false
@State private var modelToDelete: ModelInfo? @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.modelManager = modelManager
self.whisperEngine = whisperEngine self.whisperEngine = whisperEngine
self.permissionManager = permissionManager self.permissionManager = permissionManager
self.settings = settings
self.onClose = onClose self.onClose = onClose
self._selectedTab = State(initialValue: initialTab) self._selectedTab = State(initialValue: initialTab)
} }
@ -75,37 +83,56 @@ struct PreferencesView: View {
var body: some View { var body: some View {
TabView(selection: $selectedTab) { TabView(selection: $selectedTab) {
GeneralTab(settings: settings)
.tabItem {
Label(NSLocalizedString("preferences.general", comment: "General"), systemImage: "gearshape")
}
.tag(0)
ModelsTab( ModelsTab(
modelManager: modelManager, modelManager: modelManager,
whisperEngine: whisperEngine, whisperEngine: whisperEngine,
settings: settings,
isDownloading: $isDownloading, isDownloading: $isDownloading,
downloadProgress: $downloadProgress, downloadProgress: $downloadProgress,
showingDeleteAlert: $showingDeleteAlert, showingDeleteAlert: $showingDeleteAlert,
modelToDelete: $modelToDelete modelToDelete: $modelToDelete
) )
.tabItem { .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) PermissionsTab(permissionManager: permissionManager)
.tabItem { .tabItem {
Label("Permissions", systemImage: "lock.shield") Label(NSLocalizedString("preferences.permissions", comment: "Permissions"), systemImage: "lock.shield")
} }
.tag(1) .tag(5)
GeneralTab()
.tabItem {
Label("General", systemImage: "gearshape")
}
.tag(2)
} }
.frame(width: 600, height: 500) .frame(minWidth: 750, idealWidth: 800, maxWidth: 1200, minHeight: 600, idealHeight: 600, maxHeight: 800)
.alert("Delete Model", isPresented: $showingDeleteAlert) { .alert(NSLocalizedString("alert.delete_model", comment: "Delete Model"), isPresented: $showingDeleteAlert) {
Button("Cancel", role: .cancel) { Button(NSLocalizedString("general.cancel", comment: "Cancel"), role: .cancel) {
modelToDelete = nil modelToDelete = nil
} }
Button("Delete", role: .destructive) { Button(NSLocalizedString("preferences.models.delete", comment: "Delete"), role: .destructive) {
if let model = modelToDelete { if let model = modelToDelete {
deleteModel(model) deleteModel(model)
} }
@ -113,7 +140,7 @@ struct PreferencesView: View {
} }
} message: { } message: {
if let model = modelToDelete { 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 { struct ModelsTab: View {
@ObservedObject var modelManager: ModelManager @ObservedObject var modelManager: ModelManager
let whisperEngine: WhisperCPPEngine let whisperEngine: WhisperCPPEngine
@ObservedObject var settings: CoreSettings.Settings
@Binding var isDownloading: [String: Bool] @Binding var isDownloading: [String: Bool]
@Binding var downloadProgress: [String: Double] @Binding var downloadProgress: [String: Double]
@ -138,17 +166,17 @@ struct ModelsTab: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
Text("Speech Recognition Models") Text(NSLocalizedString("preferences.models.title", comment: "Speech Recognition Models"))
.font(.title2) .font(.title2)
.fontWeight(.semibold) .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) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
// Current Model Status // Current Model Status
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("Current Model") Text(NSLocalizedString("preferences.models.current_model", comment: "Current Model"))
.font(.headline) .font(.headline)
if let activeModel = modelManager.activeModel { if let activeModel = modelManager.activeModel {
@ -168,7 +196,7 @@ struct ModelsTab: View {
.fill(whisperEngine.isModelLoaded() ? Color.green : Color.orange) .fill(whisperEngine.isModelLoaded() ? Color.green : Color.orange)
.frame(width: 8, height: 8) .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) .font(.caption)
.foregroundColor(whisperEngine.isModelLoaded() ? .green : .orange) .foregroundColor(whisperEngine.isModelLoaded() ? .green : .orange)
} }
@ -176,7 +204,7 @@ struct ModelsTab: View {
.background(Color(NSColor.controlBackgroundColor)) .background(Color(NSColor.controlBackgroundColor))
.cornerRadius(8) .cornerRadius(8)
} else { } else {
Text("No model selected") Text(NSLocalizedString("preferences.models.no_model", comment: "No model selected"))
.foregroundColor(.secondary) .foregroundColor(.secondary)
.padding(12) .padding(12)
.frame(maxWidth: .infinity, alignment: .leading) .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 // Available Models
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("Available Models") Text(NSLocalizedString("preferences.models.available_models", comment: "Available Models"))
.font(.headline) .font(.headline)
ScrollView { ScrollView {
@ -286,7 +352,7 @@ struct ModelRow: View {
.fontWeight(.medium) .fontWeight(.medium)
if isActive { if isActive {
Text("ACTIVE") Text(NSLocalizedString("preferences.models.active_badge", comment: "ACTIVE"))
.font(.caption) .font(.caption)
.fontWeight(.semibold) .fontWeight(.semibold)
.foregroundColor(.white) .foregroundColor(.white)
@ -315,13 +381,13 @@ struct ModelRow: View {
if model.isDownloaded { if model.isDownloaded {
HStack(spacing: 8) { HStack(spacing: 8) {
if !isActive { if !isActive {
Button("Select") { Button(NSLocalizedString("general.select", comment: "Select")) {
onSelect() onSelect()
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
} }
Button("Delete") { Button(NSLocalizedString("preferences.models.delete", comment: "Delete")) {
onDelete() onDelete()
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
@ -329,17 +395,20 @@ struct ModelRow: View {
} }
} else { } else {
if isDownloading { if isDownloading {
VStack { HStack {
ProgressView(value: downloadProgress) ProgressView()
.frame(width: 80) .scaleEffect(0.8)
Text("\(Int(downloadProgress * 100))%") Text(NSLocalizedString("preferences.models.downloading", comment: "Downloading..."))
.font(.caption) .font(.caption)
.foregroundColor(.secondary)
} }
} else { } else {
Button("Download") { Button(NSLocalizedString("preferences.models.download", comment: "Download")) {
onDownload() onDownload()
} }
.buttonStyle(.bordered) .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 { var body: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
Text("Permissions") Text(NSLocalizedString("preferences.permissions", comment: "Permissions"))
.font(.title2) .font(.title2)
.fontWeight(.semibold) .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) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
// Microphone Permission // Microphone Permission
PermissionRow( PermissionRow(
title: "Microphone", title: NSLocalizedString("permissions.microphone.title_short", comment: "Microphone"),
description: "Required to capture speech for transcription", description: NSLocalizedString("permissions.microphone.description_short", comment: "Microphone description"),
status: permissionManager.microphoneStatus, status: permissionManager.microphoneStatus,
onOpenSettings: { onOpenSettings: {
permissionManager.openSystemSettings(for: .microphone) permissionManager.openSystemSettings(for: .microphone)
@ -385,8 +454,8 @@ struct PermissionsTab: View {
// Accessibility Permission // Accessibility Permission
PermissionRow( PermissionRow(
title: "Accessibility", title: NSLocalizedString("permissions.accessibility.title_short", comment: "Accessibility"),
description: "Required to insert transcribed text into other applications", description: NSLocalizedString("permissions.accessibility.description_short", comment: "Accessibility description"),
status: permissionManager.accessibilityStatus, status: permissionManager.accessibilityStatus,
onOpenSettings: { onOpenSettings: {
permissionManager.openSystemSettings(for: .accessibility) permissionManager.openSystemSettings(for: .accessibility)
@ -400,8 +469,8 @@ struct PermissionsTab: View {
// Input Monitoring Permission // Input Monitoring Permission
PermissionRow( PermissionRow(
title: "Input Monitoring", title: NSLocalizedString("permissions.input_monitoring.title_short", comment: "Input Monitoring"),
description: "Required to send keyboard events for text insertion", description: NSLocalizedString("permissions.input_monitoring.description_short", comment: "Input Monitoring description"),
status: permissionManager.inputMonitoringStatus, status: permissionManager.inputMonitoringStatus,
onOpenSettings: { onOpenSettings: {
permissionManager.openSystemSettings(for: .inputMonitoring) permissionManager.openSystemSettings(for: .inputMonitoring)
@ -417,17 +486,17 @@ struct PermissionsTab: View {
// Help text // Help text
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("Need Help?") Text(NSLocalizedString("preferences.permissions.need_help", comment: "Need Help?"))
.font(.headline) .font(.headline)
Text("After granting permissions in System Settings:") Text(NSLocalizedString("preferences.permissions.after_granting", comment: "After granting permissions"))
.font(.body) .font(.body)
.foregroundColor(.secondary) .foregroundColor(.secondary)
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("1. Close System Settings") Text(NSLocalizedString("preferences.permissions.step1", comment: "Step 1"))
Text("2. Click 'Refresh Status' to update permission status") Text(NSLocalizedString("preferences.permissions.step2", comment: "Step 2"))
Text("3. Some permissions may require restarting MenuWhisper") Text(NSLocalizedString("preferences.permissions.step3", comment: "Step 3"))
} }
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
@ -479,14 +548,14 @@ struct PermissionRow: View {
VStack(spacing: 6) { VStack(spacing: 6) {
if status != .granted { if status != .granted {
Button("Open System Settings") { Button(NSLocalizedString("permissions.open_settings", comment: "Open System Settings")) {
onOpenSettings() onOpenSettings()
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.controlSize(.small) .controlSize(.small)
} }
Button("Refresh Status") { Button(NSLocalizedString("permissions.refresh_status", comment: "Refresh Status")) {
onRefresh() onRefresh()
} }
.buttonStyle(.borderless) .buttonStyle(.borderless)
@ -510,30 +579,415 @@ struct PermissionRow: View {
private var statusText: String { private var statusText: String {
switch status { switch status {
case .granted: case .granted:
return "Granted" return NSLocalizedString("status.granted", comment: "Granted")
case .denied: case .denied:
return "Denied" return NSLocalizedString("status.denied", comment: "Denied")
case .notDetermined: case .notDetermined:
return "Not Set" return NSLocalizedString("status.not_set", comment: "Not Set")
case .restricted: case .restricted:
return "Restricted" return NSLocalizedString("status.restricted", comment: "Restricted")
} }
} }
} }
struct GeneralTab: View { struct GeneralTab: View {
@ObservedObject var settings: CoreSettings.Settings
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 20) {
Text("General Settings") Text(NSLocalizedString("preferences.general.title", comment: "General Settings"))
.font(.title2) .font(.title2)
.fontWeight(.semibold) .fontWeight(.semibold)
Text("Additional settings will be available in Phase 4.") // Hotkey Configuration
.font(.body) VStack(alignment: .leading, spacing: 12) {
.foregroundColor(.secondary) 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() Spacer()
} }
.padding(20) .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> <dict>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>en</string> <string>en</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>es</string>
</array>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Menu-Whisper</string> <string>Tell me</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>MenuWhisper</string> <string>TellMe</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
<string>com.menuwhisper.app</string> <string>com.fmartingr.tellme</string>
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>Menu-Whisper</string> <string>Tell me</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
@ -27,7 +32,7 @@
<key>NSHumanReadableCopyright</key> <key>NSHumanReadableCopyright</key>
<string>Copyright © 2025. All rights reserved.</string> <string>Copyright © 2025. All rights reserved.</string>
<key>NSMicrophoneUsageDescription</key> <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> <key>NSSupportsAutomaticTermination</key>
<true/> <true/>
<key>NSSupportsSuddenTermination</key> <key>NSSupportsSuddenTermination</key>

View file

@ -1,7 +1,7 @@
/* Menu-Whisper - English Localization */ /* Tell me - English Localization */
/* General */ /* General */
"app.name" = "Menu-Whisper"; "app.name" = "Tell me";
"general.ok" = "OK"; "general.ok" = "OK";
"general.cancel" = "Cancel"; "general.cancel" = "Cancel";
"general.continue" = "Continue"; "general.continue" = "Continue";
@ -13,29 +13,54 @@
"menubar.listening" = "Listening"; "menubar.listening" = "Listening";
"menubar.processing" = "Processing"; "menubar.processing" = "Processing";
"menubar.preferences" = "Preferences..."; "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 States */
"hud.listening" = "Listening..."; "hud.listening" = "Listening...";
"hud.processing" = "Transcribing..."; "hud.processing" = "Transcribing...";
"hud.cancel" = "Press Esc to cancel"; "hud.cancel" = "Press Esc to cancel";
"hud.please_wait" = "Please wait";
/* Permissions */ /* Permissions */
"permissions.microphone.title" = "Microphone Access Required"; "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.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.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.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 Window */
"preferences.title" = "Menu-Whisper Preferences"; "preferences.title" = "Tell me Preferences";
"preferences.general" = "General"; "preferences.general" = "General";
"preferences.models" = "Models"; "preferences.models" = "Models";
"preferences.hotkeys" = "Hotkeys"; "preferences.hotkeys" = "Hotkeys";
"preferences.insertion" = "Text Insertion"; "preferences.insertion" = "Text Insertion";
"preferences.interface" = "Interface";
"preferences.advanced" = "Advanced"; "preferences.advanced" = "Advanced";
"preferences.permissions" = "Permissions";
/* General Preferences */ /* General Preferences */
"preferences.general.hotkey" = "Global Hotkey:"; "preferences.general.hotkey" = "Global Hotkey:";
@ -51,6 +76,7 @@
"preferences.models.language" = "Language:"; "preferences.models.language" = "Language:";
"preferences.models.language.auto" = "Auto-detect"; "preferences.models.language.auto" = "Auto-detect";
"preferences.models.download" = "Download"; "preferences.models.download" = "Download";
"preferences.models.downloading" = "Downloading...";
"preferences.models.delete" = "Delete"; "preferences.models.delete" = "Delete";
"preferences.models.size" = "Size:"; "preferences.models.size" = "Size:";
"preferences.models.languages" = "Languages:"; "preferences.models.languages" = "Languages:";
@ -75,3 +101,160 @@
"success.model.downloaded" = "Model downloaded successfully"; "success.model.downloaded" = "Model downloaded successfully";
"success.settings.exported" = "Settings exported 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 */ /* General */
"app.name" = "Menu-Whisper"; "app.name" = "Tell me";
"general.ok" = "Aceptar"; "general.ok" = "Aceptar";
"general.cancel" = "Cancelar"; "general.cancel" = "Cancelar";
"general.continue" = "Continuar"; "general.continue" = "Continuar";
@ -13,29 +13,54 @@
"menubar.listening" = "Escuchando"; "menubar.listening" = "Escuchando";
"menubar.processing" = "Procesando"; "menubar.processing" = "Procesando";
"menubar.preferences" = "Preferencias..."; "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 States */
"hud.listening" = "Escuchando..."; "hud.listening" = "Escuchando...";
"hud.processing" = "Transcribiendo..."; "hud.processing" = "Transcribiendo...";
"hud.cancel" = "Presiona Esc para cancelar"; "hud.cancel" = "Presiona Esc para cancelar";
"hud.please_wait" = "Por favor espera";
/* Permissions */ /* Permissions */
"permissions.microphone.title" = "Acceso al Micrófono Requerido"; "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.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.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.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 Window */
"preferences.title" = "Preferencias de Menu-Whisper"; "preferences.title" = "Preferencias de Tell me";
"preferences.general" = "General"; "preferences.general" = "General";
"preferences.models" = "Modelos"; "preferences.models" = "Modelos";
"preferences.hotkeys" = "Atajos"; "preferences.hotkeys" = "Atajos";
"preferences.insertion" = "Inserción de Texto"; "preferences.insertion" = "Inserción de Texto";
"preferences.interface" = "Interfaz";
"preferences.advanced" = "Avanzado"; "preferences.advanced" = "Avanzado";
"preferences.permissions" = "Permisos";
/* General Preferences */ /* General Preferences */
"preferences.general.hotkey" = "Atajo Global:"; "preferences.general.hotkey" = "Atajo Global:";
@ -51,6 +76,7 @@
"preferences.models.language" = "Idioma:"; "preferences.models.language" = "Idioma:";
"preferences.models.language.auto" = "Detección automática"; "preferences.models.language.auto" = "Detección automática";
"preferences.models.download" = "Descargar"; "preferences.models.download" = "Descargar";
"preferences.models.downloading" = "Descargando...";
"preferences.models.delete" = "Eliminar"; "preferences.models.delete" = "Eliminar";
"preferences.models.size" = "Tamaño:"; "preferences.models.size" = "Tamaño:";
"preferences.models.languages" = "Idiomas:"; "preferences.models.languages" = "Idiomas:";
@ -75,3 +101,160 @@
"success.model.downloaded" = "Modelo descargado exitosamente"; "success.model.downloaded" = "Modelo descargado exitosamente";
"success.settings.exported" = "Configuración exportada 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 AVFoundation
import AppKit import AppKit
import CoreUtils import CoreUtils
import CoreSettings
public class SoundManager: ObservableObject { public class SoundManager: ObservableObject {
private let logger = Logger(category: "SoundManager") private let logger = Logger(category: "SoundManager")
private let settings: CoreSettings.Settings
@Published public var soundsEnabled: Bool = true
private var startSound: AVAudioPlayer? private var startSound: AVAudioPlayer?
private var stopSound: AVAudioPlayer? private var stopSound: AVAudioPlayer?
public init() { public init(settings: CoreSettings.Settings) {
self.settings = settings
setupSounds() setupSounds()
} }
@ -28,7 +29,7 @@ public class SoundManager: ObservableObject {
} }
public func playStartSound() { public func playStartSound() {
guard soundsEnabled else { return } guard settings.playSounds else { return }
logger.debug("Playing start sound") logger.debug("Playing start sound")
// Use a subtle system sound for start // Use a subtle system sound for start
@ -36,7 +37,7 @@ public class SoundManager: ObservableObject {
} }
public func playStopSound() { public func playStopSound() {
guard soundsEnabled else { return } guard settings.playSounds else { return }
logger.debug("Playing stop sound") logger.debug("Playing stop sound")
// Use a different system sound for stop // Use a different system sound for stop

View file

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

View file

@ -26,7 +26,7 @@ public struct ModelInfo: Codable, Identifiable {
public var fileURL: URL { public var fileURL: URL {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! 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) return modelsDirectory.appendingPathComponent(filename)
} }
@ -102,7 +102,7 @@ public enum ModelError: Error, LocalizedError {
} }
@MainActor @MainActor
public class ModelManager: ObservableObject { public class ModelManager: NSObject, ObservableObject {
private let logger = Logger(category: "ModelManager") private let logger = Logger(category: "ModelManager")
@Published public private(set) var availableModels: [ModelInfo] = [] @Published public private(set) var availableModels: [ModelInfo] = []
@ -111,19 +111,22 @@ public class ModelManager: ObservableObject {
@Published public private(set) var downloadProgress: [String: DownloadProgress] = [:] @Published public private(set) var downloadProgress: [String: DownloadProgress] = [:]
private let modelsDirectory: URL private let modelsDirectory: URL
private let urlSession: URLSession private var urlSession: URLSession
private var downloadTasks: [String: URLSessionDownloadTask] = [:] 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! 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 let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 30 config.timeoutIntervalForRequest = 30
config.timeoutIntervalForResource = 3600 // 1 hour for large model downloads config.timeoutIntervalForResource = 3600 // 1 hour for large model downloads
urlSession = URLSession(configuration: config) urlSession = URLSession(configuration: config)
super.init()
try? FileManager.default.createDirectory(at: modelsDirectory, withIntermediateDirectories: true) try? FileManager.default.createDirectory(at: modelsDirectory, withIntermediateDirectories: true)
// Ensure we have models available - use fallback approach first // Ensure we have models available - use fallback approach first
@ -172,35 +175,34 @@ public class ModelManager: ObservableObject {
throw ModelError.downloadFailed("Invalid download URL") throw ModelError.downloadFailed("Invalid download URL")
} }
// Create temporary file for download let modelName = model.name
let tempURL = modelsDirectory.appendingPathComponent("\(model.name).tmp") let modelSHA256 = model.sha256
let modelFileURL = model.fileURL
do { print("Starting download for \(modelName) from \(url)")
let (tempFileURL, response) = try await urlSession.download(from: url)
guard let httpResponse = response as? HTTPURLResponse, // Use simple URLSession download for reliability (progress spinners don't need exact progress)
(200..<300).contains(httpResponse.statusCode) else { let (tempURL, response) = try await urlSession.download(from: url)
throw ModelError.downloadFailed("HTTP error: \(String(describing: (response as? HTTPURLResponse)?.statusCode))")
}
// Verify SHA256 checksum if provided guard let httpResponse = response as? HTTPURLResponse,
if !model.sha256.isEmpty { (200..<300).contains(httpResponse.statusCode) else {
try await verifyChecksum(fileURL: tempFileURL, expectedSHA256: model.sha256) throw ModelError.downloadFailed("HTTP error: \(String(describing: (response as? HTTPURLResponse)?.statusCode))")
}
// 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)
} }
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 { private func downloadCoreMlEncoder(_ model: ModelInfo) async throws {
@ -402,14 +404,14 @@ public class ModelManager: ObservableObject {
private func saveActiveModelPreference() { private func saveActiveModelPreference() {
if let activeModel = activeModel { if let activeModel = activeModel {
UserDefaults.standard.set(activeModel.name, forKey: "MenuWhisper.ActiveModel") UserDefaults.standard.set(activeModel.name, forKey: "TellMe.ActiveModel")
} else { } else {
UserDefaults.standard.removeObject(forKey: "MenuWhisper.ActiveModel") UserDefaults.standard.removeObject(forKey: "TellMe.ActiveModel")
} }
} }
private func loadActiveModelPreference() { private func loadActiveModelPreference() {
guard let modelName = UserDefaults.standard.string(forKey: "MenuWhisper.ActiveModel") else { guard let modelName = UserDefaults.standard.string(forKey: "TellMe.ActiveModel") else {
return return
} }
@ -417,7 +419,8 @@ public class ModelManager: ObservableObject {
if activeModel == nil { if activeModel == nil {
// Clear preference if model is no longer available or downloaded // 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 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 { public class Settings: ObservableObject {
private let logger = Logger(category: "Settings") private let logger = Logger(category: "Settings")
private let userDefaults = UserDefaults.standard private let userDefaults = UserDefaults.standard
@ -49,6 +63,15 @@ public class Settings: ObservableObject {
didSet { userDefaults.set(dictationTimeLimit, forKey: "dictationTimeLimit") } 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 // Model Settings
@Published public var activeModelName: String? { @Published public var activeModelName: String? {
didSet { userDefaults.set(activeModelName, forKey: "activeModelName") } didSet { userDefaults.set(activeModelName, forKey: "activeModelName") }
@ -59,25 +82,47 @@ public class Settings: ObservableObject {
} }
// Insertion Settings // Insertion Settings
@Published public var insertionMethod: String { @Published public var insertionMethod: InsertionMethod {
didSet { userDefaults.set(insertionMethod, forKey: "insertionMethod") } didSet { userDefaults.set(insertionMethod.rawValue, forKey: "insertionMethod") }
} }
@Published public var showPreview: Bool { @Published public var showPreview: Bool {
didSet { userDefaults.set(showPreview, forKey: "showPreview") } 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() { public init() {
// Load settings from UserDefaults // Load settings from UserDefaults
self.hotkey = Settings.loadHotkey() self.hotkey = Settings.loadHotkey()
self.hotkeyMode = HotkeyMode(rawValue: userDefaults.string(forKey: "hotkeyMode") ?? "") ?? .pushToTalk self.hotkeyMode = HotkeyMode(rawValue: userDefaults.string(forKey: "hotkeyMode") ?? "") ?? .pushToTalk
self.playSounds = userDefaults.object(forKey: "playSounds") as? Bool ?? false self.playSounds = userDefaults.object(forKey: "playSounds") as? Bool ?? false
self.dictationTimeLimit = userDefaults.object(forKey: "dictationTimeLimit") as? TimeInterval ?? 600 // 10 minutes 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.activeModelName = userDefaults.string(forKey: "activeModelName")
self.forcedLanguage = userDefaults.string(forKey: "forcedLanguage") 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 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") logger.info("Settings initialized")
} }
@ -88,10 +133,14 @@ public class Settings: ObservableObject {
"hotkeyMode": hotkeyMode.rawValue, "hotkeyMode": hotkeyMode.rawValue,
"playSounds": playSounds, "playSounds": playSounds,
"dictationTimeLimit": dictationTimeLimit, "dictationTimeLimit": dictationTimeLimit,
"hudOpacity": hudOpacity,
"hudSize": hudSize,
"activeModelName": activeModelName as Any, "activeModelName": activeModelName as Any,
"forcedLanguage": forcedLanguage as Any, "forcedLanguage": forcedLanguage as Any,
"insertionMethod": insertionMethod, "insertionMethod": insertionMethod.rawValue,
"showPreview": showPreview "showPreview": showPreview,
"enableLogging": enableLogging,
"processingThreads": processingThreads
] ]
return try JSONSerialization.data(withJSONObject: settingsDict, options: .prettyPrinted) return try JSONSerialization.data(withJSONObject: settingsDict, options: .prettyPrinted)
@ -118,10 +167,19 @@ public class Settings: ObservableObject {
dictationTimeLimit = timeLimit 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 activeModelName = settingsDict["activeModelName"] as? String
forcedLanguage = settingsDict["forcedLanguage"] 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 insertionMethod = method
} }
@ -129,6 +187,14 @@ public class Settings: ObservableObject {
showPreview = preview 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") 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)
}

70
TODO.md
View file

@ -93,7 +93,7 @@ Conventions:
- [x] **Model Manager** (backend + minimal UI): - [x] **Model Manager** (backend + minimal UI):
- [x] Bundle a **curated JSON catalog** (name, size, languages, license, URL, SHA256). - [x] Bundle a **curated JSON catalog** (name, size, languages, license, URL, SHA256).
- [x] Download via `URLSession` with progress + resume support. - [x] Download via `URLSession` with progress + resume support.
- [x] Validate **SHA256**; store under `~/Library/Application Support/MenuWhisper/Models`. - [x] Validate **SHA256**; store under `~/Library/Application Support/TellMe/Models`.
- [x] Allow **select active model**; persist selection. - [x] Allow **select active model**; persist selection.
- [x] Language: **auto** or **forced** (persist). - [x] Language: **auto** or **forced** (persist).
- [x] Text normalization pass (basic replacements; punctuation from model). - [x] Text normalization pass (basic replacements; punctuation from model).
@ -116,10 +116,10 @@ Conventions:
- Preferences window with model management UI - Preferences window with model management UI
- NSStatusItem menu bar with model status - NSStatusItem menu bar with model status
- Hotkey protection (shows alert if no model loaded) - Hotkey protection (shows alert if no model loaded)
- Proper model path handling (`~/Library/Application Support/MenuWhisper/Models`) - Proper model path handling (`~/Library/Application Support/TellMe/Models`)
**User Experience:** **User Experience:**
1. Launch MenuWhisper → Menu shows "No model - click Preferences" 1. Launch Tell me → Menu shows "No model - click Preferences"
2. Open Preferences → See available models, download options 2. Open Preferences → See available models, download options
3. Download model → Progress tracking, SHA256 verification 3. Download model → Progress tracking, SHA256 verification
4. Select model → Loads automatically 4. Select model → Loads automatically
@ -177,27 +177,53 @@ No automatic downloads - users must download and select models first.
**Goal:** Complete options, localization, and stability. **Goal:** Complete options, localization, and stability.
### Tasks ### Tasks
- [ ] Full **Preferences** window: - [x] Full **Preferences** window:
- [ ] Hotkey recorder (change ⌘⇧V if needed). - [x] Hotkey recorder (change ⌘⇧V if needed).
- [ ] Mode: Push-to-talk / Toggle. - [x] Mode: Push-to-talk / Toggle.
- [ ] Model picker: list, **download**, **delete**, **set active**, show size/language/license. - [x] Model picker: list, **download**, **delete**, **set active**, show size/language/license.
- [ ] Language: Auto / Forced (dropdown). - [x] Language: Auto / Forced (dropdown).
- [ ] Insertion: **Direct** (default) vs **Preview**; Paste vs Typing preference. - [x] Insertion: **Direct** (default) vs **Preview**; Paste vs Typing preference.
- [ ] HUD: opacity/size, show/hide sounds toggles. - [x] HUD: opacity/size, show/hide sounds toggles.
- [ ] Dictation limit: editable (default 10 min). - [x] Dictation limit: editable (default 10 min).
- [ ] Advanced: threads/batch; **local logs opt-in**. - [x] Advanced: threads/batch; **local logs opt-in**.
- [ ] **Export/Import** settings (JSON). - [x] **Export/Import** settings (JSON).
- [ ] Implement **Preview** dialog (off by default): shows transcribed text with **Insert** / **Cancel**. - [x] Implement **Preview** dialog (off by default): shows transcribed text with **Insert** / **Cancel**.
- [ ] Expand **localization** (ES/EN) for all UI strings. - [x] Expand **localization** (ES/EN) for all UI strings.
- [ ] Onboarding & help views (permissions, Secure Input explanation). - [x] Onboarding & help views (permissions, Secure Input explanation).
- [ ] Persist all settings in `UserDefaults`; validate on load; migrate if needed. - [x] Persist all settings in `UserDefaults`; validate on load; migrate if needed.
- [ ] UX polish: icons, animation timing, keyboard navigation, VoiceOver labels. - [x] UX polish: icons, animation timing, keyboard navigation, VoiceOver labels.
- [ ] Optional: internal **timing instrumentation** (guarded by logs opt-in). - [x] Optional: internal **timing instrumentation** (guarded by logs opt-in).
### AC ### AC
- [ ] All preferences persist and take effect without relaunch. - [x] All preferences persist and take effect without relaunch.
- [ ] Preview (when enabled) allows quick edit & insertion. - [x] Preview (when enabled) allows quick edit & insertion.
- [ ] ES/EN localization passes a manual spot-check. - [x] ES/EN localization passes a manual spot-check.
**Current Status:** Phase 4 **COMPLETE**.
**What works:**
- Comprehensive preferences window with 6 tabs (General, Models, Text Insertion, Interface, Advanced, Permissions)
- Hotkey recorder with visual feedback and customization
- Push-to-talk and toggle mode selection
- Language selection with 10+ language options
- HUD customization (opacity, size, sound toggles)
- Text insertion preferences (paste vs type, direct vs preview)
- Advanced settings (processing threads, logging, settings reset)
- Settings export/import to JSON format
- Preview dialog for editing transcriptions before insertion
- Complete localization for English and Spanish
- Onboarding flow with step-by-step setup
- Comprehensive help system with troubleshooting guides
- Accessibility improvements with VoiceOver support
- All settings persist in UserDefaults and take effect immediately
**User Experience:**
1. Full-featured preferences with intuitive tabbed interface
2. Customizable hotkeys with visual recorder
3. Comprehensive help and onboarding for new users
4. Preview transcriptions before insertion
5. Export/import settings for backup and sharing
6. Complete offline experience with multi-language support
--- ---

View file

@ -1,5 +1,5 @@
import XCTest import XCTest
@testable import MenuWhisperAudio @testable import TellMeAudio
final class AudioEngineTests: XCTestCase { final class AudioEngineTests: XCTestCase {
func testAudioEngineInitialization() { func testAudioEngineInitialization() {

View file

@ -1,7 +1,7 @@
import XCTest import XCTest
@testable import CoreSTT @testable import CoreSTT
@testable import CoreModels @testable import CoreModels
@testable import MenuWhisperAudio @testable import TellMeAudio
/// Integration tests to verify Phase 2 whisper.cpp implementation /// Integration tests to verify Phase 2 whisper.cpp implementation
/// These tests validate the architecture without requiring real model files /// These tests validate the architecture without requiring real model files
@ -132,7 +132,7 @@ final class Phase2IntegrationTests: XCTestCase {
// Test model path generation // Test model path generation
let modelPath = testModel.fileURL let modelPath = testModel.fileURL
XCTAssertTrue(modelPath.absoluteString.contains("MenuWhisper/Models"), "Should use correct models directory") XCTAssertTrue(modelPath.absoluteString.contains("TellMe/Models"), "Should use correct models directory")
XCTAssertTrue(modelPath.lastPathComponent.hasSuffix(".bin"), "Should generate .bin filename") XCTAssertTrue(modelPath.lastPathComponent.hasSuffix(".bin"), "Should generate .bin filename")
// Test estimated RAM info // Test estimated RAM info