Tauri(八)—— 实现全局快捷键功能

999 阅读12分钟

前言

在现代桌面应用中,快捷键是提高用户体验和工作效率的重要工具。许多应用程序都允许用户通过快捷键执行某些操作,Tauri 作为一个跨平台的桌面应用框架,也提供了丰富的功能来支持全局快捷键的实现。

本文将介绍如何在 Tauri 中实现全局快捷键功能,带领你一步步创建一个支持全局快捷键的桌面应用。

安装依赖

pnpm tauri add global-shortcut

pnpm tauri add store

安装完之后,在前端部分就能看到 @tauri-apps/plugin-global-shortcut,前端就可以类似的引入使用了:

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');
});

安装完之后,在Rust 部分就能看到 tauri-plugin-global-shortcut,Rsut 也可以使用了:

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");
}

权限配置

src-tauri/capabilities/default.json 文件中添加:

{
  "$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",
  ]
}

实现全局快捷键

src-tauri/src 目录下创建 shortcut.rs 文件:

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;

/// Tauri 存储的名称
const COCO_TAURI_STORE: &str = "coco_tauri_store";

/// 用来存储全局快捷键的键值
const COCO_GLOBAL_SHORTCUT: &str = "coco_global_shortcut";

/// macOS 默认的快捷键
#[cfg(target_os = "macos")]
const DEFAULT_SHORTCUT: &str = "command+shift+space";

/// Windows 和 Linux 默认的快捷键
#[cfg(any(target_os = "windows", target_os = "linux"))]
const DEFAULT_SHORTCUT: &str = "ctrl+shift+space";

/// 在应用启动时设置快捷键
pub fn enable_shortcut(app: &App) {
    let store = app
        .store(COCO_TAURI_STORE)
        .expect("创建存储时不应该失败");

    // 如果存在存储的快捷键,使用它
    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 快捷键应存储为字符串,发现类型: {} ",
                unexpected_type
            ),
        };
        let stored_shortcut = stored_shortcut_str
            .parse::<Shortcut>()
            .expect("存储的快捷键字符串应该有效");
        _register_shortcut_upon_start(app, stored_shortcut); // 注册存储的快捷键
    } else {
        // 如果没有存储快捷键,使用默认快捷键
        store.set(
            COCO_GLOBAL_SHORTCUT,
            JsonValue::String(DEFAULT_SHORTCUT.to_string()),
        );
        let default_shortcut = DEFAULT_SHORTCUT
            .parse::<Shortcut>()
            .expect("默认快捷键应该有效");
        _register_shortcut_upon_start(app, default_shortcut); // 注册默认快捷键
    }
}

/// 获取当前存储的快捷键,作为字符串返回
#[tauri::command]
pub fn get_current_shortcut<R: Runtime>(app: AppHandle<R>) -> Result<String, String> {
    let shortcut = _get_shortcut(&app);
    Ok(shortcut)
}

/// 获取当前快捷键并在 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("存储的快捷键字符串应该有效");

    // 取消注册快捷键
    app.global_shortcut()
        .unregister(shortcut)
        .expect("取消注册快捷键失败")
}

/// 更改全局快捷键
#[tauri::command]
pub fn change_shortcut<R: Runtime>(
    app: AppHandle<R>,
    _window: tauri::Window<R>,
    key: String,
) -> Result<(), String> {
    println!("按键: {}", key);
    let shortcut = match key.parse::<Shortcut>() {
        Ok(shortcut) => shortcut,
        Err(_) => return Err(format!("无效的快捷键 {}", key)),
    };

    // 存储新的快捷键
    let store = app
        .get_store(COCO_TAURI_STORE)
        .expect("存储应该已经加载或创建");
    store.set(COCO_GLOBAL_SHORTCUT, JsonValue::String(key));

    // 注册新的快捷键
    _register_shortcut(&app, shortcut);

    Ok(())
}

