fix(slint): hand-rolled macOS tray menu (objc2 0.5) with correct icon size and click routing

Replace the tray-icon/muda macOS backend with a hand-rolled NSStatusItem +
NSMenu built directly on objc2 0.5 / app-kit 0.2 (the same objc2 generation
winit 0.30 uses), so the menu lives in winit's NSApplication runtime and the
target-action dispatches. tray-icon's bundled muda collided with the muda
Slint's winit backend already registers and never fired the menu items. This
drops the tray-icon dependency and its whole GTK tree from the lockfile.

- Menu-bar icon pinned to 18pt via NSImage::setSize; the 44px PNG was
  rendering at native pixel size (giant icon).
- The status button gets its own target-action firing on left AND right
  mouse-up: left-click toggles the window, right/control-click pops the menu
  transiently (setMenu -> performClick -> clear, the non-deprecated
  replacement for popUpStatusItemMenu:). The menu is no longer permanently
  attached, so a left-click no longer opens it.
- Add the NSEvent feature to objc2-app-kit.

cc
This commit is contained in:
vicrodh committed 2026-06-06 16:43:38 -06:00
1 parent 2a2328e6c6
commit b9434a5034
5 files changed
+442 -845

No files matched your search

+72 -598
View File
File diff suppressed because it is too large. Load diff
+24 -12
View File
@@ -68,23 +68,35 @@ 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.
# macOS menu-bar tray (NSStatusItem), hand-rolled directly on objc2 0.5 /
# app-kit 0.2 — the SAME objc2 generation winit 0.30 uses — so the NSMenu lives
# in winit's NSApplication runtime and `[NSApp sendAction:]` dispatches the menu
# item target-action. A second `muda` (from tray-icon) registers a duplicate
# `MudaMenuItem` objc class and either silently fails to dispatch (objc2 0.6) or
# panics at startup (objc2 0.5), so tray-icon/muda is dropped on macOS. Windows
# has no tray for now (untested/unshipped).
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = [
objc2 = "0.5"
objc2-foundation = { version = "0.2", default-features = false, features = [
"std",
"NSData",
"NSString",
] }
objc2-app-kit = { version = "0.2", default-features = false, features = [
"std",
"NSApplication",
"NSResponder",
"NSRunningApplication",
"NSStatusBar",
"NSStatusItem",
"NSStatusBarButton",
"NSMenu",
"NSMenuItem",
"NSImage",
"NSButton",
"NSControl",
"NSView",
"NSEvent",
] }
[build-dependencies]
-219
View File
@@ -1,219 +0,0 @@
//! 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));
// A bare `cargo run` binary is NOT a bundled .app. Without an explicit
// Regular activation policy + activation, macOS never dispatches
// NSStatusItem menu-item actions (the menu shows, items are enabled, but
// clicking them fires nothing — exactly the observed symptom). Forcing the
// app Regular + active makes AppKit route the menu item target-action.
#[cfg(target_os = "macos")]
ensure_regular_active_app();
// Menu clicks: use muda's event HANDLER, not channel polling. On macOS an
// open NSMenu runs a modal tracking loop that blocks the slint::Timer, so a
// polled MenuEvent would sit in the channel until the next loop wake — the
// "menu items do nothing" symptom. The handler fires immediately on the
// AppKit main thread; the dispatch helpers marshal onto / wake the Slint
// loop themselves (slint::Weak is Send+Sync, satisfying the handler bound).
let pump_weak = weak.clone();
MenuEvent::set_event_handler(Some(move |event: MenuEvent| {
log::info!("[tray] menu item activated: {}", event.id.0);
match event.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}'"),
}
}));
// Left-click on the menu-bar item: polled from a main-thread timer. No
// modal loop is involved for a plain icon click, so polling works (this is
// the path the user confirmed working).
let timer = slint::Timer::default();
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(120),
move || pump(&pump_weak),
);
PUMP_TIMER.with(|t| *t.borrow_mut() = Some(timer));
log::info!("[tray] menu-bar item initialized (theme={theme_override})");
}
fn pump(weak: &slint::Weak<AppWindow>) {
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;
}
});
}
/// Force the app to a Regular, active application so macOS dispatches
/// NSStatusItem menu-item actions (a bare `cargo run` binary otherwise isn't
/// treated as a foreground app and the menu's target-action never fires).
/// Main thread only.
#[cfg(target_os = "macos")]
fn ensure_regular_active_app() {
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy};
let Some(mtm) = MainThreadMarker::new() else {
return;
};
let app = NSApplication::sharedApplication(mtm);
app.setActivationPolicy(NSApplicationActivationPolicy::Regular);
app.activate();
}
/// 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) {}
+331
View File
@@ -0,0 +1,331 @@
//! macOS menu-bar tray (`NSStatusItem`), hand-rolled on objc2 0.5.
//!
//! Why not `tray-icon`/`muda`: Slint's `i-slint-backend-winit` bundles its own
//! `muda` and registers the `MudaMenuItem` objc class. A second `muda` (from
//! tray-icon) registering the same class either silently fails to dispatch the
//! menu item target-action (objc2 0.6) or panics at startup ("could not create
//! new class MudaMenuItem", objc2 0.5). So we build the `NSStatusItem` + its
//! `NSMenu` directly on objc2 0.5 / objc2-app-kit 0.2 — the SAME objc2 era
//! winit 0.30 uses — so the menu lives in winit's `NSApplication` runtime and
//! `[NSApp sendAction:to:from:]` routes our items' target-action.
//!
//! Everything here is main-thread only: the `NSStatusItem`, the menu, and the
//! `QbzTrayMenuTarget` instance are `!Send` (`thread_local!`). `create` is
//! invoked via `slint::invoke_from_event_loop` (main thread). The action
//! callback reads the clicked item's `tag` and routes to the shared dispatch
//! helpers in the parent `tray` module (those marshal back onto the Slint loop
//! / tokio runtime themselves; the captured `slint::Weak`, `Runtime`, and
//! `tokio::runtime::Handle` are all `Send + Sync`, kept in a process-global
//! `OnceLock`).
//!
//! Click behavior matches the Tauri tray (`show_menu_on_left_click(false)`):
//! the menu is NOT permanently attached to the status item. Instead the status
//! button gets its own target-action firing on both left and right mouse-up.
//! LEFT-click toggles the window; RIGHT-click (or control-click) pops the menu
//! up transiently (set menu → `performClick` → clear menu, the non-deprecated
//! replacement for `popUpStatusItemMenu:`).
use std::cell::RefCell;
use std::sync::OnceLock;
use objc2::rc::Retained;
use objc2::runtime::{AnyObject, NSObject};
use objc2::{declare_class, msg_send_id, mutability, sel, ClassType, DeclaredClass};
use objc2_app_kit::{
NSApplication, NSApplicationActivationPolicy, NSEventMask, NSEventModifierFlags, NSEventType,
NSImage, NSMenu, NSMenuItem, NSStatusBar, NSStatusItem, NSVariableStatusItemLength,
};
use objc2_foundation::{MainThreadMarker, NSData, NSInteger, NSSize, NSString};
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");
// Menu item tags → actions.
const TAG_PLAY_PAUSE: NSInteger = 1;
const TAG_NEXT: NSInteger = 2;
const TAG_PREVIOUS: NSInteger = 3;
const TAG_SHOW_HIDE: NSInteger = 4;
const TAG_QUIT: NSInteger = 5;
/// Process-global dispatch context. Set once by `create`. The captured types
/// (`Runtime` = `Arc<..>`, `slint::Weak`, `tokio::runtime::Handle`) are all
/// `Send + Sync`, so reading this from the AppKit action callback (main thread)
/// is sound.
static CTX: OnceLock<(Runtime, slint::Weak<AppWindow>, tokio::runtime::Handle)> = OnceLock::new();
thread_local! {
// Kept alive for the tray's lifetime; dropping the status item removes it
// from the menu bar. Both are `!Send`, main-thread only.
static STATUS_ITEM: RefCell<Option<Retained<NSStatusItem>>> = const { RefCell::new(None) };
static MENU_TARGET: RefCell<Option<Retained<QbzTrayMenuTarget>>> = const { RefCell::new(None) };
// The menu is NOT permanently attached to the status item (that would make
// a left-click pop it). It lives here and is only flashed onto the status
// item for the duration of a right/control-click pop-up.
static MENU: RefCell<Option<Retained<NSMenu>>> = const { RefCell::new(None) };
}
declare_class!(
struct QbzTrayMenuTarget;
unsafe impl ClassType for QbzTrayMenuTarget {
type Super = NSObject;
type Mutability = mutability::MainThreadOnly;
const NAME: &'static str = "QbzTrayMenuTarget";
}
impl DeclaredClass for QbzTrayMenuTarget {
type Ivars = ();
}
unsafe impl QbzTrayMenuTarget {
#[method(onMenuItem:)]
fn on_menu_item(&self, sender: Option<&NSMenuItem>) {
let tag = sender.map(|s| unsafe { s.tag() }).unwrap_or(0);
dispatch_tag(tag);
}
// Fires on left AND right mouse-up of the status-bar button (see
// `sendActionOn` in `create`). We inspect the current event to route:
// right-click / control-click → pop the menu; plain left-click → toggle.
#[method(onStatusButton:)]
fn on_status_button(&self, _sender: Option<&AnyObject>) {
handle_status_click();
}
}
);
impl QbzTrayMenuTarget {
fn new(mtm: MainThreadMarker) -> Retained<Self> {
let this = mtm.alloc().set_ivars(());
unsafe { msg_send_id![super(this), init] }
}
}
/// Route a clicked menu item's tag to the shared dispatch helpers.
fn dispatch_tag(tag: NSInteger) {
log::info!("[tray] menu item activated: tag={tag}");
let Some((runtime, weak, handle)) = CTX.get() else {
log::warn!("[tray] dispatch_tag: context not initialized");
return;
};
match tag {
TAG_PLAY_PAUSE => {
super::dispatch_play_pause(runtime.clone(), weak.clone(), handle.clone())
}
TAG_NEXT => super::dispatch_next(runtime.clone(), weak.clone(), handle.clone()),
TAG_PREVIOUS => super::dispatch_previous(runtime.clone(), weak.clone(), handle.clone()),
TAG_SHOW_HIDE => super::toggle_window(weak),
TAG_QUIT => super::quit(),
other => log::debug!("[tray] unhandled menu tag {other}"),
}
}
/// Status-bar button click router. Reads the current AppKit event: a
/// right-click or control-click pops the menu, a plain left-click toggles the
/// window. Main thread only (it's an AppKit action callback).
fn handle_status_click() {
let Some(mtm) = MainThreadMarker::new() else {
return;
};
let app = NSApplication::sharedApplication(mtm);
let (is_right, is_ctrl) = match app.currentEvent() {
Some(ev) => {
let ty = unsafe { ev.r#type() };
let mods = unsafe { ev.modifierFlags() };
(
ty == NSEventType::RightMouseUp,
mods.contains(NSEventModifierFlags::NSEventModifierFlagControl),
)
}
None => (false, false),
};
if is_right || is_ctrl {
pop_up_menu(mtm);
} else if let Some((_, weak, _)) = CTX.get() {
super::toggle_window(weak);
}
}
/// Pop the tray menu transiently. Non-deprecated replacement for
/// `popUpStatusItemMenu:`: flash the menu onto the status item, simulate a
/// click (which opens it modally), then detach it so a left-click doesn't open
/// it. Main thread only.
fn pop_up_menu(mtm: MainThreadMarker) {
STATUS_ITEM.with(|s| {
let Some(status_item) = s.borrow().as_ref().cloned() else {
return;
};
MENU.with(|m| {
if let Some(menu) = m.borrow().as_ref() {
unsafe { status_item.setMenu(Some(menu)) };
if let Some(button) = unsafe { status_item.button(mtm) } {
unsafe { button.performClick(None) };
}
unsafe { status_item.setMenu(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),
}
}
/// Build an `NSImage` from PNG bytes, marking it a template image when asked.
fn make_image(bytes: &[u8], is_template: bool) -> Option<Retained<NSImage>> {
let data = NSData::with_bytes(bytes);
let image = NSImage::initWithData(NSImage::alloc(), &data)?;
unsafe { image.setTemplate(is_template) };
// The PNG assets are 44px (22pt @2x). Without an explicit point size the
// menu bar renders them at native pixel size → a giant icon. Pin to the
// standard menu-bar glyph box (18pt; the bar is 22pt tall).
unsafe { image.setSize(NSSize::new(18.0, 18.0)) };
Some(image)
}
/// Apply the resolved icon to the status item's button.
fn apply_icon(status_item: &NSStatusItem, theme: &str, mtm: MainThreadMarker) {
let (bytes, is_template) = icon_for(theme);
let Some(image) = make_image(bytes, is_template) else {
log::error!("[tray] failed to decode menu-bar icon");
return;
};
if let Some(button) = unsafe { status_item.button(mtm) } {
unsafe { button.setImage(Some(&image)) };
}
}
/// Build the menu-bar item + menu. 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 Some(mtm) = MainThreadMarker::new() else {
log::error!("[tray] create called off the main thread");
return;
};
// Store the dispatch context (only the first init wins).
let _ = CTX.set((runtime, weak, handle));
// The action target. Held alive in a thread_local so the menu's weak
// target reference stays valid.
let target = QbzTrayMenuTarget::new(mtm);
let target_obj: &AnyObject = &target;
let action = sel!(onMenuItem:);
// Build the menu: 3 transport items, separator, show/hide, separator, quit.
let menu = NSMenu::new(mtm);
let empty_key = NSString::from_str("");
let make_item = |title: &str, tag: NSInteger| -> Retained<NSMenuItem> {
let item = unsafe {
NSMenuItem::initWithTitle_action_keyEquivalent(
mtm.alloc(),
&NSString::from_str(title),
Some(action),
&empty_key,
)
};
unsafe {
item.setTarget(Some(target_obj));
item.setTag(tag);
item.setEnabled(true);
}
item
};
menu.addItem(&make_item("Play/Pause", TAG_PLAY_PAUSE));
menu.addItem(&make_item("Next Track", TAG_NEXT));
menu.addItem(&make_item("Previous Track", TAG_PREVIOUS));
menu.addItem(&NSMenuItem::separatorItem(mtm));
menu.addItem(&make_item("Show/Hide Window", TAG_SHOW_HIDE));
menu.addItem(&NSMenuItem::separatorItem(mtm));
menu.addItem(&make_item("Quit QBZ", TAG_QUIT));
// Build the status item and wire the icon.
let status_bar = unsafe { NSStatusBar::systemStatusBar() };
let status_item = unsafe { status_bar.statusItemWithLength(NSVariableStatusItemLength) };
apply_icon(&status_item, theme_override, mtm);
// Do NOT attach the menu permanently (that makes any click open it). Give
// the status button its own action that fires on left AND right mouse-up;
// `handle_status_click` decides toggle-vs-menu. The menu is kept in the
// MENU thread_local and only flashed on for a right-click pop-up.
if let Some(button) = unsafe { status_item.button(mtm) } {
unsafe {
button.setTarget(Some(target_obj));
button.setAction(Some(sel!(onStatusButton:)));
button.sendActionOn(NSEventMask::LeftMouseUp | NSEventMask::RightMouseUp);
}
}
STATUS_ITEM.with(|s| *s.borrow_mut() = Some(status_item));
MENU_TARGET.with(|t| *t.borrow_mut() = Some(target));
MENU.with(|m| *m.borrow_mut() = Some(menu));
// A bare `cargo run` binary is NOT a bundled .app. Without an explicit
// Regular activation policy + activation, macOS treats the app as a
// background process and `[NSApp sendAction:]` may not route the menu item
// target-action. Force Regular + active.
ensure_regular_active_app(mtm);
log::info!("[tray] menu-bar item initialized (theme={theme_override})");
}
/// Re-theme the live menu-bar icon (called on the main thread).
pub fn set_icon_theme(theme: &str) {
let Some(mtm) = MainThreadMarker::new() else {
return;
};
STATUS_ITEM.with(|s| {
if let Some(status_item) = s.borrow().as_ref() {
apply_icon(status_item, theme, mtm);
}
});
}
/// Force the app to a Regular, active application so macOS dispatches the
/// `NSStatusItem` menu-item actions. Main thread only.
fn ensure_regular_active_app(mtm: MainThreadMarker) {
let app = NSApplication::sharedApplication(mtm);
app.setActivationPolicy(NSApplicationActivationPolicy::Regular);
#[allow(deprecated)]
app.activateIgnoringOtherApps(true);
}
/// Switch the macOS activation policy: `.accessory` hides the Dock icon
/// (menu-bar-only), `.regular` keeps it (Spotify default). Must run on the
/// main thread.
pub fn set_dock_icon_hidden(hidden: bool) {
let Some(mtm) = MainThreadMarker::new() else {
return;
};
let app = NSApplication::sharedApplication(mtm);
let policy = if hidden {
NSApplicationActivationPolicy::Accessory
} else {
NSApplicationActivationPolicy::Regular
};
app.setActivationPolicy(policy);
}
+15 -16
View File
@@ -25,8 +25,8 @@ use qbz_app::shell::AppRuntime;
#[cfg(target_os = "linux")]
mod linux;
#[cfg(any(target_os = "macos", target_os = "windows"))]
mod mac_win;
#[cfg(target_os = "macos")]
mod macos;
/// Shared runtime handle type used across the Slint app.
pub(crate) type Runtime = Arc<AppRuntime<SlintAdapter>>;
@@ -79,14 +79,14 @@ impl TrayHandle {
h.set_icon_theme(theme);
return;
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
#[cfg(target_os = "macos")]
{
// 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));
// The NSStatusItem is !Send and lives on the main thread — re-theme
// it there.
let _ = slint::invoke_from_event_loop(move || macos::set_icon_theme(&theme));
return;
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
let _ = theme;
}
}
@@ -140,18 +140,17 @@ pub fn init(
.expect("spawn tray init thread");
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
#[cfg(target_os = "macos")]
{
// 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.
// The NSStatusItem is !Send and 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);
macos::create(runtime, weak, handle, &theme_override);
let _ = TRAY.set(TrayHandle::default());
});
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
let _ = (runtime, weak, handle, theme_override);
log::info!("[tray] no tray backend on this platform");
@@ -192,7 +191,7 @@ pub(crate) fn show_window(weak: &slint::Weak<AppWindow>) {
});
// Restore the Dock icon when coming back from the menu bar.
#[cfg(target_os = "macos")]
mac_win::set_dock_icon_hidden(false);
macos::set_dock_icon_hidden(false);
});
}
@@ -207,7 +206,7 @@ pub(crate) fn hide_window(weak: &slint::Weak<AppWindow>) {
// 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);
macos::set_dock_icon_hidden(true);
}
});
}
@@ -224,7 +223,7 @@ pub(crate) fn set_window_shown(shown: bool) {
/// 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);
macos::set_dock_icon_hidden(hidden);
#[cfg(not(target_os = "macos"))]
let _ = hidden;
}