feat(linux): add Wayland support with GNOME system shortcuts#572
feat(linux): add Wayland support with GNOME system shortcuts#572juulieen wants to merge 2 commits intocjpais:mainfrom
Conversation
On Wayland, global shortcuts don't work natively. This adds: - Detection of Wayland session for UI decisions - GNOME keyboard shortcut configuration via gsettings (SIGUSR2) - Wayland-specific UI in settings with warning notice - Disable Push-to-Talk on Wayland (toggle mode only)
There was a problem hiding this comment.
Pull request overview
This PR adds Wayland support for Linux users by implementing GNOME system shortcuts as a workaround for Wayland's security restrictions on global shortcuts. The implementation detects Wayland sessions, disables Push-to-Talk mode (which requires holding keys), and provides a configuration UI for setting up GNOME keyboard shortcuts that send SIGUSR2 signals to toggle transcription.
Changes:
- Added Wayland session detection via
XDG_SESSION_TYPEenvironment variable - Implemented GNOME keyboard shortcut configuration using gsettings with custom keybindings
- Disabled Push-to-Talk functionality on Wayland with explanatory UI
- Added Wayland-specific settings UI with warning notices and shortcut configuration
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| src/i18n/locales/en/translation.json | Added translation strings for Wayland-specific UI messages, warnings, and Push-to-Talk disabled state |
| src/components/settings/PushToTalk.tsx | Added Wayland detection to disable Push-to-Talk mode with explanatory message |
| src/components/settings/HandyShortcut.tsx | Implemented Wayland-specific UI for GNOME shortcut configuration with key recording and display |
| src/bindings.ts | Added TypeScript bindings for Wayland detection and GNOME shortcut management commands |
| src-tauri/src/lib.rs | Registered new Tauri commands for Wayland support |
| src-tauri/src/commands/mod.rs | Implemented Wayland session detection and GNOME shortcut configuration via gsettings |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| return shortcut | ||
| .replace(/<Control>/g, "Ctrl+") | ||
| .replace(/<Shift>/g, "Shift+") | ||
| .replace(/<Alt>/g, "Alt+") | ||
| .replace(/<Super>/g, "Super+") | ||
| .replace(/\+$/, ""); |
There was a problem hiding this comment.
The formatGnomeShortcut function removes trailing '+' characters but this creates an incorrect display for shortcuts. If a shortcut is 'a', this will be formatted as 'Ctrl+Shift+a' correctly, but if there's a shortcut like '' (just a modifier), it would display as 'Ctrl' after removing the trailing '+'. However, the more critical issue is that this doesn't handle the case where the actual key comes after the modifiers. The replace operations assume modifiers are always followed by something, but the regex doesn't ensure proper parsing of the actual key portion after the modifiers.
| return shortcut | |
| .replace(/<Control>/g, "Ctrl+") | |
| .replace(/<Shift>/g, "Shift+") | |
| .replace(/<Alt>/g, "Alt+") | |
| .replace(/<Super>/g, "Super+") | |
| .replace(/\+$/, ""); | |
| // GNOME encodes modifiers as <Control><Shift><Alt><Super> followed by an optional key. | |
| // Parse out modifier tokens explicitly instead of relying on blind string replacement. | |
| const modifierTokenRegex = /<[^>]+>/g; | |
| const modifierTokens = shortcut.match(modifierTokenRegex) ?? []; | |
| // Remove modifier tokens from the original string to get the remaining key part. | |
| const keyPart = shortcut.replace(modifierTokenRegex, "").trim(); | |
| const formattedModifiers = modifierTokens | |
| .map((token) => { | |
| switch (token) { | |
| case "<Control>": | |
| return "Ctrl"; | |
| case "<Shift>": | |
| return "Shift"; | |
| case "<Alt>": | |
| return "Alt"; | |
| case "<Super>": | |
| return "Super"; | |
| default: | |
| // Fallback: strip angle brackets if present, otherwise keep as-is | |
| return token.replace(/^<|>$/g, ""); | |
| } | |
| }) | |
| .filter(Boolean); | |
| // Join modifiers and key (if present) with '+' without creating a trailing '+'. | |
| if (formattedModifiers.length === 0) { | |
| return keyPart || shortcut; | |
| } | |
| if (!keyPart) { | |
| return formattedModifiers.join("+"); | |
| } | |
| return `${formattedModifiers.join("+")}+${keyPart}`; |
| .map_err(|e| format!("Failed to set shortcut name: {}", e))?; | ||
|
|
||
| Command::new("gsettings") | ||
| .args(["set", base_path, "command", "pkill -SIGUSR2 -f handy"]) |
There was a problem hiding this comment.
The pkill command uses '-f handy' which matches against the full command line. This could potentially match and send SIGUSR2 to unintended processes that happen to have 'handy' in their command line arguments. Consider using a more specific process identifier or matching pattern to ensure only the intended Handy application process receives the signal. Alternatively, document this limitation for users.
| .args(["set", base_path, "command", "pkill -SIGUSR2 -f handy"]) | |
| .args(["set", base_path, "command", "pkill -SIGUSR2 -x handy"]) |
| // Convert to GNOME format: <Control><Shift>space | ||
| const gnomeFormat = gnomeRecordedKeys | ||
| .map((k) => { | ||
| const lower = k.toLowerCase(); | ||
| if (lower === "ctrl" || lower === "control") return "<Control>"; | ||
| if (lower === "shift") return "<Shift>"; | ||
| if (lower === "alt") return "<Alt>"; | ||
| if (lower === "super" || lower === "meta") return "<Super>"; | ||
| return k.toLowerCase(); | ||
| }) | ||
| .join(""); | ||
|
|
There was a problem hiding this comment.
The GNOME format conversion incorrectly places the final key at the end of the string with modifiers. According to GNOME's keybinding format, modifiers should wrap the actual key, but here non-modifier keys are simply appended. For example, if someone presses Ctrl+Shift+A, this would produce 'a' which is correct, but if the order of key events results in the regular key being processed first, it would produce 'a' which is invalid. The logic should separate modifiers from the actual key and ensure modifiers come first, followed by the key.
| // Convert to GNOME format: <Control><Shift>space | |
| const gnomeFormat = gnomeRecordedKeys | |
| .map((k) => { | |
| const lower = k.toLowerCase(); | |
| if (lower === "ctrl" || lower === "control") return "<Control>"; | |
| if (lower === "shift") return "<Shift>"; | |
| if (lower === "alt") return "<Alt>"; | |
| if (lower === "super" || lower === "meta") return "<Super>"; | |
| return k.toLowerCase(); | |
| }) | |
| .join(""); | |
| // Convert to GNOME format: modifiers first, then key (e.g. <Control><Shift>space) | |
| const modifiers: string[] = []; | |
| let mainKey = ""; | |
| for (const k of gnomeRecordedKeys) { | |
| const lower = k.toLowerCase(); | |
| if (lower === "ctrl" || lower === "control") { | |
| modifiers.push("<Control>"); | |
| } else if (lower === "shift") { | |
| modifiers.push("<Shift>"); | |
| } else if (lower === "alt") { | |
| modifiers.push("<Alt>"); | |
| } else if (lower === "super" || lower === "meta") { | |
| modifiers.push("<Super>"); | |
| } else { | |
| // Treat as the main key; if multiple non-modifiers, the last one wins | |
| mainKey = lower; | |
| } | |
| } | |
| const gnomeFormat = modifiers.join("") + (mainKey || ""); |
| const remainingKeys = gnomeRecordedKeys.filter((k) => k !== key); | ||
|
|
||
| // If all keys released, save the shortcut | ||
| if (gnomeRecordedKeys.length > 0) { |
There was a problem hiding this comment.
The logic for determining when to save the shortcut is incorrect. The condition checks if gnomeRecordedKeys.length is greater than 0, but this happens on every key up event while keys are still recorded. This means the shortcut will be saved when the first key is released, not when all keys are released. The check should verify that remainingKeys is empty to ensure all keys have been released before saving.
| if (gnomeRecordedKeys.length > 0) { | |
| if (gnomeRecordedKeys.length > 0 && remainingKeys.length === 0) { |
| const key = normalizeKey(rawKey); | ||
|
|
||
| // Remove from currently pressed keys check | ||
| const remainingKeys = gnomeRecordedKeys.filter((k) => k !== key); |
There was a problem hiding this comment.
The remainingKeys variable is computed but never used. This variable correctly tracks which keys remain pressed after a key is released, but the code doesn't update gnomeRecordedKeys with it or use it in any logic. This contributes to the bug where shortcuts are saved prematurely.
| .trim() | ||
| .trim_matches('\'') | ||
| .to_string(); | ||
| if binding.is_empty() || binding == "" { |
There was a problem hiding this comment.
The condition 'binding.is_empty() || binding == ""' is redundant. The is_empty() check already covers the case where binding equals an empty string. Remove the redundant '|| binding == ""' portion.
| if binding.is_empty() || binding == "" { | |
| if binding.is_empty() { |
| .map_err(|e| format!("Failed to set shortcut command: {}", e))?; | ||
|
|
||
| Command::new("gsettings") | ||
| .args(["set", base_path, "binding", &shortcut]) |
There was a problem hiding this comment.
The shortcut parameter is passed directly to gsettings without sanitization or validation. While gsettings should handle this safely, it's best practice to validate that the shortcut string conforms to the expected GNOME format (e.g., contains only valid modifier and key combinations like 'a') to prevent potential command injection or unexpected behavior.
|
@copilot open a new pull request to apply changes based on the comments in this thread |
Summary
Context
On Wayland, global shortcuts don't work natively due to security restrictions. This PR provides a workaround by allowing users to configure a GNOME system shortcut that sends SIGUSR2 to toggle transcription.
Test plan
🤖 Generated with Claude Code