diff --git a/CHANGELOG.md b/CHANGELOG.md index 72583807..a4b20a13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ the frozen-backend fallback mirror it for their toolchains. ## [Unreleased] **Highlights** +- A dictation shortcut another app already owns now says so, instead of silently doing nothing (#1858) - Generating from a one-character input now says the input was too short, instead of quoting a convolution error (#1826) - First run asks about text size before the install, not after it (#1849) - Cloning without a reference clip now says so, instead of naming library parameters you cannot set (#1879) diff --git a/frontend/src-tauri/src/dictation_shortcut.rs b/frontend/src-tauri/src/dictation_shortcut.rs index 94d18394..5ac9c750 100644 --- a/frontend/src-tauri/src/dictation_shortcut.rs +++ b/frontend/src-tauri/src/dictation_shortcut.rs @@ -64,7 +64,20 @@ impl DictationShortcutManager { Ok(()) => { manager.publish(&app, accelerator, None, "native"); } - Err(error) => log::warn!("Failed to register global shortcut: {error}"), + Err(error) => { + // Publish the failure instead of only logging it. Whichever + // app registers a global shortcut first wins, and the default + // collides with 1Password Quick Access on macOS — so for a lot + // of installs the hotkey the onboarding screen advertises + // silently does nothing. With no publish on this path the + // frontend kept reporting whatever accelerator was REQUESTED, + // with no way to know the OS never granted it (#1858). + // + // The accelerator is still published so the UI can name the + // shortcut that failed; `backend` carries the outcome. + log::warn!("Failed to register global shortcut: {error}"); + manager.publish(&app, accelerator, None, "unregistered"); + } } } diff --git a/frontend/src/components/DictationDemo.jsx b/frontend/src/components/DictationDemo.jsx index 0be2cec2..b781a29b 100644 --- a/frontend/src/components/DictationDemo.jsx +++ b/frontend/src/components/DictationDemo.jsx @@ -101,9 +101,17 @@ export default function DictationDemo({ embedded = false }) { useEffect(() => { if (!desktop) return; - setHotkeyState((current) => - current === 'verified' ? current : shortcut.backend === 'focused' ? 'unknown' : 'registered', - ); + // `unregistered` is its own state, not a flavour of `unknown`: the OS + // refused the accelerator, usually because another app already holds it + // (the default collides with 1Password Quick Access on macOS). Saying + // "no hotkey registered" there would read as "we have not checked yet", + // when what the user needs to know is that this specific combination is + // taken and they should pick another (#1858). + setHotkeyState((current) => { + if (shortcut.backend === 'unregistered') return 'unregistered'; + if (current === 'verified') return current; + return shortcut.backend === 'focused' ? 'unknown' : 'registered'; + }); }, [desktop, shortcut.backend]); // Subscribe to dictation events: the moment the user presses their @@ -203,6 +211,21 @@ export default function DictationDemo({ embedded = false }) { {t('demo.dictation_status_ok')} ); + case 'unregistered': + return ( + + {' '} + {t('demo.dictation_status_taken', { + defaultValue: + 'Another app already uses this shortcut — pick a different one in Settings.', + })}{' '} + + {shortcut.display} + + + ); case 'registered': return ( ({ }, apiJson: vi.fn(), })); +const { shortcutInfo } = vi.hoisted(() => ({ + shortcutInfo: { accelerator: 'CmdOrCtrl+Shift+Space', display: '⌘⇧Space', backend: 'native' }, +})); +vi.mock('../hooks/useEffectiveDictationShortcut', () => ({ + useEffectiveDictationShortcut: () => ({ info: shortcutInfo }), +})); vi.mock('../hooks/useDictationReadiness', () => ({ useDictationReadiness: () => readiness, })); @@ -150,3 +156,43 @@ describe('DictationDemo', () => { expect(screen.queryByTestId('asr-model-chooser')).not.toBeInTheDocument(); }); }); + +// #1858: whichever app registers a global shortcut first wins, and the default +// collides with 1Password Quick Access on macOS. Registration failure used to +// be a Rust-side log line and nothing else — the frontend kept reporting the +// accelerator that had been REQUESTED, so the onboarding screen advertised a +// hotkey the OS had refused, with no way for the user to find out. +describe('DictationDemo — hotkey registration failure', () => { + beforeEach(() => { + window.__TAURI_INTERNALS__ = {}; + }); + afterEach(() => { + delete window.__TAURI_INTERNALS__; + }); + + it('says the shortcut is taken rather than merely unregistered', () => { + shortcutInfo.backend = 'unregistered'; + shortcutInfo.display = '⌘⇧Space'; + render(withI18n()); + + expect(screen.getByText(/Another app already uses this shortcut/i)).toBeInTheDocument(); + // "No hotkey registered" means "not checked yet" — a different situation. + expect(screen.queryByText(/No hotkey registered/i)).not.toBeInTheDocument(); + }); + + it('still names the shortcut that failed', () => { + // The user has to know WHICH combination is taken to pick another. + shortcutInfo.backend = 'unregistered'; + shortcutInfo.display = '⌘⇧Space'; + render(withI18n()); + + expect(screen.getByText('⌘⇧Space')).toBeInTheDocument(); + }); + + it('leaves a successfully registered shortcut alone', () => { + shortcutInfo.backend = 'native'; + render(withI18n()); + + expect(screen.queryByText(/Another app already uses this shortcut/i)).not.toBeInTheDocument(); + }); +});