feat(slint): macOS/Windows system tray (tray-icon) + macOS Dock policy

Add the NSStatusItem (macOS) / Shell_NotifyIcon (Windows) tray via the
tray-icon crate: the same 5-item menu, left-click window toggle, a themed
icon (auto uses a macOS template image that adapts to the menu bar), a
static QBZ tooltip, and live icon-variant switching. tray_icon::TrayIcon is
!Send and (on macOS) must be built on the main thread with NSApplication
running, so it lives in a thread_local created via invoke_from_event_loop;
its events are drained from a slint::Timer.

macOS close-to-tray reuses the cross-platform Slint hide/show path. The
opt-in Hide Dock icon toggle switches the NSApplication activation policy to
.accessory while closed to the menu bar (.regular by default, Spotify-style)
via objc2-app-kit.

cc
This commit is contained in:
vicrodh committed 2026-06-06 02:15:40 -06:00
1 parent 8a78c2250f
commit a91d629c2b
5 files changed
+850 -74

No files matched your search

+597 -68
View File
File diff suppressed because it is too large. Load diff
+19
View File
@@ -67,5 +67,24 @@ qbz-credentials = { path = "../qbz-credentials" }
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
ksni = { version = "0.3", features = ["blocking"] } ksni = { version = "0.3", features = ["blocking"] }
# macOS (NSStatusItem) + Windows (Shell_NotifyIcon) tray via the tao team's
# tray-icon crate. Re-exports muda as tray_icon::menu. Windows is built but
# not shipped/tested.
[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies]
tray-icon = "0.24"
# macOS Dock-icon activation-policy toggle. Pinned to tray-icon's objc2 0.6 /
# app-kit 0.3 series (NOT winit's 0.5) so MainThreadMarker/NSApplication unify
# with tray-icon. setActivationPolicy is gated behind the NSRunningApplication
# feature.
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = [
"std",
"NSApplication",
"NSResponder",
"NSRunningApplication",
] }
[build-dependencies] [build-dependencies]
slint-build = "1.16" slint-build = "1.16"
+6 -1
View File
@@ -6637,11 +6637,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// with quit_on_last_window_closed = false (so a tray-hide keeps the app // with quit_on_last_window_closed = false (so a tray-hide keeps the app
// alive) — without this, the native close would leave a headless process. // alive) — without this, the native close would leave a headless process.
window.window().on_close_requested(move || { window.window().on_close_requested(move || {
if tray_settings::get().close_to_tray && tray::handle().is_some() { let settings = tray_settings::get();
if settings.close_to_tray && tray::handle().is_some() {
// Slint performs the hide (destroys the surface) for HideWindow; // Slint performs the hide (destroys the surface) for HideWindow;
// we only sync the shown flag so the next tray toggle shows it. // we only sync the shown flag so the next tray toggle shows it.
log::info!("[qbz-slint] close-to-tray (WM close): hiding to tray"); log::info!("[qbz-slint] close-to-tray (WM close): hiding to tray");
tray::set_window_shown(false); tray::set_window_shown(false);
// macOS: drop the Dock icon if the user opted in (no-op elsewhere).
if settings.mac_hide_dock {
tray::set_mac_dock_hidden(true);
}
slint::CloseRequestResponse::HideWindow slint::CloseRequestResponse::HideWindow
} else { } else {
log::info!("[qbz-slint] WM close requested: quitting"); log::info!("[qbz-slint] WM close requested: quitting");
+183
View File
@@ -0,0 +1,183 @@
//! macOS / Windows system tray via the `tray-icon` crate.
//!
//! macOS has no "system tray" — `tray-icon` gives us an `NSStatusItem` in the
//! menu bar. Unlike the Linux ksni path, `tray_icon::TrayIcon` is `!Send`
//! (`Rc<RefCell<..>>`) and on macOS MUST be built on the main thread with the
//! `NSApplication` already running, so the whole thing lives in a
//! `thread_local!` and is created via `slint::invoke_from_event_loop` (main
//! thread). Events arrive on global crossbeam channels which we drain from a
//! repeating `slint::Timer` (also main-thread).
//!
//! Faithful to the Tauri macOS tray: same 5-item menu, left-click toggles the
//! window, static "QBZ - Music Player" tooltip (no live track reflection on
//! macOS), icon theme switchable. close-to-tray itself is handled in the
//! cross-platform `on_close_requested` path (Slint hide/show). This module
//! adds the macOS Dock-icon activation-policy toggle on top.
use std::cell::RefCell;
use image::GenericImageView;
use tray_icon::menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem};
use tray_icon::{Icon, MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent};
use super::Runtime;
use crate::AppWindow;
// 44px assets (= 22pt @2x menu bar). Filename trap (shared with Linux):
// `tray-dark-*` holds the WHITE glyph, `tray-light-*` holds the BLACK glyph.
const ICON_COLOR: &[u8] = include_bytes!("../../icons/tray-color-44.png");
const ICON_WHITE: &[u8] = include_bytes!("../../icons/tray-dark-44.png");
const ICON_BLACK: &[u8] = include_bytes!("../../icons/tray-light-44.png");
thread_local! {
// Kept alive for the icon's lifetime; dropping the TrayIcon removes it.
static TRAY: RefCell<Option<TrayIcon>> = const { RefCell::new(None) };
// The event-pump timer; dropping it stops polling.
static PUMP_TIMER: RefCell<Option<slint::Timer>> = const { RefCell::new(None) };
}
/// Resolve the icon bytes + whether to render it as a macOS template image
/// (template = adapts to the light/dark menu bar automatically).
/// - "color" → full vinyl, not a template
/// - "mono-light" → white glyph (`tray-dark`), not a template
/// - "mono-dark" → black glyph (`tray-light`), not a template
/// - "auto"/other → black glyph as a template, so macOS adapts it
fn icon_for(theme: &str) -> (&'static [u8], bool) {
match theme {
"color" => (ICON_COLOR, false),
"mono-light" => (ICON_WHITE, false),
"mono-dark" => (ICON_BLACK, false),
_ => (ICON_BLACK, true),
}
}
fn decode(bytes: &[u8]) -> Option<Icon> {
let img = image::load_from_memory(bytes).ok()?;
let (w, h) = img.dimensions();
let rgba = img.into_rgba8().into_vec();
Icon::from_rgba(rgba, w, h).ok()
}
/// Build the menu-bar item + menu and start the event pump. MUST run on the
/// main thread (call via `slint::invoke_from_event_loop`).
pub fn create(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
theme_override: &str,
) {
let (bytes, is_template) = icon_for(theme_override);
let Some(icon) = decode(bytes) else {
log::error!("[tray] failed to decode menu-bar icon");
return;
};
let menu = Menu::new();
let play = MenuItem::with_id("play_pause", "Play/Pause", true, None);
let next = MenuItem::with_id("next", "Next Track", true, None);
let prev = MenuItem::with_id("previous", "Previous Track", true, None);
let sep1 = PredefinedMenuItem::separator();
let show = MenuItem::with_id("show_hide", "Show/Hide Window", true, None);
let sep2 = PredefinedMenuItem::separator();
let quit = MenuItem::with_id("quit", "Quit QBZ", true, None);
if let Err(e) =
menu.append_items(&[&play, &next, &prev, &sep1, &show, &sep2, &quit])
{
log::error!("[tray] failed to build menu: {e}");
return;
}
let tray = match TrayIconBuilder::new()
.with_icon(icon)
.with_tooltip("QBZ - Music Player")
.with_menu(Box::new(menu))
.with_menu_on_left_click(false)
.build()
{
Ok(t) => t,
Err(e) => {
log::error!("[tray] menu-bar item build failed: {e}");
return;
}
};
#[cfg(target_os = "macos")]
tray.set_icon_as_template(is_template);
#[cfg(not(target_os = "macos"))]
let _ = is_template;
TRAY.with(|t| *t.borrow_mut() = Some(tray));
// Drain tray + menu events on the main thread.
let timer = slint::Timer::default();
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(120),
move || pump(&runtime, &weak, &handle),
);
PUMP_TIMER.with(|t| *t.borrow_mut() = Some(timer));
log::info!("[tray] menu-bar item initialized (theme={theme_override})");
}
fn pump(runtime: &Runtime, weak: &slint::Weak<AppWindow>, handle: &tokio::runtime::Handle) {
while let Ok(ev) = MenuEvent::receiver().try_recv() {
match ev.id.0.as_str() {
"play_pause" => {
super::dispatch_play_pause(runtime.clone(), weak.clone(), handle.clone())
}
"next" => super::dispatch_next(runtime.clone(), weak.clone(), handle.clone()),
"previous" => super::dispatch_previous(runtime.clone(), weak.clone(), handle.clone()),
"show_hide" => super::toggle_window(weak),
"quit" => super::quit(),
other => log::debug!("[tray] unhandled menu id '{other}'"),
}
}
while let Ok(ev) = TrayIconEvent::receiver().try_recv() {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = ev
{
super::toggle_window(weak);
}
}
}
/// Re-theme the live menu-bar icon (called on the main thread).
pub fn set_icon_theme(theme: &str) {
let (bytes, is_template) = icon_for(theme);
let Some(icon) = decode(bytes) else { return };
TRAY.with(|t| {
if let Some(tray) = t.borrow().as_ref() {
let _ = tray.set_icon(Some(icon));
#[cfg(target_os = "macos")]
tray.set_icon_as_template(is_template);
#[cfg(not(target_os = "macos"))]
let _ = is_template;
}
});
}
/// Switch the macOS activation policy: `.accessory` hides the Dock icon
/// (menu-bar-only), `.regular` keeps it (Spotify default). Must run on the
/// main thread. No-op on Windows.
#[cfg(target_os = "macos")]
pub fn set_dock_icon_hidden(hidden: bool) {
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy};
let Some(mtm) = MainThreadMarker::new() else {
return;
};
let app = NSApplication::sharedApplication(mtm);
let policy = if hidden {
NSApplicationActivationPolicy::Accessory
} else {
NSApplicationActivationPolicy::Regular
};
app.setActivationPolicy(policy);
}
#[cfg(not(target_os = "macos"))]
pub fn set_dock_icon_hidden(_hidden: bool) {}
+45 -5
View File
@@ -25,6 +25,9 @@ use qbz_app::shell::AppRuntime;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
mod linux; mod linux;
#[cfg(any(target_os = "macos", target_os = "windows"))]
mod mac_win;
/// Shared runtime handle type used across the Slint app. /// Shared runtime handle type used across the Slint app.
pub(crate) type Runtime = Arc<AppRuntime<SlintAdapter>>; pub(crate) type Runtime = Arc<AppRuntime<SlintAdapter>>;
@@ -76,7 +79,14 @@ impl TrayHandle {
h.set_icon_theme(theme); h.set_icon_theme(theme);
return; return;
} }
#[cfg(not(target_os = "linux"))] #[cfg(any(target_os = "macos", target_os = "windows"))]
{
// The TrayIcon is !Send and lives on the main thread — re-theme it
// there.
let _ = slint::invoke_from_event_loop(move || mac_win::set_icon_theme(&theme));
return;
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
let _ = theme; let _ = theme;
} }
} }
@@ -130,11 +140,21 @@ pub fn init(
.expect("spawn tray init thread"); .expect("spawn tray init thread");
} }
#[cfg(not(target_os = "linux"))] #[cfg(any(target_os = "macos", target_os = "windows"))]
{
// tray-icon's TrayIcon is !Send and (on macOS) must be built on the
// main thread with NSApplication already running — create it on the
// Slint event loop.
let _ = slint::invoke_from_event_loop(move || {
mac_win::create(runtime, weak, handle, &theme_override);
let _ = TRAY.set(TrayHandle::default());
});
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{ {
// macOS / Windows tray lands with the CustomApplicationHandler slice.
let _ = (runtime, weak, handle, theme_override); let _ = (runtime, weak, handle, theme_override);
log::info!("[tray] no tray backend on this platform yet"); log::info!("[tray] no tray backend on this platform");
} }
} }
@@ -170,6 +190,9 @@ pub(crate) fn show_window(weak: &slint::Weak<AppWindow>) {
w.window().with_winit_window(|win| { w.window().with_winit_window(|win| {
win.focus_window(); win.focus_window();
}); });
// Restore the Dock icon when coming back from the menu bar.
#[cfg(target_os = "macos")]
mac_win::set_dock_icon_hidden(false);
}); });
} }
@@ -181,6 +204,11 @@ pub(crate) fn hide_window(weak: &slint::Weak<AppWindow>) {
if let Err(e) = w.hide() { if let Err(e) = w.hide() {
log::error!("[tray] window hide failed: {e}"); log::error!("[tray] window hide failed: {e}");
} }
// Spotify-style opt-in: drop the Dock icon while closed to the menu bar.
#[cfg(target_os = "macos")]
if crate::tray_settings::get().mac_hide_dock {
mac_win::set_dock_icon_hidden(true);
}
}); });
} }
@@ -191,6 +219,16 @@ pub(crate) fn set_window_shown(shown: bool) {
WINDOW_SHOWN.store(shown, Ordering::Relaxed); WINDOW_SHOWN.store(shown, Ordering::Relaxed);
} }
/// Apply the macOS Dock-icon activation policy (`.accessory` hides the Dock
/// icon, `.regular` keeps it). No-op off macOS. Must be called on the main
/// thread (it is, from the close handlers / window hide-show).
pub(crate) fn set_mac_dock_hidden(hidden: bool) {
#[cfg(target_os = "macos")]
mac_win::set_dock_icon_hidden(hidden);
#[cfg(not(target_os = "macos"))]
let _ = hidden;
}
/// Quit the whole app from a tray action (any thread). /// Quit the whole app from a tray action (any thread).
pub(crate) fn quit() { pub(crate) fn quit() {
log::info!("[tray] quit requested"); log::info!("[tray] quit requested");
@@ -270,7 +308,9 @@ pub(crate) fn dispatch_previous(
/// Step the local volume by `ticks` notches of 5% (positive = up). Mirrors the /// Step the local volume by `ticks` notches of 5% (positive = up). Mirrors the
/// Tauri `tray:volume_delta` handler. Local-only for now (remote-renderer /// Tauri `tray:volume_delta` handler. Local-only for now (remote-renderer
/// volume forwarding is a later refinement). /// volume forwarding is a later refinement). Linux-only: scroll-to-volume is a
/// StatusNotifierItem feature the macOS/Windows tray doesn't expose.
#[cfg(target_os = "linux")]
pub(crate) fn dispatch_volume_delta( pub(crate) fn dispatch_volume_delta(
runtime: Runtime, runtime: Runtime,
weak: slint::Weak<AppWindow>, weak: slint::Weak<AppWindow>,