Sep 17, 2025
3 min read
Tauri,
Rust,
macOS,

使用Tauri开发macOS菜单栏应用的完整指南

详细介绍如何使用Tauri框架在macOS上开发菜单栏应用,包括创建面板窗口、系统托盘图标、面板定位和显示/隐藏控制等关键技术实现。

macOS 有一类应用,叫 menubar app,也就是只活在菜单栏上的 app。比如系统自带的 🛜 wifi蓝牙开关🔋 电量显示等。它其实算是 macOS 的 NSPanel 的一种。

我有在之前的文章里提到如何使用Tauri 主窗口实现一些悬浮窗之类的浮动控件功能,其实在 macOS 上完全可以把主窗口转换为NSPanel 来实现。

这类应用有以下特点:

  1. 浮在其他窗口之上,或者要使用时才显示。
  2. 应用通常不会出现在 Dock 中。
  3. 点击图标打开显示面板。

当然,其实更多的应用是正常应用再带一个 menubar app 的功能,二合为一。

Tauri 是否可以创建这类型的应用?答案是可以。

事实上,Tauri 的底层依赖在 macOS 平台上本身就是使用 cocoa 框架的 API 创建的窗口。自然而然地,我们也能通过 cocoa API 去创建 menubar app。

大致流程如下:

  1. 应用启动时创建一个隐藏的无边框透明窗口
  2. 在菜单栏创建一个托盘图标
  3. 用户点击托盘图标时,计算面板应该出现的位置(鼠标位置正下方)
  4. 显示面板,但不抢夺焦点(非激活面板)
  5. 当用户点击其他地方或切换应用时,自动隐藏面板

禁止全屏、resize、窗口装饰,可见性,正常的 menubar app 是不显示的。

{
	"fullscreen": false,
	"resizable": false,
	"title": "menubar",
	"width": 350,
	"height": 400,
	"decorations": false,
	"transparent": true,
	"visible": false
  }

面板

Tauri 最大的实现难点是如何把 Tauri 默认的主窗口转换成 menubar app 的菜单栏面板。 这部分脏活可以使用下面这个 crate 实现:

tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
#[allow(non_upper_case_globals)]
const NSWindowStyleMaskNonActivatingPanel: i32 = 1 << 7;

/// 将指定的应用窗口转换为菜单栏面板
/// 
/// 该函数将主窗口转换为一个非激活面板,使其能够作为菜单栏应用显示,
/// 而不会抢占主应用的焦点。面板将被设置为可以存在于所有空间中,
/// 并在失去焦点时发出事件通知。
/// 
/// 参数:
/// - app_handle: Tauri 应用句柄,用于获取和操作主窗口
pub fn swizzle_to_menubar_panel(app_handle: &tauri::AppHandle) {
    let panel_delegate = panel_delegate!(SpotlightPanelDelegate {
        window_did_resign_key
    });
	// 获取主窗口
    let window = app_handle.get_webview_window("main").unwrap();

    let panel = window.to_panel().unwrap();

    let handle = app_handle.clone();

    // 设置面板代理监听器,当面板失去键盘焦点时发出事件
    panel_delegate.set_listener(Box::new(move |delegate_name: String| {
        if delegate_name.as_str() == "window_did_resign_key" {
            let _ = handle.emit("menubar_panel_did_resign_key", ());
        }
    }));

    // 设置面板层级高于主菜单栏
    panel.set_level(NSMainMenuWindowLevel + 1);

    // 设置面板样式为非激活面板
    panel.set_style_mask(NSWindowStyleMaskNonActivatingPanel);

    // 设置面板集合行为:可加入所有空间、固定位置、全屏辅助
    panel.set_collection_behaviour(
        NSWindowCollectionBehavior::NSWindowCollectionBehaviorCanJoinAllSpaces
            | NSWindowCollectionBehavior::NSWindowCollectionBehaviorStationary
            | NSWindowCollectionBehavior::NSWindowCollectionBehaviorFullScreenAuxiliary,
    );

    panel.set_delegate(panel_delegate);
}

创建系统托盘图标

Tauri 本身就提供了创建菜单栏的相关功能,因此自然也支持系统托盘图标,不过需要开启一些 features:

