Introduction
In modern desktop applications, shortcuts are an essential tool for enhancing user experience and productivity. Many applications allow users to perform specific actions through shortcuts. As a cross-platform desktop application framework, Tauri provides rich functionality to support global shortcuts.
This article introduces how to implement global shortcut functionality in Tauri, guiding you step-by-step to create a desktop application that supports global shortcuts.
Installing Dependencies
To get started, install the necessary dependencies:
pnpm tauri add global-shortcut pnpm tauri add store
After installation, you can use @tauri-apps/plugin-global-shortcut
in the frontend as follows:
import { register } from '@tauri-apps/plugin-global-shortcut'; // When using `"withGlobalTauri": true`, you may use: // const { register } = window.__TAURI__.globalShortcut; await register('CommandOrControl+Shift+C', () => { console.log('Shortcut triggered'); });
On the Rust side, the tauri-plugin-global-shortcut
plugin will also be available for use:
pub fn run() { tauri::Builder::default() .setup(|app| { #[cfg(desktop)] { use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState}; let ctrl_n_shortcut = Shortcut::new(Some(Modifiers::CONTROL), Code::KeyN); app.handle().plugin( tauri_plugin_global_shortcut::Builder::new().with_handler(move |_app, shortcut, event| { println!("{:?}", shortcut); if shortcut == &ctrl_n_shortcut { match event.state() { ShortcutState::Pressed => { println!("Ctrl-N Pressed!"); } ShortcutState::Released => { println!("Ctrl-N Released!"); } } } }) .build(), )?; app.global_shortcut().register(ctrl_n_shortcut)?; } Ok(()) }) .run(tauri::generate_context!()) .expect("error while running tauri application"); }
Permission Configuration
In the src-tauri/capabilities/default.json
file, add the following configuration:
{ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "main-capability", "description": "Capability for the main window", "windows": ["main"], "permissions": [ "global-shortcut:allow-is-registered", "global-shortcut:allow-register", "global-shortcut:allow-unregister", "global-shortcut:allow-unregister-all" ] }
Implement Global Shortcuts
Create a shortcut.rs
file in the src-tauri/src
directory:
use tauri::App; use tauri::AppHandle; use tauri::Manager; use tauri::Runtime; use tauri_plugin_global_shortcut::GlobalShortcutExt; use tauri_plugin_global_shortcut::Shortcut; use tauri_plugin_global_shortcut::ShortcutState; use tauri_plugin_store::JsonValue; use tauri_plugin_store::StoreExt; /// Name of the Tauri storage const COCO_TAURI_STORE: &str = "coco_tauri_store"; /// Key for storing global shortcuts const COCO_GLOBAL_SHORTCUT: &str = "coco_global_shortcut"; /// Default shortcut for macOS #[cfg(target_os = "macos")] const DEFAULT_SHORTCUT: &str = "command+shift+space"; /// Default shortcut for Windows and Linux #[cfg(any(target_os = "windows", target_os = "linux"))] const DEFAULT_SHORTCUT: &str = "ctrl+shift+space"; /// Set shortcut during application startup pub fn enable_shortcut(app: &App) { let store = app .store(COCO_TAURI_STORE) .expect("Creating the store should not fail"); // Use stored shortcut if it exists if let Some(stored_shortcut) = store.get(COCO_GLOBAL_SHORTCUT) { let stored_shortcut_str = match stored_shortcut { JsonValue::String(str) => str, unexpected_type => panic!( "COCO shortcuts should be stored as strings, found type: {} ", unexpected_type ), }; let stored_shortcut = stored_shortcut_str .parse::<Shortcut>() .expect("Stored shortcut string should be valid"); _register_shortcut_upon_start(app, stored_shortcut); // Register stored shortcut } else { // Use default shortcut if none is stored store.set( COCO_GLOBAL_SHORTCUT, JsonValue::String(DEFAULT_SHORTCUT.to_string()), ); let default_shortcut = DEFAULT_SHORTCUT .parse::<Shortcut>() .expect("Default shortcut should be valid"); _register_shortcut_upon_start(app, default_shortcut); // Register default shortcut } } /// Get the current stored shortcut as a string #[tauri::command] pub fn get_current_shortcut<R: Runtime>(app: AppHandle<R>) -> Result<String, String> { let shortcut = _get_shortcut(&app); Ok(shortcut) } /// Unregister the current shortcut in Tauri #[tauri::command] pub fn unregister_shortcut<R: Runtime>(app: AppHandle<R>) { let shortcut_str = _get_shortcut(&app); let shortcut = shortcut_str .parse::<Shortcut>() .expect("Stored shortcut string should be valid"); // Unregister the shortcut app.global_shortcut() .unregister(shortcut) .expect("Failed to unregister shortcut") } /// Change the global shortcut #[tauri::command] pub fn change_shortcut<R: Runtime>( app: AppHandle<R>, _window: tauri::Window<R>, key: String, ) -> Result<(), String> { println!("Key: {}", key); let shortcut = match key.parse::<Shortcut>() { Ok(shortcut) => shortcut, Err(_) => return Err(format!("Invalid shortcut {}", key)), }; // Store the new shortcut let store = app .get_store(COCO_TAURI_STORE) .expect("Store should already be loaded or created"); store.set(COCO_GLOBAL_SHORTCUT, JsonValue::String(key)); // Register the new shortcut _register_shortcut(&app, shortcut); Ok(()) } /// Helper function to register a shortcut, primarily for updating shortcuts fn _register_shortcut<R: Runtime>(app: &AppHandle<R>, shortcut: Shortcut) { let main_window = app.get_webview_window("main").unwrap(); // Register global shortcut and define its behavior app.global_shortcut() .on_shortcut(shortcut, move |_app, scut, event| { if scut == &shortcut { if let ShortcutState::Pressed = event.state() { // Toggle window visibility if main_window.is_visible().unwrap() { main_window.hide().unwrap(); // Hide window } else { main_window.show().unwrap(); // Show window main_window.set_focus().unwrap(); // Focus window } } } }) .map_err(|err| format!("Failed to register new shortcut '{}'", err)) .unwrap(); } /// Helper function to register shortcuts during application startup fn _register_shortcut_upon_start(app: &App, shortcut: Shortcut) { let window = app.get_webview_window("main").unwrap(); // Initialize global shortcut and set its handler app.handle() .plugin( tauri_plugin_global_shortcut::Builder::new() .with_handler(move |_app, scut, event| { if scut == &shortcut { if let ShortcutState::Pressed = event.state() { // Toggle window visibility if window.is_visible().unwrap() { window.hide().unwrap(); // Hide window } else { window.show().unwrap(); // Show window window.set_focus().unwrap(); // Focus window } } } }) .build(), ) .unwrap(); app.global_shortcut().register(shortcut).unwrap(); // Register global shortcut } /// Retrieve the stored global shortcut as a string pub fn _get_shortcut<R: Runtime>(app: &AppHandle<R>) -> String { let store = app .get_store(COCO_TAURI_STORE) .expect("Store should already be loaded or created"); match store .get(COCO_GLOBAL_SHORTCUT) .expect("Shortcut should already be stored") { JsonValue::String(str) => str, unexpected_type => panic!( "COCO shortcuts should be stored as strings, found type: {} ", unexpected_type ), } }
In the src-tauri/src/lib.rs
file, import and register:
mod shortcut; pub fn run() { let mut ctx = tauri::generate_context!(); tauri::Builder::default() .plugin(tauri_plugin_store::Builder::default().build()) .invoke_handler(tauri::generate_handler![ shortcut::change_shortcut, shortcut::unregister_shortcut, shortcut::get_current_shortcut, ]) .setup(|app| { init(app.app_handle()); shortcut::enable_shortcut(app); enable_autostart(app); Ok(()) }) .run(ctx) .expect("error while running tauri application"); }
At this point, the app has implemented the functionality to toggle its visibility using a global shortcut.
- The default shortcut for macOS is
command+shift+space
. - The default shortcut for Windows and Linux is
ctrl+shift+space
.
If the default shortcut conflicts with another application or the user has personal preferences, they can modify it.
Modify shortcut keys
Then you need to create a front-end interface to allow users to operate on the front-end interface.
import { useState, useEffect } from "react"; import { isTauri, invoke } from "@tauri-apps/api/core"; import { ShortcutItem } from "./ShortcutItem"; import { Shortcut } from "./shortcut"; import { useShortcutEditor } from "@/hooks/useShortcutEditor"; export default function GeneralSettings() { const [shortcut, setShortcut] = useState<Shortcut>([]); async function getCurrentShortcut() { try { const res: string = await invoke("get_current_shortcut"); console.log("DBG: ", res); setShortcut(res?.split("+")); } catch (err) { console.error("Failed to fetch shortcut:", err); } } useEffect(() => { getCurrentShortcut(); }, []); const changeShortcut = (key: Shortcut) => { setShortcut(key); if (key.length === 0) return; invoke("change_shortcut", { key: key?.join("+") }).catch((err) => { console.error("Failed to save hotkey:", err); }); }; const { isEditing, currentKeys, startEditing, saveShortcut, cancelEditing } = useShortcutEditor(shortcut, changeShortcut); const onEditShortcut = async () => { startEditing(); invoke("unregister_shortcut").catch((err) => { console.error("Failed to save hotkey:", err); }); }; const onCancelShortcut = async () => { cancelEditing(); invoke("change_shortcut", { key: shortcut?.join("+") }).catch((err) => { console.error("Failed to save hotkey:", err); }); }; const onSaveShortcut = async () => { saveShortcut(); }; return ( <div className="space-y-8"> <div> <h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4"> General Settings </h2> <div className="space-y-6"> <ShortcutItem shortcut={shortcut} isEditing={isEditing} currentKeys={currentKeys} onEdit={onEditShortcut} onSave={onSaveShortcut} onCancel={onCancelShortcut} /> </div> </div> </div> ); }
ShortcutItem.tsx
file:
import { formatKey, sortKeys } from "@/utils/keyboardUtils"; import { X } from "lucide-react"; interface ShortcutItemProps { shortcut: string[]; isEditing: boolean; currentKeys: string[]; onEdit: () => void; onSave: () => void; onCancel: () => void; } export function ShortcutItem({ shortcut, isEditing, currentKeys, onEdit, onSave, onCancel, }: ShortcutItemProps) { const renderKeys = (keys: string[]) => { const sortedKeys = sortKeys(keys); return sortedKeys.map((key, index) => ( <kbd key={index} className={`px-2 py-1 text-sm font-semibold rounded shadow-sm bg-gray-100 border-gray-300 text-gray-900 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-200`} > {formatKey(key)} </kbd> )); }; return ( <div className={`flex items-center justify-between p-4 rounded-lg bg-gray-50 dark:bg-gray-700`} > <div className="flex items-center gap-4"> {isEditing ? ( <> <div className="flex gap-1 min-w-[120px] justify-end"> {currentKeys.length > 0 ? ( renderKeys(currentKeys) ) : ( <span className={`italic text-gray-500 dark:text-gray-400`}> Press keys... </span> )} </div> <div className="flex gap-2"> <button onClick={onSave} disabled={currentKeys.length < 2} className={`px-3 py-1 text-sm rounded bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:text-white dark:hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed`} > Save </button> <button onClick={onCancel} className={`p-1 rounded text-gray-500 hover:text-gray-700 hover:bg-gray-200 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:bg-gray-600`} > <X className="w-4 h-4" /> </button> </div> </> ) : ( <> <div className="flex gap-1">{renderKeys(shortcut)}</div> <button onClick={onEdit} className={`px-3 py-1 text-sm rounded bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-600 dark:text-gray-200 dark:hover:bg-gray-500`} > Edit </button> </> )} </div> </div> ); }
hooks/useShortcutEditor.ts
file:
import { useState, useCallback, useEffect } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { Shortcut } from '@/components/Settings/shortcut'; import { normalizeKey, isModifierKey, sortKeys } from '@/utils/keyboardUtils'; const RESERVED_SHORTCUTS = [ ["Command", "C"], ["Command", "V"], ["Command", "X"], ["Command", "A"], ["Command", "Z"], ["Command", "Q"], // Windows/Linux ["Control", "C"], ["Control", "V"], ["Control", "X"], ["Control", "A"], ["Control", "Z"], // Coco ["Command", "I"], ["Command", "T"], ["Command", "N"], ["Command", "G"], ["Command", "O"], ["Command", "U"], ["Command", "M"], ["Command", "Enter"], ["Command", "ArrowLeft"], ["Command", "ArrowRight"], ["Command", "ArrowUp"], ["Command", "ArrowDown"], ["Command", "0"], ["Command", "1"], ["Command", "2"], ["Command", "3"], ["Command", "4"], ["Command", "5"], ["Command", "6"], ["Command", "7"], ["Command", "8"], ["Command", "9"], ]; export function useShortcutEditor(shortcut: Shortcut, onChange: (shortcut: Shortcut) => void) { console.log("shortcut", shortcut) const [isEditing, setIsEditing] = useState(false); const [currentKeys, setCurrentKeys] = useState<string[]>([]); const [pressedKeys] = useState(new Set<string>()); const startEditing = useCallback(() => { setIsEditing(true); setCurrentKeys([]); }, []); const saveShortcut = async () => { if (!isEditing || currentKeys.length < 2) return; const hasModifier = currentKeys.some(isModifierKey); const hasNonModifier = currentKeys.some(key => !isModifierKey(key)); if (!hasModifier || !hasNonModifier) return; console.log(111111, currentKeys) const isReserved = RESERVED_SHORTCUTS.some(reserved => reserved.length === currentKeys.length && reserved.every((key, index) => key.toLowerCase() === currentKeys[index].toLowerCase()) ); console.log(22222, isReserved) if (isReserved) { console.error("This is a system reserved shortcut"); return; } // Sort keys to ensure consistent order (modifiers first) const sortedKeys = sortKeys(currentKeys); onChange(sortedKeys); setIsEditing(false); setCurrentKeys([]); }; const cancelEditing = useCallback(() => { setIsEditing(false); setCurrentKeys([]); }, []); // Register key capture for editing state useHotkeys( '*', (e) => { if (!isEditing) return; e.preventDefault(); e.stopPropagation(); const key = normalizeKey(e.code); // Update pressed keys pressedKeys.add(key); setCurrentKeys(() => { const keys = Array.from(pressedKeys); let modifiers = keys.filter(isModifierKey); let nonModifiers = keys.filter(k => !isModifierKey(k)); if (modifiers.length > 2) { modifiers = modifiers.slice(0, 2) } if (nonModifiers.length > 2) { nonModifiers = nonModifiers.slice(0, 2) } // Combine modifiers and non-modifiers return [...modifiers, ...nonModifiers]; }); }, { enabled: isEditing, keydown: true, enableOnContentEditable: true }, [isEditing, pressedKeys] ); // Handle key up events useHotkeys( '*', (e) => { if (!isEditing) return; const key = normalizeKey(e.code); pressedKeys.delete(key); }, { enabled: isEditing, keyup: true, enableOnContentEditable: true }, [isEditing, pressedKeys] ); // Clean up editing state when component unmounts useEffect(() => { return () => { if (isEditing) { cancelEditing(); } }; }, [isEditing, cancelEditing]); return { isEditing, currentKeys, startEditing, saveShortcut, cancelEditing }; }
Summary
Through the introduction of this article, you can integrate global shortcuts into your Tauri application to provide users with a smoother operation experience. If you have not used Tauri yet, I hope you can have a deeper understanding of it through this article and start trying this feature in your own projects!
Open Source
Recently, I’ve been working on a project based on Tauri called Coco. It’s open source and under continuous improvement. I’d love your support—please give the project a free star 🌟!
This is my first Tauri project, and I’ve been learning while exploring. I look forward to connecting with like-minded individuals to share experiences and grow together!
- Official website: coco.rs/
- Frontend repo: github.com/infinilabs/coco-app
- Backend repo: github.com/infinilabs/coco-server
Thank you for your support and attention!
Top comments (0)