-
-
Notifications
You must be signed in to change notification settings - Fork 988
feat(linux): add Wayland support with GNOME system shortcuts #572
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -130,3 +130,131 @@ pub fn check_apple_intelligence_available() -> bool { | |||||
| false | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| /// Check if running on Wayland (Linux only) | ||||||
| /// Returns true if the system session is Wayland (regardless of GDK_BACKEND) | ||||||
| /// This is used for UI decisions about global shortcuts which don't work on Wayland | ||||||
| #[specta::specta] | ||||||
| #[tauri::command] | ||||||
| pub fn is_wayland_session() -> bool { | ||||||
| #[cfg(target_os = "linux")] | ||||||
| { | ||||||
| // Check the actual session type, ignoring GDK_BACKEND | ||||||
| // Global shortcuts don't work on Wayland even if we run the app with GDK_BACKEND=x11 | ||||||
| std::env::var("XDG_SESSION_TYPE") | ||||||
| .map(|v| v.to_lowercase() == "wayland") | ||||||
| .unwrap_or(false) | ||||||
| } | ||||||
| #[cfg(not(target_os = "linux"))] | ||||||
| { | ||||||
| false | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| /// Configure a GNOME keyboard shortcut to trigger Handy via SIGUSR2 | ||||||
| /// This is needed on Wayland where global shortcuts don't work | ||||||
| #[specta::specta] | ||||||
| #[tauri::command] | ||||||
| pub fn configure_gnome_shortcut(shortcut: String) -> Result<(), String> { | ||||||
| #[cfg(target_os = "linux")] | ||||||
| { | ||||||
| use std::process::Command; | ||||||
|
|
||||||
| // Get existing custom keybindings | ||||||
| let existing = Command::new("gsettings") | ||||||
| .args([ | ||||||
| "get", | ||||||
| "org.gnome.settings-daemon.plugins.media-keys", | ||||||
| "custom-keybindings", | ||||||
| ]) | ||||||
| .output() | ||||||
| .map_err(|e| format!("Failed to get existing keybindings: {}", e))?; | ||||||
|
|
||||||
| let existing_str = String::from_utf8_lossy(&existing.stdout).trim().to_string(); | ||||||
|
|
||||||
| // Check if handy keybinding already exists | ||||||
| let handy_path = "/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/handy/"; | ||||||
| let new_bindings = if existing_str == "@as []" || existing_str.is_empty() { | ||||||
| format!("['{}']", handy_path) | ||||||
| } else if existing_str.contains(handy_path) { | ||||||
| existing_str | ||||||
| } else { | ||||||
| // Add handy to existing list | ||||||
| let trimmed = existing_str.trim_matches(|c| c == '[' || c == ']'); | ||||||
| format!("[{}, '{}']", trimmed, handy_path) | ||||||
| }; | ||||||
|
|
||||||
| // Set the custom keybindings list | ||||||
| Command::new("gsettings") | ||||||
| .args([ | ||||||
| "set", | ||||||
| "org.gnome.settings-daemon.plugins.media-keys", | ||||||
| "custom-keybindings", | ||||||
| &new_bindings, | ||||||
| ]) | ||||||
| .output() | ||||||
| .map_err(|e| format!("Failed to set keybindings list: {}", e))?; | ||||||
|
|
||||||
| // Configure the handy shortcut | ||||||
| let base_path = | ||||||
| "org.gnome.settings-daemon.plugins.media-keys.custom-keybinding:/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/handy/"; | ||||||
|
|
||||||
| Command::new("gsettings") | ||||||
| .args(["set", base_path, "name", "Handy Transcribe"]) | ||||||
| .output() | ||||||
| .map_err(|e| format!("Failed to set shortcut name: {}", e))?; | ||||||
|
|
||||||
| Command::new("gsettings") | ||||||
| .args(["set", base_path, "command", "pkill -SIGUSR2 -f handy"]) | ||||||
| .output() | ||||||
| .map_err(|e| format!("Failed to set shortcut command: {}", e))?; | ||||||
|
|
||||||
| Command::new("gsettings") | ||||||
| .args(["set", base_path, "binding", &shortcut]) | ||||||
|
||||||
| .output() | ||||||
| .map_err(|e| format!("Failed to set shortcut binding: {}", e))?; | ||||||
|
|
||||||
| Ok(()) | ||||||
| } | ||||||
| #[cfg(not(target_os = "linux"))] | ||||||
| { | ||||||
| let _ = shortcut; | ||||||
| Err("GNOME shortcuts are only available on Linux".to_string()) | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| /// Get current GNOME shortcut for Handy if configured | ||||||
| #[specta::specta] | ||||||
| #[tauri::command] | ||||||
| pub fn get_gnome_shortcut() -> Result<Option<String>, String> { | ||||||
| #[cfg(target_os = "linux")] | ||||||
| { | ||||||
| use std::process::Command; | ||||||
|
|
||||||
| let base_path = | ||||||
| "org.gnome.settings-daemon.plugins.media-keys.custom-keybinding:/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/handy/"; | ||||||
|
|
||||||
| let output = Command::new("gsettings") | ||||||
| .args(["get", base_path, "binding"]) | ||||||
| .output() | ||||||
| .map_err(|e| format!("Failed to get shortcut: {}", e))?; | ||||||
|
|
||||||
| if output.status.success() { | ||||||
| let binding = String::from_utf8_lossy(&output.stdout) | ||||||
| .trim() | ||||||
| .trim_matches('\'') | ||||||
| .to_string(); | ||||||
| if binding.is_empty() || binding == "" { | ||||||
|
||||||
| if binding.is_empty() || binding == "" { | |
| if binding.is_empty() { |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -12,6 +12,7 @@ import { SettingContainer } from "../ui/SettingContainer"; | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { useSettings } from "../../hooks/useSettings"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { commands } from "@/bindings"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { toast } from "sonner"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { AlertCircle } from "lucide-react"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| interface HandyShortcutProps { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| descriptionMode?: "inline" | "tooltip"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -40,6 +41,32 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const bindings = getSetting("bindings") || {}; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Wayland-specific state | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const [isWayland, setIsWayland] = useState(false); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const [gnomeShortcut, setGnomeShortcut] = useState<string | null>(null); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const [isConfiguringGnome, setIsConfiguringGnome] = useState(false); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const [gnomeRecordedKeys, setGnomeRecordedKeys] = useState<string[]>([]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Detect Wayland session | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const checkWayland = async () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const wayland = await commands.isWaylandSession(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setIsWayland(wayland); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (wayland) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Get current GNOME shortcut | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const result = await commands.getGnomeShortcut(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (result.status === "ok" && result.data) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setGnomeShortcut(result.data); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.error("Error checking Wayland session:", error); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| checkWayland(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, []); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Detect and store OS type | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const detectOsType = async () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -304,6 +331,133 @@ export const HandyShortcut: React.FC<HandyShortcutProps> = ({ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| binding.description, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Handle GNOME shortcut recording for Wayland | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const startGnomeRecording = () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setIsConfiguringGnome(true); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setGnomeRecordedKeys([]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const handleGnomeKeyDown = async (e: React.KeyboardEvent) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!isConfiguringGnome) return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| e.preventDefault(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (e.key === "Escape") { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setIsConfiguringGnome(false); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setGnomeRecordedKeys([]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const rawKey = getKeyName(e.nativeEvent, osType); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const key = normalizeKey(rawKey); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!gnomeRecordedKeys.includes(key)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setGnomeRecordedKeys((prev) => [...prev, key]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const handleGnomeKeyUp = async (e: React.KeyboardEvent) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!isConfiguringGnome) return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| e.preventDefault(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const rawKey = getKeyName(e.nativeEvent, osType); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const key = normalizeKey(rawKey); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Remove from currently pressed keys check | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const remainingKeys = gnomeRecordedKeys.filter((k) => k !== key); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // If all keys released, save the shortcut | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (gnomeRecordedKeys.length > 0) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (gnomeRecordedKeys.length > 0) { | |
| if (gnomeRecordedKeys.length > 0 && remainingKeys.length === 0) { |
Copilot
AI
Jan 12, 2026
There was a problem hiding this comment.
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.
| // 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
AI
Jan 12, 2026
There was a problem hiding this comment.
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.
| 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}`; |
There was a problem hiding this comment.
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.