系统托盘(TrayIcon)
系统托盘是桌面应用常见功能,允许应用在后台运行并提供快捷操作入口。Tauri 2.0 提供了完整的托盘 API:
use tauri::{
menu::{Menu, MenuItem},
tray::{TrayIcon, TrayIconBuilder, MouseButton, TrayIconEvent},
AppHandle, Manager, Runtime,
};
pub fn create_tray<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<()> {
// 构建托盘菜单
let menu = Menu::with_items(app, &[
&MenuItem::with_id(app, "show", "Show Window", true, None::<&str>)?,
&MenuItem::with_id(app, "settings", "Settings", true, None::<&str>)?,
&tauri::menu::PredefinedMenuItem::separator(app)?,
&MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?,
])?;
// 创建托盘图标
let tray = TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.tooltip("My Tauri App")
.on_menu_event(|app, event| {
match event.id.as_ref() {
"show" => {
if let Some(window) = app.get_webview_window("main") {
window.show().unwrap();
window.set_focus().unwrap();
}
}
"settings" => {
// 打开设置窗口
open_settings_window(app);
}
"quit" => {
app.exit(0);
}
_ => {}
}
})
.on_tray_icon_event(|tray, event| {
// 双击托盘图标时显示主窗口
if let TrayIconEvent::DoubleClick { button: MouseButton::Left, .. } = event {
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
window.show().unwrap();
window.set_focus().unwrap();
}
}
})
.build(app)?;
Ok(())
}
// 在 setup 钩子中初始化托盘
pub fn run() {
tauri::Builder::default()
.setup(|app| {
create_tray(app.handle())?;
Ok(())
})
.run(tauri::generate_context!())
.unwrap();
}
原生菜单(macOS 菜单栏)
use tauri::menu::{Menu, Submenu, MenuItem, PredefinedMenuItem};
fn build_app_menu<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<Menu<R>> {
let file_menu = Submenu::with_items(app, "File", true, &[
&MenuItem::with_id(app, "new", "New", true, Some("CmdOrCtrl+N"))?,
&MenuItem::with_id(app, "open", "Open...", true, Some("CmdOrCtrl+O"))?,
&PredefinedMenuItem::separator(app)?,
&MenuItem::with_id(app, "save", "Save", true, Some("CmdOrCtrl+S"))?,
])?;
let edit_menu = Submenu::with_items(app, "Edit", true, &[
&PredefinedMenuItem::undo(app, None)?,
&PredefinedMenuItem::redo(app, None)?,
&PredefinedMenuItem::separator(app)?,
&PredefinedMenuItem::copy(app, None)?,
&PredefinedMenuItem::paste(app, None)?,
])?;
Menu::with_items(app, &[&file_menu, &edit_menu])
}
系统通知
npm install @tauri-apps/plugin-notification
import {
sendNotification,
requestPermission,
isPermissionGranted,
} from '@tauri-apps/plugin-notification';
async function notify(title: string, body: string) {
// 检查并请求通知权限
let permission = await isPermissionGranted();
if (!permission) {
const result = await requestPermission();
permission = result === 'granted';
}
if (permission) {
sendNotification({
title,
body,
icon: 'icons/icon.png', // 可选:自定义图标
sound: 'default', // 可选:提示音
});
}
}
// 使用示例
await notify('下载完成', '文件已成功保存到文档目录');
多窗口管理
use tauri::{AppHandle, Manager, WebviewWindow, WebviewWindowBuilder, WebviewUrl};
// 打开设置窗口(如果已存在则聚焦)
fn open_settings_window(app: &AppHandle) {
if let Some(window) = app.get_webview_window("settings") {
// 已存在:显示并聚焦
window.show().unwrap();
window.set_focus().unwrap();
return;
}
// 创建新窗口
WebviewWindowBuilder::new(
app,
"settings", // 窗口唯一标签
WebviewUrl::App("/settings".into()), // 前端路由路径
)
.title("Settings")
.inner_size(600.0, 500.0)
.resizable(false)
.center()
.build()
.unwrap();
}
// 隐藏主窗口而非关闭(保留后台运行)
#[tauri::command]
fn hide_window(window: tauri::WebviewWindow) {
window.hide().unwrap();
}
import { getCurrentWebviewWindow, WebviewWindow } from '@tauri-apps/api/webviewWindow';
// 获取当前窗口
const appWindow = getCurrentWebviewWindow();
// 最小化
await appWindow.minimize();
// 最大化/还原
await appWindow.toggleMaximize();
// 隐藏(不关闭)
await appWindow.hide();
// 从前端创建新窗口
const aboutWindow = new WebviewWindow('about', {
url: '/about',
title: 'About',
width: 400,
height: 300,
resizable: false,
center: true,
});
run_on_main_thread
所有 UI 操作(窗口显示/隐藏/聚焦等)必须在主线程执行。从 Rust 的 async 上下文中调用 UI API 时,需要使用 app.run_on_main_thread(|| { /* UI 操作 */ }) 确保在正确的线程执行,否则可能触发 panic 或无响应。
系统托盘与窗口管理的工作原理
Tauri 如何与原生 OS API 交互
Tauri 的系统托盘、菜单、通知等功能背后,是对各平台原生 API 的抽象封装:
系统托盘的平台实现
Tauri 使用
tray-icon crate 作为底层,在 Windows 上调用 Win32 API(Shell_NotifyIcon),在 macOS 上使用 NSStatusBar,在 Linux 上支持 AppIndicator(GNOME)和 libayatana(KDE/Qt)。由于 Linux 桌面环境碎片化,托盘图标在 Linux 上的行为可能与 Windows/macOS 有细微差异。主线程要求(UI Thread Constraint)
几乎所有 GUI 工具包(Win32、Cocoa、GTK)都要求 UI 操作从主线程发起。这是一个历史约束:早期 GUI 框架没有线程安全设计。Tauri 通过
run_on_main_thread(同步)和事件循环消息(异步)将操作请求发送到主线程队列处理。多窗口架构
每个 Tauri 窗口是一个独立的 WebView 实例(WebView2 on Windows, WKWebView on macOS, WebKitGTK on Linux),有独立的 JavaScript 上下文。窗口间通信必须通过 Rust 后端(Events 系统)而不能直接访问对方的 JavaScript 全局变量。
通知的权限要求
macOS 从应用首次请求通知时弹出权限确认对话框,用户拒绝后只能在系统设置中手动开启。iOS/Android 的 tauri-plugin-notification 需要额外的权限声明(
NSUserNotificationUsageDescription 等)。用 isPermissionGranted() 在发送前检查权限状态。常见误区:托盘与窗口的陷阱
误区:关闭主窗口会退出应用
默认行为下,关闭最后一个窗口会终止 Tauri 应用。对于"关闭主窗口但继续后台运行"的应用(如音乐播放器、IM),需要监听
CloseRequested 事件并调用 event.preventDefault() + window.hide() 来阻止默认退出行为,改为隐藏到托盘。误区:从 Command 直接操作 UI
Tauri Command(
#[tauri::command])在 Tokio 线程池上执行,而不是主线程。在 Command 中直接调用 window.hide() 可能在非主线程执行,导致平台相关的问题(Windows 通常可以,macOS 会 panic)。解决:app_handle.run_on_main_thread(move || { window.hide().unwrap() })。关闭主窗口改为隐藏(后台运行)
许多桌面应用(即时通讯、音乐播放器、任务管理器)需要"关闭窗口但不退出应用"的行为。默认下,Tauri 关闭最后一个窗口会退出应用,需要拦截 CloseRequested 事件:
use tauri::{AppHandle, Manager, WindowEvent};
pub fn run() {
tauri::Builder::default()
.setup(|app| {
create_tray(app.handle())?;
Ok(())
})
.on_window_event(|window, event| {
// 拦截所有窗口的关闭请求
if let WindowEvent::CloseRequested { api, .. } = event {
if window.label() == "main" {
// 阻止默认关闭行为
api.prevent_close();
// 隐藏窗口(应用继续在后台运行)
window.hide().unwrap();
}
// settings 窗口等允许直接关闭
}
})
.run(tauri::generate_context!())
.unwrap();
}
在前端也可以监听 close-requested 事件(通过 Chapter 5 的 Events 系统),显示"确认退出"对话框再决定是否真正退出:
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
import { confirm } from '@tauri-apps/plugin-dialog';
import { listen } from '@tauri-apps/api/event';
const appWindow = getCurrentWebviewWindow();
// 监听 Rust 发出的"用户请求关闭"事件
await listen('close-requested', async () => {
const shouldClose = await confirm('确定要退出应用吗?', {
title: '退出确认',
kind: 'warning',
});
if (shouldClose) {
// 真正退出应用(触发 destroy 而非 hide)
await appWindow.destroy();
}
// 否则继续运行
});
上下文菜单(右键菜单)
Tauri 2.0 支持在 WebView 内容区域显示系统原生右键菜单:
use tauri::{AppHandle, Manager};
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
/// 在指定位置显示上下文菜单
#[tauri::command]
fn show_context_menu(app: AppHandle, window: tauri::WebviewWindow) {
let menu = Menu::with_items(&app, &[
&MenuItem::with_id(&app, "copy", "复制", true, Some("CmdOrCtrl+C")).unwrap(),
&MenuItem::with_id(&app, "paste", "粘贴", true, Some("CmdOrCtrl+V")).unwrap(),
&PredefinedMenuItem::separator(&app).unwrap(),
&MenuItem::with_id(&app, "delete", "删除", true, None::<&str>).unwrap(),
]).unwrap();
window.popup_menu(&menu).unwrap();
}
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
// 在元素上绑定右键菜单
document.addEventListener('contextmenu', async (e) => {
e.preventDefault(); // 阻止浏览器默认菜单
await invoke('show_context_menu');
});
// 监听菜单项点击
await listen('tauri://menu', (event) => {
console.log('Menu item clicked:', event.payload);
});
本章小结
本章核心要点
- 系统托盘通过平台原生 API 实现(Win32/NSStatusBar/AppIndicator),Linux 行为因桌面环境而异;TrayIconBuilder 构建托盘,on_menu_event/on_tray_icon_event 处理交互。
- 所有 UI 操作必须在主线程:在 Tokio 异步上下文中使用
run_on_main_thread发送 UI 操作请求,避免跨线程 UI 调用导致的 panic 或无响应。 - CloseRequested 拦截:通过
api.prevent_close()+window.hide()实现"关闭主窗口改为隐藏到托盘"的后台运行模式;前端也可通过事件监听显示确认对话框。 - 多窗口通信必须经过 Rust 后端:窗口间不能直接访问对方的 JS 上下文,用 Events 系统(emit/listen)传递数据;WebviewWindowBuilder 动态创建新窗口。
- 通知权限管理:macOS 需要用户授权,发送前检查
isPermissionGranted();iOS/Android 需要在项目配置中声明通知权限。 - 上下文菜单:通过
window.popup_menu()显示系统原生右键菜单,配合 contextmenu 事件监听替代浏览器默认菜单。