Skip to content

fix(hotkeys): report shortcuts the OS refuses instead of silently dropping them#2041

Open
DPS0340 wants to merge 1 commit into
CapSoftware:mainfrom
DPS0340:fix/hotkey-registration-failure
Open

fix(hotkeys): report shortcuts the OS refuses instead of silently dropping them#2041
DPS0340 wants to merge 1 commit into
CapSoftware:mainfrom
DPS0340:fix/hotkey-registration-failure

Conversation

@DPS0340

@DPS0340 DPS0340 commented Jul 26, 2026

Copy link
Copy Markdown

Closes #1883.

What @rlnt hit

Two complaints in that issue, and they turn out to be one bug plus one misreading:

  1. "I can't bind the print key" — PrintScreen is reserved by Windows, so register() fails. But the binding was still stored and still shown in Settings as bound. Nothing surfaced the failure, so the only observable behaviour was "I set it and it does nothing".
  2. "key combinations are not possible" — they are; modifiers are captured (ctrl/shift/alt/meta) and rendered by HotkeyText. What almost certainly happened is that the combination they tried was already owned by another application, which fails the same silent way and is indistinguishable from "combinations aren't supported".

Root cause

Every registration threw its result away:

global_shortcut.register(Shortcut::from(hotkey)).ok();   // set_hotkey
global_shortcut.register(Shortcut::from(*hotkey)).ok();  // init, on startup
global_shortcut.unregister(Shortcut::from(prev)).ok();

set_hotkey was -> Result<(), ()> and unconditionally returned Ok(()), so the frontend had nothing to react to either.

Fix

  • set_hotkey returns Result<(), String> and reports the failure. On error the store is rolled back to the previous binding (and that binding re-registered), so what Settings shows matches what the OS actually has.
  • Ordering change worth flagging: the previous combination is now unregistered before the new one is registered. global-hotkey rejects an already-registered shortcut, so registering-then-unregistering would have broken re-binding an action to a combination another action had just released. I caught this by reading tauri-plugin-global-shortcut's register_internal after writing the first version the other way round.
  • Startup registration logs failures instead of dropping them — a shortcut claimed by another app since last launch is now diagnosable from the log rather than being a mystery.
  • Settings shows a toast and reverts the row.

On the generated bindings

tauri.ts is unchanged and that is expected, not an oversight: specta maps Result<(), String> to the same Promise<null> it already produced for Result<(), ()> — same shape as set_mic_input, which is also Result<(), String>. The difference is that the promise now rejects instead of resolving, which is what the new try/catch handles.

Verification

  • npx tsc --noEmit -p apps/desktop/tsconfig.json0 errors.
  • biome check clean; cargo fmt applied.
  • Confirmed solid-toast is a real dependency of apps/desktop and <Toaster> is mounted in app.tsx, so the error actually renders in the settings window rather than going nowhere.

What I could not do: I don't have a Windows machine, so I have not pressed PrintScreen against a running build — the OS-refusal path is reasoned from the global-hotkey API contract, not observed. cargo check doesn't complete in my environment either, for a reason unrelated to this change: the build script needs binaries/cap-muxer-aarch64-apple-darwin and target/native-deps/Spacedrive.framework, and it fails identically on unmodified main — I verified that by stashing. So the Rust side is compile-checked by review only, and worth a real build before merge.

…pping them

Every global-shortcut registration discarded its result with `.ok()`, so a
combination the OS rejects was still written to the store and rendered in
Settings as bound. Pressing it did nothing, with no error anywhere.

That is CapSoftware#1883: PrintScreen is reserved by Windows, so `register()` fails and
the binding silently never fires. The reporter read this as "can't bind the
print key", and separately concluded key combinations were unsupported --
they are (modifiers are captured and displayed), but any combination another
application already owns fails the same silent way.

- `set_hotkey` returns `Result<(), String>` and reports the failure. On error
  the store is rolled back to the previous binding so it matches what is
  actually registered with the OS.
- Unregister the previous combination *before* registering the new one. The
  underlying global-hotkey layer rejects an already-registered shortcut, so
  re-binding to a combination another action just released would otherwise
  fail.
- Startup registration logs failures rather than dropping them, so a shortcut
  claimed by another app since last run is diagnosable.
- Settings surfaces the error as a toast and reverts the row.

`Result<(), String>` generates the same `Promise<null>` binding as the
existing `Result<(), ()>` (matching `set_mic_input`), so tauri.ts is
unchanged; the promise now rejects instead of resolving.

Closes CapSoftware#1883
// another application claimed it since. Surface it in the log rather
// than starting up with a binding the user believes is active.
if let Err(e) = global_shortcut.register(Shortcut::from(*hotkey)) {
tracing::warn!(?action, ?hotkey, "failed to register stored hotkey: {e}");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you mind logging the error as a structured field here (easier to search/alert on than interpolating into the message)?

Suggested change
tracing::warn!(?action, ?hotkey, "failed to register stored hotkey: {e}");
tracing::warn!(?action, ?hotkey, error = %e, "failed to register stored hotkey");

match prev {
Some(prev) => {
store.hotkeys.insert(action, prev);
global_shortcut.register(Shortcut::from(prev)).ok();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the rollback path, register(prev).ok() can fail silently and bring back the same "Settings shows bound but OS didn’t take it" mismatch. Worth at least logging the restore failure.

Suggested change
global_shortcut.register(Shortcut::from(prev)).ok();
if let Err(restore_err) = global_shortcut.register(Shortcut::from(prev)) {
tracing::warn!(?action, prev = ?prev, "failed to restore previous hotkey after rollback: {restore_err}");
}

// back so it doesn't display as bound when
// pressing it will do nothing.
setHotkeys(action, previous);
toast.error(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the rejection comes back as an Error, it might be nicer to show error.message (right now non-string errors always fall back).

Suggested change
toast.error(
toast.error(
typeof error === "string"
? error
: error instanceof Error
? error.message
: "Could not register this shortcut.",
);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Can't bind some shortcuts

1 participant