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)