diff --git a/crates/Cargo.lock b/crates/Cargo.lock index e11b41cd1..536df6797 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -6624,6 +6624,8 @@ dependencies = [ "ksni", "libc", "log", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", "open", "qbz-app", "qbz-audio", diff --git a/crates/qbz-qt/Cargo.toml b/crates/qbz-qt/Cargo.toml index c8d091b6a..acd068eea 100644 --- a/crates/qbz-qt/Cargo.toml +++ b/crates/qbz-qt/Cargo.toml @@ -157,3 +157,35 @@ ksni = { version = "0.3", default-features = false, features = ["blocking", "asy [build-dependencies] cxx-qt-build = "0.7" + +# macOS only: the Dock-icon activation policy behind the "Hide Dock icon when +# closed to menu bar" setting (Spotify behaviour — close puts the app in the +# menu bar and drops the Dock icon; minimize still goes to the Dock). +# +# objc2 0.5 / objc2-app-kit 0.2 are pinned to the SAME generation the Slint +# crate uses (crates/qbz/Cargo.toml:166-172). That matters even though this +# crate has no winit: mixing objc2 generations in one binary registers +# duplicate objc classes, which is the failure the reference documents at +# length. Only the two items `set_mac_dock_hidden` names are enabled. +# +# ⚠ NOT COMPILED OR TESTED HERE. This tree builds on Linux; the macOS arm is +# behind cfg, so `cargo build` cannot see it. First real check is a macOS +# build — the Mac mini (192.168.0.69) is where that happens. +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.5" +objc2-app-kit = { version = "0.2", default-features = false, features = [ + "std", + # NSApplication: the class and `setActivationPolicy`. + "NSApplication", + # NSResponder: `sharedApplication` is behind `cfg(feature = "NSResponder")` + # (objc2-app-kit-0.2.2/src/generated/NSApplication.rs:326-329) — it is + # NSApplication's superclass, and the NSApplication feature does NOT pull + # it. + "NSResponder", + # NSRunningApplication: where `NSApplicationActivationPolicy` actually + # lives (…/src/generated/NSRunningApplication.rs:33-35), NOT in + # NSApplication.rs as the name suggests. Verified by reading the crate + # rather than by compiling — this arm cannot be built on Linux, and the + # reference's own proven set (crates/qbz/Cargo.toml) lists the same three. + "NSRunningApplication", +] } diff --git a/crates/qbz-qt/src/tray_bridge.rs b/crates/qbz-qt/src/tray_bridge.rs index f99518719..acd524573 100644 --- a/crates/qbz-qt/src/tray_bridge.rs +++ b/crates/qbz-qt/src/tray_bridge.rs @@ -183,8 +183,27 @@ impl qbz_tray::QbzTray { } /// D18's report-back. Flag only — it never touches a window. + /// + /// It IS, however, the one place that knows the main window just became + /// hidden or visible, and it is called from exactly two sites + /// (`hideToTray` / `showFromTray`), so the macOS Dock-icon policy hangs + /// here rather than growing a third visibility owner. + /// + /// Hiding drops the Dock icon only when the user asked for it; showing + /// ALWAYS restores it, with no setting check — a user who turns the option + /// off while the app sits in the menu bar must still get their icon back, + /// and restoring an icon that is already there is free. pub fn set_window_shown(self: Pin<&mut Self>, shown: bool) { crate::tray_qt::set_window_shown(shown); + if shown { + crate::tray_qt::set_mac_dock_hidden(false); + } else if crate::settings_qt::tray() + .get_settings() + .map(|t| t.mac_hide_dock) + .unwrap_or(false) + { + crate::tray_qt::set_mac_dock_hidden(true); + } } /// The close-choreography evidence line — see the declaration above. The diff --git a/crates/qbz-qt/src/tray_qt.rs b/crates/qbz-qt/src/tray_qt.rs index a104beff7..ec7cbe40d 100644 --- a/crates/qbz-qt/src/tray_qt.rs +++ b/crates/qbz-qt/src/tray_qt.rs @@ -166,6 +166,44 @@ pub(crate) fn handle() -> Option<&'static TrayHandle> { TRAY.get() } +/// Apply the macOS Dock-icon activation policy: `.accessory` hides the icon +/// (menu-bar-only), `.regular` keeps it. No-op off macOS. +/// +/// This is the Spotify behaviour the owner asked for on 2026-08-04: closing +/// puts the app in the menu bar and DROPS the Dock icon when the setting is +/// on, while a plain minimize still goes to the Dock like any app. Only the +/// close path calls this; minimize is untouched by design. +/// +/// **Main thread only.** Its one caller is `QbzTray::set_window_shown`, a +/// `#[qinvokable]` — those run on the Qt GUI thread, which on macOS IS the +/// AppKit main thread, so `MainThreadMarker::new()` succeeds there. It returns +/// `None` and no-ops anywhere else rather than panicking. +/// +/// Port of `crates/qbz/src/tray/macos.rs:320-331`, reached through the +/// reference's own `tray::set_mac_dock_hidden` (`tray/mod.rs:285-291`). +#[cfg(target_os = "macos")] +pub(crate) fn set_mac_dock_hidden(hidden: bool) { + use objc2::MainThreadMarker; + use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy}; + + let Some(mtm) = MainThreadMarker::new() else { + log::warn!("[tray] dock policy skipped: not on the AppKit main thread"); + return; + }; + let app = NSApplication::sharedApplication(mtm); + app.setActivationPolicy(if hidden { + NSApplicationActivationPolicy::Accessory + } else { + NSApplicationActivationPolicy::Regular + }); + log::info!("[tray] dock icon {}", if hidden { "hidden" } else { "shown" }); +} + +#[cfg(not(target_os = "macos"))] +pub(crate) fn set_mac_dock_hidden(hidden: bool) { + let _ = hidden; +} + /// The close-to-tray predicate, ported verbatim from the reference's /// `tray_settings::get().close_to_tray && tray::handle().is_some()` /// (`crates/qbz/src/main.rs:23253`, WM arm `:23276`). QML evaluates the same