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:
5 files changed
+850
-74
No files matched your search
Generated
+597
-68
File diff suppressed because it is too large.
Load diff
@@ -67,5 +67,24 @@ qbz-credentials = { path = "../qbz-credentials" }
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
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]
|
||||
slint-build = "1.16"
|
||||
@@ -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
|
||||
// alive) — without this, the native close would leave a headless process.
|
||||
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;
|
||||
// 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");
|
||||
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
|
||||
} else {
|
||||
log::info!("[qbz-slint] WM close requested: quitting");
|
||||
|
||||
@@ -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) {}
|
||||
@@ -25,6 +25,9 @@ use qbz_app::shell::AppRuntime;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
mod mac_win;
|
||||
|
||||
/// Shared runtime handle type used across the Slint app.
|
||||
pub(crate) type Runtime = Arc<AppRuntime<SlintAdapter>>;
|
||||
|
||||
@@ -76,7 +79,14 @@ impl TrayHandle {
|
||||
h.set_icon_theme(theme);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -130,11 +140,21 @@ pub fn init(
|
||||
.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);
|
||||
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| {
|
||||
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() {
|
||||
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);
|
||||
}
|
||||
|
||||
/// 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).
|
||||
pub(crate) fn quit() {
|
||||
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
|
||||
/// 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(
|
||||
runtime: Runtime,
|
||||
weak: slint::Weak<AppWindow>,
|
||||
|
||||
Reference in new issue
Block a user