diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 803cf6fa5..70b2d6a9c 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -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, 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 == "" { + Ok(None) + } else { + Ok(Some(binding)) + } + } else { + Ok(None) + } + } + #[cfg(not(target_os = "linux"))] + { + Ok(None) + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6472167d6..46dac98c4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -275,6 +275,9 @@ pub fn run() { commands::open_log_dir, commands::open_app_data_dir, commands::check_apple_intelligence_available, + commands::is_wayland_session, + commands::configure_gnome_shortcut, + commands::get_gnome_shortcut, commands::models::get_available_models, commands::models::get_model_info, commands::models::download_model, diff --git a/src/bindings.ts b/src/bindings.ts index 2877b1bf9..87ef1c3e5 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -350,6 +350,37 @@ async openAppDataDir() : Promise> { async checkAppleIntelligenceAvailable() : Promise { return await TAURI_INVOKE("check_apple_intelligence_available"); }, +/** + * 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 + */ +async isWaylandSession() : Promise { + return await TAURI_INVOKE("is_wayland_session"); +}, +/** + * Configure a GNOME keyboard shortcut to trigger Handy via SIGUSR2 + * This is needed on Wayland where global shortcuts don't work + */ +async configureGnomeShortcut(shortcut: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("configure_gnome_shortcut", { shortcut }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +/** + * Get current GNOME shortcut for Handy if configured + */ +async getGnomeShortcut() : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("get_gnome_shortcut") }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async getAvailableModels() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("get_available_models") }; @@ -603,10 +634,8 @@ async updateRecordingRetentionPeriod(period: string) : Promise> { try { diff --git a/src/components/settings/HandyShortcut.tsx b/src/components/settings/HandyShortcut.tsx index 0a715281a..c021c4e63 100644 --- a/src/components/settings/HandyShortcut.tsx +++ b/src/components/settings/HandyShortcut.tsx @@ -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 = ({ const bindings = getSetting("bindings") || {}; + // Wayland-specific state + const [isWayland, setIsWayland] = useState(false); + const [gnomeShortcut, setGnomeShortcut] = useState(null); + const [isConfiguringGnome, setIsConfiguringGnome] = useState(false); + const [gnomeRecordedKeys, setGnomeRecordedKeys] = useState([]); + + // 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 = ({ 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) { + // Convert to GNOME format: space + const gnomeFormat = gnomeRecordedKeys + .map((k) => { + const lower = k.toLowerCase(); + if (lower === "ctrl" || lower === "control") return ""; + if (lower === "shift") return ""; + if (lower === "alt") return ""; + if (lower === "super" || lower === "meta") return ""; + return k.toLowerCase(); + }) + .join(""); + + try { + const result = await commands.configureGnomeShortcut(gnomeFormat); + if (result.status === "ok") { + setGnomeShortcut(gnomeFormat); + toast.success(t("settings.general.shortcut.wayland.configured")); + } else { + toast.error(t("settings.general.shortcut.wayland.error")); + } + } catch (error) { + console.error("Failed to configure GNOME shortcut:", error); + toast.error(t("settings.general.shortcut.wayland.error")); + } + + setIsConfiguringGnome(false); + setGnomeRecordedKeys([]); + } + }; + + // Format GNOME shortcut for display + const formatGnomeShortcut = (shortcut: string | null): string => { + if (!shortcut) return t("settings.general.shortcut.wayland.notConfigured"); + return shortcut + .replace(//g, "Ctrl+") + .replace(//g, "Shift+") + .replace(//g, "Alt+") + .replace(//g, "Super+") + .replace(/\+$/, ""); + }; + + // Wayland-specific UI + if (isWayland && shortcutId === "transcribe") { + return ( + +
+
+ +

+ {t("settings.general.shortcut.wayland.notice")} +

+
+ +
+ {isConfiguringGnome ? ( +
+ {gnomeRecordedKeys.length === 0 + ? t("settings.general.shortcut.pressKeys") + : gnomeRecordedKeys.join("+")} +
+ ) : ( +
+ {formatGnomeShortcut(gnomeShortcut)} +
+ )} +
+ +

+ {t("settings.general.shortcut.wayland.hint")} +

+
+
+ ); + } + return ( = React.memo( ({ descriptionMode = "tooltip", grouped = false }) => { const { t } = useTranslation(); const { getSetting, updateSetting, isUpdating } = useSettings(); + const [isWayland, setIsWayland] = useState(false); + + // Detect Wayland session + useEffect(() => { + const checkWayland = async () => { + try { + const wayland = await commands.isWaylandSession(); + setIsWayland(wayland); + } catch (error) { + console.error("Error checking Wayland session:", error); + } + }; + checkWayland(); + }, []); const pttEnabled = getSetting("push_to_talk") || false; + // On Wayland, push-to-talk is not available (only toggle mode via SIGUSR2) + if (isWayland) { + return ( + {}} + isUpdating={false} + label={t("settings.general.pushToTalk.label")} + description={t("settings.general.pushToTalk.waylandDisabled")} + descriptionMode={descriptionMode} + grouped={grouped} + disabled={true} + /> + ); + } + return (