/// 注册快捷键的辅助函数,主要用于更新快捷键
fn _register_shortcut<R: Runtime>(app: &AppHandle<R>, shortcut: Shortcut) {
    let main_window = app.get_webview_window("main").unwrap();
    // 注册全局快捷键,按下快捷键时执行指定操作
    app.global_shortcut()
        .on_shortcut(shortcut, move |_app, scut, event| {
            if scut == &shortcut {
                if let ShortcutState::Pressed = event.state() {
                    // 判断窗口是否可见,进行切换显示状态
                    if main_window.is_visible().unwrap() {
                        main_window.hide().unwrap(); // 隐藏窗口
                    } else {
                        main_window.show().unwrap(); // 显示窗口
                        main_window.set_focus().unwrap(); // 设置窗口焦点
                    }
                }
            }
        })
        .map_err(|err| format!("注册新的快捷键失败 '{}'", err))
        .unwrap();
}

/// 注册快捷键的辅助函数,在应用启动时设置快捷键
fn _register_shortcut_upon_start(app: &App, shortcut: Shortcut) {
    let window = app.get_webview_window("main").unwrap();
    // 初始化全局快捷键并设置快捷键事件处理器
    app.handle()
        .plugin(
            tauri_plugin_global_shortcut::Builder::new()
                .with_handler(move |_app, scut, event| {
                    if scut == &shortcut {
                        if let ShortcutState::Pressed = event.state() {
                            // 判断窗口是否可见,进行切换显示状态
                            if window.is_visible().unwrap() {
                                window.hide().unwrap(); // 隐藏窗口
                            } else {
                                window.show().unwrap(); // 显示窗口
                                window.set_focus().unwrap(); // 设置窗口焦点
                            }
                        }
                    }
                })
                .build(),
        )
        .unwrap();
    app.global_shortcut().register(shortcut).unwrap(); // 注册全局快捷键
}

/// 获取存储的全局快捷键,返回为字符串格式
pub fn _get_shortcut<R: Runtime>(app: &AppHandle<R>) -> String {
    let store = app
        .get_store(COCO_TAURI_STORE)
        .expect("存储应该已经加载或创建");

    match store
        .get(COCO_GLOBAL_SHORTCUT)
        .expect("快捷键应已存储")
    {
        JsonValue::String(str) => str,
        unexpected_type => panic!(
            "COCO 快捷键应该存储为字符串,发现类型: {} ",
            unexpected_type
        ),
    }
}

src-tauri/src/lib.rs 文件中引入并注册:

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");
}

到此 APP 已经实现了 快捷键启动隐藏的功能了。

Mac 默认的快捷键是 command+shift+space

Windows 和 Linux 默认的快捷键 ctrl+shift+space

那么默认了,那用户电脑上使用有冲突,或者个人习惯问题,用户想修改呢?

修改快捷键

那就需要做一个前端界面,在前端界面上让用户操作。

image.png

import { useState, useEffect } from "react";
import { isTauri, invoke } from "@tauri-apps/api/core"; 
// 从 Tauri API 导入工具函数,isTauri 用于检测是否在 Tauri 环境中,invoke 用于调用后端 Rust 命令

import { ShortcutItem } from "./ShortcutItem"; // 引入自定义组件,用于渲染快捷键项
import { Shortcut } from "./shortcut"; // 引入 Shortcut 类型,定义快捷键的类型结构
import { useShortcutEditor } from "@/hooks/useShortcutEditor"; // 自定义 hook,用于快捷键的编辑逻辑