tauri = { version = "2.8.5", features = [
  "macos-private-api",
  "tray-icon",
  "image-png",
] }
/// 创建系统托盘图标
/// 
/// 该函数负责创建应用程序的系统托盘图标,并设置点击事件处理逻辑。
/// 当用户点击托盘图标时,会显示或隐藏应用程序面板。
/// 
/// # 参数
/// * `app_handle` - 应用程序句柄,用于访问应用程序的各种功能和资源
/// 
/// # 返回值
/// 返回一个 Result 类型,成功时包含创建的托盘图标对象,失败时包含错误信息
pub fn create(app_handle: &AppHandle) -> tauri::Result<TrayIcon> {
    let icon = Image::from_bytes(include_bytes!("../icons/tray.png"))?;

    TrayIconBuilder::with_id("tray")
        .icon(icon)
        .icon_as_template(true)
        .on_tray_icon_event(|tray, event| {
            let app_handle = tray.app_handle();

            // 处理托盘图标点击事件
            if let TrayIconEvent::Click { button_state, .. } = event {
                // 只处理鼠标按键抬起事件
                if button_state == MouseButtonState::Up {
                    let panel = app_handle.get_webview_panel("main").unwrap();

                    // 如果面板当前可见,则隐藏它
                    if panel.is_visible() {
                        panel.order_out(None);
                        return;
                    }

                    // 定位面板位置并显示
                    position_menubar_panel(app_handle, 0.0);

                    panel.show();
                }
            }
        })
        .build(app_handle)
}

菜单栏面板定位

当用户点击菜单栏时,需要在指定的鼠标点击位置显示菜单栏面板,这需要我们实现指定窗口定位在鼠标光标位置的上方。并且确保面板不会超出屏幕边界,如果面板右侧超出屏幕,则会相应调整位置。

/// 将菜单栏面板定位在屏幕上的适当位置
/// 
/// 该函数会将指定窗口定位在鼠标光标位置的上方,主要用于菜单栏面板的定位。
/// 它会确保面板不会超出屏幕边界,如果面板右侧超出屏幕,则会相应调整位置。
/// 
/// # 参数
/// 
/// * `app_handle` - Tauri 应用句柄,用于获取窗口实例
/// * `padding_top` - 顶部填充值,用于微调面板在垂直方向上的位置
pub fn position_menubar_panel(app_handle: &tauri::AppHandle, padding_top: f64) {
    let window = app_handle.get_webview_window("main").unwrap();

    // 获取鼠标光标所在的显示器信息
    let monitor = monitor::get_monitor_with_cursor().unwrap();

    // 获取显示器的缩放因子
    let scale_factor = monitor.scale_factor();

    // 获取显示器的可见区域
    let visible_area = monitor.visible_area();

    // 获取显示器位置并转换为逻辑坐标
    let monitor_pos = visible_area.position().to_logical::<f64>(scale_factor);

    // 获取显示器尺寸并转换为逻辑坐标
    let monitor_size = visible_area.size().to_logical::<f64>(scale_factor);

    // 获取当前鼠标位置
    let mouse_location: NSPoint = unsafe { msg_send![class!(NSEvent), mouseLocation] };

    // 获取窗口的原生句柄
    let handle: id = window.ns_window().unwrap() as _;

    // 获取窗口当前的框架信息
    let mut win_frame: NSRect = unsafe { msg_send![handle, frame] };

    // 设置窗口在Y轴上的位置,使其位于屏幕顶部下方且高度适配
    win_frame.origin.y = (monitor_pos.y + monitor_size.height) - win_frame.size.height;

    // 根据指定的顶部填充值调整Y轴位置
    win_frame.origin.y -= padding_top;

    // 计算并设置窗口在X轴上的位置,使其居中于鼠标位置,同时避免超出屏幕右边界
    win_frame.origin.x = {
        // 计算面板右侧位置
        let top_right = mouse_location.x + (win_frame.size.width / 2.0);

        // 检查面板右侧是否会超出屏幕右边界
        let is_offscreen = top_right > monitor_pos.x + monitor_size.width;

        if !is_offscreen {
            // 如果未超出屏幕,则将面板中心对齐到鼠标位置
            mouse_location.x - (win_frame.size.width / 2.0)
        } else {
            // 如果超出屏幕,则计算偏移量并调整位置
            let diff = top_right - (monitor_pos.x + monitor_size.width);

            mouse_location.x - (win_frame.size.width / 2.0) - diff
        }
    };

    // 应用新的框架位置到窗口,不立即刷新显示
    let _: () = unsafe { msg_send![handle, setFrame: win_frame display: NO] };
}

隐藏面板

fn hide_menubar_panel(app_handle: &tauri::AppHandle) {
	if check_menubar_frontmost() {
		return;
	}

	let panel = app_handle.get_webview_panel("main").unwrap();

	panel.order_out(None);
}

显示面板

pub fn show_menubar_panel(app_handle: tauri::AppHandle) {
    let panel = app_handle.get_webview_panel("main").unwrap();

    panel.show();
}

上面的代码会让主窗口代码变成面板窗口,比如,

function App() {
  useEffect(() => {
    invoke("init");
  }, []);

  return (
    <div className="container" style={{backgroundColor: "#ab54c8ff"}}>
      <h1>Menubar App</h1>
      <p>Hello World</p>
    </div>
  );
}

效果如下:

[Pasted image 20250922145040.png]

一步到胃:

如果你有类似的需求,可以使用 tauri-nspanel 作者的一步到位的项目作为启动项目脚手架:

git clone https://github.com/ahkohd/tauri-macos-menubar-app-example.git