Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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"])
.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 == "" {
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);

// If all keys released, save the shortcut
if (gnomeRecordedKeys.length > 0) {
// 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("");

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(/\+$/, "");
};

// 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="stacked"
>
<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 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