export default function GeneralSettings() {
  const [shortcut, setShortcut] = useState<Shortcut>([]); // 声明 state 变量 `shortcut`,用于存储当前的快捷键

  /**
   * 获取当前的快捷键
   * 调用 Tauri 的后端命令 `get_current_shortcut`
   * 将结果分割成数组并更新到 `shortcut` 状态中
   */
  async function getCurrentShortcut() {
    try {
      const res: string = await invoke("get_current_shortcut"); // 调用 Tauri 后端的 Rust 命令
      console.log("DBG: ", res); // 调试日志,打印获取的快捷键字符串
      setShortcut(res?.split("+")); // 将快捷键字符串分割成数组并存储
    } catch (err) {
      console.error("Failed to fetch shortcut:", err); // 捕获并打印获取快捷键失败的错误
    }
  }

  /**
   * 在组件加载时(mount)获取当前的快捷键
   */
  useEffect(() => {
    getCurrentShortcut(); // 调用获取快捷键的函数
  }, []);

  /**
   * 修改快捷键的逻辑
   * @param key - 新的快捷键数组
   */
  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); // 捕获并打印保存快捷键失败的错误
    });
  };

  // 使用自定义的快捷键编辑 hook,管理快捷键编辑的逻辑
  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); // 捕获并打印保存快捷键失败的错误
    });
  };

  /**
   * 保存快捷键的编辑
   * 调用 saveShortcut 以保存编辑后的快捷键
   */
  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 文件:

import { formatKey, sortKeys } from "@/utils/keyboardUtils"; // 导入工具函数,用于格式化和排序键值
import { X } from "lucide-react"; // 导入 X 图标,用于取消按钮的图标显示

// 定义组件的 props 类型
interface ShortcutItemProps {
  shortcut: string[]; // 当前的快捷键数组
  isEditing: boolean; // 是否处于编辑模式
  currentKeys: string[]; // 当前按下的快捷键
  onEdit: () => void; // 编辑快捷键的回调函数
  onSave: () => void; // 保存快捷键的回调函数
  onCancel: () => void; // 取消编辑的回调函数
}

