macOS 有一类应用,叫 menubar app,也就是只活在菜单栏上的 app。比如系统自带的 🛜 wifi、蓝牙开关、🔋 电量显示等。它其实算是 macOS 的 NSPanel 的一种。
我有在之前的文章里提到如何使用Tauri 主窗口实现一些悬浮窗之类的浮动控件功能,其实在 macOS 上完全可以把主窗口转换为NSPanel 来实现。
这类应用有以下特点:
- 浮在其他窗口之上,或者要使用时才显示。
- 应用通常不会出现在 Dock 中。
- 点击图标打开显示面板。
当然,其实更多的应用是正常应用再带一个 menubar app 的功能,二合为一。
Tauri 是否可以创建这类型的应用?答案是可以。
事实上,Tauri 的底层依赖在 macOS 平台上本身就是使用 cocoa 框架的 API 创建的窗口。自然而然地,我们也能通过 cocoa API 去创建 menubar app。
大致流程如下:
- 应用启动时创建一个隐藏的无边框透明窗口
- 在菜单栏创建一个托盘图标
- 用户点击托盘图标时,计算面板应该出现的位置(鼠标位置正下方)
- 显示面板,但不抢夺焦点(非激活面板)
- 当用户点击其他地方或切换应用时,自动隐藏面板
禁止全屏、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]](/_astro/Pasted%20image%2020250922145040.DoktY5Js_83QeM.webp)
一步到胃:
如果你有类似的需求,可以使用 tauri-nspanel 作者的一步到位的项目作为启动项目脚手架:
git clone https://github.com/ahkohd/tauri-macos-menubar-app-example.git