Skip to content
Open
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
51 changes: 46 additions & 5 deletions apps/desktop/src-tauri/src/hotkeys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,13 @@ pub fn init(app: &AppHandle) {
};

let global_shortcut = app.global_shortcut();
for hotkey in store.hotkeys.values() {
global_shortcut.register(Shortcut::from(*hotkey)).ok();
for (action, hotkey) in store.hotkeys.iter() {
// A shortcut stored on a previous run can stop being registrable, e.g.
// 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");

}
}

app.manage(Mutex::new(store));
Expand Down Expand Up @@ -258,27 +263,63 @@ async fn handle_hotkey(app: AppHandle, action: HotkeyAction) -> Result<(), Strin
#[tauri::command(async)]
#[specta::specta]
#[instrument(skip(app))]
pub fn set_hotkey(app: AppHandle, action: HotkeyAction, hotkey: Option<Hotkey>) -> Result<(), ()> {
pub fn set_hotkey(
app: AppHandle,
action: HotkeyAction,
hotkey: Option<Hotkey>,
) -> Result<(), String> {
let global_shortcut = app.global_shortcut();
let state = app.state::<HotkeysState>();
let mut store = state.lock().unwrap();

let prev = store.hotkeys.get(&action).cloned();

// Apply to the store first so the "is this combination still used by
// another action?" check below sees the post-change state, then reconcile
// the OS registrations.
if let Some(hotkey) = hotkey {
store.hotkeys.insert(action, hotkey);
} else {
store.hotkeys.remove(&action);
}

// Release the previous combination before registering the new one: the
// underlying global-hotkey layer rejects a shortcut that is already
// registered, so re-binding an action to a combination another action just
// gave up would otherwise fail.
if let Some(prev) = prev
&& !store.hotkeys.values().any(|h| h == &prev)
{
global_shortcut.unregister(Shortcut::from(prev)).ok();
if let Err(e) = global_shortcut.unregister(Shortcut::from(prev)) {
// Not fatal on its own; log it so a stale registration that keeps
// firing the old combination is diagnosable.
tracing::warn!(?action, prev = ?prev, "failed to unregister previous hotkey: {e}");
}
}

if let Some(hotkey) = hotkey {
global_shortcut.register(Shortcut::from(hotkey)).ok();
// The OS can refuse a shortcut: reserved by the system (PrintScreen on
// Windows) or already claimed by another application. Previously this
// was `.ok()`, so the binding was stored and shown in Settings while
// pressing it did nothing.
if let Err(e) = global_shortcut.register(Shortcut::from(hotkey)) {
tracing::warn!(?action, ?hotkey, "failed to register hotkey: {e}");

// Roll the store back so it matches what is actually registered.
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}");
}

}
None => {
store.hotkeys.remove(&action);
}
}

return Err(format!(
"Could not register this shortcut. It may already be in use by another application, or reserved by the system. ({e})"
));
}
}

Ok(())
Expand Down
26 changes: 21 additions & 5 deletions apps/desktop/src/routes/(window-chrome)/settings/hotkeys.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
Switch,
} from "solid-js";
import { createStore } from "solid-js/store";
import toast from "solid-toast";
import { hotkeysStore } from "~/store";

import {
Expand Down Expand Up @@ -141,14 +142,29 @@ function Inner(props: { initialStore: HotkeysStore | null }) {
class="w-fit"
type="button"
onBlur={(e) => console.log(e)}
onClick={(e) => {
onClick={async (e) => {
e.stopPropagation();

const action = item();
const binding = hotkeys[action] ?? null;
const previous = listening()?.prev;

setListening();
commands.setHotkey(
item(),
hotkeys[item()] ?? null,
);

try {
await commands.setHotkey(action, binding);
} catch (error) {
// The OS can refuse a shortcut (reserved, or
// already taken by another app). Roll the row
// 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.",
);

typeof error === "string"
? error
: "Could not register this shortcut.",
);
}
}}
>
<IconCapCircleCheck class="transition-colors text-gray-12 hover:text-gray-10 size-5" />
Expand Down