// 定义 `ShortcutItem` 组件
export function ShortcutItem({
  shortcut,
  isEditing,
  currentKeys,
  onEdit,
  onSave,
  onCancel,
}: ShortcutItemProps) {
  /**
   * 渲染键值的函数
   * @param keys - 需要渲染的键值数组
   * @returns 渲染后的键值组件
   */
  const renderKeys = (keys: string[]) => {
    const sortedKeys = sortKeys(keys); // 对键值数组进行排序
    return sortedKeys.map((key, index) => (
      <kbd
        key={index} // 使用键值数组的索引作为唯一 key
        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} // 如果按下的键少于 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" /> {/* 使用 X 图标 */}
              </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 文件:

import { useState, useCallback, useEffect } from 'react'; // 导入 React hooks
import { useHotkeys } from 'react-hotkeys-hook'; // 导入用于捕获键盘快捷键的 hook

import { Shortcut } from '@/components/Settings/shortcut'; // 导入 Shortcut 类型
import { normalizeKey, isModifierKey, sortKeys } from '@/utils/keyboardUtils'; // 导入用于键盘操作的工具函数

// 定义保留的快捷键,这些快捷键是系统或应用预定义的,用户不能使用它们
const RESERVED_SHORTCUTS = [
  // 系统快捷键(Mac)
  ["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"],
];

// `useShortcutEditor` 是一个自定义 hook,用于处理快捷键编辑的逻辑
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; // 如果没有进入编辑状态或快捷键少于 2 个,不能保存

    // 检查是否包含修饰键(例如 Command 或 Control)
    const hasModifier = currentKeys.some(isModifierKey);
    const hasNonModifier = currentKeys.some(key => !isModifierKey(key));

    // 如果没有修饰键或者没有非修饰键,则不允许保存
    if (!hasModifier || !hasNonModifier) return;

    // 检查当前快捷键是否与保留的快捷键冲突
    const isReserved = RESERVED_SHORTCUTS.some(reserved =>
      reserved.length === currentKeys.length && // 确保长度一致
      reserved.every((key, index) => key.toLowerCase() === currentKeys[index].toLowerCase()) // 比较大小写不敏感
    );
    
    if (isReserved) {
      console.error("This is a system reserved shortcut"); // 如果是保留的快捷键,输出错误并停止保存
      return;
    }

    // 对快捷键进行排序,确保修饰键在前,其他键在后
    const sortedKeys = sortKeys(currentKeys);

    // 调用外部传入的 onChange 函数,通知父组件更新快捷键
    onChange(sortedKeys);

    // 结束编辑状态并清空当前按下的键
    setIsEditing(false);
    setCurrentKeys([]);
  };

  // 取消编辑,恢复之前的状态
  const cancelEditing = useCallback(() => {
    setIsEditing(false);
    setCurrentKeys([]); // 清空当前按下的键
  }, []);

  // 注册键盘事件以捕获按键
  useHotkeys(
    '*', // 捕获所有键
    (e) => {
      if (!isEditing) return; // 如果没有处于编辑状态,则不处理
      e.preventDefault(); // 阻止默认事件
      e.stopPropagation(); // 阻止事件冒泡

      const key = normalizeKey(e.code); // 标准化键名

      // 更新按下的键
      pressedKeys.add(key);

      setCurrentKeys(() => {
        const keys = Array.from(pressedKeys); // 获取所有按下的键
        let modifiers = keys.filter(isModifierKey); // 获取所有修饰键
        let nonModifiers = keys.filter(k => !isModifierKey(k)); // 获取所有非修饰键

        // 限制修饰键和非修饰键的数量最多为 2 个
        if (modifiers.length > 2) {
          modifiers = modifiers.slice(0, 2);
        }

        if (nonModifiers.length > 2) {
          nonModifiers = nonModifiers.slice(0, 2);
        }

        // 合并修饰键和非修饰键
        return [...modifiers, ...nonModifiers];
      });
    },
    {
      enabled: isEditing, // 只有在编辑状态下才启用
      keydown: true, // 捕获按下键
      enableOnContentEditable: true // 在内容可编辑区域启用
    },
    [isEditing, pressedKeys] // 依赖项:isEditing 和 pressedKeys
  );

  // 注册键盘松开事件
  useHotkeys(
    '*',
    (e) => {
      if (!isEditing) return; // 如果没有处于编辑状态,则不处理
      const key = normalizeKey(e.code); // 标准化键名
      pressedKeys.delete(key); // 从按下的键集合中删除松开的键
    },
    {
      enabled: isEditing, // 只有在编辑状态下才启用
      keyup: true, // 捕获松开键
      enableOnContentEditable: true // 在内容可编辑区域启用
    },
    [isEditing, pressedKeys] // 依赖项:isEditing 和 pressedKeys
  );

  // 清理编辑状态,当组件卸载时,取消编辑状态
  useEffect(() => {
    return () => {
      if (isEditing) {
        cancelEditing(); // 如果仍然处于编辑状态,则取消编辑
      }
    };
  }, [isEditing, cancelEditing]); // 依赖项:isEditing 和 cancelEditing

  // 返回的对象包含了编辑状态和快捷键的相关方法
  return {
    isEditing,
    currentKeys,
    startEditing,
    saveShortcut,
    cancelEditing
  };
}

小结

通过本文的介绍,你可以将全局快捷键集成到你的 Tauri 应用中,进而为用户提供更流畅的操作体验。如果你还没有使用过 Tauri,希望你能通过这篇文章对它有更深入的了解,并开始在自己的项目中尝试这一功能!

开源

最近,我正在基于 Tauri 开发一款项目,名为 Coco。目前已开源,项目仍在不断完善中,欢迎大家前往支持并为项目点亮免费的 star 🌟!

作为个人的第一个 Tauri 项目,开发过程中也是边探索边学习。希望能与志同道合的朋友一起交流经验、共同成长!

代码中如有问题或不足之处,期待小伙伴们的宝贵建议和指导!

非常感谢您的支持与关注!