From 9bbae4778e5715790078c14b4dad5a4f7e681dfc Mon Sep 17 00:00:00 2001 From: shivansh sen Date: Sat, 25 Jul 2026 18:58:10 +0530 Subject: [PATCH] fix(settings): clear orphaned per-prompt shortcuts on legacy retirement Retiring the legacy secondary-prompt-mode hotkey cleared PromptModeHotkeyShortcut/PromptModeSelectedPromptID but never touched DictationPromptConfigurations, a separate store still read unconditionally by dictationPromptShortcutAssignments(). Any residual .shortcut there (commonly a bare Command keypress left under __default__) kept firing as an always-on global hotkey with no dependency on PromptModeShortcutEnabled. Clear stale shortcut bindings once, at the same retirement boundary that already governs the legacy key, while preserving any real provider/model overrides. Fixes altic-dev/FluidVoice#675 --- Sources/Fluid/Persistence/SettingsStore.swift | 24 +++++++++++ .../HotkeyShortcutTests.swift | 41 +++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index e786893f..47819682 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -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) diff --git a/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift b/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift index 65bd2b5e..10eaa558 100644 --- a/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift +++ b/Tests/FluidDictationIntegrationTests/HotkeyShortcutTests.swift @@ -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" @@ -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)