Apply rustfmt consistently to the live Qt frontend and shared audio support modules without changing behavior. cx
782 lines
34 KiB
Rust
782 lines
34 KiB
Rust
//! System tray — the platform-agnostic façade.
|
|
//!
|
|
//! Port of `crates/qbz/src/tray/mod.rs` (391 L) per the 2026-08-03
|
|
//! miniplayer/tray contract (rows A-19, A-23, A-24; §5.1, §5.6, §5.8).
|
|
//! The ksni half lives in `tray_linux.rs` (A-20); the QObject QML talks to is
|
|
//! `tray_bridge.rs` (A-21).
|
|
//!
|
|
//! Linux (ksni, `tray_linux.rs`) and macOS (`NSStatusItem`, `tray_macos.rs`).
|
|
//! Windows has no tray in either tree.
|
|
//!
|
|
//! RETIRED 2026-08-05 — the note that used to sit here said macOS "would be a
|
|
//! NEW implementation, not a port", because the reference only hand-rolls its
|
|
//! menu-bar item to dodge the `muda` that Slint's winit backend bundles, and
|
|
//! Qt has no such conflict. The premise was right and the conclusion was
|
|
//! backwards: `tray/macos.rs` avoids muda entirely, depends on nothing winit
|
|
//! provides, and ported here almost verbatim (it got SHORTER — its
|
|
//! `CTX`/`slint::Weak` plumbing has no counterpart on this side). The cost of
|
|
//! believing it: the owner's macOS smoke found the tray icon missing and
|
|
//! close-to-tray quitting the app, which is the one absence wearing two
|
|
//! faces.
|
|
//!
|
|
//! ## Three things about this module are not style choices
|
|
//!
|
|
//! 1. **Nothing here touches a window.** Rust cannot reach a `QQuickWindow`
|
|
//! (§2.3), so every window verb emits a `#[qsignal]` on `QbzTray` and QML's
|
|
//! `Connections` in `Main.qml` answers it — the same hop
|
|
//! `hotkeys_bridge.rs:137-138` → `qml/shell/AppShell.qml:104-107` already
|
|
//! uses for `focus_search_requested`.
|
|
//! 2. **Nothing here calls `ksni`.** `ksni`'s blocking API wraps every call in
|
|
//! `Runtime::block_on`, which panics inside a tokio runtime **and the panic
|
|
//! is swallowed, so updates appear to succeed and never apply**
|
|
//! (`ksni-0.3.4/src/blocking.rs:206-208`; the reference states it twice,
|
|
//! `tray/mod.rs:141-150` and `tray/linux.rs:320-327`). `init` runs the ksni
|
|
//! setup on a dedicated `qbz-tray-init` thread and every mutation crosses an
|
|
//! `mpsc` into a second `qbz-tray-updater` thread. §15 traps 24-25.
|
|
//! 3. **The tray does zero work per tick** (§7-M12). Every update below is
|
|
//! edge-triggered by its publisher; there is no tray poll and no tray timer.
|
|
//!
|
|
//! On non-Linux targets the whole surface is inert, so the per-item
|
|
//! `allow(dead_code)` noise is collapsed into one module attribute rather than
|
|
//! sprinkled over a dozen items.
|
|
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
|
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::{Mutex, OnceLock};
|
|
use std::time::Instant;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tooltip model + composition (§5.4)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Now-playing info shown in the tooltip. Cleared when no track is loaded.
|
|
/// 1:1 with `crates/qbz/src/tray/linux.rs:157-162`; it lives HERE rather than
|
|
/// beside the ksni item so the composition is unit-testable without a D-Bus
|
|
/// connection (UT-4, contract §11).
|
|
#[derive(Clone, Debug, Default)]
|
|
pub(crate) struct NowPlaying {
|
|
pub title: String,
|
|
pub artist: String,
|
|
pub album: String,
|
|
}
|
|
|
|
/// Compose the SNI tooltip's `(title, description)` pair.
|
|
///
|
|
/// Verbatim port of `QbzTray::tool_tip`'s body
|
|
/// (`crates/qbz/src/tray/linux.rs:201-228`), including every omission rule:
|
|
///
|
|
/// - no track → title `"QBZ"` (hard-coded, NOT translated) and the single
|
|
/// description msgid `"Music Player\nNothing playing"` (the `\n` is inside
|
|
/// the msgid);
|
|
/// - track loaded → title = the track title, falling back to the literal
|
|
/// `"QBZ"` when empty, and the description is these lines joined with `\n`:
|
|
/// 1. `by {artist}` — **only when the artist is non-empty**,
|
|
/// 2. the album — **only when non-empty**,
|
|
/// 3. the middle-click hint, keyed on `is_playing`,
|
|
/// 4. the scroll hint (always).
|
|
///
|
|
/// All five msgids already exist in all seven translated catalogs (§6.1); this
|
|
/// port adds none.
|
|
pub(crate) fn compose_tooltip(np: Option<&NowPlaying>, is_playing: bool) -> (String, String) {
|
|
match np {
|
|
Some(np) => {
|
|
let header = if np.title.is_empty() {
|
|
"QBZ".to_string()
|
|
} else {
|
|
np.title.clone()
|
|
};
|
|
let mut lines: Vec<String> = Vec::with_capacity(4);
|
|
if !np.artist.is_empty() {
|
|
lines.push(qbz_i18n::t_args("by {}", &[np.artist.as_str()]));
|
|
}
|
|
if !np.album.is_empty() {
|
|
lines.push(np.album.clone());
|
|
}
|
|
lines.push(if is_playing {
|
|
qbz_i18n::t("Middle-click to pause")
|
|
} else {
|
|
qbz_i18n::t("Middle-click to play")
|
|
});
|
|
lines.push(qbz_i18n::t("Scroll to adjust volume"));
|
|
(header, lines.join("\n"))
|
|
}
|
|
None => (
|
|
"QBZ".to_string(),
|
|
qbz_i18n::t("Music Player\nNothing playing"),
|
|
),
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The live handle (A-19)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Cross-thread handle to the live tray. Cloneable; mutators forward to the
|
|
/// platform backend (ksni on Linux) and are no-ops when the tray is disabled or
|
|
/// on a platform without a live-update path.
|
|
/// 1:1 with `crates/qbz/src/tray/mod.rs:52-102`, macOS arm included since
|
|
/// 2026-08-05.
|
|
///
|
|
/// The macOS side carries no per-handle state: its `NSStatusItem` lives in a
|
|
/// main-thread `thread_local` (it is `!Send`), so the flag only records that a
|
|
/// tray EXISTS. That is the load-bearing part — `handle().is_some()` is what
|
|
/// `should_hide_on_close` consults, and it is why an absent macOS tray made
|
|
/// close-to-tray quit instead of hiding.
|
|
#[derive(Clone, Default)]
|
|
pub(crate) struct TrayHandle {
|
|
#[cfg(target_os = "linux")]
|
|
linux: Option<crate::tray_linux::LinuxTrayHandle>,
|
|
#[cfg(target_os = "macos")]
|
|
macos: bool,
|
|
}
|
|
|
|
impl TrayHandle {
|
|
pub(crate) fn set_track(&self, title: String, artist: String, album: String) {
|
|
#[cfg(target_os = "linux")]
|
|
if let Some(h) = &self.linux {
|
|
h.set_track(title, artist, album);
|
|
return;
|
|
}
|
|
#[cfg(not(target_os = "linux"))]
|
|
let _ = (title, artist, album);
|
|
}
|
|
|
|
pub(crate) fn clear_track(&self) {
|
|
#[cfg(target_os = "linux")]
|
|
if let Some(h) = &self.linux {
|
|
h.clear_track();
|
|
}
|
|
}
|
|
|
|
pub(crate) fn set_playing(&self, is_playing: bool) {
|
|
#[cfg(target_os = "linux")]
|
|
if let Some(h) = &self.linux {
|
|
h.set_playing(is_playing);
|
|
return;
|
|
}
|
|
#[cfg(not(target_os = "linux"))]
|
|
let _ = is_playing;
|
|
}
|
|
|
|
pub(crate) fn set_icon_theme(&self, theme: String) {
|
|
#[cfg(target_os = "linux")]
|
|
if let Some(h) = &self.linux {
|
|
h.set_icon_theme(theme);
|
|
return;
|
|
}
|
|
#[cfg(target_os = "macos")]
|
|
if self.macos {
|
|
// Main-thread only — the status item is a thread_local there.
|
|
crate::tray_bridge::ui(move |_b| crate::tray_macos::set_icon_theme(&theme));
|
|
return;
|
|
}
|
|
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
|
let _ = theme;
|
|
}
|
|
}
|
|
|
|
/// Process-global tray handle, set once by `init`. `None` until the tray is
|
|
/// created — **or forever, if it was disabled, suppressed, or failed to
|
|
/// initialise** (an icon-decode `Err` aborts creation, §5.6). That `None` is
|
|
/// exactly what makes the close-to-tray predicate honest: tray off ⇒ close
|
|
/// QUITS (§5.7, AC-32).
|
|
static TRAY: OnceLock<TrayHandle> = OnceLock::new();
|
|
|
|
/// The live tray handle, if the tray was initialized. Callers (the now-playing
|
|
/// publisher, the poll loop, Settings) use this to push tooltip / theme
|
|
/// updates, and every one of them is a silent no-op when it returns `None`.
|
|
/// 1:1 with `tray/mod.rs:110-112`.
|
|
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) {
|
|
// `MainThreadMarker` lives in objc2-FOUNDATION in the 0.2/0.5 generation,
|
|
// not at the objc2 root — that is where it moved later. The reference
|
|
// imports it from exactly here (crates/qbz/src/tray/macos.rs:38), and a
|
|
// Mac build caught the wrong path on the first try.
|
|
use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy};
|
|
use objc2_foundation::MainThreadMarker;
|
|
|
|
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
|
|
/// `&&` as `QbzTray.trayLive && QbzTray.closeToTray` (§5.7), so this function
|
|
/// has no production caller — it is the Rust statement of the rule and what
|
|
/// UT-8 / AC-32 assert on. **Gating on the setting alone would strand the
|
|
/// process with no window on a desktop with no SNI host** (§15 trap 22).
|
|
#[allow(dead_code)]
|
|
pub(crate) fn should_hide_on_close(close_to_tray: bool) -> bool {
|
|
close_to_tray && handle().is_some()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Init — the THREE gates, in order (§5.6, §15 trap 20)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// The synchronous one-shot that closes the race the `OnceLock` cannot: on
|
|
/// Linux `TRAY` is set *asynchronously* inside the init thread, so a second
|
|
/// shell entry arriving before that thread finishes would pass gate 2 and spawn
|
|
/// a DUPLICATE ksni tray. The Qt port has the same re-entry
|
|
/// (`QbzSession.recoveryLogin`, `src/session_bridge.rs:72`, and
|
|
/// `on_session_entered` is explicitly MULTI-ENTRY).
|
|
static INIT_STARTED: AtomicBool = AtomicBool::new(false);
|
|
|
|
/// The three gates, extracted so their ORDER is unit-testable without spawning
|
|
/// a D-Bus service (UT-6, AC-23).
|
|
///
|
|
/// Returns `true` when `init` may proceed. The order is load-bearing:
|
|
///
|
|
/// 1. `enabled` — the user setting (and gamescope suppression) — logs and stops;
|
|
/// 2. the `OnceLock` — already initialised;
|
|
/// 3. the one-shot — **checked LAST on purpose, so a disabled first call does
|
|
/// NOT burn it** and a later enabled call can still initialise
|
|
/// (`tray/mod.rs:131-135`). Reintroducing the obvious "check the cheap flag
|
|
/// first" ordering is the bug §15 trap 20 names.
|
|
fn init_gates(enabled: bool, tray_present: bool, started: &AtomicBool) -> bool {
|
|
if !enabled {
|
|
log::info!("[tray] disabled by user setting");
|
|
return false;
|
|
}
|
|
if tray_present {
|
|
return false;
|
|
}
|
|
if started.swap(true, Ordering::SeqCst) {
|
|
return false;
|
|
}
|
|
true
|
|
}
|
|
|
|
/// Initialize the system tray, gated by the user's `enable_tray` setting.
|
|
/// `theme_override` is the persisted `tray_icon_theme`
|
|
/// ("auto"/"mono-light"/"mono-dark"/"color"). No-op when disabled or already
|
|
/// initialized.
|
|
///
|
|
/// **Signature note (A-19).** The reference takes FIVE parameters
|
|
/// (`tray/mod.rs:117-123`); the Qt shape drops three: `runtime` and `handle`
|
|
/// come from `crate::app()` / `crate::spawn` (both driven by process-global
|
|
/// `OnceLock`s, so they are reachable from the non-tokio ksni thread), and
|
|
/// `weak` has no analogue at all — the hop to the window is a `#[qsignal]`
|
|
/// (§5.8).
|
|
///
|
|
/// **There is NO teardown and logout does not touch the tray** — `TRAY` is a
|
|
/// never-cleared `OnceLock`, `INIT_STARTED` a one-shot, and `ksni`'s
|
|
/// `shutdown()` is never called. That is the reference's behaviour, reproduced
|
|
/// 1:1 (§12-P12): after logout the icon stays in the panel and its menu still
|
|
/// drives playback. **Do not add a teardown.**
|
|
pub(crate) fn init(theme_override: String, enabled: bool) {
|
|
if !init_gates(enabled, TRAY.get().is_some(), &INIT_STARTED) {
|
|
return;
|
|
}
|
|
|
|
// Re-seed the bridge's `close_to_tray` from the per-user store now that a
|
|
// session is entered.
|
|
//
|
|
// A-21b(a) seeds it at CONSTRUCTION, but a QML singleton is constructed at
|
|
// `Main.qml`'s `Component.onCompleted` — i.e. at the LOGIN SCREEN, where
|
|
// `sidebar_qt::user_dir()` is not bound yet, so the store answers `Err` and
|
|
// the seed is structurally the `unwrap_or(true)` default rather than the
|
|
// user's value. Reading it here (on the caller's thread, NOT on the ksni
|
|
// thread) is what makes the seed the user's own setting before the first
|
|
// close can happen. The Settings write arm keeps it live afterwards
|
|
// (A-21b(c)).
|
|
push_close_to_tray_seed();
|
|
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
// ksni's blocking `spawn()` calls `Runtime::block_on` internally, which
|
|
// panics inside an existing tokio runtime — and `init` is called from
|
|
// `on_session_entered`, which runs ON the tokio runtime. Run the ksni
|
|
// setup on a dedicated std::thread, outside any tokio context. The ksni
|
|
// service + updater thread persist independently; this thread exits.
|
|
std::thread::Builder::new()
|
|
.name("qbz-tray-init".into())
|
|
.spawn(move || match crate::tray_linux::init(&theme_override) {
|
|
Ok(linux_handle) => {
|
|
let _ = TRAY.set(TrayHandle {
|
|
linux: Some(linux_handle),
|
|
});
|
|
// A-21b(b): the `tray_live` push lives in the SAME two arms
|
|
// the reference sets `TRAY` in (`tray/mod.rs:151-158`).
|
|
// Without it the property keeps cxx-qt's derived `false`
|
|
// and `trayLive && closeToTray` is permanently false —
|
|
// close-to-tray never fires and the failure looks exactly
|
|
// like "not implemented" (§15 trap 31).
|
|
push_tray_live(true);
|
|
}
|
|
Err(e) => {
|
|
log::error!("[tray] Linux tray init failed: {e}");
|
|
// An icon-decode Err aborts tray creation entirely and
|
|
// `TRAY` stays None, so close-to-tray must quit instead of
|
|
// hiding. AC-32 is unprovable without this arm.
|
|
push_tray_live(false);
|
|
}
|
|
})
|
|
.expect("spawn tray init thread");
|
|
}
|
|
|
|
// macOS: the NSStatusItem must be built on the AppKit main thread, and
|
|
// `init` runs on the tokio runtime — so it is queued through the tray
|
|
// bridge, whose Qt GUI thread IS the AppKit main thread. (The Linux arm
|
|
// above needs the opposite: a thread OUTSIDE tokio, because ksni blocks.)
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
crate::tray_bridge::ui(move |_b| {
|
|
let ok = crate::tray_macos::create(&theme_override);
|
|
// Same two arms as Linux: `TRAY` and `tray_live` are set together,
|
|
// and a failure must publish `false` or close-to-tray hides the
|
|
// window into a tray that is not there.
|
|
if ok {
|
|
let _ = TRAY.set(TrayHandle { macos: true });
|
|
push_tray_live(true);
|
|
} else {
|
|
log::error!("[tray] macOS menu-bar item could not be created");
|
|
push_tray_live(false);
|
|
}
|
|
});
|
|
}
|
|
|
|
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
|
{
|
|
let _ = theme_override;
|
|
log::info!("[tray] no tray backend on this platform");
|
|
}
|
|
}
|
|
|
|
/// True when running under gamescope (Steam Deck Game Mode). Stateless, and a
|
|
/// verbatim port of `crates/qbz/src/main.rs:8333-8336` — the raw
|
|
/// `GAMESCOPE_WAYLAND_DISPLAY` env is inherited by a re-spawn, and
|
|
/// `QBZ_GAMESCOPE` is the marker `apply_gamescope_quirks` sets at boot.
|
|
///
|
|
/// ONE predicate serves both halves of the contract: the tray's `enabled`
|
|
/// argument (`crates/qbz/src/main.rs:262`,
|
|
/// `tray.enable_tray && !in_gamescope()`) and the miniplayer's two entry points
|
|
/// (§4.10.1). An extra mapped window or tray surface can steal gamescope's
|
|
/// focused-window pick.
|
|
pub(crate) fn in_gamescope() -> bool {
|
|
std::env::var_os("QBZ_GAMESCOPE").is_some()
|
|
|| std::env::var_os("GAMESCOPE_WAYLAND_DISPLAY").is_some()
|
|
}
|
|
|
|
/// Push `QbzTray.tray_live`. Separate from the closure so both init arms read
|
|
/// identically and neither can be edited without the other being visible.
|
|
fn push_tray_live(live: bool) {
|
|
crate::tray_bridge::ui(move |mut t| {
|
|
t.as_mut().set_tray_live(live);
|
|
});
|
|
}
|
|
|
|
/// Push `QbzTray.close_to_tray` from the per-user store, applying the SAME
|
|
/// `.unwrap_or(true)` the Settings document uses after the S1 fix — the shared
|
|
/// default is `close_to_tray: true` (`qbz-app/src/settings/tray.rs:61`), and a
|
|
/// second copy of the old `unwrap_or(false)` bug here would make the user's
|
|
/// very first close quit instead of hide (AC-29).
|
|
fn push_close_to_tray_seed() {
|
|
let close_to_tray = crate::settings_qt::tray()
|
|
.get_settings()
|
|
.map(|t| t.close_to_tray)
|
|
.unwrap_or(true);
|
|
crate::tray_bridge::ui(move |mut t| {
|
|
t.as_mut().set_close_to_tray(close_to_tray);
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Window verbs (A-23, §5.8)
|
|
//
|
|
// Rust owns no window. Each verb emits the matching `QbzTray` signal and
|
|
// `Main.qml`'s `Connections` calls the window function (A-25/A-26, B6).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Whether the main window is currently shown.
|
|
///
|
|
/// **D18 — there is ONE visibility owner.** Every path that changes the main
|
|
/// window's visibility goes through `hideToTray()` / `showFromTray()`, and both
|
|
/// report back through `QbzTray.setWindowShown(bool)` → `set_window_shown`
|
|
/// below. The reference has a real desync here: the miniplayer's `enter()` /
|
|
/// `exit()` bypass `tray::hide_window` / `show_window`, so this flag goes
|
|
/// stale and two tray clicks can raise the MAIN window beside a live mini
|
|
/// (§5.8.1). The Qt port defines the invariant instead of inheriting the hole.
|
|
static WINDOW_SHOWN: AtomicBool = AtomicBool::new(true);
|
|
|
|
/// Last tray-toggle timestamp, for the double-click debounce (`toggle_window`).
|
|
static LAST_TOGGLE: Mutex<Option<Instant>> = Mutex::new(None);
|
|
|
|
/// Ignore a 2nd tray activation within this window. A tray double-click
|
|
/// otherwise fires two `Activate` in milliseconds; in the reference each toggle
|
|
/// destroys/recreates the winit surface AND the wgpu shader underlay and a
|
|
/// render in flight then use-after-frees a `TextureView` (wgpu-core panic).
|
|
/// **Qt's renderer differs, so the crash may not reproduce — but "one toggle
|
|
/// per double-click" is the user-visible semantics and it is part of 1:1**
|
|
/// (§5.8, AC-26).
|
|
const TOGGLE_DEBOUNCE_MS: u128 = 400;
|
|
|
|
/// The debounce decision, extracted so it is unit-testable without sleeping
|
|
/// (UT-7). `last` is read-modify-written: **the timestamp is stored only when
|
|
/// the click PASSES**, so a rejected click does not extend the window — a burst
|
|
/// lets one through every 400 ms rather than never — and **the first click
|
|
/// always passes** (`last` starts `None`).
|
|
fn debounce_pass(last: &mut Option<Instant>, now: Instant) -> bool {
|
|
if let Some(prev) = *last {
|
|
if now.duration_since(prev).as_millis() < TOGGLE_DEBOUNCE_MS {
|
|
return false;
|
|
}
|
|
}
|
|
*last = Some(now);
|
|
true
|
|
}
|
|
|
|
/// Toggle the main window: hide if shown, else show + focus — **unless the
|
|
/// miniplayer is open, in which case this degenerates to `present()` and
|
|
/// raises the MINI** (D21, §5.8.2).
|
|
///
|
|
/// The mini-awareness is not a nicety: with D18's honest `WINDOW_SHOWN`, a
|
|
/// mini-blind toggle would show the main window beside a live mini on the FIRST
|
|
/// click — the "second instance" symptom of #559/#618, reached one click sooner
|
|
/// than the reference's own bug. While the mini is up, the mini IS the app's
|
|
/// window; exiting it is not the tray's job (the capsule's expand button,
|
|
/// `Shift+M` and `Escape` all do that).
|
|
pub(crate) fn toggle_window() {
|
|
{
|
|
// Poison-tolerant on purpose (`tray/mod.rs:196`): a panic elsewhere
|
|
// while holding this lock must not wedge the toggle permanently. The
|
|
// guard is dropped by this bare block before anything else runs.
|
|
let now = Instant::now();
|
|
let mut last = LAST_TOGGLE.lock().unwrap_or_else(|p| p.into_inner());
|
|
// `&mut *last`: `last` is the guard; the state is the `Option<Instant>`
|
|
// behind it.
|
|
if !debounce_pass(&mut *last, now) {
|
|
return;
|
|
}
|
|
}
|
|
if crate::mini_bridge::is_open() {
|
|
present();
|
|
return;
|
|
}
|
|
if WINDOW_SHOWN.load(Ordering::Relaxed) {
|
|
hide_window();
|
|
} else {
|
|
show_window();
|
|
}
|
|
}
|
|
|
|
/// Show the main window and focus it → `Main.qml`'s `showFromTray()`.
|
|
pub(crate) fn show_window() {
|
|
// The flag moves HERE, synchronously, exactly as the reference does
|
|
// (`tray/mod.rs:212-213` sets it before the queued window call). QML's
|
|
// `setWindowShown` is the RECONCILER, not the only writer: the hop through
|
|
// `ui()` is queued, so a second tray click that arrives after the 400 ms
|
|
// debounce but before the Qt loop drains would otherwise read the stale
|
|
// value and queue a SECOND hide — the window hides once and the user's
|
|
// second click does nothing, one click out of phase. D18 requires one
|
|
// visibility OWNER, not one writer of the flag.
|
|
WINDOW_SHOWN.store(true, Ordering::Relaxed);
|
|
crate::tray_bridge::ui(|mut t| {
|
|
t.as_mut().window_show_requested();
|
|
});
|
|
}
|
|
|
|
/// Hide the main window to the tray → `Main.qml`'s `hideToTray()`.
|
|
///
|
|
/// The reference clears the dynamic-background shader texture BEFORE the
|
|
/// surface dies (`tray/mod.rs:258-265`). That step is Slint/wgpu-specific — the
|
|
/// texture wraps a wgpu texture from that surface's instance — and has no Qt
|
|
/// analogue (§12-P13). The SHAPE still transfers (drop GPU resources tied to a
|
|
/// surface before destroying it); nothing in the Qt path holds one.
|
|
pub(crate) fn hide_window() {
|
|
// See `show_window` — the optimistic store is the reference's shape
|
|
// (`tray/mod.rs:255`) and is what keeps rapid clicks in phase.
|
|
WINDOW_SHOWN.store(false, Ordering::Relaxed);
|
|
crate::tray_bridge::ui(|mut t| {
|
|
t.as_mut().window_hide_requested();
|
|
});
|
|
}
|
|
|
|
/// Raise whichever window is CURRENT: the miniplayer when it is open, else the
|
|
/// main window. `show_window` would force the MAIN window up next to a live
|
|
/// mini — visually a "second instance", #559/#618 (`tray/mod.rs:236-241`).
|
|
///
|
|
/// Its caller in this diff is `toggle_window` (§2.4). Owner ruling K3 keeps
|
|
/// MPRIS out of scope, and this verb is deliberately left in the shape MPRIS
|
|
/// `Raise` and the single-instance `Present()` will call **unchanged** when
|
|
/// they land — one decision serving all three tray entry points.
|
|
///
|
|
/// Unlike the reference this needs no event-loop hop: `mini_bridge::is_open()`
|
|
/// reads an `AtomicBool` mirror (`src/mini_bridge.rs:140-144`), where Slint's
|
|
/// `miniplayer::is_open` reads `thread_local` state.
|
|
pub(crate) fn present() {
|
|
if crate::mini_bridge::is_open() {
|
|
crate::tray_bridge::ui(|mut t| {
|
|
t.as_mut().mini_present_requested();
|
|
});
|
|
} else {
|
|
show_window();
|
|
}
|
|
}
|
|
|
|
/// Sync the shown-state flag without touching any window — the QML side reports
|
|
/// the REAL visibility back through `QbzTray.setWindowShown(bool)`, which is
|
|
/// what keeps the toggle honest (D18). 1:1 with `tray/mod.rs:281-283`.
|
|
pub(crate) fn set_window_shown(shown: bool) {
|
|
WINDOW_SHOWN.store(shown, Ordering::Relaxed);
|
|
}
|
|
|
|
/// Quit the whole app from a tray action (any thread) → `Main.qml`'s
|
|
/// `persistWindowGeometryOnExit(); Qt.exit(0)`.
|
|
///
|
|
/// `Qt.exit(0)`, NEVER `Qt.quit()` — the difference IS the 2026-08-04 bug the
|
|
/// owner reported three times ("Quit QBZ closes the window, not the app"):
|
|
/// `Qt.quit()` delivers `QEvent::Quit`, whose `QGuiApplication` handler first
|
|
/// tries to close every top-level window and silently CANCELS the quit when
|
|
/// any window refuses its close event — which Main.qml's close-to-tray arm
|
|
/// does whenever `trayLive && closeToTray`, and MiniWindow.qml does
|
|
/// unconditionally. So with close-to-tray on, "Quit" degenerated into
|
|
/// hide-to-tray (window gone, process alive — reproduced under
|
|
/// `QT_QPA_PLATFORM=vnc`, see `arm_hard_exit_watchdog`'s header). `Qt.exit`
|
|
/// stops the event loops directly; no window gets a vote.
|
|
///
|
|
/// The watchdog is armed HERE, before the hop, because this is the moment of
|
|
/// user intent: if the queued signal is dropped (bridge unbooted, loop
|
|
/// wedged) or anything downstream hangs, the process still dies within 5s.
|
|
///
|
|
/// The reference flushes the session from each quit HANDLER
|
|
/// (`session_persist::save_on_exit()`, `tray/mod.rs:300` and three more in
|
|
/// `main.rs`); this port flushes ONCE after the event loop instead
|
|
/// (`main.rs:2913`), behind the same watchdog armed here — window close, tray
|
|
/// Quit and the hotkey all converge there, so tray quit persists the session
|
|
/// without this path owing anything beyond the geometry flush (§5.7).
|
|
pub(crate) fn quit() {
|
|
log::info!("[tray] quit requested");
|
|
crate::arm_hard_exit_watchdog("tray quit");
|
|
crate::tray_bridge::ui(|mut t| {
|
|
t.as_mut().quit_requested();
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Player-action dispatch (A-24, §5.5)
|
|
//
|
|
// In Slint the remote-first ladder is open-coded in each dispatcher
|
|
// (`tray/mod.rs:311-372`). In Qt it lives INSIDE the verb, so these are thin.
|
|
//
|
|
// §13-D19, and it is a divergence IN THE PORT'S FAVOUR: the Qt verbs ride the
|
|
// now-playing bar's FULL `cast -> QConnect -> local` ladder
|
|
// (`src/playback_qt.rs:1384-1409` tries `cast_qt::service().toggle_play_if_cast()`
|
|
// FIRST), where the Slint tray's dispatchers are cast-blind. While a Cast
|
|
// session is active the Qt tray drives the cast device. **Do NOT "fix" this
|
|
// back to cast-blind.**
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub(crate) fn dispatch_play_pause() {
|
|
crate::transport_toggle_play();
|
|
}
|
|
|
|
pub(crate) fn dispatch_next() {
|
|
crate::transport_next();
|
|
}
|
|
|
|
pub(crate) fn dispatch_previous() {
|
|
crate::transport_previous();
|
|
}
|
|
|
|
/// Step the local volume by `ticks` notches of 5 % (positive = up).
|
|
///
|
|
/// **Local-only, REPRODUCE-1:1** (§12-P11): the reference's own header says
|
|
/// *"Local-only for now (remote-renderer volume forwarding is a later
|
|
/// refinement)"* (`tray/mod.rs:374-378`). Linux-only, because scroll-to-volume
|
|
/// is a StatusNotifierItem feature the macOS/Windows trays do not expose.
|
|
///
|
|
/// The 5 % step lives HERE, not in the ksni `scroll` handler — same split as the
|
|
/// reference. The current level is read through
|
|
/// `runtime.core().player().get_playback_event().volume` (an `f32` in `0..1`;
|
|
/// worked precedent `src/playback_qt.rs:1671`), and the read happens on the
|
|
/// tokio runtime rather than on the ksni thread, exactly as the reference does
|
|
/// it inside `handle.spawn`.
|
|
#[cfg(target_os = "linux")]
|
|
pub(crate) fn dispatch_volume_delta(ticks: i32) {
|
|
let runtime = crate::app();
|
|
crate::spawn(async move {
|
|
let current = runtime.core().player().get_playback_event().volume;
|
|
let next = (current + ticks as f32 * 0.05).clamp(0.0, 1.0);
|
|
// `transport_set_volume` updates the local model first (instant UI),
|
|
// then the engine (`src/main.rs:1667-1672`).
|
|
crate::transport_set_volume(next);
|
|
});
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// UT-4 — the tooltip's four description lines, in order, with the
|
|
/// empty-artist and empty-album omissions, the `is_playing` branch and the
|
|
/// `"QBZ"` title fallback (AC-19).
|
|
///
|
|
/// The msgids resolve to themselves under the test binary's untranslated
|
|
/// locale, which is what makes the assertions readable AND is the `en`
|
|
/// behaviour (§6.1: the 42 msgids resolve 7/7 in the translated catalogs
|
|
/// and 0/7 in `en`).
|
|
#[test]
|
|
fn tray_tooltip_composition() {
|
|
// No track: hard-coded title, one description msgid.
|
|
let (title, desc) = compose_tooltip(None, false);
|
|
assert_eq!(title, "QBZ");
|
|
assert_eq!(desc, "Music Player\nNothing playing");
|
|
|
|
// Full track, playing: title, "by {artist}", album, pause hint, scroll.
|
|
let np = NowPlaying {
|
|
title: "Pull Me Under".to_string(),
|
|
artist: "Dream Theater".to_string(),
|
|
album: "Images and Words".to_string(),
|
|
};
|
|
let (title, desc) = compose_tooltip(Some(&np), true);
|
|
assert_eq!(title, "Pull Me Under");
|
|
let lines: Vec<&str> = desc.split('\n').collect();
|
|
assert_eq!(lines.len(), 4);
|
|
assert!(lines[0].contains("Dream Theater"), "line 0: {}", lines[0]);
|
|
assert_eq!(lines[1], "Images and Words");
|
|
assert_eq!(lines[2], "Middle-click to pause");
|
|
assert_eq!(lines[3], "Scroll to adjust volume");
|
|
|
|
// Paused flips ONLY line 3 of the block.
|
|
let (_, desc) = compose_tooltip(Some(&np), false);
|
|
let lines: Vec<&str> = desc.split('\n').collect();
|
|
assert_eq!(lines[2], "Middle-click to play");
|
|
|
|
// Empty artist is OMITTED, not rendered as "by ".
|
|
let np = NowPlaying {
|
|
title: "Untitled".to_string(),
|
|
artist: String::new(),
|
|
album: "Bootleg".to_string(),
|
|
};
|
|
let (title, desc) = compose_tooltip(Some(&np), true);
|
|
assert_eq!(title, "Untitled");
|
|
let lines: Vec<&str> = desc.split('\n').collect();
|
|
assert_eq!(lines.len(), 3);
|
|
assert_eq!(lines[0], "Bootleg");
|
|
|
|
// Empty album is OMITTED too, and an empty TITLE falls back to "QBZ".
|
|
let np = NowPlaying {
|
|
title: String::new(),
|
|
artist: "Nobody".to_string(),
|
|
album: String::new(),
|
|
};
|
|
let (title, desc) = compose_tooltip(Some(&np), false);
|
|
assert_eq!(title, "QBZ");
|
|
let lines: Vec<&str> = desc.split('\n').collect();
|
|
assert_eq!(lines.len(), 3);
|
|
assert!(lines[0].contains("Nobody"));
|
|
assert_eq!(lines[1], "Middle-click to play");
|
|
assert_eq!(lines[2], "Scroll to adjust volume");
|
|
|
|
// Everything empty: the two hints survive on their own.
|
|
let (title, desc) = compose_tooltip(Some(&NowPlaying::default()), false);
|
|
assert_eq!(title, "QBZ");
|
|
assert_eq!(desc.split('\n').count(), 2);
|
|
}
|
|
|
|
/// UT-6 — the gate ORDER (AC-23). The one-shot must survive a disabled
|
|
/// first call, or a user who launches with the tray off can never get one
|
|
/// in that process.
|
|
#[test]
|
|
fn tray_init_gate_order() {
|
|
let started = AtomicBool::new(false);
|
|
|
|
// 1. Disabled first call: refused, and it does NOT burn the one-shot.
|
|
assert!(!init_gates(false, false, &started));
|
|
assert!(!started.load(Ordering::SeqCst));
|
|
|
|
// 2. A later ENABLED call proceeds — and now the one-shot is burnt.
|
|
assert!(init_gates(true, false, &started));
|
|
assert!(started.load(Ordering::SeqCst));
|
|
|
|
// 3. A third call is refused by the one-shot even though `TRAY` is not
|
|
// set yet — that is the whole point: on Linux `TRAY` is filled
|
|
// asynchronously, so the OnceLock check alone leaves a race window.
|
|
assert!(!init_gates(true, false, &started));
|
|
|
|
// The OnceLock gate refuses on its own once the tray exists.
|
|
let already = AtomicBool::new(false);
|
|
assert!(!init_gates(true, true, &already));
|
|
assert!(!already.load(Ordering::SeqCst));
|
|
}
|
|
|
|
/// UT-7 — the 400 ms toggle debounce (AC-26): the first click always
|
|
/// passes, a second 100 ms later is swallowed, and a second 500 ms later
|
|
/// passes.
|
|
#[test]
|
|
fn tray_toggle_debounce() {
|
|
use std::time::Duration;
|
|
|
|
let t0 = Instant::now();
|
|
let mut last: Option<Instant> = None;
|
|
|
|
// First click always passes.
|
|
assert!(debounce_pass(&mut last, t0));
|
|
|
|
// 100 ms later: swallowed — a double-click is ONE toggle.
|
|
assert!(!debounce_pass(&mut last, t0 + Duration::from_millis(100)));
|
|
|
|
// A rejected click does NOT extend the window: 401 ms after the click
|
|
// that PASSED is enough, even though a click landed at 100 ms.
|
|
assert!(debounce_pass(&mut last, t0 + Duration::from_millis(401)));
|
|
|
|
// 500 ms apart produce two effects.
|
|
let mut last: Option<Instant> = None;
|
|
assert!(debounce_pass(&mut last, t0));
|
|
assert!(debounce_pass(&mut last, t0 + Duration::from_millis(500)));
|
|
|
|
// Exactly at the boundary the click passes (`<` is the reject test).
|
|
let mut last: Option<Instant> = None;
|
|
assert!(debounce_pass(&mut last, t0));
|
|
assert!(debounce_pass(&mut last, t0 + Duration::from_millis(400)));
|
|
}
|
|
|
|
/// UT-8 — with no tray, close QUITS regardless of the setting (AC-32).
|
|
/// The unit test runs in a process where `init` never ran, so `handle()` is
|
|
/// `None` — which is also the state an icon-decode `Err` leaves behind.
|
|
#[test]
|
|
fn tray_absent_makes_close_quit() {
|
|
assert!(handle().is_none());
|
|
assert!(!should_hide_on_close(true));
|
|
assert!(!should_hide_on_close(false));
|
|
}
|
|
}
|