From febc96c639e04fbebf457b3e3217da3c9a8530a9 Mon Sep 17 00:00:00 2001 From: altic-dev Date: Sat, 4 Apr 2026 16:30:14 -0700 Subject: [PATCH 01/11] login app Startup basic fixes --- Sources/Fluid/AppDelegate.swift | 7 +- Sources/Fluid/Persistence/SettingsStore.swift | 158 ++++++++++++++++-- Sources/Fluid/Services/MenuBarManager.swift | 9 - Sources/Fluid/UI/SettingsView.swift | 23 ++- 4 files changed, 171 insertions(+), 26 deletions(-) diff --git a/Sources/Fluid/AppDelegate.swift b/Sources/Fluid/AppDelegate.swift index 60bb698c..aaa0261f 100644 --- a/Sources/Fluid/AppDelegate.swift +++ b/Sources/Fluid/AppDelegate.swift @@ -77,7 +77,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { } private func forceFrontOnLaunch() { - for delay in [0.0, 0.12, 0.35] { + // Login-item launches can take longer before SwiftUI's main window exists. + // Keep retrying for a few seconds so the existing ContentView startup path runs. + for delay in [0.0, 0.12, 0.35, 1.0, 2.0, 4.0] { DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in guard let self else { return } self.bringMainWindowToFront() @@ -95,6 +97,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { }) { mainWindow.orderFrontRegardless() mainWindow.makeKeyAndOrderFront(nil) + DebugLogger.shared.debug("Brought main window to front", source: "AppDelegate") + } else { + DebugLogger.shared.debug("Main window not ready yet during launch-front retry", source: "AppDelegate") } } diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index ac301b40..9ae6a400 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -16,6 +16,10 @@ final class SettingsStore: ObservableObject { static let defaultTranscriptionPreviewCharLimit = 150 private let defaults = UserDefaults.standard private let keychain = KeychainService.shared + private(set) var launchAtStartupEnabled = false + private(set) var launchAtStartupErrorMessage: String? + private(set) var launchAtStartupStatusMessage = + "FluidVoice reflects the actual macOS login item state. Unsigned or development builds may fail to enable this." private init() { self.migrateTranscriptionStartSoundIfNeeded() @@ -26,6 +30,7 @@ final class SettingsStore: ObservableObject { self.migrateLegacyDictationAIPreferenceIfNeeded() self.normalizePromptSelectionsIfNeeded() self.migrateOverlayBottomOffsetTo50IfNeeded() + self.refreshLaunchAtStartupStatus(clearError: true, logMismatch: false) } // MARK: - Prompt Profiles (Unified) @@ -1397,11 +1402,9 @@ final class SettingsStore: ObservableObject { } var launchAtStartup: Bool { - get { self.defaults.bool(forKey: Keys.launchAtStartup) } + get { self.launchAtStartupEnabled } set { - self.defaults.set(newValue, forKey: Keys.launchAtStartup) - // Update launch agent registration - self.updateLaunchAtStartup(newValue) + self.setLaunchAtStartup(newValue) } } @@ -1409,6 +1412,8 @@ final class SettingsStore: ObservableObject { func initializeAppSettings() { #if os(macOS) + self.refreshLaunchAtStartupStatus(clearError: true) + // Apply dock visibility setting on app launch let dockVisible = self.showInDock DebugLogger.shared.info("Initializing app with dock visibility: \(dockVisible)", source: "SettingsStore") @@ -2243,28 +2248,159 @@ final class SettingsStore: ObservableObject { } } - private func updateLaunchAtStartup(_ enabled: Bool) { + func refreshLaunchAtStartupStatus(clearError: Bool = false, logMismatch: Bool = true) { + #if os(macOS) + let persistedValue = self.defaults.bool(forKey: Keys.launchAtStartup) + let systemState = self.currentLaunchAtStartupSystemState() + let systemEnabled = systemState.isEnabled + + if logMismatch, persistedValue != systemEnabled { + DebugLogger.shared.warning( + "Launch at startup preference mismatch. Stored: \(persistedValue), actual: \(systemEnabled). Preferring macOS state.", + source: "SettingsStore" + ) + } + + self.defaults.set(systemEnabled, forKey: Keys.launchAtStartup) + + let nextErrorMessage = clearError ? nil : self.launchAtStartupErrorMessage + if self.launchAtStartupEnabled != systemEnabled || + self.launchAtStartupStatusMessage != systemState.message || + self.launchAtStartupErrorMessage != nextErrorMessage + { + objectWillChange.send() + self.launchAtStartupEnabled = systemEnabled + self.launchAtStartupStatusMessage = systemState.message + self.launchAtStartupErrorMessage = nextErrorMessage + } + #else + let unavailableMessage = "Launch at startup is only available on macOS." + let nextErrorMessage = clearError ? nil : self.launchAtStartupErrorMessage + if self.launchAtStartupEnabled || + self.launchAtStartupStatusMessage != unavailableMessage || + self.launchAtStartupErrorMessage != nextErrorMessage + { + objectWillChange.send() + self.launchAtStartupEnabled = false + self.launchAtStartupStatusMessage = unavailableMessage + self.launchAtStartupErrorMessage = nextErrorMessage + } + #endif + } + + func setLaunchAtStartup(_ enabled: Bool) { #if os(macOS) - // Note: SMAppService.mainApp requires the app to be signed with Developer ID - // and have proper entitlements. This may not work in development builds. let service = SMAppService.mainApp + let statusBeforeChange = self.currentLaunchAtStartupSystemState() + + if statusBeforeChange.isEnabled == enabled { + self.refreshLaunchAtStartupStatus(clearError: true, logMismatch: false) + return + } do { if enabled { try service.register() - DebugLogger.shared.info("Successfully registered for launch at startup", source: "SettingsStore") + DebugLogger.shared.info("Requested registration for launch at startup", source: "SettingsStore") } else { try service.unregister() - DebugLogger.shared.info("Successfully unregistered from launch at startup", source: "SettingsStore") + DebugLogger.shared.info("Requested unregistration from launch at startup", source: "SettingsStore") + } + + self.refreshLaunchAtStartupStatus(clearError: true, logMismatch: false) + + if self.launchAtStartupEnabled != enabled { + let fallbackMessage = enabled + ? "macOS did not enable FluidVoice in Login Items. Unsigned or development builds may not support launch at startup." + : "macOS still shows FluidVoice in Login Items. Check System Settings > General > Login Items." + objectWillChange.send() + self.launchAtStartupErrorMessage = fallbackMessage + DebugLogger.shared.warning(fallbackMessage, source: "SettingsStore") } } catch { DebugLogger.shared.error("Failed to update launch at startup: \(error)", source: "SettingsStore") - // In development, this is expected to fail without proper signing/entitlements - // The setting is still saved and will work when the app is properly signed + self.refreshLaunchAtStartupStatus(clearError: false, logMismatch: false) + + let message = self.launchAtStartupFailureMessage(for: error, enabling: enabled) + if self.launchAtStartupErrorMessage != message { + objectWillChange.send() + self.launchAtStartupErrorMessage = message + } + } + #else + let message = "Launch at startup is only available on macOS." + if self.launchAtStartupErrorMessage != message { + objectWillChange.send() + self.launchAtStartupErrorMessage = message } #endif } + #if os(macOS) + private func currentLaunchAtStartupSystemState() -> LaunchAtStartupSystemState { + let service = SMAppService.mainApp + switch service.status { + case .enabled: + return .enabled + case .requiresApproval: + return .requiresApproval + case .notFound: + return .disabled + case .notRegistered: + return .disabled + @unknown default: + return .disabled + } + } + + private func launchAtStartupFailureMessage(for error: Error, enabling: Bool) -> String { + let nsError = error as NSError + let action = enabling ? "enable" : "disable" + let lowercasedDescription = nsError.localizedDescription.lowercased() + + if lowercasedDescription.contains("developer") || + lowercasedDescription.contains("sign") || + lowercasedDescription.contains("entitlement") + { + return "FluidVoice could not \(action) launch at startup. This build may not be signed correctly for macOS Login Items." + } + + if lowercasedDescription.contains("approval") || + lowercasedDescription.contains("authorize") + { + return "macOS needs approval before FluidVoice can \(action) launch at startup. Check System Settings > General > Login Items." + } + + return "FluidVoice could not \(action) launch at startup. macOS reported: \(nsError.localizedDescription)" + } + #endif + + private enum LaunchAtStartupSystemState { + case enabled + case disabled + case requiresApproval + + var isEnabled: Bool { + switch self { + case .enabled: + return true + case .disabled, .requiresApproval: + return false + } + } + + var message: String { + switch self { + case .enabled: + return "FluidVoice reflects the actual macOS login item state." + case .disabled: + return "FluidVoice reflects the actual macOS login item state. Unsigned or development builds may fail to enable this." + case .requiresApproval: + return "macOS requires approval for FluidVoice in Login Items before launch at startup becomes active." + } + } + } + private func updateDockVisibility(_ visible: Bool) { #if os(macOS) // IMPORTANT: This is a simplified implementation for development diff --git a/Sources/Fluid/Services/MenuBarManager.swift b/Sources/Fluid/Services/MenuBarManager.swift index 37dbf7e0..e5427eda 100644 --- a/Sources/Fluid/Services/MenuBarManager.swift +++ b/Sources/Fluid/Services/MenuBarManager.swift @@ -242,15 +242,6 @@ final class MenuBarManager: ObservableObject { } private func setupMenuBarSafely() { - // Check if window server connection is available - guard NSApp.isActive || NSApp.isRunning else { - // Retry after a short delay if app isn't ready - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in - self?.setupMenuBarSafely() - } - return - } - do { try self.setupMenuBar() self.isSetup = true diff --git a/Sources/Fluid/UI/SettingsView.swift b/Sources/Fluid/UI/SettingsView.swift index 124d25fe..5bed0e57 100644 --- a/Sources/Fluid/UI/SettingsView.swift +++ b/Sources/Fluid/UI/SettingsView.swift @@ -106,6 +106,13 @@ struct SettingsView: View { Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "Unknown" } + private var launchAtStartupBinding: Binding { + Binding( + get: { self.settings.launchAtStartupEnabled }, + set: { self.settings.setLaunchAtStartup($0) } + ) + } + var body: some View { ScrollView(.vertical, showsIndicators: false) { VStack(spacing: 16) { @@ -122,11 +129,9 @@ struct SettingsView: View { self.settingsToggleRow( title: "Launch at startup", description: "Automatically start FluidVoice when you log in", - footnote: "Note: Requires app to be signed for this to work.", - isOn: Binding( - get: { SettingsStore.shared.launchAtStartup }, - set: { SettingsStore.shared.launchAtStartup = $0 } - ) + footnote: self.settings.launchAtStartupStatusMessage, + errorMessage: self.settings.launchAtStartupErrorMessage, + isOn: self.launchAtStartupBinding ) Divider().opacity(0.2) @@ -1327,6 +1332,7 @@ struct SettingsView: View { self.cachedDefaultInputName = AudioDevice.getDefaultInputDevice()?.name ?? "" self.cachedDefaultOutputName = AudioDevice.getDefaultOutputDevice()?.name ?? "" self.refreshRollbackState() + self.settings.refreshLaunchAtStartupStatus(clearError: true, logMismatch: false) } } .onChange(of: self.visualizerNoiseThreshold) { _, newValue in @@ -1406,6 +1412,7 @@ struct SettingsView: View { title: String, description: String, footnote: String? = nil, + errorMessage: String? = nil, isOn: Binding ) -> some View { VStack(alignment: .leading, spacing: 6) { @@ -1431,6 +1438,12 @@ struct SettingsView: View { .font(.caption) .foregroundStyle(.tertiary) } + + if let errorMessage = errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundStyle(self.theme.palette.warning) + } } } From 90483701cd7946e1e321c1609e829dcb67b661ce Mon Sep 17 00:00:00 2001 From: altic-dev Date: Sat, 4 Apr 2026 16:51:11 -0700 Subject: [PATCH 02/11] Rename overlay prompt caption to AI --- Sources/Fluid/Views/BottomOverlayView.swift | 12 +++++------- Sources/Fluid/Views/NotchContentViews.swift | 2 +- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/Sources/Fluid/Views/BottomOverlayView.swift b/Sources/Fluid/Views/BottomOverlayView.swift index c211902f..bac70cbc 100644 --- a/Sources/Fluid/Views/BottomOverlayView.swift +++ b/Sources/Fluid/Views/BottomOverlayView.swift @@ -2086,13 +2086,11 @@ struct BottomOverlayView: View { private var promptSelectorTrigger: some View { HStack(spacing: 5) { - if !self.isCompactControls { - Text("Prompt:") - .font(.system(size: self.promptSelectorFontSize, weight: .medium)) - .foregroundStyle(.white.opacity(0.5)) - .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) - } + Text("AI:") + .font(.system(size: self.promptSelectorFontSize, weight: .medium)) + .foregroundStyle(.white.opacity(0.5)) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) Text(self.promptSelectorDisplayLabel) .font(.system(size: self.promptSelectorLabelFontSize, weight: .semibold)) .foregroundStyle(.white.opacity(0.75)) diff --git a/Sources/Fluid/Views/NotchContentViews.swift b/Sources/Fluid/Views/NotchContentViews.swift index e097ad2c..0a14b847 100644 --- a/Sources/Fluid/Views/NotchContentViews.swift +++ b/Sources/Fluid/Views/NotchContentViews.swift @@ -550,7 +550,7 @@ struct NotchExpandedView: View { if !self.contentState.isProcessing { ZStack(alignment: .top) { HStack(spacing: 6) { - Text("Prompt:") + Text("AI:") .font(.system(size: 9, weight: .medium)) .foregroundStyle(.white.opacity(0.5)) Text(self.selectedPromptLabel) From 2d7f9907834ea06daa4e75a91867b8955da938ec Mon Sep 17 00:00:00 2001 From: altic-dev Date: Sat, 4 Apr 2026 17:12:36 -0700 Subject: [PATCH 03/11] Add configurable cancel recording shortcut Fixes #24 --- Sources/Fluid/ContentView.swift | 97 ++++++++++++------- Sources/Fluid/Models/HotkeyShortcut.swift | 10 ++ Sources/Fluid/Persistence/SettingsStore.swift | 18 ++++ .../Fluid/Services/GlobalHotkeyManager.swift | 8 +- .../Fluid/Services/NotchOverlayManager.swift | 32 ++---- Sources/Fluid/UI/SettingsView.swift | 20 +++- Sources/Fluid/Views/NotchContentViews.swift | 2 + 7 files changed, 122 insertions(+), 65 deletions(-) diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index f0dac998..bb07c735 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -79,6 +79,7 @@ struct ContentView: View { @State private var promptModeHotkeyShortcut: HotkeyShortcut = SettingsStore.shared.promptModeHotkeyShortcut @State private var commandModeHotkeyShortcut: HotkeyShortcut = SettingsStore.shared.commandModeHotkeyShortcut @State private var rewriteModeHotkeyShortcut: HotkeyShortcut = SettingsStore.shared.rewriteModeHotkeyShortcut + @State private var cancelRecordingHotkeyShortcut: HotkeyShortcut = SettingsStore.shared.cancelRecordingHotkeyShortcut @State private var isPromptModeShortcutEnabled: Bool = SettingsStore.shared.promptModeShortcutEnabled @State private var isCommandModeShortcutEnabled: Bool = SettingsStore.shared.commandModeShortcutEnabled @State private var aiSettingsExpanded: Bool = true @@ -91,6 +92,7 @@ struct ContentView: View { @State private var isRecordingPromptModeShortcut = false @State private var isRecordingCommandModeShortcut = false @State private var isRecordingRewriteShortcut = false + @State private var isRecordingCancelShortcut = false @State private var pendingModifierFlags: NSEvent.ModifierFlags = [] @State private var pendingModifierKeyCode: UInt16? @State private var pendingModifierOnly = false @@ -365,61 +367,36 @@ struct ContentView: View { NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .flagsChanged]) { event in let eventModifiers = event.modifierFlags.intersection([.function, .command, .option, .control, .shift]) - let shortcutModifiers = self.hotkeyShortcut.modifierFlags.intersection([.function, .command, .option, .control, .shift]) - - let isRecordingAnyShortcut = self.isRecordingShortcut || self.isRecordingPromptModeShortcut || self.isRecordingCommandModeShortcut || self.isRecordingRewriteShortcut + let isRecordingAnyShortcut = self.isRecordingShortcut || + self.isRecordingPromptModeShortcut || + self.isRecordingCommandModeShortcut || + self.isRecordingRewriteShortcut || + self.isRecordingCancelShortcut if event.type == .keyDown { - if event.keyCode == self.hotkeyShortcut.keyCode && eventModifiers == shortcutModifiers { + if self.hotkeyShortcut.matches(keyCode: event.keyCode, modifiers: eventModifiers) { DebugLogger.shared.debug("NSEvent monitor: Global hotkey matched on keyDown, passing event through (GlobalHotkeyManager handles)", source: "ContentView") return event } guard isRecordingAnyShortcut else { - if event.keyCode == 53 { - // Escape pressed - handle cancellation - var handled = false - - // Close expanded command notch if visible (highest priority) - if NotchOverlayManager.shared.isCommandOutputExpanded { - DebugLogger.shared - .debug( - "NSEvent monitor: Escape pressed, closing expanded command notch", - source: "ContentView" - ) - NotchOverlayManager.shared.hideExpandedCommandOutput() - handled = true - } - - if self.asr.isRunning { - DebugLogger.shared.debug("NSEvent monitor: Escape pressed, cancelling ASR recording", source: "ContentView") - Task { await self.asr.stopWithoutTranscription() } - handled = true - } - - // Close mode views if active - if self.selectedSidebarItem == .commandMode || self.selectedSidebarItem == .rewriteMode { - DebugLogger.shared.debug("NSEvent monitor: Escape pressed, closing mode view", source: "ContentView") - let isOnboarded = self.asr.isAsrReady || self.asr.modelsExistOnDisk - self.selectedSidebarItem = isOnboarded ? .preferences : .welcome - handled = true - } - - if handled { - return nil // Suppress beep - } + if self.cancelRecordingHotkeyShortcut.matches(keyCode: event.keyCode, modifiers: eventModifiers), + self.handleCancelShortcut() + { + return nil } self.resetPendingShortcutState() return event } let keyCode = event.keyCode - if keyCode == 53 { + if keyCode == 53 && !self.isRecordingCancelShortcut { DebugLogger.shared.debug("NSEvent monitor: Escape pressed, cancelling shortcut recording", source: "ContentView") self.isRecordingShortcut = false self.isRecordingPromptModeShortcut = false self.isRecordingCommandModeShortcut = false self.isRecordingRewriteShortcut = false + self.isRecordingCancelShortcut = false self.resetPendingShortcutState() return nil } @@ -433,6 +410,10 @@ struct ContentView: View { SettingsStore.shared.rewriteModeHotkeyShortcut = newShortcut self.hotkeyManager?.updateRewriteModeShortcut(newShortcut) self.isRecordingRewriteShortcut = false + } else if self.isRecordingCancelShortcut { + self.cancelRecordingHotkeyShortcut = newShortcut + SettingsStore.shared.cancelRecordingHotkeyShortcut = newShortcut + self.isRecordingCancelShortcut = false } else if self.isRecordingPromptModeShortcut { self.promptModeHotkeyShortcut = newShortcut SettingsStore.shared.promptModeHotkeyShortcut = newShortcut @@ -476,6 +457,10 @@ struct ContentView: View { SettingsStore.shared.rewriteModeHotkeyShortcut = newShortcut self.hotkeyManager?.updateRewriteModeShortcut(newShortcut) self.isRecordingRewriteShortcut = false + } else if self.isRecordingCancelShortcut { + self.cancelRecordingHotkeyShortcut = newShortcut + SettingsStore.shared.cancelRecordingHotkeyShortcut = newShortcut + self.isRecordingCancelShortcut = false } else if self.isRecordingPromptModeShortcut { self.promptModeHotkeyShortcut = newShortcut SettingsStore.shared.promptModeHotkeyShortcut = newShortcut @@ -1142,6 +1127,8 @@ struct ContentView: View { isRecordingCommandModeShortcut: self.$isRecordingCommandModeShortcut, rewriteShortcut: self.$rewriteModeHotkeyShortcut, isRecordingRewriteShortcut: self.$isRecordingRewriteShortcut, + cancelRecordingShortcut: self.$cancelRecordingHotkeyShortcut, + isRecordingCancelShortcut: self.$isRecordingCancelShortcut, commandModeShortcutEnabled: self.$isCommandModeShortcutEnabled, rewriteShortcutEnabled: self.$isRewriteModeShortcutEnabled, hotkeyManagerInitialized: self.$hotkeyManagerInitialized, @@ -2415,6 +2402,9 @@ struct ContentView: View { NotchContentState.shared.onOpenPreferencesRequested = { self.menuBarManager.openPreferencesFromUI() } + NotchContentState.shared.onCancelRequested = { + _ = self.handleCancelShortcut() + } NotchContentState.shared.onPromptModeProfileChangeRequested = { profile in if let p = profile { self.promptModeOverrideText = SettingsStore.combineBasePrompt( @@ -2627,6 +2617,39 @@ struct ContentView: View { } } + @discardableResult + private func handleCancelShortcut() -> Bool { + var handled = false + + if NotchOverlayManager.shared.isCommandOutputExpanded { + DebugLogger.shared.debug("Cancel shortcut: closing expanded command notch", source: "ContentView") + NotchOverlayManager.shared.hideExpandedCommandOutput() + NotchOverlayManager.shared.onCommandOutputDismiss?() + handled = true + } + + if self.asr.isRunning { + DebugLogger.shared.debug("Cancel shortcut: cancelling ASR recording", source: "ContentView") + Task { await self.asr.stopWithoutTranscription() } + handled = true + } + + if NotchOverlayManager.shared.isBottomOverlayVisible || NotchOverlayManager.shared.isOverlayVisible { + DebugLogger.shared.debug("Cancel shortcut: hiding recording overlay", source: "ContentView") + NotchOverlayManager.shared.hide() + handled = true + } + + if self.selectedSidebarItem == .commandMode || self.selectedSidebarItem == .rewriteMode { + DebugLogger.shared.debug("Cancel shortcut: closing mode view", source: "ContentView") + let isOnboarded = self.asr.isAsrReady || self.asr.modelsExistOnDisk + self.selectedSidebarItem = isOnboarded ? .preferences : .welcome + handled = true + } + + return handled + } + // MARK: - Model Management Helpers private func isCustomModel(_ model: String) -> Bool { diff --git a/Sources/Fluid/Models/HotkeyShortcut.swift b/Sources/Fluid/Models/HotkeyShortcut.swift index 861968a9..92130f9d 100644 --- a/Sources/Fluid/Models/HotkeyShortcut.swift +++ b/Sources/Fluid/Models/HotkeyShortcut.swift @@ -99,6 +99,16 @@ struct HotkeyShortcut: Codable, Equatable { } extension HotkeyShortcut { + private static let relevantModifierMask: NSEvent.ModifierFlags = [.function, .command, .option, .control, .shift] + + var relevantModifierFlags: NSEvent.ModifierFlags { + self.modifierFlags.intersection(Self.relevantModifierMask) + } + + func matches(keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> Bool { + keyCode == self.keyCode && modifiers.intersection(Self.relevantModifierMask) == self.relevantModifierFlags + } + init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) self.keyCode = try c.decode(UInt16.self, forKey: .keyCode) diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index 9ae6a400..441f4ba2 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -1721,6 +1721,23 @@ final class SettingsStore: ObservableObject { } } + var cancelRecordingHotkeyShortcut: HotkeyShortcut { + get { + if let data = defaults.data(forKey: Keys.cancelRecordingHotkeyShortcut), + let shortcut = try? JSONDecoder().decode(HotkeyShortcut.self, from: data) + { + return shortcut + } + return HotkeyShortcut(keyCode: 53, modifierFlags: []) + } + set { + objectWillChange.send() + if let data = try? JSONEncoder().encode(newValue) { + self.defaults.set(data, forKey: Keys.cancelRecordingHotkeyShortcut) + } + } + } + var commandModeConfirmBeforeExecute: Bool { get { // Default to true (safer - ask before running commands) @@ -3226,6 +3243,7 @@ private extension SettingsStore { static let commandModeSelectedProviderID = "CommandModeSelectedProviderID" static let commandModeHotkeyShortcut = "CommandModeHotkeyShortcut" static let commandModeConfirmBeforeExecute = "CommandModeConfirmBeforeExecute" + static let cancelRecordingHotkeyShortcut = "CancelRecordingHotkeyShortcut" static let commandModeLinkedToGlobal = "CommandModeLinkedToGlobal" static let commandModeShortcutEnabled = "CommandModeShortcutEnabled" diff --git a/Sources/Fluid/Services/GlobalHotkeyManager.swift b/Sources/Fluid/Services/GlobalHotkeyManager.swift index dc17af3f..d64277ca 100644 --- a/Sources/Fluid/Services/GlobalHotkeyManager.swift +++ b/Sources/Fluid/Services/GlobalHotkeyManager.swift @@ -382,12 +382,12 @@ final class GlobalHotkeyManager: NSObject { await PostTranscriptionEditTracker.shared.handleKeyDown(keyCode: keyCode, modifiers: eventModifiers) } - // Check Escape key first (keyCode 53) - cancels recording and closes mode views - if keyCode == 53, eventModifiers.isEmpty { + // Check the configured cancel shortcut first. + if SettingsStore.shared.cancelRecordingHotkeyShortcut.matches(keyCode: keyCode, modifiers: eventModifiers) { var handled = false if self.asrService.isRunning { - DebugLogger.shared.info("Escape pressed - cancelling recording", source: "GlobalHotkeyManager") + DebugLogger.shared.info("Cancel shortcut pressed - cancelling recording", source: "GlobalHotkeyManager") Task { @MainActor in await self.asrService.stopWithoutTranscription() } @@ -396,7 +396,7 @@ final class GlobalHotkeyManager: NSObject { // Trigger cancel callback to close mode views / reset state if let callback = cancelCallback, callback() { - DebugLogger.shared.info("Escape pressed - cancel callback handled", source: "GlobalHotkeyManager") + DebugLogger.shared.info("Cancel shortcut pressed - cancel callback handled", source: "GlobalHotkeyManager") handled = true } diff --git a/Sources/Fluid/Services/NotchOverlayManager.swift b/Sources/Fluid/Services/NotchOverlayManager.swift index 68dabe99..939cf5f1 100644 --- a/Sources/Fluid/Services/NotchOverlayManager.swift +++ b/Sources/Fluid/Services/NotchOverlayManager.swift @@ -54,6 +54,7 @@ final class NotchOverlayManager { /// Track if bottom overlay is visible private(set) var isBottomOverlayVisible: Bool = false + var isOverlayVisible: Bool { self.state == .visible } // Callbacks for command output interaction var onCommandOutputDismiss: (() -> Void)? @@ -73,7 +74,7 @@ final class NotchOverlayManager { /// Track pending retry task for cancellation private var pendingRetryTask: Task? - // Escape key monitors for dismissing notch + // Cancel shortcut monitors for dismissing notch / overlay private var globalEscapeMonitor: Any? private var localEscapeMonitor: Any? @@ -90,38 +91,25 @@ final class NotchOverlayManager { } } - /// Setup escape key monitors - both global (other apps) and local (our app) + /// Setup cancel shortcut monitors - both global (other apps) and local (our app) private func setupEscapeKeyMonitors() { let escapeHandler: (NSEvent) -> NSEvent? = { [weak self] event in - guard event.keyCode == 53 else { return event } // Escape key + guard SettingsStore.shared.cancelRecordingHotkeyShortcut.matches( + keyCode: event.keyCode, + modifiers: event.modifierFlags + ) else { return event } Task { @MainActor in - guard let self = self else { return } - - // If expanded command output is showing, hide it - if self.isCommandOutputExpanded { - self.hideExpandedCommandOutput() - self.onCommandOutputDismiss?() - } - // Hide bottom overlay if visible - else if self.isBottomOverlayVisible { - self.hide() - } - // Hide regular notch if visible - else if self.state == .visible { - self.hide() - } + guard self != nil else { return } + NotchContentState.shared.onCancelRequested?() } return nil // Consume the event } - // Global monitor - catches escape when OTHER apps have focus + // Global monitor - catches the cancel shortcut when OTHER apps have focus self.globalEscapeMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { event in _ = escapeHandler(event) } - - // Local monitor - catches escape when OUR app/notch has focus - self.localEscapeMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown, handler: escapeHandler) } func show(audioLevelPublisher: AnyPublisher, mode: OverlayMode) { diff --git a/Sources/Fluid/UI/SettingsView.swift b/Sources/Fluid/UI/SettingsView.swift index 5bed0e57..752b8cfb 100644 --- a/Sources/Fluid/UI/SettingsView.swift +++ b/Sources/Fluid/UI/SettingsView.swift @@ -33,6 +33,8 @@ struct SettingsView: View { @Binding var isRecordingCommandModeShortcut: Bool @Binding var rewriteShortcut: HotkeyShortcut @Binding var isRecordingRewriteShortcut: Bool + @Binding var cancelRecordingShortcut: HotkeyShortcut + @Binding var isRecordingCancelShortcut: Bool @Binding var commandModeShortcutEnabled: Bool @Binding var rewriteShortcutEnabled: Bool @Binding var hotkeyManagerInitialized: Bool @@ -520,7 +522,7 @@ struct SettingsView: View { Spacer() if self.accessibilityEnabled { - if self.isRecordingShortcut || self.isRecordingCommandModeShortcut || self.isRecordingRewriteShortcut { + if self.isRecordingShortcut || self.isRecordingCommandModeShortcut || self.isRecordingRewriteShortcut || self.isRecordingCancelShortcut { Text("Recording…") .font(.caption.weight(.semibold)) .foregroundStyle(.orange) @@ -543,7 +545,7 @@ struct SettingsView: View { if self.accessibilityEnabled { VStack(alignment: .leading, spacing: 12) { - if self.isRecordingShortcut || self.isRecordingCommandModeShortcut || self.isRecordingRewriteShortcut { + if self.isRecordingShortcut || self.isRecordingCommandModeShortcut || self.isRecordingRewriteShortcut || self.isRecordingCancelShortcut { HStack(spacing: 8) { Image(systemName: "hand.point.up.left.fill") .foregroundStyle(.orange) @@ -659,6 +661,20 @@ struct SettingsView: View { self.isRecordingRewriteShortcut = true } ) + Divider().opacity(0.2).padding(.vertical, 4) + + self.shortcutRow( + icon: "xmark.circle.fill", + iconColor: .secondary, + title: "Cancel Recording", + description: "Cancel the current recording or dismiss the active recording overlay", + shortcut: self.cancelRecordingShortcut, + isRecording: self.isRecordingCancelShortcut, + onChangePressed: { + DebugLogger.shared.debug("Starting to record new cancel shortcut", source: "SettingsView") + self.isRecordingCancelShortcut = true + } + ) } .padding(12) .background( diff --git a/Sources/Fluid/Views/NotchContentViews.swift b/Sources/Fluid/Views/NotchContentViews.swift index 0a14b847..bba198f1 100644 --- a/Sources/Fluid/Views/NotchContentViews.swift +++ b/Sources/Fluid/Views/NotchContentViews.swift @@ -143,6 +143,8 @@ class NotchContentState: ObservableObject { var onUndoLastAIRequested: (() -> Void)? /// Called when the user requests opening Preferences. var onOpenPreferencesRequested: (() -> Void)? + /// Called when the user requests cancelling the current recording or overlay session. + var onCancelRequested: (() -> Void)? /// Set recording state (for waveform visibility in expanded view) func setRecordingInExpandedMode(_ recording: Bool) { From 2310d4c762c678fc2bf9e5c97a640f981acfe7d8 Mon Sep 17 00:00:00 2001 From: altic-dev Date: Sun, 5 Apr 2026 11:30:34 -0700 Subject: [PATCH 04/11] Add manual backup and restore --- Sources/Fluid/ContentView.swift | 60 +++++++ Sources/Fluid/Persistence/BackupService.swift | 159 ++++++++++++++++++ Sources/Fluid/Persistence/SettingsStore.swift | 142 +++++++++++++++- .../TranscriptionHistoryStore.swift | 10 ++ Sources/Fluid/UI/SettingsView.swift | 137 +++++++++++++++ 5 files changed, 504 insertions(+), 4 deletions(-) create mode 100644 Sources/Fluid/Persistence/BackupService.swift diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index bb07c735..ce7b4256 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -584,6 +584,9 @@ struct ContentView: View { .onReceive(NotificationCenter.default.publisher(for: .openCustomDictionaryFromVoiceEngine)) { _ in self.selectedSidebarItem = .customDictionary } + .onReceive(NotificationCenter.default.publisher(for: .settingsBackupDidRestore)) { _ in + self.reloadSettingsStateAfterBackupRestore() + } .toolbar { if !self.settings.shouldShowOnboarding { ToolbarItem(placement: .primaryAction) { @@ -2863,6 +2866,63 @@ extension ContentView { } } +private extension ContentView { + func reloadSettingsStateAfterBackupRestore() { + self.hotkeyShortcut = SettingsStore.shared.hotkeyShortcut + self.promptModeHotkeyShortcut = SettingsStore.shared.promptModeHotkeyShortcut + self.commandModeHotkeyShortcut = SettingsStore.shared.commandModeHotkeyShortcut + self.rewriteModeHotkeyShortcut = SettingsStore.shared.rewriteModeHotkeyShortcut + self.cancelRecordingHotkeyShortcut = SettingsStore.shared.cancelRecordingHotkeyShortcut + self.isPromptModeShortcutEnabled = SettingsStore.shared.promptModeShortcutEnabled + self.isCommandModeShortcutEnabled = SettingsStore.shared.commandModeShortcutEnabled + self.isRewriteModeShortcutEnabled = SettingsStore.shared.rewriteModeShortcutEnabled + self.playgroundUsed = SettingsStore.shared.playgroundUsed + self.visualizerNoiseThreshold = SettingsStore.shared.visualizerNoiseThreshold + self.selectedInputUID = SettingsStore.shared.preferredInputDeviceUID ?? "" + self.selectedOutputUID = SettingsStore.shared.preferredOutputDeviceUID ?? "" + self.enableDebugLogs = SettingsStore.shared.enableDebugLogs + self.pressAndHoldModeEnabled = SettingsStore.shared.pressAndHoldMode + self.enableStreamingPreview = SettingsStore.shared.enableStreamingPreview + self.copyToClipboard = SettingsStore.shared.copyTranscriptionToClipboard + self.launchAtStartup = SettingsStore.shared.launchAtStartup + self.showInDock = SettingsStore.shared.showInDock + self.availableModelsByProvider = SettingsStore.shared.availableModelsByProvider + self.selectedModelByProvider = SettingsStore.shared.selectedModelByProvider + self.savedProviders = SettingsStore.shared.savedProviders + self.selectedProviderID = SettingsStore.shared.selectedProviderID + + self.hotkeyManager?.updateShortcut(self.hotkeyShortcut) + self.hotkeyManager?.updatePromptModeShortcut(self.promptModeHotkeyShortcut) + self.hotkeyManager?.updatePromptModeShortcutEnabled(self.isPromptModeShortcutEnabled) + self.hotkeyManager?.updateCommandModeShortcut(self.commandModeHotkeyShortcut) + self.hotkeyManager?.updateCommandModeShortcutEnabled(self.isCommandModeShortcutEnabled) + self.hotkeyManager?.updateRewriteModeShortcut(self.rewriteModeHotkeyShortcut) + self.hotkeyManager?.updateRewriteModeShortcutEnabled(self.isRewriteModeShortcutEnabled) + + self.currentProvider = self.providerKey(for: self.selectedProviderID) + if let saved = self.savedProviders.first(where: { $0.id == self.selectedProviderID }) { + self.availableModels = saved.models + self.openAIBaseURL = saved.baseURL + } else if let stored = self.availableModelsByProvider[self.currentProvider], !stored.isEmpty { + self.availableModels = stored + self.openAIBaseURL = ModelRepository.shared.defaultBaseURL(for: self.selectedProviderID) + } else { + self.availableModels = ModelRepository.shared.defaultModels(for: self.currentProvider) + self.openAIBaseURL = ModelRepository.shared.defaultBaseURL(for: self.selectedProviderID) + } + + if let restoredSelectedModel = self.selectedModelByProvider[self.currentProvider], + self.availableModels.contains(restoredSelectedModel) + { + self.selectedModel = restoredSelectedModel + } else if let firstModel = self.availableModels.first { + self.selectedModel = firstModel + } + + self.refreshDevices() + } +} + // MARK: - Card Animation Modifier struct CardAppearAnimation: ViewModifier { diff --git a/Sources/Fluid/Persistence/BackupService.swift b/Sources/Fluid/Persistence/BackupService.swift new file mode 100644 index 00000000..158de0c0 --- /dev/null +++ b/Sources/Fluid/Persistence/BackupService.swift @@ -0,0 +1,159 @@ +import Foundation + +struct BackupFileVersion: Codable, Equatable { + let major: Int + let minor: Int + + static let current = BackupFileVersion(major: 1, minor: 0) +} + +struct SettingsBackupPayload: Codable, Equatable { + let selectedProviderID: String + let selectedModelByProvider: [String: String] + let savedProviders: [SettingsStore.SavedProvider] + let modelReasoningConfigs: [String: SettingsStore.ModelReasoningConfig] + let selectedSpeechModel: SettingsStore.SpeechModel + let selectedCohereLanguage: SettingsStore.CohereLanguage + let hotkeyShortcut: HotkeyShortcut + let promptModeHotkeyShortcut: HotkeyShortcut + let promptModeShortcutEnabled: Bool + let promptModeSelectedPromptID: String? + let commandModeHotkeyShortcut: HotkeyShortcut + let commandModeShortcutEnabled: Bool + let commandModeSelectedModel: String? + let commandModeSelectedProviderID: String + let commandModeConfirmBeforeExecute: Bool + let commandModeLinkedToGlobal: Bool + let rewriteModeHotkeyShortcut: HotkeyShortcut + let rewriteModeShortcutEnabled: Bool + let rewriteModeSelectedModel: String? + let rewriteModeSelectedProviderID: String + let rewriteModeLinkedToGlobal: Bool + let cancelRecordingHotkeyShortcut: HotkeyShortcut + let showThinkingTokens: Bool + let hideFromDockAndAppSwitcher: Bool + let accentColorOption: SettingsStore.AccentColorOption + let transcriptionStartSound: SettingsStore.TranscriptionStartSound + let transcriptionSoundVolume: Float + let transcriptionSoundIndependentVolume: Bool + let autoUpdateCheckEnabled: Bool + let betaReleasesEnabled: Bool + let enableDebugLogs: Bool + let shareAnonymousAnalytics: Bool + let pressAndHoldMode: Bool + let enableStreamingPreview: Bool + let enableAIStreaming: Bool + let copyTranscriptionToClipboard: Bool + let textInsertionMode: SettingsStore.TextInsertionMode + let preferredInputDeviceUID: String? + let preferredOutputDeviceUID: String? + let visualizerNoiseThreshold: Double + let overlayPosition: SettingsStore.OverlayPosition + let overlayBottomOffset: Double + let overlaySize: SettingsStore.OverlaySize + let transcriptionPreviewCharLimit: Int + let userTypingWPM: Int + let saveTranscriptionHistory: Bool + let weekendsDontBreakStreak: Bool + let fillerWords: [String] + let removeFillerWordsEnabled: Bool + let gaavModeEnabled: Bool + let pauseMediaDuringTranscription: Bool + let vocabularyBoostingEnabled: Bool + let customDictionaryEntries: [SettingsStore.CustomDictionaryEntry] + let selectedDictationPromptID: String? + let selectedEditPromptID: String? + let defaultDictationPromptOverride: String? + let defaultEditPromptOverride: String? +} + +struct AppBackupDocument: Codable, Equatable { + let schemaVersion: BackupFileVersion + let appVersion: String + let exportedAt: Date + let settings: SettingsBackupPayload + let promptProfiles: [SettingsStore.DictationPromptProfile] + let appPromptBindings: [SettingsStore.AppPromptBinding] + let transcriptionHistory: [TranscriptionHistoryEntry] +} + +enum BackupServiceError: LocalizedError { + case unsupportedSchemaVersion(BackupFileVersion) + case invalidJSON + + var errorDescription: String? { + switch self { + case let .unsupportedSchemaVersion(version): + return "This backup uses an unsupported schema version (\(version.major).\(version.minor))." + case .invalidJSON: + return "The selected backup file is not a valid FluidVoice backup." + } + } +} + +final class BackupService { + static let shared = BackupService() + + private init() {} + + func makeBackupDocument() -> AppBackupDocument { + AppBackupDocument( + schemaVersion: .current, + appVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "Unknown", + exportedAt: Date(), + settings: SettingsStore.shared.makeBackupPayload(), + promptProfiles: SettingsStore.shared.dictationPromptProfiles, + appPromptBindings: SettingsStore.shared.appPromptBindings, + transcriptionHistory: TranscriptionHistoryStore.shared.makeBackupPayload() + ) + } + + func encode(_ document: AppBackupDocument) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + return try encoder.encode(document) + } + + func decode(_ data: Data) throws -> AppBackupDocument { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + do { + let document = try decoder.decode(AppBackupDocument.self, from: data) + try self.validate(document) + return document + } catch let error as BackupServiceError { + throw error + } catch { + throw BackupServiceError.invalidJSON + } + } + + func restore(_ document: AppBackupDocument) throws { + try self.validate(document) + SettingsStore.shared.restore( + from: document.settings, + promptProfiles: document.promptProfiles, + appPromptBindings: document.appPromptBindings + ) + TranscriptionHistoryStore.shared.restore(from: document.transcriptionHistory) + NotificationCenter.default.post(name: .settingsBackupDidRestore, object: nil) + } + + func suggestedFilename(for date: Date = Date()) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd_HH-mm" + return "FluidVoice_Backup_\(formatter.string(from: date)).json" + } + + private func validate(_ document: AppBackupDocument) throws { + guard document.schemaVersion.major == BackupFileVersion.current.major else { + throw BackupServiceError.unsupportedSchemaVersion(document.schemaVersion) + } + } +} + +extension Notification.Name { + static let settingsBackupDidRestore = Notification.Name("SettingsBackupDidRestore") +} diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index 441f4ba2..2749af85 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -1167,7 +1167,7 @@ final class SettingsStore: ObservableObject { // MARK: - Overlay Position /// Size options for the recording overlay - enum OverlaySize: String, CaseIterable { + enum OverlaySize: String, CaseIterable, Codable { case small case medium case large @@ -1182,7 +1182,7 @@ final class SettingsStore: ObservableObject { } /// Position options for the recording overlay - enum OverlayPosition: String, CaseIterable { + enum OverlayPosition: String, CaseIterable, Codable { case top // Top of screen (notch area or floating) case bottom // Bottom of screen @@ -1278,7 +1278,7 @@ final class SettingsStore: ObservableObject { // MARK: - Preferences Settings - enum AccentColorOption: String, CaseIterable, Identifiable { + enum AccentColorOption: String, CaseIterable, Identifiable, Codable { case cyan = "Cyan" case green = "Green" case blue = "Blue" @@ -1300,7 +1300,7 @@ final class SettingsStore: ObservableObject { } } - enum TranscriptionStartSound: String, CaseIterable, Identifiable { + enum TranscriptionStartSound: String, CaseIterable, Identifiable, Codable { case none case fluidSfx1 = "fluid_sfx_1" case fluidSfx2 = "fluid_sfx_2" @@ -1981,6 +1981,140 @@ final class SettingsStore: ObservableObject { } } + func makeBackupPayload() -> SettingsBackupPayload { + SettingsBackupPayload( + selectedProviderID: self.selectedProviderID, + selectedModelByProvider: self.selectedModelByProvider, + savedProviders: self.savedProviders, + modelReasoningConfigs: self.modelReasoningConfigs, + selectedSpeechModel: self.selectedSpeechModel, + selectedCohereLanguage: self.selectedCohereLanguage, + hotkeyShortcut: self.hotkeyShortcut, + promptModeHotkeyShortcut: self.promptModeHotkeyShortcut, + promptModeShortcutEnabled: self.promptModeShortcutEnabled, + promptModeSelectedPromptID: self.promptModeSelectedPromptID, + commandModeHotkeyShortcut: self.commandModeHotkeyShortcut, + commandModeShortcutEnabled: self.commandModeShortcutEnabled, + commandModeSelectedModel: self.commandModeSelectedModel, + commandModeSelectedProviderID: self.commandModeSelectedProviderID, + commandModeConfirmBeforeExecute: self.commandModeConfirmBeforeExecute, + commandModeLinkedToGlobal: self.commandModeLinkedToGlobal, + rewriteModeHotkeyShortcut: self.rewriteModeHotkeyShortcut, + rewriteModeShortcutEnabled: self.rewriteModeShortcutEnabled, + rewriteModeSelectedModel: self.rewriteModeSelectedModel, + rewriteModeSelectedProviderID: self.rewriteModeSelectedProviderID, + rewriteModeLinkedToGlobal: self.rewriteModeLinkedToGlobal, + cancelRecordingHotkeyShortcut: self.cancelRecordingHotkeyShortcut, + showThinkingTokens: self.showThinkingTokens, + hideFromDockAndAppSwitcher: self.hideFromDockAndAppSwitcher, + accentColorOption: self.accentColorOption, + transcriptionStartSound: self.transcriptionStartSound, + transcriptionSoundVolume: self.transcriptionSoundVolume, + transcriptionSoundIndependentVolume: self.transcriptionSoundIndependentVolume, + autoUpdateCheckEnabled: self.autoUpdateCheckEnabled, + betaReleasesEnabled: self.betaReleasesEnabled, + enableDebugLogs: self.enableDebugLogs, + shareAnonymousAnalytics: self.shareAnonymousAnalytics, + pressAndHoldMode: self.pressAndHoldMode, + enableStreamingPreview: self.enableStreamingPreview, + enableAIStreaming: self.enableAIStreaming, + copyTranscriptionToClipboard: self.copyTranscriptionToClipboard, + textInsertionMode: self.textInsertionMode, + preferredInputDeviceUID: self.preferredInputDeviceUID, + preferredOutputDeviceUID: self.preferredOutputDeviceUID, + visualizerNoiseThreshold: self.visualizerNoiseThreshold, + overlayPosition: self.overlayPosition, + overlayBottomOffset: self.overlayBottomOffset, + overlaySize: self.overlaySize, + transcriptionPreviewCharLimit: self.transcriptionPreviewCharLimit, + userTypingWPM: self.userTypingWPM, + saveTranscriptionHistory: self.saveTranscriptionHistory, + weekendsDontBreakStreak: self.weekendsDontBreakStreak, + fillerWords: self.fillerWords, + removeFillerWordsEnabled: self.removeFillerWordsEnabled, + gaavModeEnabled: self.gaavModeEnabled, + pauseMediaDuringTranscription: self.pauseMediaDuringTranscription, + vocabularyBoostingEnabled: self.vocabularyBoostingEnabled, + customDictionaryEntries: self.customDictionaryEntries, + selectedDictationPromptID: self.selectedDictationPromptID, + selectedEditPromptID: self.selectedEditPromptID, + defaultDictationPromptOverride: self.defaultDictationPromptOverride, + defaultEditPromptOverride: self.defaultEditPromptOverride + ) + } + + func restore(from payload: SettingsBackupPayload) { + self.restore(from: payload, promptProfiles: self.dictationPromptProfiles, appPromptBindings: self.appPromptBindings) + } + + func restore( + from payload: SettingsBackupPayload, + promptProfiles: [DictationPromptProfile], + appPromptBindings: [AppPromptBinding] + ) { + self.savedProviders = payload.savedProviders + self.selectedProviderID = payload.selectedProviderID + self.selectedModelByProvider = payload.selectedModelByProvider + self.modelReasoningConfigs = payload.modelReasoningConfigs + self.selectedSpeechModel = payload.selectedSpeechModel + self.selectedCohereLanguage = payload.selectedCohereLanguage + self.hotkeyShortcut = payload.hotkeyShortcut + self.promptModeHotkeyShortcut = payload.promptModeHotkeyShortcut + self.promptModeShortcutEnabled = payload.promptModeShortcutEnabled + self.commandModeHotkeyShortcut = payload.commandModeHotkeyShortcut + self.commandModeShortcutEnabled = payload.commandModeShortcutEnabled + self.commandModeSelectedModel = payload.commandModeSelectedModel + self.commandModeSelectedProviderID = payload.commandModeSelectedProviderID + self.commandModeConfirmBeforeExecute = payload.commandModeConfirmBeforeExecute + self.commandModeLinkedToGlobal = payload.commandModeLinkedToGlobal + self.rewriteModeHotkeyShortcut = payload.rewriteModeHotkeyShortcut + self.rewriteModeShortcutEnabled = payload.rewriteModeShortcutEnabled + self.rewriteModeSelectedModel = payload.rewriteModeSelectedModel + self.rewriteModeSelectedProviderID = payload.rewriteModeSelectedProviderID + self.rewriteModeLinkedToGlobal = payload.rewriteModeLinkedToGlobal + self.cancelRecordingHotkeyShortcut = payload.cancelRecordingHotkeyShortcut + self.showThinkingTokens = payload.showThinkingTokens + self.hideFromDockAndAppSwitcher = payload.hideFromDockAndAppSwitcher + self.accentColorOption = payload.accentColorOption + self.transcriptionStartSound = payload.transcriptionStartSound + self.transcriptionSoundVolume = payload.transcriptionSoundVolume + self.transcriptionSoundIndependentVolume = payload.transcriptionSoundIndependentVolume + self.autoUpdateCheckEnabled = payload.autoUpdateCheckEnabled + self.betaReleasesEnabled = payload.betaReleasesEnabled + self.enableDebugLogs = payload.enableDebugLogs + self.shareAnonymousAnalytics = payload.shareAnonymousAnalytics + self.pressAndHoldMode = payload.pressAndHoldMode + self.enableStreamingPreview = payload.enableStreamingPreview + self.enableAIStreaming = payload.enableAIStreaming + self.copyTranscriptionToClipboard = payload.copyTranscriptionToClipboard + self.textInsertionMode = payload.textInsertionMode + self.preferredInputDeviceUID = payload.preferredInputDeviceUID + self.preferredOutputDeviceUID = payload.preferredOutputDeviceUID + self.visualizerNoiseThreshold = payload.visualizerNoiseThreshold + self.overlayPosition = payload.overlayPosition + self.overlayBottomOffset = payload.overlayBottomOffset + self.overlaySize = payload.overlaySize + self.transcriptionPreviewCharLimit = payload.transcriptionPreviewCharLimit + self.userTypingWPM = payload.userTypingWPM + self.saveTranscriptionHistory = payload.saveTranscriptionHistory + self.weekendsDontBreakStreak = payload.weekendsDontBreakStreak + self.fillerWords = payload.fillerWords + self.removeFillerWordsEnabled = payload.removeFillerWordsEnabled + self.gaavModeEnabled = payload.gaavModeEnabled + self.pauseMediaDuringTranscription = payload.pauseMediaDuringTranscription + self.vocabularyBoostingEnabled = payload.vocabularyBoostingEnabled + self.customDictionaryEntries = payload.customDictionaryEntries + + self.dictationPromptProfiles = promptProfiles + self.appPromptBindings = appPromptBindings + self.selectedDictationPromptID = payload.selectedDictationPromptID + self.selectedEditPromptID = payload.selectedEditPromptID + self.defaultDictationPromptOverride = payload.defaultDictationPromptOverride + self.defaultEditPromptOverride = payload.defaultEditPromptOverride + self.promptModeSelectedPromptID = payload.promptModeSelectedPromptID + self.normalizePromptSelectionsIfNeeded() + } + // MARK: - Private Methods private func persistProviderAPIKeys(_ values: [String: String]) { diff --git a/Sources/Fluid/Persistence/TranscriptionHistoryStore.swift b/Sources/Fluid/Persistence/TranscriptionHistoryStore.swift index 79939b80..5b211c00 100644 --- a/Sources/Fluid/Persistence/TranscriptionHistoryStore.swift +++ b/Sources/Fluid/Persistence/TranscriptionHistoryStore.swift @@ -177,6 +177,16 @@ final class TranscriptionHistoryStore: ObservableObject { self.entries.filter { $0.wasAIProcessed }.count } + func makeBackupPayload() -> [TranscriptionHistoryEntry] { + self.entries + } + + func restore(from payload: [TranscriptionHistoryEntry]) { + self.entries = payload.sorted { $0.timestamp > $1.timestamp } + self.selectedEntryID = self.entries.first?.id + self.saveEntries() + } + // MARK: - Private Methods private func loadEntries() { diff --git a/Sources/Fluid/UI/SettingsView.swift b/Sources/Fluid/UI/SettingsView.swift index 752b8cfb..71ecdb5b 100644 --- a/Sources/Fluid/UI/SettingsView.swift +++ b/Sources/Fluid/UI/SettingsView.swift @@ -5,9 +5,11 @@ // App preferences and audio device settings // +import AppKit import AVFoundation import PromiseKit import SwiftUI +import UniformTypeIdentifiers struct SettingsView: View { @EnvironmentObject var appServices: AppServices @@ -1215,6 +1217,12 @@ struct SettingsView: View { .padding(16) } + // Backup & Restore Card + ThemedCard(style: .standard) { + self.backupUtilityRow() + .padding(16) + } + // Debug Settings Card ThemedCard(style: .standard) { VStack(alignment: .leading, spacing: 14) { @@ -1365,6 +1373,101 @@ struct SettingsView: View { NSWorkspace.shared.open(url) } + private func exportBackup() { + do { + let panel = NSSavePanel() + panel.canCreateDirectories = true + panel.allowedContentTypes = [.json] + panel.nameFieldStringValue = BackupService.shared.suggestedFilename() + + guard panel.runModal() == .OK, let url = panel.url else { return } + + let document = BackupService.shared.makeBackupDocument() + let data = try BackupService.shared.encode(document) + try data.write(to: url, options: .atomic) + + self.presentInfoAlert( + title: "Backup Exported", + message: "Saved your FluidVoice backup to:\n\(url.path)" + ) + } catch { + self.presentErrorAlert( + title: "Backup Export Failed", + message: error.localizedDescription + ) + } + } + + private func importBackup() { + do { + let panel = NSOpenPanel() + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.allowsMultipleSelection = false + panel.allowedContentTypes = [.json] + + guard panel.runModal() == .OK, let url = panel.url else { return } + + let data = try Data(contentsOf: url) + let document = try BackupService.shared.decode(data) + + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + + let confirm = NSAlert() + confirm.messageText = "Import this backup?" + confirm.informativeText = """ + This replaces your current settings, prompt profiles, and stats history. + + Exported: \(formatter.string(from: document.exportedAt)) + API keys are not included and will not be changed. + """ + confirm.alertStyle = .warning + confirm.addButton(withTitle: "Import") + confirm.addButton(withTitle: "Cancel") + + guard confirm.runModal() == .alertFirstButtonReturn else { return } + + try BackupService.shared.restore(document) + self.syncLocalSettingsAfterBackupRestore() + + self.presentInfoAlert( + title: "Backup Imported", + message: "Your settings, prompt profiles, and stats were restored successfully." + ) + } catch { + self.presentErrorAlert( + title: "Backup Import Failed", + message: error.localizedDescription + ) + } + } + + private func syncLocalSettingsAfterBackupRestore() { + self.shareAnonymousAnalytics = SettingsStore.shared.shareAnonymousAnalytics + self.pendingAnalyticsValue = nil + self.showAreYouSureToStopAnalytics = false + } + + private func presentInfoAlert(title: String, message: String) { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.alertStyle = .informational + alert.addButton(withTitle: "OK") + alert.runModal() + } + + private func presentErrorAlert(title: String, message: String) { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.alertStyle = .critical + alert.addButton(withTitle: "OK") + alert.runModal() + } + private func openPreviousBuildPicker() { Task { @MainActor in do { @@ -1463,6 +1566,40 @@ struct SettingsView: View { } } + private func backupUtilityRow() -> some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "externaldrive.fill") + .font(.headline) + .foregroundStyle(.primary) + .frame(width: 24, alignment: .center) + + VStack(alignment: .leading, spacing: 2) { + Text("Backup & Restore") + .font(.body) + Text("Export or import settings, prompt profiles, history, and stats. API keys excluded.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 16) + + HStack(spacing: 8) { + Button(action: self.exportBackup) { + Label("Export", systemImage: "square.and.arrow.up") + } + .buttonStyle(.borderedProminent) + .tint(self.theme.palette.accent) + .controlSize(.regular) + + Button(action: self.importBackup) { + Label("Import", systemImage: "square.and.arrow.down") + } + .buttonStyle(.bordered) + .controlSize(.regular) + } + } + } + private func optionToggleRow( title: String, description: String, From 74a9e00ca8be8497aa1ce40a70f0a1b12767c2b3 Mon Sep 17 00:00:00 2001 From: altic-dev Date: Sun, 5 Apr 2026 14:51:03 -0700 Subject: [PATCH 05/11] Add hotkey restart guidance --- Sources/Fluid/UI/SettingsView.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/Fluid/UI/SettingsView.swift b/Sources/Fluid/UI/SettingsView.swift index 71ecdb5b..91ba5e2b 100644 --- a/Sources/Fluid/UI/SettingsView.swift +++ b/Sources/Fluid/UI/SettingsView.swift @@ -573,6 +573,10 @@ struct SettingsView: View { .font(.subheadline.weight(.medium)) .foregroundStyle(.secondary) + Text("Changes usually apply immediately. If a new shortcut does not respond, restart FluidVoice.") + .font(.caption) + .foregroundStyle(.tertiary) + self.shortcutRow( icon: "mic.fill", iconColor: .secondary, From 0259fa0cb28ad0ca113568e8dfa5d743522460ff Mon Sep 17 00:00:00 2001 From: altic-dev Date: Sun, 5 Apr 2026 15:11:36 -0700 Subject: [PATCH 06/11] Sync microphone selection across menu bar and settings (#225) --- RELEASE_NOTES_1.5.12.md | 36 ++++++++ Sources/Fluid/ContentView.swift | 14 ++- Sources/Fluid/Services/MenuBarManager.swift | 95 ++++++++++++++++++++- Sources/Fluid/UI/SettingsView.swift | 16 +--- 4 files changed, 139 insertions(+), 22 deletions(-) create mode 100644 RELEASE_NOTES_1.5.12.md diff --git a/RELEASE_NOTES_1.5.12.md b/RELEASE_NOTES_1.5.12.md new file mode 100644 index 00000000..5bc73bb6 --- /dev/null +++ b/RELEASE_NOTES_1.5.12.md @@ -0,0 +1,36 @@ +# FluidVoice v1.5.12 + +## What's New +- Added `Transcribe with Prompt`, a new recording mode that lets you run dictation with a chosen AI prompt for that session without changing your global prompt selection. +- Simplified dictation AI controls by moving AI off/on into prompt selection with `Off`, `Default`, and custom prompts. +- Fixed AI post-processing so selecting a dictation prompt correctly counts as opting into AI, instead of silently skipping processing. +- Fixed overlay actions staying functional after the main settings window closes. + + +## Voice Engine Updates +- Added `Cohere Transcribe` as a new speech model option. Very accurate.( 14 languages but needs manual selection) +- Added `Parakeet Flash (Beta)`, a faster English-only local streaming model for low-latency live dictation. +- Improved Cohere performance with split Neural Engine/GPU execution and async chunk prefetch. +- Fixed Cohere model downloads and transcription failures. +- Added manual language selection for Cohere in Voice Engine settings. +- Added stronger validation for external Cohere artifacts so mismatched model contracts fail earlier and more clearly. + +## File and Meeting Transcription +- Added OGG support for file transcription uploads and drag-and-drop. +- Expanded meeting transcription format support with broader macOS-native audio and video compatibility. + +## Other Fixes +- Added manual backup export and import for app settings, prompt profiles, transcription history, and stats, with API keys excluded from backup files. +- Added a compact `Backup & Restore` utility row in Preferences for quicker export and import access. +- Added a configurable `Cancel Recording` shortcut in Settings, defaulting to `Escape`, so recording cancel behavior can be remapped without changing the rest of the shortcuts. +- Added microphone selection to the menu bar and synced microphone selection state between the menu bar and Settings. +- Fixed API key authentication for localhost and other local model endpoints that still require an `Authorization` header. +- Fixed the top notch overlay so it shows the active prompt name correctly during prompt-mode recording. + +## Credits +- Thanks to @yelloduxx for the original prompt-mode and overlay work. +- Thanks to @kabhijeet for the localhost API auth fix in PR #233. +- Thanks to @daaain for the media format support contribution. + +## Need Help? +- Report issues: https://github.com/altic-dev/FluidVoice/issues diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index ce7b4256..7cf8d801 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -110,7 +110,7 @@ struct ContentView: View { @State private var visualizerNoiseThreshold: Double = SettingsStore.shared.visualizerNoiseThreshold @State private var inputDevices: [AudioDevice.Device] = [] @State private var outputDevices: [AudioDevice.Device] = [] - @State private var selectedInputUID: String = SettingsStore.shared.preferredInputDeviceUID ?? "" + @State private var selectedInputUID: String = AudioDevice.getDefaultInputDevice()?.uid ?? "" @State private var selectedOutputUID: String = SettingsStore.shared.preferredOutputDeviceUID ?? "" // AI Prompts Tab State @@ -241,13 +241,11 @@ struct ContentView: View { if self.selectedInputUID.isEmpty, let defIn = AudioDevice.getDefaultInputDevice()?.uid { self.selectedInputUID = defIn } if self.selectedOutputUID.isEmpty, let defOut = AudioDevice.getDefaultOutputDevice()?.uid { self.selectedOutputUID = defOut } - // Load saved preferences for UI display (but don't force system defaults) - // FluidVoice should NOT control system-wide audio routing - if let prefIn = SettingsStore.shared.preferredInputDeviceUID, - prefIn.isEmpty == false, - inputDevices.first(where: { $0.uid == prefIn }) != nil + // Input device UI should mirror the current macOS default device. + if let systemInputUID = AudioDevice.getDefaultInputDevice()?.uid, + self.inputDevices.contains(where: { $0.uid == systemInputUID }) { - self.selectedInputUID = prefIn + self.selectedInputUID = systemInputUID } if let prefOut = SettingsStore.shared.preferredOutputDeviceUID, @@ -2878,7 +2876,7 @@ private extension ContentView { self.isRewriteModeShortcutEnabled = SettingsStore.shared.rewriteModeShortcutEnabled self.playgroundUsed = SettingsStore.shared.playgroundUsed self.visualizerNoiseThreshold = SettingsStore.shared.visualizerNoiseThreshold - self.selectedInputUID = SettingsStore.shared.preferredInputDeviceUID ?? "" + self.selectedInputUID = AudioDevice.getDefaultInputDevice()?.uid ?? "" self.selectedOutputUID = SettingsStore.shared.preferredOutputDeviceUID ?? "" self.enableDebugLogs = SettingsStore.shared.enableDebugLogs self.pressAndHoldModeEnabled = SettingsStore.shared.pressAndHoldMode diff --git a/Sources/Fluid/Services/MenuBarManager.swift b/Sources/Fluid/Services/MenuBarManager.swift index e5427eda..f1771905 100644 --- a/Sources/Fluid/Services/MenuBarManager.swift +++ b/Sources/Fluid/Services/MenuBarManager.swift @@ -8,7 +8,7 @@ enum MenuBarNavigationDestination: String { } @MainActor -final class MenuBarManager: ObservableObject { +final class MenuBarManager: NSObject, ObservableObject, NSMenuDelegate { private var statusItem: NSStatusItem? private var menu: NSMenu? private var isSetup: Bool = false @@ -17,6 +17,8 @@ final class MenuBarManager: ObservableObject { // Cached menu items to avoid rebuilding entire menu private var statusMenuItem: NSMenuItem? private var rollbackMenuItem: NSMenuItem? + private var microphoneMenuItem: NSMenuItem? + private var microphoneSubmenu: NSMenu? // References to app state private weak var asrService: ASRService? @@ -46,7 +48,8 @@ final class MenuBarManager: ObservableObject { // Subscription for forwarding audio levels to expanded command notch private var expandedModeAudioSubscription: AnyCancellable? - init() { + override init() { + super.init() // Don't setup menu bar immediately - defer until app is ready } @@ -270,6 +273,7 @@ final class MenuBarManager: ObservableObject { // Create menu self.menu = NSMenu() + self.menu?.delegate = self statusItem.menu = self.menu self.updateMenu() @@ -310,6 +314,13 @@ final class MenuBarManager: ObservableObject { preferencesItem.keyEquivalentModifierMask = [.command] menu.addItem(preferencesItem) + let microphoneSubmenu = NSMenu(title: "Microphone") + let microphoneMenuItem = NSMenuItem(title: "Microphone", action: nil, keyEquivalent: "") + microphoneMenuItem.submenu = microphoneSubmenu + menu.addItem(microphoneMenuItem) + self.microphoneMenuItem = microphoneMenuItem + self.microphoneSubmenu = microphoneSubmenu + // Check for Updates let updateItem = NSMenuItem( title: "Check for Updates...", @@ -362,11 +373,91 @@ final class MenuBarManager: ObservableObject { let hotkeyInfo = hotkeyShortcut.displayString.isEmpty ? "" : " (\(hotkeyShortcut.displayString))" let statusTitle = self.isRecording ? "Recording...\(hotkeyInfo)" : "Ready to Record\(hotkeyInfo)" self.statusMenuItem?.title = statusTitle + self.microphoneMenuItem?.isEnabled = true // Update rollback availability text self.rollbackMenuItem?.isEnabled = SimpleUpdater.shared.hasRollbackBackup() } + func menuWillOpen(_ menu: NSMenu) { + if menu === self.menu { + self.updateMenuItemsText() + self.refreshMicrophoneMenu() + } + } + + private func refreshMicrophoneMenu() { + guard let submenu = self.microphoneSubmenu else { return } + + submenu.removeAllItems() + let loadingItem = NSMenuItem(title: "Loading...", action: nil, keyEquivalent: "") + loadingItem.isEnabled = false + submenu.addItem(loadingItem) + + DispatchQueue.global(qos: .userInitiated).async { + let inputDevices = AudioDevice.listInputDevices() + let defaultInputUID = AudioDevice.getDefaultInputDevice()?.uid + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.populateMicrophoneMenu( + inputDevices: inputDevices, + defaultInputUID: defaultInputUID + ) + } + } + } + + private func populateMicrophoneMenu(inputDevices: [AudioDevice.Device], defaultInputUID: String?) { + guard let submenu = self.microphoneSubmenu else { return } + + submenu.removeAllItems() + + guard !inputDevices.isEmpty else { + let emptyItem = NSMenuItem(title: "No microphones found", action: nil, keyEquivalent: "") + emptyItem.isEnabled = false + submenu.addItem(emptyItem) + return + } + + let currentUID = self.currentPreferredInputUID(defaultInputUID: defaultInputUID) + + for device in inputDevices { + let isSystemDefault = device.uid == defaultInputUID + let title = isSystemDefault ? "\(device.name) (System Default)" : device.name + let item = NSMenuItem(title: title, action: #selector(selectMicrophone(_:)), keyEquivalent: "") + item.target = self + item.representedObject = device.uid + item.state = device.uid == currentUID ? .on : .off + item.isEnabled = !self.isRecording + submenu.addItem(item) + } + + if self.isRecording { + submenu.addItem(.separator()) + let recordingItem = NSMenuItem(title: "Unavailable while recording", action: nil, keyEquivalent: "") + recordingItem.isEnabled = false + submenu.addItem(recordingItem) + } + } + + private func currentPreferredInputUID(defaultInputUID: String?) -> String? { + return defaultInputUID + } + + @objc private func selectMicrophone(_ sender: NSMenuItem) { + guard self.isRecording == false else { return } + guard let uid = sender.representedObject as? String, !uid.isEmpty else { return } + + SettingsStore.shared.preferredInputDeviceUID = uid + + if SettingsStore.shared.syncAudioDevicesWithSystem { + _ = AudioDevice.setDefaultInputDevice(uid: uid) + } + + self.refreshMicrophoneMenu() + } + @objc private func checkForUpdates(_ sender: Any?) { DebugLogger.shared.info("🔎 Menu action: Check for Updates…", source: "MenuBarManager") diff --git a/Sources/Fluid/UI/SettingsView.swift b/Sources/Fluid/UI/SettingsView.swift index 91ba5e2b..1c771179 100644 --- a/Sources/Fluid/UI/SettingsView.swift +++ b/Sources/Fluid/UI/SettingsView.swift @@ -948,12 +948,8 @@ struct SettingsView: View { if !newDevices.isEmpty { let currentValid = newDevices.contains { $0.uid == self.selectedInputUID } if !currentValid { - if let prefUID = SettingsStore.shared.preferredInputDeviceUID, - newDevices.contains(where: { $0.uid == prefUID }) - { - self.selectedInputUID = prefUID - } else if let defaultUID = AudioDevice.getDefaultInputDevice()?.uid, - newDevices.contains(where: { $0.uid == defaultUID }) + if let defaultUID = AudioDevice.getDefaultInputDevice()?.uid, + newDevices.contains(where: { $0.uid == defaultUID }) { self.selectedInputUID = defaultUID } else { @@ -1323,12 +1319,8 @@ struct SettingsView: View { if !self.inputDevices.isEmpty { let inputValid = self.inputDevices.contains { $0.uid == self.selectedInputUID } if !inputValid || self.selectedInputUID.isEmpty { - if let prefUID = SettingsStore.shared.preferredInputDeviceUID, - self.inputDevices.contains(where: { $0.uid == prefUID }) - { - self.selectedInputUID = prefUID - } else if let defaultUID = AudioDevice.getDefaultInputDevice()?.uid, - self.inputDevices.contains(where: { $0.uid == defaultUID }) + if let defaultUID = AudioDevice.getDefaultInputDevice()?.uid, + self.inputDevices.contains(where: { $0.uid == defaultUID }) { self.selectedInputUID = defaultUID } else { From 5cc7ff91e3ba1c86f1cb61ce92dcaa3ecb35f3e4 Mon Sep 17 00:00:00 2001 From: altic-dev Date: Sun, 5 Apr 2026 15:13:47 -0700 Subject: [PATCH 07/11] Remove release notes from commit --- RELEASE_NOTES_1.5.12.md | 36 ------------------------------------ 1 file changed, 36 deletions(-) delete mode 100644 RELEASE_NOTES_1.5.12.md diff --git a/RELEASE_NOTES_1.5.12.md b/RELEASE_NOTES_1.5.12.md deleted file mode 100644 index 5bc73bb6..00000000 --- a/RELEASE_NOTES_1.5.12.md +++ /dev/null @@ -1,36 +0,0 @@ -# FluidVoice v1.5.12 - -## What's New -- Added `Transcribe with Prompt`, a new recording mode that lets you run dictation with a chosen AI prompt for that session without changing your global prompt selection. -- Simplified dictation AI controls by moving AI off/on into prompt selection with `Off`, `Default`, and custom prompts. -- Fixed AI post-processing so selecting a dictation prompt correctly counts as opting into AI, instead of silently skipping processing. -- Fixed overlay actions staying functional after the main settings window closes. - - -## Voice Engine Updates -- Added `Cohere Transcribe` as a new speech model option. Very accurate.( 14 languages but needs manual selection) -- Added `Parakeet Flash (Beta)`, a faster English-only local streaming model for low-latency live dictation. -- Improved Cohere performance with split Neural Engine/GPU execution and async chunk prefetch. -- Fixed Cohere model downloads and transcription failures. -- Added manual language selection for Cohere in Voice Engine settings. -- Added stronger validation for external Cohere artifacts so mismatched model contracts fail earlier and more clearly. - -## File and Meeting Transcription -- Added OGG support for file transcription uploads and drag-and-drop. -- Expanded meeting transcription format support with broader macOS-native audio and video compatibility. - -## Other Fixes -- Added manual backup export and import for app settings, prompt profiles, transcription history, and stats, with API keys excluded from backup files. -- Added a compact `Backup & Restore` utility row in Preferences for quicker export and import access. -- Added a configurable `Cancel Recording` shortcut in Settings, defaulting to `Escape`, so recording cancel behavior can be remapped without changing the rest of the shortcuts. -- Added microphone selection to the menu bar and synced microphone selection state between the menu bar and Settings. -- Fixed API key authentication for localhost and other local model endpoints that still require an `Authorization` header. -- Fixed the top notch overlay so it shows the active prompt name correctly during prompt-mode recording. - -## Credits -- Thanks to @yelloduxx for the original prompt-mode and overlay work. -- Thanks to @kabhijeet for the localhost API auth fix in PR #233. -- Thanks to @daaain for the media format support contribution. - -## Need Help? -- Report issues: https://github.com/altic-dev/FluidVoice/issues From 71c570232a07a6ecb3f9407d17bee3a441bd6104 Mon Sep 17 00:00:00 2001 From: altic-dev Date: Sun, 5 Apr 2026 15:32:37 -0700 Subject: [PATCH 08/11] Add Parakeet Flash load diagnostics --- Sources/Fluid/Services/ASRService.swift | 2 +- .../Services/ParakeetRealtimeProvider.swift | 96 +++++++++++++++++-- 2 files changed, 90 insertions(+), 8 deletions(-) diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index 27f1ff19..3f8304e1 100644 --- a/Sources/Fluid/Services/ASRService.swift +++ b/Sources/Fluid/Services/ASRService.swift @@ -2036,7 +2036,7 @@ final class ASRService: ObservableObject { self.isDownloadingModel = true self.isLoadingModel = false self.downloadProgress = nil - self.stopDownloadProgressMonitor() + self.startParakeetDownloadProgressMonitor() DebugLogger.shared.info("⬇️ DOWNLOADING model...", source: "ASRService") } } diff --git a/Sources/Fluid/Services/ParakeetRealtimeProvider.swift b/Sources/Fluid/Services/ParakeetRealtimeProvider.swift index 9ddefe04..321b4c86 100644 --- a/Sources/Fluid/Services/ParakeetRealtimeProvider.swift +++ b/Sources/Fluid/Services/ParakeetRealtimeProvider.swift @@ -23,14 +23,75 @@ final class ParakeetRealtimeProvider: TranscriptionProvider { func prepare(progressHandler: ((Double) -> Void)? = nil) async throws { guard self.isReady == false else { return } + let modelDirectory = self.modelDirectory() + let missingBefore = self.missingRequiredModelFiles() + DebugLogger.shared.info( + "ParakeetRealtimeProvider.prepare: cacheRoot=\(Self.cacheRootDirectory().path), modelDir=\(modelDirectory.path), chunkSize=\(self.chunkSize.modelSubdirectory)", + source: "ParakeetRealtimeProvider" + ) + if missingBefore.isEmpty { + DebugLogger.shared.info( + "ParakeetRealtimeProvider.prepare: all required Flash files already present on disk", + source: "ParakeetRealtimeProvider" + ) + } else { + DebugLogger.shared.warning( + "ParakeetRealtimeProvider.prepare: missing required Flash files before load: \(missingBefore.joined(separator: ", "))", + source: "ParakeetRealtimeProvider" + ) + DebugLogger.shared.debug( + "ParakeetRealtimeProvider.prepare: cache snapshot before load: \(self.cacheSnapshotDescription())", + source: "ParakeetRealtimeProvider" + ) + } + let configuration = MLModelConfiguration() configuration.computeUnits = .cpuAndNeuralEngine configuration.allowLowPrecisionAccumulationOnGPU = true let engine = StreamingEouAsrManager(configuration: configuration, chunkSize: self.chunkSize) - try await engine.loadModelsFromHuggingFace(progressHandler: { progress in - progressHandler?(max(0.0, min(1.0, progress.fractionCompleted))) - }) + do { + try await engine.loadModelsFromHuggingFace(progressHandler: { progress in + progressHandler?(max(0.0, min(1.0, progress.fractionCompleted))) + }) + } catch { + let missingAfterFailure = self.missingRequiredModelFiles() + DebugLogger.shared.error( + "ParakeetRealtimeProvider.prepare: Flash load failed. modelDir=\(modelDirectory.path), missingAfterFailure=\(missingAfterFailure.joined(separator: ", "))", + source: "ParakeetRealtimeProvider" + ) + DebugLogger.shared.debug( + "ParakeetRealtimeProvider.prepare: cache snapshot after failure: \(self.cacheSnapshotDescription())", + source: "ParakeetRealtimeProvider" + ) + throw error + } + + let missingAfter = self.missingRequiredModelFiles() + guard missingAfter.isEmpty else { + DebugLogger.shared.error( + "ParakeetRealtimeProvider.prepare: Flash load returned, but required files are still missing: \(missingAfter.joined(separator: ", "))", + source: "ParakeetRealtimeProvider" + ) + DebugLogger.shared.debug( + "ParakeetRealtimeProvider.prepare: cache snapshot after load: \(self.cacheSnapshotDescription())", + source: "ParakeetRealtimeProvider" + ) + throw NSError( + domain: "ParakeetRealtimeProvider", + code: -3, + userInfo: [ + NSLocalizedDescriptionKey: "Parakeet Flash models are incomplete after load", + "modelDirectory": modelDirectory.path, + "missingFiles": missingAfter.joined(separator: ", "), + ] + ) + } + + DebugLogger.shared.info( + "ParakeetRealtimeProvider.prepare: Flash models verified at \(modelDirectory.path)", + source: "ParakeetRealtimeProvider" + ) self.engine = engine self.streamedSampleCount = 0 @@ -67,10 +128,7 @@ final class ParakeetRealtimeProvider: TranscriptionProvider { } func modelsExistOnDisk() -> Bool { - let modelDirectory = Self.cacheRootDirectory().appendingPathComponent(Repo.parakeetEou160.folderName, isDirectory: true) - return ModelNames.ParakeetEOU.requiredModels.allSatisfy { fileName in - FileManager.default.fileExists(atPath: modelDirectory.appendingPathComponent(fileName).path) - } + self.missingRequiredModelFiles().isEmpty } func clearCache() async throws { @@ -134,6 +192,30 @@ final class ParakeetRealtimeProvider: TranscriptionProvider { return buffer } + private func modelDirectory() -> URL { + Self.cacheRootDirectory().appendingPathComponent(Repo.parakeetEou160.folderName, isDirectory: true) + } + + private func missingRequiredModelFiles() -> [String] { + let modelDirectory = self.modelDirectory() + return ModelNames.ParakeetEOU.requiredModels + .sorted() + .filter { fileName in + !FileManager.default.fileExists(atPath: modelDirectory.appendingPathComponent(fileName).path) + } + } + + private func cacheSnapshotDescription() -> String { + let fm = FileManager.default + let modelDirectory = self.modelDirectory() + let rootDirectory = Self.cacheRootDirectory() + let rootContents = (try? fm.contentsOfDirectory(atPath: rootDirectory.path).sorted()) ?? [] + let modelContents = (try? fm.contentsOfDirectory(atPath: modelDirectory.path).sorted()) ?? [] + let rootExists = fm.fileExists(atPath: rootDirectory.path) + let modelExists = fm.fileExists(atPath: modelDirectory.path) + return "rootExists=\(rootExists), modelExists=\(modelExists), rootContents=\(rootContents), modelContents=\(modelContents)" + } + private static func cacheRootDirectory() -> URL { let baseDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first From 4c7a42f7433e5e3a9764397a0636a5eae1e372b0 Mon Sep 17 00:00:00 2001 From: altic-dev Date: Sun, 5 Apr 2026 16:48:56 -0700 Subject: [PATCH 09/11] Unify dictation into two configurable shortcuts --- .../Fluid/Analytics/AnalyticsService.swift | 6 +- Sources/Fluid/ContentView.swift | 284 ++++++++++-------- Sources/Fluid/Persistence/BackupService.swift | 2 + Sources/Fluid/Persistence/SettingsStore.swift | 178 ++++++++++- .../DictationAIPostProcessingGate.swift | 6 +- .../AIEnhancementSettingsViewModel.swift | 7 +- .../UI/AISettingsView+AdvancedSettings.swift | 25 +- Sources/Fluid/UI/SettingsView.swift | 89 +++--- Sources/Fluid/Views/BottomOverlayView.swift | 56 ++-- Sources/Fluid/Views/NotchContentViews.swift | 67 ++--- 10 files changed, 477 insertions(+), 243 deletions(-) diff --git a/Sources/Fluid/Analytics/AnalyticsService.swift b/Sources/Fluid/Analytics/AnalyticsService.swift index 6167a799..654066af 100644 --- a/Sources/Fluid/Analytics/AnalyticsService.swift +++ b/Sources/Fluid/Analytics/AnalyticsService.swift @@ -74,6 +74,10 @@ final class AnalyticsService { let settings = SettingsStore.shared + let anyDictationShortcutUsesAI = + settings.dictationPromptSelection(for: .primary) != .off || + settings.dictationPromptSelection(for: .secondary) != .off + var properties: [String: Any] = [ "app_version": version, "app_build": build, @@ -82,7 +86,7 @@ final class AnalyticsService { "environment": environment, // Low-cardinality settings snapshot - "ai_processing_enabled": !settings.isDictationPromptOff, + "ai_processing_enabled": anyDictationShortcutUsesAI, "streaming_preview_enabled": settings.enableStreamingPreview, "press_and_hold_mode": settings.pressAndHoldMode, "copy_to_clipboard_enabled": settings.copyTranscriptionToClipboard, diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index 7cf8d801..31a52fd8 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -87,6 +87,7 @@ struct ContentView: View { @State private var isRecordingForRewrite: Bool = false // Track if current recording is for rewrite mode @State private var isRecordingForCommand: Bool = false // Track if current recording is for command mode @State private var promptModeOverrideText: String? // System prompt text to use when in prompt mode + @State private var activeDictationShortcutSlot: SettingsStore.DictationShortcutSlot? = nil @State private var activeRecordingMode: ActiveRecordingMode = .none @State private var isRecordingShortcut = false @State private var isRecordingPromptModeShortcut = false @@ -1395,64 +1396,15 @@ struct ContentView: View { } */ - /// Build a general system prompt with voice editing commands support - private func buildSystemPrompt(appInfo: (name: String, bundleId: String, windowTitle: String)) -> String { - return SettingsStore.shared.effectiveSystemPrompt( - for: .dictate, - appBundleID: appInfo.bundleId - ) - } - - private var shouldTracePromptProcessing: Bool { - self.forcePromptTraceToConsole || - UserDefaults.standard.bool(forKey: "EnableDebugLogs") - } - - private var forcePromptTraceToConsole: Bool { - ProcessInfo.processInfo.environment["FLUID_PROMPT_TRACE"] == "1" - } - - private func logDictationPromptTrace(_ title: String, value: String) { - let line = "[PromptTrace][Dictate] \(title):\n\(value)" - if self.forcePromptTraceToConsole { - print(line) - } - DebugLogger.shared.debug(line, source: "ContentView") - } - - private func customPromptAnalyticsProperties(promptSource: String, overrideEmpty: Bool?) -> [String: Any] { - let providerID = SettingsStore.shared.selectedProviderID - let providerKey = self.providerKey(for: providerID) - let selectedModel = SettingsStore.shared.selectedModelByProvider[providerKey] ?? SettingsStore.shared.selectedModel ?? "" - let isCustomProvider = !ModelRepository.shared.isBuiltIn(providerID) - let providerName = isCustomProvider ? "Custom Provider" : ModelRepository.shared.displayName(for: providerID) - - var properties: [String: Any] = [ - "prompt_source": promptSource, - "provider_id": isCustomProvider ? "custom" : providerID, - "provider_name": providerName, - "provider_type": isCustomProvider ? "custom" : "built_in", - ] - if !selectedModel.isEmpty { - properties["model"] = isCustomProvider ? "custom" : selectedModel - } - if let overrideEmpty { - properties["override_empty"] = overrideEmpty - } - return properties - } - - // MARK: - Local Endpoint Detection - - private func isLocalEndpoint(_ urlString: String) -> Bool { - return ModelRepository.shared.isLocalEndpoint(urlString) - } - // NOTE: Thinking token filtering is now handled by LLMClient.stripThinkingTags() // MARK: - Modular AI Processing - private func processTextWithAI(_ inputText: String, overrideSystemPrompt: String? = nil) async -> String { + private func processTextWithAI( + _ inputText: String, + overrideSystemPrompt: String? = nil, + dictationSlot: SettingsStore.DictationShortcutSlot? = nil + ) async -> String { // CRITICAL FIX: Read current settings from SettingsStore, not stale @State copies // This ensures AI provider/model changes in AISettingsView take effect immediately let currentSelectedProviderID = SettingsStore.shared.selectedProviderID @@ -1491,7 +1443,7 @@ struct ContentView: View { let systemPrompt: String = { let override = overrideSystemPrompt?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if !override.isEmpty { return override } - return self.buildSystemPrompt(appInfo: appInfo) + return self.buildSystemPrompt(appInfo: appInfo, dictationSlot: dictationSlot) }() // Route to Apple Intelligence if selected @@ -1500,20 +1452,24 @@ struct ContentView: View { if #available(macOS 26.0, *) { let provider = AppleIntelligenceProvider() if self.shouldTracePromptProcessing { - let selectedProfile = SettingsStore.shared.resolvedPromptProfile( - for: .dictate, + let activeSlot = dictationSlot ?? self.currentDictationShortcutSlot(for: self.activeRecordingMode) ?? .primary + let selectedProfile = SettingsStore.shared.resolvedDictationPromptProfile( + for: activeSlot, appBundleID: appInfo.bundleId ) let selectedPromptName: String = { + if SettingsStore.shared.dictationPromptSelection(for: activeSlot) == .off { + return "Off" + } if let profile = selectedProfile { return profile.name.isEmpty ? "Untitled Prompt" : profile.name } - return "Default Dictate" + return "Default" }() self.logDictationPromptTrace("Selected prompt profile", value: selectedPromptName) self.logDictationPromptTrace( "Prompt body (custom/default body)", - value: SettingsStore.shared.effectivePromptBody(for: .dictate, appBundleID: appInfo.bundleId) + value: SettingsStore.shared.effectiveDictationPromptBody(for: activeSlot, appBundleID: appInfo.bundleId) ) self.logDictationPromptTrace("Built-in default system prompt (baseline)", value: SettingsStore.defaultSystemPromptText(for: .dictate)) self.logDictationPromptTrace("Final system prompt sent to model", value: systemPrompt) @@ -1543,20 +1499,24 @@ struct ContentView: View { DebugLogger.shared.debug("Using app context for AI: app=\(appInfo.name), bundleId=\(appInfo.bundleId), title=\(appInfo.windowTitle)", source: "ContentView") if self.shouldTracePromptProcessing { - let selectedProfile = SettingsStore.shared.resolvedPromptProfile( - for: .dictate, + let activeSlot = dictationSlot ?? self.currentDictationShortcutSlot(for: self.activeRecordingMode) ?? .primary + let selectedProfile = SettingsStore.shared.resolvedDictationPromptProfile( + for: activeSlot, appBundleID: appInfo.bundleId ) let selectedPromptName: String = { + if SettingsStore.shared.dictationPromptSelection(for: activeSlot) == .off { + return "Off" + } if let profile = selectedProfile { return profile.name.isEmpty ? "Untitled Prompt" : profile.name } - return "Default Dictate" + return "Default" }() self.logDictationPromptTrace("Selected prompt profile", value: selectedPromptName) self.logDictationPromptTrace( "Prompt body (custom/default body)", - value: SettingsStore.shared.effectivePromptBody(for: .dictate, appBundleID: appInfo.bundleId) + value: SettingsStore.shared.effectiveDictationPromptBody(for: activeSlot, appBundleID: appInfo.bundleId) ) self.logDictationPromptTrace("Built-in default system prompt (baseline)", value: SettingsStore.defaultSystemPromptText(for: .dictate)) self.logDictationPromptTrace("Prompt override in use", value: (overrideSystemPrompt?.isEmpty == false) ? "yes" : "no") @@ -1656,6 +1616,7 @@ struct ContentView: View { let modeAtStop = self.activeRecordingMode let wasRewriteMode = modeAtStop == .edit || self.isRecordingForRewrite let wasCommandMode = modeAtStop == .command || self.isRecordingForCommand + let activeDictationSlot = self.currentDictationShortcutSlot(for: modeAtStop) let promptOverride = self.promptModeOverrideText DebugLogger.shared.info( "Routing decision snapshot | activeMode=\(modeAtStop.rawValue) | rewrite=\(wasRewriteMode) | command=\(wasCommandMode) | overlay=\(NotchContentState.shared.mode.rawValue)", @@ -1748,10 +1709,8 @@ struct ContentView: View { var finalText: String - // Check if we should use AI processing. - // Prompt mode can still use AI when a provider is configured even if the global gate is off. - let shouldUseAI = DictationAIPostProcessingGate.isConfigured() || - (promptOverride != nil && DictationAIPostProcessingGate.isProviderConfigured()) + let shouldUseAI = activeDictationSlot.map { DictationAIPostProcessingGate.isConfigured(for: $0) } ?? + DictationAIPostProcessingGate.isConfigured() let transcriptionModelInfo = self.currentTranscriptionModelInfo() if shouldUseAI { @@ -1766,7 +1725,11 @@ struct ContentView: View { // Ensure the status label becomes visible immediately. await Task.yield() - finalText = await self.processTextWithAI(transcribedText, overrideSystemPrompt: promptOverride) + finalText = await self.processTextWithAI( + transcribedText, + overrideSystemPrompt: promptOverride, + dictationSlot: activeDictationSlot + ) let postProcessingLatencyMs = Int((Date().timeIntervalSince(postProcessingStart) * 1000).rounded()) AnalyticsService.shared.capture( .dictationPostProcessingCompleted, @@ -2158,11 +2121,8 @@ struct ContentView: View { } private func setActiveRecordingMode(_ mode: ActiveRecordingMode) { - if mode != .promptMode { - self.promptModeOverrideText = nil - NotchContentState.shared.promptModeOverrideProfileName = nil - NotchContentState.shared.promptModeOverrideProfileID = nil - NotchContentState.shared.isPromptModeActive = false + if mode != .dictate, mode != .promptMode { + self.clearActiveDictationShortcutState() } self.activeRecordingMode = mode switch mode { @@ -2406,21 +2366,10 @@ struct ContentView: View { NotchContentState.shared.onCancelRequested = { _ = self.handleCancelShortcut() } - NotchContentState.shared.onPromptModeProfileChangeRequested = { profile in - if let p = profile { - self.promptModeOverrideText = SettingsStore.combineBasePrompt( - for: .dictate, - with: SettingsStore.stripBasePrompt(for: .dictate, from: p.prompt) - ) - NotchContentState.shared.promptModeOverrideProfileName = p.name - NotchContentState.shared.promptModeOverrideProfileID = p.id - SettingsStore.shared.promptModeSelectedPromptID = p.id - } else { - self.promptModeOverrideText = nil - NotchContentState.shared.promptModeOverrideProfileName = nil - NotchContentState.shared.promptModeOverrideProfileID = nil - SettingsStore.shared.promptModeSelectedPromptID = nil - } + NotchContentState.shared.onDictationPromptSelectionRequested = { selection in + let slot = self.activeDictationShortcutSlot ?? .primary + SettingsStore.shared.setDictationPromptSelection(selection, for: slot) + self.applyDictationShortcutSelectionContext(for: slot) } guard self.hotkeyManager == nil else { return } @@ -2444,18 +2393,7 @@ struct ContentView: View { "ContentView: selected model for dictate hotkey=\(SettingsStore.shared.selectedSpeechModel.displayName)", source: "ContentView" ) - self.captureRecordingContext() - self.setActiveRecordingMode(.dictate) - self.rewriteModeService.clearState() - self.menuBarManager.setOverlayMode(.dictation) - - guard !self.asr.isRunning else { return } - if SettingsStore.shared.enableTranscriptionSounds { - TranscriptionSoundPlayer.shared.playStartSound() - } - Task { - await self.asr.start() - } + self.beginDictationRecording(for: .primary, mode: .dictate) }, stopAndProcessCallback: { let route = self.currentDictationOutputRouteForHotkeyStop() @@ -2464,30 +2402,7 @@ struct ContentView: View { }, promptModeCallback: { DebugLogger.shared.info("Prompt mode triggered", source: "ContentView") - self.captureRecordingContext() - - // Resolve the full system prompt for the selected profile - let settings = SettingsStore.shared - if let promptID = settings.promptModeSelectedPromptID, - let profile = settings.dictationPromptProfiles.first(where: { $0.id == promptID }) - { - self.promptModeOverrideText = SettingsStore.combineBasePrompt(for: .dictate, with: SettingsStore.stripBasePrompt(for: .dictate, from: profile.prompt)) - NotchContentState.shared.promptModeOverrideProfileName = profile.name - NotchContentState.shared.promptModeOverrideProfileID = profile.id - } - - NotchContentState.shared.isPromptModeActive = true - self.setActiveRecordingMode(.promptMode) - self.rewriteModeService.clearState() - self.menuBarManager.setOverlayMode(.dictation) - - guard !self.asr.isRunning else { return } - if settings.enableTranscriptionSounds { - TranscriptionSoundPlayer.shared.playStartSound() - } - Task { - await self.asr.start() - } + self.beginDictationRecording(for: .secondary, mode: .promptMode) }, commandModeCallback: { DebugLogger.shared.info("Command mode triggered", source: "ContentView") @@ -2721,6 +2636,127 @@ struct ContentView: View { // MARK: - ContentView Playground & Onboarding Helpers extension ContentView { + private func buildSystemPrompt( + appInfo: (name: String, bundleId: String, windowTitle: String), + dictationSlot: SettingsStore.DictationShortcutSlot? = nil + ) -> String { + if let slot = dictationSlot ?? self.currentDictationShortcutSlot(for: self.activeRecordingMode) { + return SettingsStore.shared.effectiveDictationSystemPrompt(for: slot, appBundleID: appInfo.bundleId) + } + return SettingsStore.shared.effectiveSystemPrompt(for: .dictate, appBundleID: appInfo.bundleId) + } + + private var shouldTracePromptProcessing: Bool { + self.forcePromptTraceToConsole || + UserDefaults.standard.bool(forKey: "EnableDebugLogs") + } + + private var forcePromptTraceToConsole: Bool { + ProcessInfo.processInfo.environment["FLUID_PROMPT_TRACE"] == "1" + } + + private func logDictationPromptTrace(_ title: String, value: String) { + let line = "[PromptTrace][Dictate] \(title):\n\(value)" + if self.forcePromptTraceToConsole { + print(line) + } + DebugLogger.shared.debug(line, source: "ContentView") + } + + private func customPromptAnalyticsProperties(promptSource: String, overrideEmpty: Bool?) -> [String: Any] { + let providerID = SettingsStore.shared.selectedProviderID + let providerKey = self.providerKey(for: providerID) + let selectedModel = SettingsStore.shared.selectedModelByProvider[providerKey] ?? SettingsStore.shared.selectedModel ?? "" + let isCustomProvider = !ModelRepository.shared.isBuiltIn(providerID) + let providerName = isCustomProvider ? "Custom Provider" : ModelRepository.shared.displayName(for: providerID) + + var properties: [String: Any] = [ + "prompt_source": promptSource, + "provider_id": isCustomProvider ? "custom" : providerID, + "provider_name": providerName, + "provider_type": isCustomProvider ? "custom" : "built_in", + ] + if !selectedModel.isEmpty { + properties["model"] = isCustomProvider ? "custom" : selectedModel + } + if let overrideEmpty { + properties["override_empty"] = overrideEmpty + } + return properties + } + + private func isLocalEndpoint(_ urlString: String) -> Bool { + ModelRepository.shared.isLocalEndpoint(urlString) + } + + private func currentDictationShortcutSlot(for mode: ActiveRecordingMode) -> SettingsStore.DictationShortcutSlot? { + switch mode { + case .dictate: + return self.activeDictationShortcutSlot ?? .primary + case .promptMode: + return self.activeDictationShortcutSlot ?? .secondary + case .none, .edit, .command: + return nil + } + } + + private func clearActiveDictationShortcutState() { + self.activeDictationShortcutSlot = nil + self.promptModeOverrideText = nil + NotchContentState.shared.activeDictationShortcutSlot = nil + NotchContentState.shared.promptModeOverrideProfileName = nil + NotchContentState.shared.promptModeOverrideProfileID = nil + NotchContentState.shared.isPromptModeActive = false + } + + private func applyDictationShortcutSelectionContext(for slot: SettingsStore.DictationShortcutSlot) { + let settings = SettingsStore.shared + self.activeDictationShortcutSlot = slot + NotchContentState.shared.activeDictationShortcutSlot = slot + NotchContentState.shared.isPromptModeActive = (slot == .secondary) + + switch settings.dictationPromptSelection(for: slot) { + case .off, .default: + self.promptModeOverrideText = nil + NotchContentState.shared.promptModeOverrideProfileName = nil + NotchContentState.shared.promptModeOverrideProfileID = nil + case let .profile(profileID): + guard let profile = settings.selectedDictationPromptProfile(for: slot) ?? settings.dictationPromptProfiles.first(where: { + $0.id == profileID && $0.mode.normalized == .dictate + }) else { + settings.setDictationPromptSelection(.default, for: slot) + self.promptModeOverrideText = nil + NotchContentState.shared.promptModeOverrideProfileName = nil + NotchContentState.shared.promptModeOverrideProfileID = nil + return + } + + self.promptModeOverrideText = SettingsStore.combineBasePrompt( + for: .dictate, + with: SettingsStore.stripBasePrompt(for: .dictate, from: profile.prompt) + ) + NotchContentState.shared.promptModeOverrideProfileName = profile.name + NotchContentState.shared.promptModeOverrideProfileID = profile.id + } + } + + private func beginDictationRecording(for slot: SettingsStore.DictationShortcutSlot, mode: ActiveRecordingMode) { + DebugLogger.shared.debug("Begin dictation recording for slot \(slot.rawValue)", source: "ContentView") + self.captureRecordingContext() + self.applyDictationShortcutSelectionContext(for: slot) + self.setActiveRecordingMode(mode) + self.rewriteModeService.clearState() + self.menuBarManager.setOverlayMode(.dictation) + + guard !self.asr.isRunning else { return } + if SettingsStore.shared.enableTranscriptionSounds { + TranscriptionSoundPlayer.shared.playStartSound() + } + Task { + await self.asr.start() + } + } + private func callOpenAIChat() async { guard !self.isCallingAI else { return } await MainActor.run { self.isCallingAI = true } diff --git a/Sources/Fluid/Persistence/BackupService.swift b/Sources/Fluid/Persistence/BackupService.swift index 158de0c0..5ac4e212 100644 --- a/Sources/Fluid/Persistence/BackupService.swift +++ b/Sources/Fluid/Persistence/BackupService.swift @@ -18,6 +18,7 @@ struct SettingsBackupPayload: Codable, Equatable { let promptModeHotkeyShortcut: HotkeyShortcut let promptModeShortcutEnabled: Bool let promptModeSelectedPromptID: String? + let secondaryDictationPromptOff: Bool? let commandModeHotkeyShortcut: HotkeyShortcut let commandModeShortcutEnabled: Bool let commandModeSelectedModel: String? @@ -62,6 +63,7 @@ struct SettingsBackupPayload: Codable, Equatable { let vocabularyBoostingEnabled: Bool let customDictionaryEntries: [SettingsStore.CustomDictionaryEntry] let selectedDictationPromptID: String? + let dictationPromptOff: Bool? let selectedEditPromptID: String? let defaultDictationPromptOverride: String? let defaultEditPromptOverride: String? diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index 2749af85..7f9859b8 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -88,6 +88,22 @@ final class SettingsStore: ObservableObject { } } + enum DictationShortcutSlot: String, Codable, CaseIterable, Identifiable { + case primary + case secondary + + var id: String { self.rawValue } + + var displayName: String { + switch self { + case .primary: + return "Primary Dictation Shortcut" + case .secondary: + return "Secondary Dictation Shortcut" + } + } + } + enum DictationPromptSelection: Equatable { case off case `default` @@ -287,26 +303,34 @@ final class SettingsStore: ObservableObject { } var dictationPromptSelection: DictationPromptSelection { - if self.isDictationPromptOff { + self.dictationPromptSelection(for: .primary) + } + + func setDictationPromptSelection(_ selection: DictationPromptSelection) { + self.setDictationPromptSelection(selection, for: .primary) + } + + func dictationPromptSelection(for slot: DictationShortcutSlot) -> DictationPromptSelection { + if self.isDictationPromptOff(for: slot) { return .off } - if let promptID = self.selectedDictationPromptID { + if let promptID = self.selectedDictationPromptID(for: slot) { return .profile(promptID) } return .default } - func setDictationPromptSelection(_ selection: DictationPromptSelection) { + func setDictationPromptSelection(_ selection: DictationPromptSelection, for slot: DictationShortcutSlot) { switch selection { case .off: - self.isDictationPromptOff = true - self.selectedDictationPromptID = nil + self.setDictationPromptOff(true, for: slot) + self.setSelectedDictationPromptID(nil, for: slot) case .default: - self.isDictationPromptOff = false - self.selectedDictationPromptID = nil + self.setDictationPromptOff(false, for: slot) + self.setSelectedDictationPromptID(nil, for: slot) case let .profile(promptID): - self.isDictationPromptOff = false - self.selectedDictationPromptID = promptID + self.setDictationPromptOff(false, for: slot) + self.setSelectedDictationPromptID(promptID, for: slot) } } @@ -371,6 +395,86 @@ final class SettingsStore: ObservableObject { } } + func selectedDictationPromptID(for slot: DictationShortcutSlot) -> String? { + switch slot { + case .primary: + return self.selectedDictationPromptID + case .secondary: + return self.promptModeSelectedPromptID + } + } + + func setSelectedDictationPromptID(_ id: String?, for slot: DictationShortcutSlot) { + switch slot { + case .primary: + self.selectedDictationPromptID = id + case .secondary: + self.promptModeSelectedPromptID = id + } + } + + func isDictationPromptOff(for slot: DictationShortcutSlot) -> Bool { + switch slot { + case .primary: + return self.isDictationPromptOff + case .secondary: + return self.isSecondaryDictationPromptOff + } + } + + func setDictationPromptOff(_ isOff: Bool, for slot: DictationShortcutSlot) { + switch slot { + case .primary: + self.isDictationPromptOff = isOff + case .secondary: + self.isSecondaryDictationPromptOff = isOff + } + } + + func selectedDictationPromptProfile(for slot: DictationShortcutSlot) -> DictationPromptProfile? { + guard let id = self.selectedDictationPromptID(for: slot) else { return nil } + return self.dictationPromptProfiles.first(where: { $0.id == id && $0.mode.normalized == .dictate }) + } + + func resolvedDictationPromptProfile(for slot: DictationShortcutSlot, appBundleID: String?) -> DictationPromptProfile? { + switch self.dictationPromptSelection(for: slot) { + case .off: + return nil + case let .profile(promptID): + return self.dictationPromptProfiles.first(where: { $0.id == promptID && $0.mode.normalized == .dictate }) + case .default: + guard let binding = self.appPromptBinding(for: .dictate, appBundleID: appBundleID) else { return nil } + let promptID = binding.promptID + return self.dictationPromptProfiles.first { + $0.id == promptID && $0.mode.normalized == .dictate + } + } + } + + func isAppDictationPromptBindingActive(for slot: DictationShortcutSlot, appBundleID: String?) -> Bool { + guard self.dictationPromptSelection(for: slot) == .default else { return false } + return self.hasAppPromptBinding(for: .dictate, appBundleID: appBundleID) + } + + func dictationPromptDisplayName(for slot: DictationShortcutSlot, appBundleID: String?) -> String { + switch self.dictationPromptSelection(for: slot) { + case .off: + return "Off" + case .default: + if let profile = self.resolvedDictationPromptProfile(for: slot, appBundleID: appBundleID) { + let name = profile.name.trimmingCharacters(in: .whitespacesAndNewlines) + return name.isEmpty ? "Untitled" : name + } + return "Default" + case let .profile(promptID): + guard let profile = self.dictationPromptProfiles.first(where: { $0.id == promptID && $0.mode.normalized == .dictate }) else { + return "Default" + } + let name = profile.name.trimmingCharacters(in: .whitespacesAndNewlines) + return name.isEmpty ? "Untitled" : name + } + } + func setSelectedPromptID(_ id: String?, for mode: PromptMode) { switch mode.normalized { case .dictate: @@ -802,6 +906,40 @@ final class SettingsStore: ObservableObject { self.promptResolution(for: mode, appBundleID: appBundleID).profile } + func effectiveDictationPromptBody(for slot: DictationShortcutSlot, appBundleID: String? = nil) -> String { + switch self.dictationPromptSelection(for: slot) { + case .off: + return "" + case .default: + return self.effectivePromptBody(for: .dictate, appBundleID: appBundleID) + case let .profile(promptID): + guard let profile = self.dictationPromptProfiles.first(where: { $0.id == promptID && $0.mode.normalized == .dictate }) else { + return self.effectivePromptBody(for: .dictate, appBundleID: appBundleID) + } + let body = Self.stripBasePrompt(for: .dictate, from: profile.prompt) + if !body.isEmpty { + return body + } + return self.effectivePromptBody(for: .dictate, appBundleID: appBundleID) + } + } + + func effectiveDictationSystemPrompt(for slot: DictationShortcutSlot, appBundleID: String? = nil) -> String { + switch self.dictationPromptSelection(for: slot) { + case .off, .default: + return self.effectiveSystemPrompt(for: .dictate, appBundleID: appBundleID) + case let .profile(promptID): + guard let profile = self.dictationPromptProfiles.first(where: { $0.id == promptID && $0.mode.normalized == .dictate }) else { + return self.effectiveSystemPrompt(for: .dictate, appBundleID: appBundleID) + } + let body = Self.stripBasePrompt(for: .dictate, from: profile.prompt) + if !body.isEmpty { + return Self.combineBasePrompt(for: .dictate, with: body) + } + return self.effectiveSystemPrompt(for: .dictate, appBundleID: appBundleID) + } + } + func effectivePromptBody(for mode: PromptMode, appBundleID: String? = nil) -> String { self.promptResolution(for: mode, appBundleID: appBundleID).promptBody } @@ -1692,6 +1830,17 @@ final class SettingsStore: ObservableObject { } } + var isSecondaryDictationPromptOff: Bool { + get { + let value = self.defaults.object(forKey: Keys.secondaryDictationPromptOff) + return value as? Bool ?? false + } + set { + objectWillChange.send() + self.defaults.set(newValue, forKey: Keys.secondaryDictationPromptOff) + } + } + var commandModeShortcutEnabled: Bool { get { let value = self.defaults.object(forKey: Keys.commandModeShortcutEnabled) @@ -1993,6 +2142,7 @@ final class SettingsStore: ObservableObject { promptModeHotkeyShortcut: self.promptModeHotkeyShortcut, promptModeShortcutEnabled: self.promptModeShortcutEnabled, promptModeSelectedPromptID: self.promptModeSelectedPromptID, + secondaryDictationPromptOff: self.isSecondaryDictationPromptOff, commandModeHotkeyShortcut: self.commandModeHotkeyShortcut, commandModeShortcutEnabled: self.commandModeShortcutEnabled, commandModeSelectedModel: self.commandModeSelectedModel, @@ -2037,6 +2187,7 @@ final class SettingsStore: ObservableObject { vocabularyBoostingEnabled: self.vocabularyBoostingEnabled, customDictionaryEntries: self.customDictionaryEntries, selectedDictationPromptID: self.selectedDictationPromptID, + dictationPromptOff: self.isDictationPromptOff, selectedEditPromptID: self.selectedEditPromptID, defaultDictationPromptOverride: self.defaultDictationPromptOverride, defaultEditPromptOverride: self.defaultEditPromptOverride @@ -2108,10 +2259,12 @@ final class SettingsStore: ObservableObject { self.dictationPromptProfiles = promptProfiles self.appPromptBindings = appPromptBindings self.selectedDictationPromptID = payload.selectedDictationPromptID + self.isDictationPromptOff = payload.dictationPromptOff ?? self.isDictationPromptOff self.selectedEditPromptID = payload.selectedEditPromptID self.defaultDictationPromptOverride = payload.defaultDictationPromptOverride self.defaultEditPromptOverride = payload.defaultEditPromptOverride self.promptModeSelectedPromptID = payload.promptModeSelectedPromptID + self.isSecondaryDictationPromptOff = payload.secondaryDictationPromptOff ?? false self.normalizePromptSelectionsIfNeeded() } @@ -2207,13 +2360,17 @@ final class SettingsStore: ObservableObject { } else if self.defaults.object(forKey: Keys.enableAIProcessing) != nil { shouldStartOff = !self.defaults.bool(forKey: Keys.enableAIProcessing) } else { - shouldStartOff = false + shouldStartOff = true } self.defaults.set(shouldStartOff, forKey: Keys.dictationPromptOff) } private func normalizePromptSelectionsIfNeeded() { + if self.defaults.object(forKey: Keys.secondaryDictationPromptOff) == nil { + self.defaults.set(false, forKey: Keys.secondaryDictationPromptOff) + } + // One-time migration to unified edit keys. if self.defaults.object(forKey: Keys.selectedEditPromptID) == nil, let migratedSelectedEditID = self.selectedEditPromptID @@ -3385,6 +3542,7 @@ private extension SettingsStore { static let promptModeHotkeyShortcut = "PromptModeHotkeyShortcut" static let promptModeShortcutEnabled = "PromptModeShortcutEnabled" static let promptModeSelectedPromptID = "PromptModeSelectedPromptID" + static let secondaryDictationPromptOff = "SecondaryDictationPromptOff" // Rewrite Mode Keys static let rewriteModeHotkeyShortcut = "RewriteModeHotkeyShortcut" diff --git a/Sources/Fluid/Services/DictationAIPostProcessingGate.swift b/Sources/Fluid/Services/DictationAIPostProcessingGate.swift index 21816754..74749a43 100644 --- a/Sources/Fluid/Services/DictationAIPostProcessingGate.swift +++ b/Sources/Fluid/Services/DictationAIPostProcessingGate.swift @@ -7,8 +7,12 @@ enum DictationAIPostProcessingGate { /// - For Apple Intelligence: requires `AppleIntelligenceService.isAvailable` /// - For other providers: requires a local endpoint OR a non-empty API key static func isConfigured() -> Bool { + self.isConfigured(for: .primary) + } + + static func isConfigured(for slot: SettingsStore.DictationShortcutSlot) -> Bool { let settings = SettingsStore.shared - guard !settings.isDictationPromptOff else { return false } + guard settings.dictationPromptSelection(for: slot) != .off else { return false } return self.isProviderConfigured() } diff --git a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift index 44a8d00a..50be6cc7 100644 --- a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift +++ b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift @@ -1494,12 +1494,11 @@ final class AIEnhancementSettingsViewModel: ObservableObject { return trimmed.isEmpty ? "Untitled Prompt" : trimmed } - func isPromptSelectionOff(for mode: SettingsStore.PromptMode) -> Bool { - mode.normalized == .dictate && self.settings.isDictationPromptOff + func isPrimaryDictationPromptSelectionOff() -> Bool { + self.settings.isDictationPromptOff } - func selectPromptOff(for mode: SettingsStore.PromptMode) { - guard mode.normalized == .dictate else { return } + func selectPrimaryDictationPromptOff() { self.settings.setDictationPromptSelection(.off) self.selectedDictationPromptID = self.settings.selectedDictationPromptID self.isDictationPromptOff = self.settings.isDictationPromptOff diff --git a/Sources/Fluid/UI/AISettingsView+AdvancedSettings.swift b/Sources/Fluid/UI/AISettingsView+AdvancedSettings.swift index 5ccb2350..dc62136a 100644 --- a/Sources/Fluid/UI/AISettingsView+AdvancedSettings.swift +++ b/Sources/Fluid/UI/AISettingsView+AdvancedSettings.swift @@ -20,7 +20,7 @@ extension AIEnhancementSettingsView { Text("Prompt Profiles") .font(.system(size: 15, weight: .semibold)) .foregroundStyle(self.theme.palette.primaryText) - Text(" - Pick one prompt for Dictate and one for Edit Text.") + Text(" - Manage prompt bodies here. Shortcut assignment lives in Keyboard Shortcuts.") .font(.system(size: 13)) .foregroundStyle(self.theme.palette.secondaryText) } @@ -182,7 +182,7 @@ extension AIEnhancementSettingsView { .frame(width: 20, height: 20) .background(Circle().fill(tone.opacity(mode.normalized == .dictate ? 0.85 : 0.65))) HStack(alignment: .firstTextBaseline, spacing: 0) { - Text(mode.normalized == .dictate ? "Dictate Mode" : "Edit Text Mode") + Text(mode.normalized == .dictate ? "Dictation Prompt Profiles" : "Edit Text Mode") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(self.theme.palette.primaryText) Text(" - \(self.promptSectionDescription(for: mode))") @@ -194,6 +194,13 @@ extension AIEnhancementSettingsView { } .padding(.horizontal, 2) + if mode.normalized == .dictate { + Text("Selection indicators here preview the primary dictation shortcut. Assign each shortcut in Keyboard Shortcuts.") + .font(.caption2) + .foregroundStyle(self.theme.palette.secondaryText) + .padding(.horizontal, 4) + } + if mode.normalized == .edit { self.editModeProviderModelRow } @@ -202,21 +209,23 @@ extension AIEnhancementSettingsView { self.promptProfileCard( cardKey: "\(mode.normalized.rawValue)-off", title: "Off", - subtitle: "Use raw transcription for normal dictation with no AI post-processing.", + subtitle: "Use raw transcription for the primary dictation shortcut with no AI post-processing.", mode: mode, - isSelected: self.viewModel.isPromptSelectionOff(for: mode), + isSelected: self.viewModel.isPrimaryDictationPromptSelectionOff(), onUse: { - self.viewModel.selectPromptOff(for: mode) + self.viewModel.selectPrimaryDictationPromptOff() } ) } self.promptProfileCard( cardKey: "\(mode.normalized.rawValue)-default", - title: "Default \(self.friendlyModeName(mode))", + title: mode.normalized == .dictate ? "Default Dictation Prompt" : "Default \(self.friendlyModeName(mode))", subtitle: self.viewModel.promptPreview(self.viewModel.defaultPromptBodyPreview(for: mode)), mode: mode, - isSelected: !self.viewModel.isPromptSelectionOff(for: mode) && self.viewModel.selectedPromptID(for: mode) == nil, + isSelected: mode.normalized == .dictate + ? (!self.viewModel.isPrimaryDictationPromptSelectionOff() && self.viewModel.selectedPromptID(for: mode) == nil) + : self.viewModel.selectedPromptID(for: mode) == nil, onUse: { self.viewModel.setSelectedPromptID(nil, for: mode) }, @@ -646,7 +655,7 @@ extension AIEnhancementSettingsView { private func promptSectionDescription(for mode: SettingsStore.PromptMode) -> String { switch mode { case .dictate: - return "No selected-text context - Process raw voice text - clean, write into email, convert terminal commands, translate etc." + return "Create dictation prompt bodies here. The primary shortcut preview is shown here; both shortcut assignments are set in Keyboard Shortcuts." case .edit, .write, .rewrite: return "Uses selected text as context (when text is selected) - Edit or rewrite selected text - answer questions, summarize, convert to bullets etc." } diff --git a/Sources/Fluid/UI/SettingsView.swift b/Sources/Fluid/UI/SettingsView.swift index 1c771179..f6dfcc4f 100644 --- a/Sources/Fluid/UI/SettingsView.swift +++ b/Sources/Fluid/UI/SettingsView.swift @@ -117,6 +117,52 @@ struct SettingsView: View { ) } + private func dictationPromptSelectionBinding(for slot: SettingsStore.DictationShortcutSlot) -> Binding { + Binding( + get: { + switch self.settings.dictationPromptSelection(for: slot) { + case .off: + return "__OFF__" + case .default: + return "__DEFAULT__" + case let .profile(id): + return id + } + }, + set: { newValue in + switch newValue { + case "__OFF__": + self.settings.setDictationPromptSelection(.off, for: slot) + case "__DEFAULT__": + self.settings.setDictationPromptSelection(.default, for: slot) + default: + self.settings.setDictationPromptSelection(.profile(newValue), for: slot) + } + } + ) + } + + @ViewBuilder + private func dictationPromptPicker(for slot: SettingsStore.DictationShortcutSlot) -> some View { + let profiles = self.settings.promptProfiles(for: .dictate) + HStack { + Text("AI Prompt") + .font(.subheadline) + .foregroundStyle(.secondary) + .padding(.leading, 30) + Spacer() + Picker("", selection: self.dictationPromptSelectionBinding(for: slot)) { + Text("Off").tag("__OFF__") + Text("Default").tag("__DEFAULT__") + ForEach(profiles) { profile in + Text(profile.name.isEmpty ? "Untitled" : profile.name).tag(profile.id) + } + } + .frame(width: 190) + } + .padding(.bottom, 4) + } + var body: some View { ScrollView(.vertical, showsIndicators: false) { VStack(spacing: 16) { @@ -524,7 +570,7 @@ struct SettingsView: View { Spacer() if self.accessibilityEnabled { - if self.isRecordingShortcut || self.isRecordingCommandModeShortcut || self.isRecordingRewriteShortcut || self.isRecordingCancelShortcut { + if self.isRecordingShortcut || self.isRecordingPromptModeShortcut || self.isRecordingCommandModeShortcut || self.isRecordingRewriteShortcut || self.isRecordingCancelShortcut { Text("Recording…") .font(.caption.weight(.semibold)) .foregroundStyle(.orange) @@ -547,7 +593,7 @@ struct SettingsView: View { if self.accessibilityEnabled { VStack(alignment: .leading, spacing: 12) { - if self.isRecordingShortcut || self.isRecordingCommandModeShortcut || self.isRecordingRewriteShortcut || self.isRecordingCancelShortcut { + if self.isRecordingShortcut || self.isRecordingPromptModeShortcut || self.isRecordingCommandModeShortcut || self.isRecordingRewriteShortcut || self.isRecordingCancelShortcut { HStack(spacing: 8) { Image(systemName: "hand.point.up.left.fill") .foregroundStyle(.orange) @@ -580,8 +626,8 @@ struct SettingsView: View { self.shortcutRow( icon: "mic.fill", iconColor: .secondary, - title: "Transcribe Mode", - description: "Dictate text anywhere", + title: "Primary Dictation Shortcut", + description: "Defaults to raw transcription, but can use Off, Default, or any custom prompt.", shortcut: self.hotkeyShortcut, isRecording: self.isRecordingShortcut, onChangePressed: { @@ -589,13 +635,14 @@ struct SettingsView: View { self.isRecordingShortcut = true } ) + self.dictationPromptPicker(for: .primary) Divider().opacity(0.2).padding(.vertical, 4) self.shortcutRow( icon: "text.bubble.fill", iconColor: .secondary, - title: "Transcribe with Prompt", - description: "Dictate with a specific AI prompt", + title: "Secondary Dictation Shortcut", + description: "Defaults to Default cleanup, but can use Off, Default, or any custom prompt.", shortcut: self.promptModeShortcut, isRecording: self.isRecordingPromptModeShortcut, isEnabled: self.$promptModeShortcutEnabled, @@ -606,35 +653,7 @@ struct SettingsView: View { ) if self.promptModeShortcutEnabled { - let profiles = self.settings.promptProfiles(for: .dictate) - if !profiles.isEmpty { - HStack { - Text("Prompt") - .font(.subheadline) - .foregroundStyle(.secondary) - .padding(.leading, 30) - Spacer() - Picker("", selection: Binding( - get: { self.settings.promptModeSelectedPromptID ?? "" }, - set: { newValue in - self.settings.promptModeSelectedPromptID = newValue.isEmpty ? nil : newValue - } - )) { - Text("Select a prompt...").tag("") - ForEach(profiles) { profile in - Text(profile.name.isEmpty ? "Untitled" : profile.name).tag(profile.id) - } - } - .frame(width: 170) - } - .padding(.bottom, 4) - } else { - Text("Add prompts in AI Enhancements → Prompt Profiles") - .font(.caption) - .foregroundStyle(.secondary) - .padding(.leading, 30) - .padding(.bottom, 4) - } + self.dictationPromptPicker(for: .secondary) } Divider().opacity(0.2).padding(.vertical, 4) diff --git a/Sources/Fluid/Views/BottomOverlayView.swift b/Sources/Fluid/Views/BottomOverlayView.swift index bac70cbc..f61dd05f 100644 --- a/Sources/Fluid/Views/BottomOverlayView.swift +++ b/Sources/Fluid/Views/BottomOverlayView.swift @@ -1235,10 +1235,11 @@ private struct BottomOverlayPromptMenuView: View { @ViewBuilder private func offRow() -> some View { - let isSelected = self.settings.isDictationPromptOff + let activeSlot = self.contentState.activeDictationShortcutSlot ?? .primary + let isSelected = self.settings.dictationPromptSelection(for: activeSlot) == .off Button(action: { - if self.contentState.isPromptModeActive, self.promptMode.normalized == .dictate { - self.contentState.onPromptModeProfileChangeRequested?(nil) + if self.promptMode.normalized == .dictate { + self.contentState.onDictationPromptSelectionRequested?(.off) } else { self.settings.setDictationPromptSelection(.off) } @@ -1265,12 +1266,13 @@ private struct BottomOverlayPromptMenuView: View { @ViewBuilder private func defaultRow(selectedID: String?) -> some View { - let isSelected = !self.settings.isDictationPromptOff && selectedID == nil + let activeSlot = self.contentState.activeDictationShortcutSlot ?? .primary + let isSelected = self.promptMode.normalized == .dictate + ? (self.settings.dictationPromptSelection(for: activeSlot) == .default) + : (selectedID == nil) Button(action: { - if self.contentState.isPromptModeActive, self.promptMode.normalized == .dictate { - self.contentState.onPromptModeProfileChangeRequested?(nil) - } else if self.promptMode.normalized == .dictate { - self.settings.setDictationPromptSelection(.default) + if self.promptMode.normalized == .dictate { + self.contentState.onDictationPromptSelectionRequested?(.default) } else { self.settings.setSelectedPromptID(nil, for: self.promptMode) } @@ -1297,10 +1299,13 @@ private struct BottomOverlayPromptMenuView: View { @ViewBuilder private func profileRow(_ profile: SettingsStore.DictationPromptProfile, selectedID: String?) -> some View { - let isSelected = selectedID == profile.id + let activeSlot = self.contentState.activeDictationShortcutSlot ?? .primary + let isSelected = self.promptMode.normalized == .dictate + ? (self.settings.dictationPromptSelection(for: activeSlot) == .profile(profile.id)) + : (selectedID == profile.id) Button(action: { - if self.contentState.isPromptModeActive, self.promptMode.normalized == .dictate { - self.contentState.onPromptModeProfileChangeRequested?(profile) + if self.promptMode.normalized == .dictate { + self.contentState.onDictationPromptSelectionRequested?(.profile(profile.id)) } else { self.settings.setSelectedPromptID(profile.id, for: self.promptMode) } @@ -1802,13 +1807,17 @@ struct BottomOverlayView: View { self.activeAppMonitor.activeAppBundleID } + private var activeDictationShortcutSlot: SettingsStore.DictationShortcutSlot { + self.contentState.activeDictationShortcutSlot ?? .primary + } + private var isAppPromptOverrideActive: Bool { guard let activePromptMode else { return false } - if activePromptMode.normalized == .dictate && - self.settings.isDictationPromptOff && - !self.contentState.isPromptModeActive - { - return false + if activePromptMode.normalized == .dictate { + return self.settings.isAppDictationPromptBindingActive( + for: self.activeDictationShortcutSlot, + appBundleID: self.promptResolutionBundleID + ) } return self.settings.hasAppPromptBinding( for: activePromptMode, @@ -1817,15 +1826,12 @@ struct BottomOverlayView: View { } private var selectedPromptLabel: String { - if let overrideName = self.contentState.promptModeOverrideProfileName { - return overrideName - } guard let activePromptMode else { return "N/A" } - if activePromptMode.normalized == .dictate && - self.settings.isDictationPromptOff && - !self.contentState.isPromptModeActive - { - return "Off" + if activePromptMode.normalized == .dictate { + return self.settings.dictationPromptDisplayName( + for: self.activeDictationShortcutSlot, + appBundleID: self.promptResolutionBundleID + ) } if let profile = self.settings.resolvedPromptProfile( for: activePromptMode, @@ -2086,7 +2092,7 @@ struct BottomOverlayView: View { private var promptSelectorTrigger: some View { HStack(spacing: 5) { - Text("AI:") + Text("AI Prompt:") .font(.system(size: self.promptSelectorFontSize, weight: .medium)) .foregroundStyle(.white.opacity(0.5)) .lineLimit(1) diff --git a/Sources/Fluid/Views/NotchContentViews.swift b/Sources/Fluid/Views/NotchContentViews.swift index bba198f1..a5a8e76d 100644 --- a/Sources/Fluid/Views/NotchContentViews.swift +++ b/Sources/Fluid/Views/NotchContentViews.swift @@ -20,12 +20,13 @@ class NotchContentState: ObservableObject { @Published var mode: OverlayMode = .dictation @Published var promptPickerMode: SettingsStore.PromptMode = .dictate @Published var isProcessing: Bool = false // AI processing state + @Published var activeDictationShortcutSlot: SettingsStore.DictationShortcutSlot? = nil @Published var promptModeOverrideProfileName: String? = nil // Name shown in overlay when prompt mode hotkey is active @Published var promptModeOverrideProfileID: String? = nil // ID of the active override profile (for checkmark in menu) @Published var isPromptModeActive: Bool = false // True for the entire prompt-mode session, even when no profile is selected - /// Called when the user picks a different prompt from the overlay during prompt mode recording. - var onPromptModeProfileChangeRequested: ((SettingsStore.DictationPromptProfile?) -> Void)? + /// Called when the user picks a different dictation prompt from the overlay during recording. + var onDictationPromptSelectionRequested: ((SettingsStore.DictationPromptSelection) -> Void)? /// Icon of the target app (where text will be typed) @Published var targetAppIcon: NSImage? @@ -347,13 +348,17 @@ struct NotchExpandedView: View { self.activeAppMonitor.activeAppBundleID } + private var activeDictationShortcutSlot: SettingsStore.DictationShortcutSlot { + self.contentState.activeDictationShortcutSlot ?? .primary + } + private var isAppPromptOverrideActive: Bool { guard let activePromptMode else { return false } - if activePromptMode.normalized == .dictate && - self.settings.isDictationPromptOff && - !self.contentState.isPromptModeActive - { - return false + if activePromptMode.normalized == .dictate { + return self.settings.isAppDictationPromptBindingActive( + for: self.activeDictationShortcutSlot, + appBundleID: self.promptResolutionBundleID + ) } return self.settings.hasAppPromptBinding( for: activePromptMode, @@ -362,15 +367,12 @@ struct NotchExpandedView: View { } private var selectedPromptLabel: String { - if let overrideName = self.contentState.promptModeOverrideProfileName { - return overrideName - } guard let activePromptMode else { return "N/A" } - if activePromptMode.normalized == .dictate && - self.settings.isDictationPromptOff && - !self.contentState.isPromptModeActive - { - return "Off" + if activePromptMode.normalized == .dictate { + return self.settings.dictationPromptDisplayName( + for: self.activeDictationShortcutSlot, + appBundleID: self.promptResolutionBundleID + ) } if let profile = self.settings.resolvedPromptProfile( for: activePromptMode, @@ -405,13 +407,12 @@ struct NotchExpandedView: View { private func promptMenuContent() -> some View { let promptMode = self.activePromptMode ?? .dictate - // During prompt mode recording, selections update the live override instead of the global prompt. - let isInPromptMode = self.contentState.isPromptModeActive + let activeDictationSlot = self.activeDictationShortcutSlot return VStack(alignment: .leading, spacing: 0) { - if promptMode.normalized == .dictate && !isInPromptMode { + if promptMode.normalized == .dictate { Button(action: { - self.settings.setDictationPromptSelection(.off) + self.contentState.onDictationPromptSelectionRequested?(.off) let pid = NotchContentState.shared.recordingTargetPID DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { if let pid { _ = TypingService.activateApp(pid: pid) } @@ -421,7 +422,7 @@ struct NotchExpandedView: View { HStack { Text("Off") Spacer() - let isSelected = !isInPromptMode && self.settings.isDictationPromptOff + let isSelected = self.settings.dictationPromptSelection(for: activeDictationSlot) == .off if isSelected { Image(systemName: "checkmark") .font(.system(size: 10, weight: .semibold)) @@ -436,14 +437,10 @@ struct NotchExpandedView: View { } Button(action: { - if isInPromptMode { - self.contentState.onPromptModeProfileChangeRequested?(nil) + if promptMode.normalized == .dictate { + self.contentState.onDictationPromptSelectionRequested?(.default) } else { - if promptMode.normalized == .dictate { - self.settings.setDictationPromptSelection(.default) - } else { - self.settings.setSelectedPromptID(nil, for: promptMode) - } + self.settings.setSelectedPromptID(nil, for: promptMode) } let pid = NotchContentState.shared.recordingTargetPID DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { @@ -454,9 +451,9 @@ struct NotchExpandedView: View { HStack { Text("Default") Spacer() - let isSelected = isInPromptMode - ? (self.contentState.promptModeOverrideProfileID == nil) - : (!self.settings.isDictationPromptOff && self.settings.selectedPromptID(for: promptMode) == nil) + let isSelected = promptMode.normalized == .dictate + ? (self.settings.dictationPromptSelection(for: activeDictationSlot) == .default) + : (self.settings.selectedPromptID(for: promptMode) == nil) if isSelected { Image(systemName: "checkmark") .font(.system(size: 10, weight: .semibold)) @@ -472,8 +469,8 @@ struct NotchExpandedView: View { ForEach(self.settings.promptProfiles(for: promptMode)) { profile in Button(action: { - if isInPromptMode { - self.contentState.onPromptModeProfileChangeRequested?(profile) + if promptMode.normalized == .dictate { + self.contentState.onDictationPromptSelectionRequested?(.profile(profile.id)) } else { self.settings.setSelectedPromptID(profile.id, for: promptMode) } @@ -486,8 +483,8 @@ struct NotchExpandedView: View { HStack { Text(profile.name.isEmpty ? "Untitled" : profile.name) Spacer() - let isSelected = isInPromptMode - ? (self.contentState.promptModeOverrideProfileID == profile.id) + let isSelected = promptMode.normalized == .dictate + ? (self.settings.dictationPromptSelection(for: activeDictationSlot) == .profile(profile.id)) : (self.settings.selectedPromptID(for: promptMode) == profile.id) if isSelected { Image(systemName: "checkmark") @@ -552,7 +549,7 @@ struct NotchExpandedView: View { if !self.contentState.isProcessing { ZStack(alignment: .top) { HStack(spacing: 6) { - Text("AI:") + Text("AI Prompt:") .font(.system(size: 9, weight: .medium)) .foregroundStyle(.white.opacity(0.5)) Text(self.selectedPromptLabel) From 64822ff552a1e85b9e8d3c7c56798449e20474df Mon Sep 17 00:00:00 2001 From: altic-dev Date: Sun, 5 Apr 2026 16:59:44 -0700 Subject: [PATCH 10/11] Update README for Parakeet Flash --- README.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b72a2031..710bf570 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # FluidVoice -[![Supported Models](https://img.shields.io/badge/Models-Parakeet%20v3%20%26%20v2%20%7C%20Cohere%20Transcribe%20%7C%20Apple%20Speech%20%7C%20Whisper-blue)](https://github.com/altic-dev/Fluid-oss) +[![Supported Models](https://img.shields.io/badge/Models-Parakeet%20Flash%20%7C%20Parakeet%20v3%20%26%20v2%20%7C%20Cohere%20Transcribe%20%7C%20Apple%20Speech%20%7C%20Whisper-blue)](https://huggingface.co/nvidia/parakeet_realtime_eou_120m-v1) Fully open source voice-to-text dictation app for macOS with AI enhancement. @@ -11,9 +11,9 @@ Fully open source voice-to-text dictation app for macOS with AI enhancement. ## Latest Update +- Added **[Parakeet Flash (Beta)](https://huggingface.co/nvidia/parakeet_realtime_eou_120m-v1)** for low-latency live English dictation on Apple Silicon - Added **Cohere Transcribe** support for higher-accuracy multilingual dictation on Apple Silicon -- Expanded the offline voice engine lineup with **Parakeet v3/v2, Cohere, Apple Speech, and Whisper** -- Continued improving the multilingual dictation experience for users who switch between languages often +- Expanded the voice engine lineup with **Parakeet Flash, Parakeet v3/v2, Cohere, Apple Speech, and Whisper** ## Star History @@ -55,7 +55,7 @@ https://github.com/user-attachments/assets/c57ef6d5-f0a1-4a3f-a121-637533442c24 ## Features - **Live Preview Mode**: Real-time transcription preview in overlay -- **Multiple Speech Models**: Parakeet TDT v3 & v2, Cohere Transcribe, Apple Speech, and Whisper +- **Multiple Speech Models**: Parakeet Flash, Parakeet TDT v3 & v2, Cohere Transcribe, Apple Speech, and Whisper - **Real-time transcription** with extremely low latency - **AI enhancement** with OpenAI, Groq, and custom providers - **Global hotkey** for instant voice capture @@ -68,6 +68,7 @@ https://github.com/user-attachments/assets/c57ef6d5-f0a1-4a3f-a121-637533442c24 | Model | Best for | Language support | Download size | Hardware | | --- | --- | --- | --- | --- | +| [Parakeet Flash (Beta)](https://huggingface.co/nvidia/parakeet_realtime_eou_120m-v1) | Lowest-latency live English dictation | English only | ~250 MB | Apple Silicon | | Parakeet TDT v3 | Fast default multilingual dictation | [25 languages](#parakeet-tdt-v3-languages) | ~500 MB | Apple Silicon | | Parakeet TDT v2 | Fastest English-only dictation | [English only](#parakeet-tdt-v2-languages) | ~500 MB | Apple Silicon | | Cohere Transcribe | High-accuracy multilingual dictation | [14 languages](#cohere-transcribe-languages) | ~1.4 GB | Apple Silicon | @@ -75,7 +76,11 @@ https://github.com/user-attachments/assets/c57ef6d5-f0a1-4a3f-a121-637533442c24 | Whisper Tiny / Base / Small / Medium / Large | Broad compatibility, including Intel Macs | [99 languages](#whisper-language-support) | ~75 MB to ~2.9 GB | Apple Silicon + Intel | Notes: -Parakeet TDT v3 is the default on Apple Silicon. Cohere is the stronger pick if you want a larger multilingual model with higher displayed accuracy. Whisper remains the fallback for Intel Macs and the widest language coverage. +Parakeet Flash is the best pick when you want words to appear live with the lowest latency. Parakeet TDT v3 remains the default multilingual Apple Silicon model. Cohere is the stronger pick if you want a larger multilingual model with higher displayed accuracy. Whisper remains the fallback for Intel Macs and the widest language coverage. + +### Parakeet Flash Languages + +English. ### Parakeet TDT v3 Languages From 73a9910d5f5b6ea057d158beefc6d62075205cdc Mon Sep 17 00:00:00 2001 From: altic-dev Date: Sun, 5 Apr 2026 17:49:27 -0700 Subject: [PATCH 11/11] Fix shortcut reassignment capture flow --- Sources/Fluid/ContentView.swift | 178 +++++++++++------- .../Fluid/Services/GlobalHotkeyManager.swift | 51 +++-- Sources/Fluid/UI/SettingsView.swift | 18 ++ 3 files changed, 160 insertions(+), 87 deletions(-) diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index 31a52fd8..1eb4835b 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -97,6 +97,7 @@ struct ContentView: View { @State private var pendingModifierFlags: NSEvent.ModifierFlags = [] @State private var pendingModifierKeyCode: UInt16? @State private var pendingModifierOnly = false + @State private var shortcutRecordingMessage: String? = nil @FocusState private var isTranscriptionFocused: Bool @State private var selectedSidebarItem: SidebarItem? @@ -371,19 +372,16 @@ struct ContentView: View { self.isRecordingCommandModeShortcut || self.isRecordingRewriteShortcut || self.isRecordingCancelShortcut + let recordingTarget = self.currentShortcutRecordingTarget() if event.type == .keyDown { - if self.hotkeyShortcut.matches(keyCode: event.keyCode, modifiers: eventModifiers) { - DebugLogger.shared.debug("NSEvent monitor: Global hotkey matched on keyDown, passing event through (GlobalHotkeyManager handles)", source: "ContentView") - return event - } - guard isRecordingAnyShortcut else { if self.cancelRecordingHotkeyShortcut.matches(keyCode: event.keyCode, modifiers: eventModifiers), self.handleCancelShortcut() { return nil } + self.shortcutRecordingMessage = nil self.resetPendingShortcutState() return event } @@ -391,12 +389,7 @@ struct ContentView: View { let keyCode = event.keyCode if keyCode == 53 && !self.isRecordingCancelShortcut { DebugLogger.shared.debug("NSEvent monitor: Escape pressed, cancelling shortcut recording", source: "ContentView") - self.isRecordingShortcut = false - self.isRecordingPromptModeShortcut = false - self.isRecordingCommandModeShortcut = false - self.isRecordingRewriteShortcut = false - self.isRecordingCancelShortcut = false - self.resetPendingShortcutState() + self.clearShortcutRecordingMode() return nil } @@ -404,44 +397,25 @@ struct ContentView: View { let newShortcut = HotkeyShortcut(keyCode: keyCode, modifierFlags: combinedModifiers) DebugLogger.shared.debug("NSEvent monitor: Recording new shortcut: \(newShortcut.displayString)", source: "ContentView") - if self.isRecordingRewriteShortcut { - self.rewriteModeHotkeyShortcut = newShortcut - SettingsStore.shared.rewriteModeHotkeyShortcut = newShortcut - self.hotkeyManager?.updateRewriteModeShortcut(newShortcut) - self.isRecordingRewriteShortcut = false - } else if self.isRecordingCancelShortcut { - self.cancelRecordingHotkeyShortcut = newShortcut - SettingsStore.shared.cancelRecordingHotkeyShortcut = newShortcut - self.isRecordingCancelShortcut = false - } else if self.isRecordingPromptModeShortcut { - self.promptModeHotkeyShortcut = newShortcut - SettingsStore.shared.promptModeHotkeyShortcut = newShortcut - self.hotkeyManager?.updatePromptModeShortcut(newShortcut) - self.isRecordingPromptModeShortcut = false - } else if self.isRecordingCommandModeShortcut { - self.commandModeHotkeyShortcut = newShortcut - SettingsStore.shared.commandModeHotkeyShortcut = newShortcut - self.hotkeyManager?.updateCommandModeShortcut(newShortcut) - self.isRecordingCommandModeShortcut = false - } else { - self.hotkeyShortcut = newShortcut - SettingsStore.shared.hotkeyShortcut = newShortcut - self.hotkeyManager?.updateShortcut(newShortcut) - self.isRecordingShortcut = false + if let recordingTarget, + let conflictMessage = self.shortcutConflictMessage(for: newShortcut, target: recordingTarget) + { + self.shortcutRecordingMessage = conflictMessage + self.resetPendingShortcutState() + DebugLogger.shared.debug("NSEvent monitor: Shortcut conflict while recording: \(conflictMessage)", source: "ContentView") + return nil + } + + self.shortcutRecordingMessage = nil + if let recordingTarget { + self.assignRecordedShortcut(newShortcut, to: recordingTarget) } self.resetPendingShortcutState() DebugLogger.shared.debug("NSEvent monitor: Finished recording shortcut", source: "ContentView") return nil } else if event.type == .flagsChanged { - if self.hotkeyShortcut.modifierFlags.isEmpty { - let isModifierKeyPressed = eventModifiers.isEmpty == false - if event.keyCode == self.hotkeyShortcut.keyCode && isModifierKeyPressed { - DebugLogger.shared.debug("NSEvent monitor: Global hotkey matched on flagsChanged, passing event through (GlobalHotkeyManager handles)", source: "ContentView") - return event - } - } - guard isRecordingAnyShortcut else { + self.shortcutRecordingMessage = nil self.resetPendingShortcutState() return event } @@ -451,30 +425,18 @@ struct ContentView: View { let newShortcut = HotkeyShortcut(keyCode: modifierKeyCode, modifierFlags: []) DebugLogger.shared.debug("NSEvent monitor: Recording modifier-only shortcut: \(newShortcut.displayString)", source: "ContentView") - if self.isRecordingRewriteShortcut { - self.rewriteModeHotkeyShortcut = newShortcut - SettingsStore.shared.rewriteModeHotkeyShortcut = newShortcut - self.hotkeyManager?.updateRewriteModeShortcut(newShortcut) - self.isRecordingRewriteShortcut = false - } else if self.isRecordingCancelShortcut { - self.cancelRecordingHotkeyShortcut = newShortcut - SettingsStore.shared.cancelRecordingHotkeyShortcut = newShortcut - self.isRecordingCancelShortcut = false - } else if self.isRecordingPromptModeShortcut { - self.promptModeHotkeyShortcut = newShortcut - SettingsStore.shared.promptModeHotkeyShortcut = newShortcut - self.hotkeyManager?.updatePromptModeShortcut(newShortcut) - self.isRecordingPromptModeShortcut = false - } else if self.isRecordingCommandModeShortcut { - self.commandModeHotkeyShortcut = newShortcut - SettingsStore.shared.commandModeHotkeyShortcut = newShortcut - self.hotkeyManager?.updateCommandModeShortcut(newShortcut) - self.isRecordingCommandModeShortcut = false - } else { - self.hotkeyShortcut = newShortcut - SettingsStore.shared.hotkeyShortcut = newShortcut - self.hotkeyManager?.updateShortcut(newShortcut) - self.isRecordingShortcut = false + if let recordingTarget, + let conflictMessage = self.shortcutConflictMessage(for: newShortcut, target: recordingTarget) + { + self.shortcutRecordingMessage = conflictMessage + self.resetPendingShortcutState() + DebugLogger.shared.debug("NSEvent monitor: Modifier shortcut conflict while recording: \(conflictMessage)", source: "ContentView") + return nil + } + + self.shortcutRecordingMessage = nil + if let recordingTarget { + self.assignRecordedShortcut(newShortcut, to: recordingTarget) } self.resetPendingShortcutState() DebugLogger.shared.debug("NSEvent monitor: Finished recording modifier shortcut", source: "ContentView") @@ -781,6 +743,80 @@ struct ContentView: View { self.pendingModifierOnly = false } + private enum ShortcutRecordingTarget { + case primaryDictation + case secondaryDictation + case command + case edit + case cancel + } + + private func currentShortcutRecordingTarget() -> ShortcutRecordingTarget? { + if self.isRecordingShortcut { return .primaryDictation } + if self.isRecordingPromptModeShortcut { return .secondaryDictation } + if self.isRecordingCommandModeShortcut { return .command } + if self.isRecordingRewriteShortcut { return .edit } + if self.isRecordingCancelShortcut { return .cancel } + return nil + } + + private func shortcutConflictMessage(for shortcut: HotkeyShortcut, target: ShortcutRecordingTarget) -> String? { + let configuredShortcuts: [(ShortcutRecordingTarget, String, HotkeyShortcut)] = [ + (.primaryDictation, "Primary Dictation Shortcut", self.hotkeyShortcut), + (.secondaryDictation, "Secondary Dictation Shortcut", self.promptModeHotkeyShortcut), + (.command, "Command Mode", self.commandModeHotkeyShortcut), + (.edit, "Edit Mode", self.rewriteModeHotkeyShortcut), + (.cancel, "Cancel Recording", self.cancelRecordingHotkeyShortcut), + ] + + for (otherTarget, title, configuredShortcut) in configuredShortcuts where otherTarget != target { + if configuredShortcut == shortcut { + return "Duplicate with \(title)" + } + } + + return nil + } + + private func assignRecordedShortcut(_ shortcut: HotkeyShortcut, to target: ShortcutRecordingTarget) { + switch target { + case .primaryDictation: + self.hotkeyShortcut = shortcut + SettingsStore.shared.hotkeyShortcut = shortcut + self.hotkeyManager?.updateShortcut(shortcut) + self.isRecordingShortcut = false + case .secondaryDictation: + self.promptModeHotkeyShortcut = shortcut + SettingsStore.shared.promptModeHotkeyShortcut = shortcut + self.hotkeyManager?.updatePromptModeShortcut(shortcut) + self.isRecordingPromptModeShortcut = false + case .command: + self.commandModeHotkeyShortcut = shortcut + SettingsStore.shared.commandModeHotkeyShortcut = shortcut + self.hotkeyManager?.updateCommandModeShortcut(shortcut) + self.isRecordingCommandModeShortcut = false + case .edit: + self.rewriteModeHotkeyShortcut = shortcut + SettingsStore.shared.rewriteModeHotkeyShortcut = shortcut + self.hotkeyManager?.updateRewriteModeShortcut(shortcut) + self.isRecordingRewriteShortcut = false + case .cancel: + self.cancelRecordingHotkeyShortcut = shortcut + SettingsStore.shared.cancelRecordingHotkeyShortcut = shortcut + self.isRecordingCancelShortcut = false + } + } + + private func clearShortcutRecordingMode() { + self.isRecordingShortcut = false + self.isRecordingPromptModeShortcut = false + self.isRecordingCommandModeShortcut = false + self.isRecordingRewriteShortcut = false + self.isRecordingCancelShortcut = false + self.shortcutRecordingMessage = nil + self.resetPendingShortcutState() + } + private func openIssueReportingPage() { guard let url = URL(string: "https://github.com/altic-dev/Fluid-oss/issues/new/choose") else { return } NSWorkspace.shared.open(url) @@ -1122,6 +1158,7 @@ struct ContentView: View { accessibilityEnabled: self.$accessibilityEnabled, hotkeyShortcut: self.$hotkeyShortcut, isRecordingShortcut: self.$isRecordingShortcut, + shortcutRecordingMessage: self.$shortcutRecordingMessage, promptModeShortcut: self.$promptModeHotkeyShortcut, isRecordingPromptModeShortcut: self.$isRecordingPromptModeShortcut, promptModeShortcutEnabled: self.$isPromptModeShortcutEnabled, @@ -2472,6 +2509,13 @@ struct ContentView: View { }, isRewriteRecordingProvider: { self.activeRecordingMode == .edit + }, + isShortcutCaptureActiveProvider: { + self.isRecordingShortcut || + self.isRecordingPromptModeShortcut || + self.isRecordingCommandModeShortcut || + self.isRecordingRewriteShortcut || + self.isRecordingCancelShortcut } ) diff --git a/Sources/Fluid/Services/GlobalHotkeyManager.swift b/Sources/Fluid/Services/GlobalHotkeyManager.swift index d64277ca..38b156cd 100644 --- a/Sources/Fluid/Services/GlobalHotkeyManager.swift +++ b/Sources/Fluid/Services/GlobalHotkeyManager.swift @@ -50,6 +50,7 @@ final class GlobalHotkeyManager: NSObject { private var isPromptModeRecordingProvider: (() -> Bool)? private var isCommandRecordingProvider: (() -> Bool)? private var isRewriteRecordingProvider: (() -> Bool)? + private var isShortcutCaptureActiveProvider: (() -> Bool)? private var cancelCallback: (() -> Bool)? // Returns true if handled private var pressAndHoldMode: Bool = SettingsStore.shared.pressAndHoldMode @@ -129,7 +130,8 @@ final class GlobalHotkeyManager: NSObject { isDictateRecordingProvider: (() -> Bool)? = nil, isPromptModeRecordingProvider: (() -> Bool)? = nil, isCommandRecordingProvider: (() -> Bool)? = nil, - isRewriteRecordingProvider: (() -> Bool)? = nil + isRewriteRecordingProvider: (() -> Bool)? = nil, + isShortcutCaptureActiveProvider: (() -> Bool)? = nil ) { self.asrService = asrService self.shortcut = shortcut @@ -149,6 +151,7 @@ final class GlobalHotkeyManager: NSObject { self.isPromptModeRecordingProvider = isPromptModeRecordingProvider self.isCommandRecordingProvider = isCommandRecordingProvider self.isRewriteRecordingProvider = isRewriteRecordingProvider + self.isShortcutCaptureActiveProvider = isShortcutCaptureActiveProvider super.init() self.initializeWithDelay() @@ -330,26 +333,11 @@ final class GlobalHotkeyManager: NSObject { } private func handleKeyEvent(proxy: CGEventTapProxy, type: CGEventType, event: CGEvent) -> Unmanaged? { - // macOS can temporarily disable event taps (e.g. timeouts, user input protection). - // If we don't immediately re-enable here, hotkeys will silently stop working until our - // periodic health check kicks in, and the OS may handle the key (e.g. system dictation). - if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { - let reason = (type == .tapDisabledByTimeout) ? "timeout" : "user input" - DebugLogger.shared.warning("Event tap disabled by \(reason) — attempting immediate re-enable", source: "GlobalHotkeyManager") - - if let tap = self.eventTap { - CGEvent.tapEnable(tap: tap, enable: true) - } - - // If re-enable failed, recreate the tap. - if !self.isEventTapEnabled() { - DebugLogger.shared.warning("Event tap re-enable failed — recreating tap", source: "GlobalHotkeyManager") - self.setupGlobalHotkeyWithRetry() - } + if let tapRecoveryResult = self.handleTapDisableEvent(type: type, event: event) { + return tapRecoveryResult + } - // CRITICAL: Return the event to let it pass through during recovery. - // Previously returning nil would consume/block all keyboard events - // (including CGEvent text insertion) during the recovery period. + if self.isShortcutCaptureActiveProvider?() ?? false { return Unmanaged.passUnretained(event) } @@ -789,6 +777,29 @@ final class GlobalHotkeyManager: NSObject { return Unmanaged.passUnretained(event) } + private func handleTapDisableEvent(type: CGEventType, event: CGEvent) -> Unmanaged? { + // macOS can temporarily disable event taps (e.g. timeouts, user input protection). + // If we don't immediately re-enable here, hotkeys will silently stop working until our + // periodic health check kicks in, and the OS may handle the key (e.g. system dictation). + guard type == .tapDisabledByTimeout || type == .tapDisabledByUserInput else { + return nil + } + + let reason = (type == .tapDisabledByTimeout) ? "timeout" : "user input" + DebugLogger.shared.warning("Event tap disabled by \(reason) — attempting immediate re-enable", source: "GlobalHotkeyManager") + + if let tap = self.eventTap { + CGEvent.tapEnable(tap: tap, enable: true) + } + + if !self.isEventTapEnabled() { + DebugLogger.shared.warning("Event tap re-enable failed — recreating tap", source: "GlobalHotkeyManager") + self.setupGlobalHotkeyWithRetry() + } + + return Unmanaged.passUnretained(event) + } + private func handlePromptModeKeyDown(keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> Bool { guard self.promptModeShortcutEnabled, self.matchesPromptModeShortcut(keyCode: keyCode, modifiers: modifiers) else { return false } if self.pressAndHoldMode { diff --git a/Sources/Fluid/UI/SettingsView.swift b/Sources/Fluid/UI/SettingsView.swift index f6dfcc4f..14a6804d 100644 --- a/Sources/Fluid/UI/SettingsView.swift +++ b/Sources/Fluid/UI/SettingsView.swift @@ -28,6 +28,7 @@ struct SettingsView: View { @Binding var accessibilityEnabled: Bool @Binding var hotkeyShortcut: HotkeyShortcut @Binding var isRecordingShortcut: Bool + @Binding var shortcutRecordingMessage: String? @Binding var promptModeShortcut: HotkeyShortcut @Binding var isRecordingPromptModeShortcut: Bool @Binding var promptModeShortcutEnabled: Bool @@ -630,8 +631,10 @@ struct SettingsView: View { description: "Defaults to raw transcription, but can use Off, Default, or any custom prompt.", shortcut: self.hotkeyShortcut, isRecording: self.isRecordingShortcut, + recordingMessage: self.isRecordingShortcut ? self.shortcutRecordingMessage : nil, onChangePressed: { DebugLogger.shared.debug("Starting to record new transcribe shortcut", source: "SettingsView") + self.shortcutRecordingMessage = nil self.isRecordingShortcut = true } ) @@ -645,9 +648,11 @@ struct SettingsView: View { description: "Defaults to Default cleanup, but can use Off, Default, or any custom prompt.", shortcut: self.promptModeShortcut, isRecording: self.isRecordingPromptModeShortcut, + recordingMessage: self.isRecordingPromptModeShortcut ? self.shortcutRecordingMessage : nil, isEnabled: self.$promptModeShortcutEnabled, onChangePressed: { DebugLogger.shared.debug("Starting to record new prompt mode shortcut", source: "SettingsView") + self.shortcutRecordingMessage = nil self.isRecordingPromptModeShortcut = true } ) @@ -665,9 +670,11 @@ struct SettingsView: View { description: "Execute voice commands", shortcut: self.commandModeShortcut, isRecording: self.isRecordingCommandModeShortcut, + recordingMessage: self.isRecordingCommandModeShortcut ? self.shortcutRecordingMessage : nil, isEnabled: self.$commandModeShortcutEnabled, onChangePressed: { DebugLogger.shared.debug("Starting to record new command mode shortcut", source: "SettingsView") + self.shortcutRecordingMessage = nil self.isRecordingCommandModeShortcut = true } ) @@ -680,9 +687,11 @@ struct SettingsView: View { description: "Select text and speak how to edit, or generate new content", shortcut: self.rewriteShortcut, isRecording: self.isRecordingRewriteShortcut, + recordingMessage: self.isRecordingRewriteShortcut ? self.shortcutRecordingMessage : nil, isEnabled: self.$rewriteShortcutEnabled, onChangePressed: { DebugLogger.shared.debug("Starting to record new write mode shortcut", source: "SettingsView") + self.shortcutRecordingMessage = nil self.isRecordingRewriteShortcut = true } ) @@ -695,8 +704,10 @@ struct SettingsView: View { description: "Cancel the current recording or dismiss the active recording overlay", shortcut: self.cancelRecordingShortcut, isRecording: self.isRecordingCancelShortcut, + recordingMessage: self.isRecordingCancelShortcut ? self.shortcutRecordingMessage : nil, onChangePressed: { DebugLogger.shared.debug("Starting to record new cancel shortcut", source: "SettingsView") + self.shortcutRecordingMessage = nil self.isRecordingCancelShortcut = true } ) @@ -1683,6 +1694,7 @@ struct SettingsView: View { description: String, shortcut: HotkeyShortcut, isRecording: Bool, + recordingMessage: String? = nil, isEnabled: Binding? = nil, onChangePressed: @escaping () -> Void ) -> some View { @@ -1748,6 +1760,12 @@ struct SettingsView: View { .buttonStyle(.bordered) .controlSize(.small) .disabled(isRecording || !enabledValue) + + if isRecording, let recordingMessage, !recordingMessage.isEmpty { + Text(recordingMessage) + .font(.caption) + .foregroundStyle(self.theme.palette.warning) + } } } .opacity(enabledValue ? 1 : 0.7)