Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
128 changes: 128 additions & 0 deletions src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
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.
.output()
.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.
.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 == "" {
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.
Ok(None)
} else {
Ok(Some(binding))
}
} else {
Ok(None)
}
}
#[cfg(not(target_os = "linux"))]
{
Ok(None)
}
}
3 changes: 3 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
37 changes: 33 additions & 4 deletions src/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,37 @@ async openAppDataDir() : Promise<Result<null, string>> {
async checkAppleIntelligenceAvailable() : Promise<boolean> {
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<boolean> {
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<Result<null, string>> {
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<Result<string | null, string>> {
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<Result<ModelInfo[], string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_available_models") };
Expand Down Expand Up @@ -603,10 +634,8 @@ async updateRecordingRetentionPeriod(period: string) : Promise<Result<null, stri
}
},
/**
* Checks if the Mac is a laptop by detecting battery presence
*
* This uses pmset to check for battery information.
* Returns true if a battery is detected (laptop), false otherwise (desktop)
* Stub implementation for non-macOS platforms
* Always returns false since laptop detection is macOS-specific
*/
async isLaptop() : Promise<Result<boolean, string>> {
try {
Expand Down
154 changes: 154 additions & 0 deletions src/components/settings/HandyShortcut.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);
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.

// 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.
// 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("");

Comment on lines +370 to +381
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.
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(/<Control>/g, "Ctrl+")
.replace(/<Shift>/g, "Shift+")
.replace(/<Alt>/g, "Alt+")
.replace(/<Super>/g, "Super+")
.replace(/\+$/, "");
Comment on lines +403 to +408
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.
};

// Wayland-specific UI
if (isWayland && shortcutId === "transcribe") {
return (
<SettingContainer
title={translatedName}
description={t("settings.general.shortcut.wayland.description")}
descriptionMode={descriptionMode}
grouped={grouped}
disabled={disabled}
layout="vertical"
>
<div className="space-y-3">
<div className="flex items-start gap-2 p-3 bg-amber-500/10 border border-amber-500/30 rounded-lg">
<AlertCircle className="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-200">
{t("settings.general.shortcut.wayland.notice")}
</p>
</div>

<div className="flex items-center space-x-2">
{isConfiguringGnome ? (
<div
tabIndex={0}
onKeyDown={handleGnomeKeyDown}
onKeyUp={handleGnomeKeyUp}
className="px-3 py-2 text-sm font-semibold border border-logo-primary bg-logo-primary/30 rounded min-w-[180px] text-center focus:outline-none"
autoFocus
>
{gnomeRecordedKeys.length === 0
? t("settings.general.shortcut.pressKeys")
: gnomeRecordedKeys.join("+")}
</div>
) : (
<div
className="px-3 py-2 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 hover:bg-logo-primary/10 rounded cursor-pointer hover:border-logo-primary min-w-[180px] text-center"
onClick={startGnomeRecording}
>
{formatGnomeShortcut(gnomeShortcut)}
</div>
)}
</div>

<p className="text-xs text-mid-gray">
{t("settings.general.shortcut.wayland.hint")}
</p>
</div>
</SettingContainer>
);
}

return (
<SettingContainer
title={translatedName}
Expand Down
Loading
Loading