Chapter 07

系统托盘、菜单与通知

构建专业桌面应用体验:系统托盘、原生菜单、通知与多窗口管理

系统托盘(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);
});

本章小结

本章核心要点