tellme/Sources/App/PreferencesWindow.swift
Felipe M. 7ba5895406
Complete Phase 3: Text injection with permissions management
- Implement text injection with paste method (NSPasteboard + ⌘V)
- Add typing fallback with Unicode support and keyboard layout respect
- Integrate secure input detection using IsSecureEventInputEnabled()
- Add comprehensive permission checking and management
- Create Permissions tab in preferences with status indicators
- Add permission onboarding flow for new users
- Implement automatic fallback between injection methods
- Add deep links to System Settings for permission grants
- Remove duplicate preferences menu item
- Create development build script for easier testing
- Update Phase 3 tasks as completed in TODO.md
2025-09-19 09:04:38 +02:00

539 lines
No EOL
18 KiB
Swift

import SwiftUI
import CoreModels
import CoreSTT
import CoreUtils
import CorePermissions
class PreferencesWindowController: NSWindowController {
private let modelManager: ModelManager
private let whisperEngine: WhisperCPPEngine
private let permissionManager: PermissionManager
private var preferencesView: PreferencesView?
init(modelManager: ModelManager, whisperEngine: WhisperCPPEngine, permissionManager: PermissionManager, initialTab: Int = 0) {
self.modelManager = modelManager
self.whisperEngine = whisperEngine
self.permissionManager = permissionManager
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 600, height: 500),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false
)
super.init(window: window)
window.title = "MenuWhisper Preferences"
window.center()
preferencesView = PreferencesView(
modelManager: modelManager,
whisperEngine: whisperEngine,
permissionManager: permissionManager,
initialTab: initialTab,
onClose: { [weak self] in
self?.close()
}
)
window.contentView = NSHostingView(rootView: preferencesView!)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setSelectedTab(_ tabIndex: Int) {
preferencesView?.setSelectedTab(tabIndex)
}
}
struct PreferencesView: View {
@ObservedObject var modelManager: ModelManager
let whisperEngine: WhisperCPPEngine
@ObservedObject var permissionManager: PermissionManager
let onClose: () -> Void
@State private var selectedTab: Int
@State private var isDownloading: [String: Bool] = [:]
@State private var downloadProgress: [String: Double] = [:]
@State private var showingDeleteAlert = false
@State private var modelToDelete: ModelInfo?
init(modelManager: ModelManager, whisperEngine: WhisperCPPEngine, permissionManager: PermissionManager, initialTab: Int = 0, onClose: @escaping () -> Void) {
self.modelManager = modelManager
self.whisperEngine = whisperEngine
self.permissionManager = permissionManager
self.onClose = onClose
self._selectedTab = State(initialValue: initialTab)
}
func setSelectedTab(_ tabIndex: Int) {
selectedTab = tabIndex
}
var body: some View {
TabView(selection: $selectedTab) {
ModelsTab(
modelManager: modelManager,
whisperEngine: whisperEngine,
isDownloading: $isDownloading,
downloadProgress: $downloadProgress,
showingDeleteAlert: $showingDeleteAlert,
modelToDelete: $modelToDelete
)
.tabItem {
Label("Models", systemImage: "brain.head.profile")
}
.tag(0)
PermissionsTab(permissionManager: permissionManager)
.tabItem {
Label("Permissions", systemImage: "lock.shield")
}
.tag(1)
GeneralTab()
.tabItem {
Label("General", systemImage: "gearshape")
}
.tag(2)
}
.frame(width: 600, height: 500)
.alert("Delete Model", isPresented: $showingDeleteAlert) {
Button("Cancel", role: .cancel) {
modelToDelete = nil
}
Button("Delete", role: .destructive) {
if let model = modelToDelete {
deleteModel(model)
}
modelToDelete = nil
}
} message: {
if let model = modelToDelete {
Text("Are you sure you want to delete '\(model.name)'? This action cannot be undone.")
}
}
}
private func deleteModel(_ model: ModelInfo) {
do {
try modelManager.deleteModel(model)
} catch {
print("Failed to delete model: \(error)")
}
}
}
struct ModelsTab: View {
@ObservedObject var modelManager: ModelManager
let whisperEngine: WhisperCPPEngine
@Binding var isDownloading: [String: Bool]
@Binding var downloadProgress: [String: Double]
@Binding var showingDeleteAlert: Bool
@Binding var modelToDelete: ModelInfo?
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Speech Recognition Models")
.font(.title2)
.fontWeight(.semibold)
Text("Download and manage speech recognition models. Larger models provide better accuracy but use more memory and processing time.")
.font(.caption)
.foregroundColor(.secondary)
// Current Model Status
VStack(alignment: .leading, spacing: 8) {
Text("Current Model")
.font(.headline)
if let activeModel = modelManager.activeModel {
HStack {
VStack(alignment: .leading) {
Text(activeModel.name)
.font(.body)
.fontWeight(.medium)
Text("\(activeModel.sizeMB) MB • \(activeModel.qualityTier) quality • \(activeModel.estimatedRAM)")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Circle()
.fill(whisperEngine.isModelLoaded() ? Color.green : Color.orange)
.frame(width: 8, height: 8)
Text(whisperEngine.isModelLoaded() ? "Loaded" : "Loading...")
.font(.caption)
.foregroundColor(whisperEngine.isModelLoaded() ? .green : .orange)
}
.padding(12)
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(8)
} else {
Text("No model selected")
.foregroundColor(.secondary)
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(8)
}
}
// Available Models
VStack(alignment: .leading, spacing: 8) {
Text("Available Models")
.font(.headline)
ScrollView {
LazyVStack(spacing: 8) {
ForEach(modelManager.availableModels) { model in
ModelRow(
model: model,
modelManager: modelManager,
whisperEngine: whisperEngine,
isDownloading: isDownloading[model.name] ?? false,
downloadProgress: downloadProgress[model.name] ?? 0.0,
onDownload: {
downloadModel(model)
},
onSelect: {
selectModel(model)
},
onDelete: {
modelToDelete = model
showingDeleteAlert = true
}
)
}
}
}
.frame(maxHeight: 200)
}
Spacer()
}
.padding(20)
}
private func downloadModel(_ model: ModelInfo) {
isDownloading[model.name] = true
downloadProgress[model.name] = 0.0
Task {
do {
try await modelManager.downloadModel(model) { progress in
DispatchQueue.main.async {
downloadProgress[model.name] = progress.progress
}
}
DispatchQueue.main.async {
isDownloading[model.name] = false
downloadProgress[model.name] = 1.0
}
} catch {
DispatchQueue.main.async {
isDownloading[model.name] = false
downloadProgress[model.name] = 0.0
}
print("Download failed: \(error)")
}
}
}
private func selectModel(_ model: ModelInfo) {
modelManager.setActiveModel(model)
Task {
do {
if let modelPath = modelManager.getModelPath(for: model) {
try await whisperEngine.loadModel(at: modelPath)
}
} catch {
print("Failed to load model: \(error)")
}
}
}
}
struct ModelRow: View {
let model: ModelInfo
@ObservedObject var modelManager: ModelManager
let whisperEngine: WhisperCPPEngine
let isDownloading: Bool
let downloadProgress: Double
let onDownload: () -> Void
let onSelect: () -> Void
let onDelete: () -> Void
private var isActive: Bool {
modelManager.activeModel?.name == model.name
}
var body: some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(model.name)
.font(.body)
.fontWeight(.medium)
if isActive {
Text("ACTIVE")
.font(.caption)
.fontWeight(.semibold)
.foregroundColor(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.blue)
.cornerRadius(4)
}
}
Text("\(model.sizeMB) MB • \(model.qualityTier) quality • \(model.estimatedRAM)")
.font(.caption)
.foregroundColor(.secondary)
if !model.notes.isEmpty {
Text(model.notes)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(2)
}
}
Spacer()
VStack(spacing: 8) {
if model.isDownloaded {
HStack(spacing: 8) {
if !isActive {
Button("Select") {
onSelect()
}
.buttonStyle(.bordered)
}
Button("Delete") {
onDelete()
}
.buttonStyle(.bordered)
.foregroundColor(.red)
}
} else {
if isDownloading {
VStack {
ProgressView(value: downloadProgress)
.frame(width: 80)
Text("\(Int(downloadProgress * 100))%")
.font(.caption)
}
} else {
Button("Download") {
onDownload()
}
.buttonStyle(.bordered)
}
}
}
}
.padding(12)
.background(isActive ? Color.blue.opacity(0.1) : Color(NSColor.controlBackgroundColor))
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(isActive ? Color.blue : Color.clear, lineWidth: 2)
)
}
}
struct PermissionsTab: View {
@ObservedObject var permissionManager: PermissionManager
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Permissions")
.font(.title2)
.fontWeight(.semibold)
Text("MenuWhisper requires certain system permissions to function properly. Click the buttons below to grant permissions in System Settings.")
.font(.caption)
.foregroundColor(.secondary)
VStack(alignment: .leading, spacing: 12) {
// Microphone Permission
PermissionRow(
title: "Microphone",
description: "Required to capture speech for transcription",
status: permissionManager.microphoneStatus,
onOpenSettings: {
permissionManager.openSystemSettings(for: .microphone)
},
onRefresh: {
permissionManager.checkAllPermissions()
}
)
Divider()
// Accessibility Permission
PermissionRow(
title: "Accessibility",
description: "Required to insert transcribed text into other applications",
status: permissionManager.accessibilityStatus,
onOpenSettings: {
permissionManager.openSystemSettings(for: .accessibility)
},
onRefresh: {
permissionManager.checkAllPermissions()
}
)
Divider()
// Input Monitoring Permission
PermissionRow(
title: "Input Monitoring",
description: "Required to send keyboard events for text insertion",
status: permissionManager.inputMonitoringStatus,
onOpenSettings: {
permissionManager.openSystemSettings(for: .inputMonitoring)
},
onRefresh: {
permissionManager.checkAllPermissions()
}
)
}
.padding(16)
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(12)
// Help text
VStack(alignment: .leading, spacing: 8) {
Text("Need Help?")
.font(.headline)
Text("After granting permissions in System Settings:")
.font(.body)
.foregroundColor(.secondary)
VStack(alignment: .leading, spacing: 4) {
Text("1. Close System Settings")
Text("2. Click 'Refresh Status' to update permission status")
Text("3. Some permissions may require restarting MenuWhisper")
}
.font(.caption)
.foregroundColor(.secondary)
.padding(.leading, 8)
}
Spacer()
}
.padding(20)
}
}
struct PermissionRow: View {
let title: String
let description: String
let status: PermissionStatus
let onOpenSettings: () -> Void
let onRefresh: () -> Void
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(title)
.font(.body)
.fontWeight(.medium)
Spacer()
// Status indicator
HStack(spacing: 4) {
Circle()
.fill(statusColor)
.frame(width: 8, height: 8)
Text(statusText)
.font(.caption)
.fontWeight(.medium)
.foregroundColor(statusColor)
}
}
Text(description)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
VStack(spacing: 6) {
if status != .granted {
Button("Open System Settings") {
onOpenSettings()
}
.buttonStyle(.bordered)
.controlSize(.small)
}
Button("Refresh Status") {
onRefresh()
}
.buttonStyle(.borderless)
.controlSize(.small)
.foregroundColor(.secondary)
}
}
}
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 GeneralTab: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("General Settings")
.font(.title2)
.fontWeight(.semibold)
Text("Additional settings will be available in Phase 4.")
.font(.body)
.foregroundColor(.secondary)
Spacer()
}
.padding(20)
}
}