Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions Sources/Fluid/Persistence/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3343,9 +3343,33 @@ final class SettingsStore: ObservableObject {
self.defaults.set(true, forKey: Keys.secondaryDictationPromptOff)
self.defaults.removeObject(forKey: Keys.promptModeHotkeyShortcut)
self.defaults.removeObject(forKey: Keys.promptModeSelectedPromptID)
self.clearOrphanedDictationPromptShortcuts()
self.defaults.set(true, forKey: Keys.legacySecondaryPromptShortcutRetired)
}

/// Before per-prompt "Custom shortcut" assignments existed, the single legacy secondary-prompt
/// hotkey (`PromptModeHotkeyShortcut` / `PromptModeSelectedPromptID`, cleared above) left a
/// residual `.shortcut` behind in `DictationPromptConfigurations` — most commonly a bare Command
/// keypress under the `__default__` entry. `dictationPromptShortcutAssignments()` treats any
/// stored `.shortcut` as an always-on global hotkey with no dependency on
/// `PromptModeShortcutEnabled`, so that residue kept firing dictation on every Cmd+key chord even
/// though the feature that created it was retired (altic-dev/FluidVoice#675). Provider/model
/// overrides are unaffected — only the shortcut binding is cleared, and only once, here, at the
/// same retirement boundary that already governs the legacy key.
func clearOrphanedDictationPromptShortcuts() {
var configurations = self.dictationPromptConfigurations
guard configurations.values.contains(where: { $0.shortcut != nil }) else { return }

for key in configurations.keys {
configurations[key]?.shortcut = nil
}
configurations = configurations.filter { _, configuration in
!configuration.providerID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ||
!configuration.modelName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
self.dictationPromptConfigurations = configurations
}

private func normalizePromptSelectionsIfNeeded() {
if self.defaults.object(forKey: Keys.secondaryDictationPromptOff) == nil {
self.defaults.set(false, forKey: Keys.secondaryDictationPromptOff)
Expand Down
41 changes: 41 additions & 0 deletions Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ final class HotkeyShortcutTests: XCTestCase {
private let pasteLastTranscriptionShortcutKey = "PasteLastTranscriptionHotkeyShortcut"
private let pasteLastTranscriptionEnabledKey = "PasteLastTranscriptionShortcutEnabled"
private let microphoneSelectionModeKey = "MicrophoneSelectionMode"
private let dictationPromptConfigurationsKey = "DictationPromptConfigurations"
private let preferredInputDeviceUIDKey = "PreferredInputDeviceUID"
private let systemInputDeviceUIDBeforeManualKey = "SystemInputDeviceUIDBeforeManual"

Expand Down Expand Up @@ -202,6 +203,46 @@ final class HotkeyShortcutTests: XCTestCase {
}
}

/// Regression test for altic-dev/FluidVoice#675: a residual `.shortcut` left behind by the
/// retired legacy secondary-prompt-mode hotkey kept firing as an always-on global hotkey
/// (most commonly a bare Command keypress under the `__default__` entry) because
/// `dictationPromptShortcutAssignments()` has no dependency on `PromptModeShortcutEnabled`.
func testClearOrphanedDictationPromptShortcutsRemovesShortcutButKeepsProviderOverrides() throws {
try self.withRestoredDefaults(keys: [self.dictationPromptConfigurationsKey]) {
let orphanedDefault = SettingsStore.DictationPromptConfiguration(
shortcut: HotkeyShortcut(keyCode: 55, modifierFlags: []), // bare Command, matches field reports
providerID: "",
modelName: ""
)
let profileWithOverride = SettingsStore.DictationPromptConfiguration(
shortcut: HotkeyShortcut(keyCode: 62, modifierFlags: []),
providerID: "openrouter",
modelName: "some-model"
)
SettingsStore.shared.dictationPromptConfigurations = [
"__default__": orphanedDefault,
"__privateAI__": profileWithOverride,
]
XCTAssertEqual(SettingsStore.shared.dictationPromptShortcutAssignments().count, 2)

SettingsStore.shared.clearOrphanedDictationPromptShortcuts()

let result = SettingsStore.shared.dictationPromptConfigurations
XCTAssertNil(result["__default__"], "Entry left with no shortcut, provider, or model should be dropped entirely")
XCTAssertNil(result["__privateAI__"]?.shortcut, "Shortcut binding must be cleared")
XCTAssertEqual(result["__privateAI__"]?.providerID, "openrouter", "Provider override must survive the cleanup")
XCTAssertEqual(result["__privateAI__"]?.modelName, "some-model", "Model override must survive the cleanup")
XCTAssertTrue(
SettingsStore.shared.dictationPromptShortcutAssignments().isEmpty,
"No stale shortcut should remain reachable as a live global hotkey"
)

// Calling it again once already clean is a no-op, not a crash or a re-write.
SettingsStore.shared.clearOrphanedDictationPromptShortcuts()
XCTAssertEqual(SettingsStore.shared.dictationPromptConfigurations, result)
}
}

func testMicrophoneSelectionModeDefaultsToSystem() throws {
try self.withRestoredDefaults(keys: [self.microphoneSelectionModeKey]) {
UserDefaults.standard.removeObject(forKey: self.microphoneSelectionModeKey)
Expand Down
Loading