Skip to content

feat(linux): add Wayland support with GNOME system shortcuts#572

Open
juulieen wants to merge 2 commits intocjpais:mainfrom
juulieen:support-ubuntu-24
Open

feat(linux): add Wayland support with GNOME system shortcuts#572
juulieen wants to merge 2 commits intocjpais:mainfrom
juulieen:support-ubuntu-24

Conversation

@juulieen
Copy link

@juulieen juulieen commented Jan 12, 2026

Summary

  • Add detection of Wayland session for UI decisions
  • Add GNOME keyboard shortcut configuration via gsettings (uses SIGUSR2 signal)
  • Add Wayland-specific UI in settings with warning notice explaining the limitation
  • Disable Push-to-Talk on Wayland (only toggle mode available)

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

  • Test on Wayland session (Ubuntu 24.04+)
  • Verify GNOME shortcut configuration works
  • Verify Push-to-Talk is disabled with explanation
  • Verify non-Wayland systems are unaffected

🤖 Generated with Claude Code

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)
Copilot AI review requested due to automatic review settings January 12, 2026 11:11
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

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_TYPE environment 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.

Comment on lines +403 to +408
return shortcut
.replace(/<Control>/g, "Ctrl+")
.replace(/<Shift>/g, "Shift+")
.replace(/<Alt>/g, "Alt+")
.replace(/<Super>/g, "Super+")
.replace(/\+$/, "");
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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}`;

Copilot uses AI. Check for mistakes.
.map_err(|e| format!("Failed to set shortcut name: {}", e))?;

Command::new("gsettings")
.args(["set", base_path, "command", "pkill -SIGUSR2 -f handy"])
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
.args(["set", base_path, "command", "pkill -SIGUSR2 -f handy"])
.args(["set", base_path, "command", "pkill -SIGUSR2 -x handy"])

Copilot uses AI. Check for mistakes.
Comment on lines +370 to +381
// 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("");

Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
// 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 || "");

Copilot uses AI. Check for mistakes.
const remainingKeys = gnomeRecordedKeys.filter((k) => k !== key);

// If all keys released, save the shortcut
if (gnomeRecordedKeys.length > 0) {
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
if (gnomeRecordedKeys.length > 0) {
if (gnomeRecordedKeys.length > 0 && remainingKeys.length === 0) {

Copilot uses AI. Check for mistakes.
const key = normalizeKey(rawKey);

// Remove from currently pressed keys check
const remainingKeys = gnomeRecordedKeys.filter((k) => k !== key);
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
.trim()
.trim_matches('\'')
.to_string();
if binding.is_empty() || binding == "" {
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
if binding.is_empty() || binding == "" {
if binding.is_empty() {

Copilot uses AI. Check for mistakes.
.map_err(|e| format!("Failed to set shortcut command: {}", e))?;

Command::new("gsettings")
.args(["set", base_path, "binding", &shortcut])
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

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

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 uses AI. Check for mistakes.
@juulieen
Copy link
Author

@copilot open a new pull request to apply changes based on the comments in this thread

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.

1 participant