From dc604bdf623faa7ee6882a23f8f2c912ca6eb765 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:55:36 -0600 Subject: [PATCH] =?UTF-8?q?feat(qt):=20wire=20the=20macOS=20Hide-Dock=20se?= =?UTF-8?q?tting=20=E2=80=94=20Spotify=20behaviour=20on=20close?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row rendered, persisted and drove nothing, which is the exact defect class the miniplayer/tray contract existed to end. The owner named the behaviour they want: close puts the app in the menu bar AND drops the Dock icon; minimize still goes to the Dock like any app. So only the CLOSE path touches the policy. The wiring point is `QbzTray.setWindowShown`, which QML calls from exactly two places — `hideToTray` and `showFromTray` — so this needs no third visibility owner and cannot drift from the real window state. Hiding drops the icon only when the setting is on; 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 costs nothing. NOT COMPILED AND NOT TESTED. This tree builds on Linux, the arm is behind cfg(target_os = "macos"), and a cross-check dies in the C build scripts for want of an Apple SDK. So the API was verified by READING objc2-app-kit 0.2.2 instead — which immediately earned its keep: `NSApplicationActivationPolicy` lives in NSRunningApplication.rs, not NSApplication.rs, and the NSApplication feature does not pull it. The three features here match the reference's own proven set. First real check is a build on the Mac mini. cc --- crates/Cargo.lock | 2 ++ crates/qbz-qt/Cargo.toml | 32 +++++++++++++++++++++++++++ crates/qbz-qt/src/tray_bridge.rs | 19 ++++++++++++++++ crates/qbz-qt/src/tray_qt.rs | 38 ++++++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+) 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