diff --git a/.github/workflows/build-qt-linux.yml b/.github/workflows/build-qt-linux.yml index 7175e1100..f1944d246 100644 --- a/.github/workflows/build-qt-linux.yml +++ b/.github/workflows/build-qt-linux.yml @@ -221,7 +221,12 @@ jobs: # The desktop file goes in under the MPRIS DesktopEntry basename # (com.blitzfc.qbz.desktop), so an integrated AppImage resolves the # media-widget icon like the deb/rpm do. Icon=qbz -> qbz.png. - cp packaging/linux/qbz.desktop com.blitzfc.qbz.desktop + # linuxdeploy looks the Exec name up under AppDir/usr/bin, so the + # AppImage entry keeps the bare name; the deb/rpm install the source + # entry as-is, with the absolute Exec KWin matches against the + # running binary (packaging/linux/qbz.desktop). + sed 's|^Exec=/usr/bin/qbz |Exec=qbz |' packaging/linux/qbz.desktop > com.blitzfc.qbz.desktop + grep -q '^Exec=qbz ' com.blitzfc.qbz.desktop cp packaging/icons/256x256.png qbz.png ./linuxdeploy --appimage-extract-and-run --appdir AppDir \ -e qbz -d com.blitzfc.qbz.desktop -i qbz.png --plugin qt diff --git a/crates/Cargo.lock b/crates/Cargo.lock index db999b796..db7bb6656 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -1458,6 +1458,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dst-decoder" version = "0.1.2" @@ -4494,6 +4500,8 @@ dependencies = [ "url", "urlencoding", "uuid 1.26.0", + "wayland-client", + "wayland-protocols-plasma", "windows-sys 0.61.2", "winresource", "zbus 5.19.0", @@ -6812,6 +6820,76 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.104" diff --git a/crates/qbz-qt/Cargo.toml b/crates/qbz-qt/Cargo.toml index bff6c3235..9bff83860 100644 --- a/crates/qbz-qt/Cargo.toml +++ b/crates/qbz-qt/Cargo.toml @@ -152,6 +152,11 @@ ksni = { version = "0.3", default-features = false, features = ["blocking", "asy # Session-bus single-instance guard. Keep the same async-io executor as ksni; # enabling zbus/tokio would poison the graph-wide executor choice. zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] } +# The "Wallpaper" background reads this window's place on a Plasma Wayland +# desktop through org_kde_plasma_window_management (wallpaper_wayland_qt.rs) +# on its OWN client connection; Qt's display is never shared. +wayland-client = "0.31" +wayland-protocols-plasma = { version = "0.3", features = ["client"] } [build-dependencies] cxx-qt-build = "0.7" diff --git a/crates/qbz-qt/qml/shell/AppShell.qml b/crates/qbz-qt/qml/shell/AppShell.qml index 9d0659f45..6c3f37a5d 100644 --- a/crates/qbz-qt/qml/shell/AppShell.qml +++ b/crates/qbz-qt/qml/shell/AppShell.qml @@ -121,13 +121,17 @@ Rectangle { opacity: root.veilStrength } - // Mode 3 — Wallpaper: the desktop wallpaper, the part of it that lies - // BEHIND this window (where the platform tells the window its place: - // X11, macOS, Windows — Wayland hides it, so a centred crop stands in), - // lightly blurred under the half-alpha chrome. Static: it costs a frame - // only when the window moves or resizes (shell/WallpaperField.qml). - // Mode 4 — Wallpaper, blurred: the wallpaper through the SAME atmosphere - // pass Blurred art gives the cover, held still (no transport to follow). + // Mode 3 — Wallpaper: the window reads as translucent to the desktop: + // ALWAYS the part of the wallpaper that lies under it, wherever it is + // moved (X11, macOS and Windows say where the window is; KDE Plasma + // Wayland says so through its window-management protocol; anywhere + // else a centred crop stands in), lightly blurred so nothing in the + // picture competes with the text, yet still recognisable. Static: it + // costs a frame only when the window moves or resizes + // (shell/WallpaperField.qml). + // Mode 4 — Wallpaper, blurred: Blurred art with the wallpaper in the + // cover's place — the SAME atmosphere pass, the SAME drift while the + // transport plays, the same still pose when it does not. // Neither waits for a playing track (QbzTheme.ambientOn). readonly property bool wallpaperModeOn: root.ambientOn && QbzShell.ambientMode === 3 readonly property bool wallpaperBlurOn: root.ambientOn && QbzShell.ambientMode === 4 @@ -136,7 +140,7 @@ Rectangle { visible: root.wallpaperModeOn source: root.wallpaperModeOn ? QbzShell.wallpaperUrl : "" hostWindow: root.hostWindow - blur: 0.35 + blur: 0.6 dim: theme.isDark ? QbzShell.ambientDim : 0.0 } Rectangle { @@ -149,7 +153,7 @@ Rectangle { anchors.fill: parent visible: root.wallpaperBlurOn source: root.wallpaperBlurOn ? QbzShell.wallpaperAtmosphereUrl : "" - animated: false + animated: root.wallpaperBlurOn && QbzPlayer.npPlaying dim: theme.isDark ? QbzShell.ambientDim : 0.0 } Rectangle { diff --git a/crates/qbz-qt/qml/shell/WallpaperField.qml b/crates/qbz-qt/qml/shell/WallpaperField.qml index 272788ad0..96a7f5dda 100644 --- a/crates/qbz-qt/qml/shell/WallpaperField.qml +++ b/crates/qbz-qt/qml/shell/WallpaperField.qml @@ -1,18 +1,27 @@ // WallpaperField — the desktop wallpaper behind the window (2026-09-14): the -// part of it that lies UNDER this window, so moving the window slides the -// picture the way a translucent window would, lightly blurred beneath the -// half-alpha chrome. Nothing animates: it costs a frame only when the window -// moves or resizes, and a screen-sized texture at most (`sourceSize` caps the -// decode — a 3x-upscaled wallpaper never reaches the GPU whole). +// part of it that lies UNDER this window, wherever the window is, so the +// window reads as translucent to the desktop — an X-ray straight to the +// wallpaper, never to the windows in between (a really translucent surface +// would show those). Lightly blurred beneath the half-alpha chrome: enough +// that nothing in the picture competes with the text, little enough that the +// picture is still recognisable. Nothing animates: it costs a frame only when +// the window moves or resizes, and a screen-sized texture at most +// (`sourceSize` caps the decode — a 3x-upscaled wallpaper never reaches the +// GPU whole). // -// WHERE THE WINDOW IS. X11, macOS and Windows report the window's position; -// Wayland hides it from clients by design, so there the crop is centred on -// the screen until the Plasma window-management protocol is wired -// (wallpaper_qt.rs header). `hostWindow` is the ApplicationWindow. +// WHERE THE WINDOW IS. X11, macOS and Windows tell Qt (`hostWindow.x/y`). +// Wayland hides it from clients by design; on KDE Plasma the compositor +// reports every window's geometry through org_kde_plasma_window_management +// (wallpaper_wayland_qt.rs), published as QbzShell.wallpaperWindowsJson — +// this process's windows with absolute coordinates — and the one whose size +// is this window's is the one the picture sits under (the miniplayer and the +// dialogs are this process's windows too). Where neither can say, the crop +// is centred on the screen. `hostWindow` is the ApplicationWindow. import QtQuick import QtQuick.Window import QtQuick.Effects +import com.blitzfc.qbz Item { id: root @@ -27,17 +36,69 @@ Item { clip: true - readonly property bool positionKnown: root.hostWindow !== null - && Qt.platform.pluginName !== "wayland" + readonly property bool onWayland: Qt.platform.pluginName === "wayland" + + // Ask the compositor as soon as this field is actually painting on a + // Wayland session (idempotent on the Rust side; a no-op elsewhere). + function _track() { + if (root.visible && root.onWayland && root.source !== "") + QbzShell.trackWindowPosition() + } + onVisibleChanged: _track() + onSourceChanged: _track() + Component.onCompleted: _track() + + // Read only while this field is the background: the tracker keeps + // reporting moves after the mode is switched away, and an invisible item + // whose geometry changes still dirties the window (the repaint rule). + readonly property var compositorRects: { + if (!root.visible || !root.onWayland) + return [] + try { + var a = JSON.parse(QbzShell.wallpaperWindowsJson || "[]") + return Array.isArray(a) ? a : [] + } catch (e) { + return [] + } + } + /// The compositor's rectangle for THIS window, or null when it has said + /// nothing (no protocol, or nothing our size). + readonly property var compositorRect: root.pickRect(root.compositorRects, + root.hostWindow ? root.hostWindow.width : root.width, + root.hostWindow ? root.hostWindow.height : root.height) + + /// The rectangle whose size is closest to `w` x `h`, when it is close + /// enough to be this window at all: a geometry that is not our size + /// belongs to another of our windows, and the centred crop beats that + /// window's place. + function pickRect(rects, w, h) { + var best = null + var bestScore = 0 + for (var i = 0; i < rects.length; i++) { + var r = rects[i] + if (!r || typeof r.w !== "number" || typeof r.h !== "number" + || typeof r.x !== "number" || typeof r.y !== "number") + continue + var score = Math.abs(r.w - w) + Math.abs(r.h - h) + if (best === null || score < bestScore) { + best = r + bestScore = score + } + } + return (best !== null && bestScore <= 64) ? best : null + } + + readonly property bool positionKnown: root.visible && root.hostWindow !== null + && (!root.onWayland || root.compositorRect !== null) readonly property real screenW: Math.max(1, Screen.width) readonly property real screenH: Math.max(1, Screen.height) // The window's origin inside its screen; centred when unknown. - readonly property real offX: root.positionKnown - ? root.hostWindow.x - Screen.virtualX - : Math.max(0, (root.screenW - root.width) / 2) - readonly property real offY: root.positionKnown - ? root.hostWindow.y - Screen.virtualY - : Math.max(0, (root.screenH - root.height) / 2) + readonly property real offX: !root.positionKnown + ? Math.max(0, (root.screenW - root.width) / 2) + : (root.onWayland ? root.compositorRect.x : root.hostWindow.x) - Screen.virtualX + readonly property real offY: !root.positionKnown + ? Math.max(0, (root.screenH - root.height) / 2) + : (root.onWayland ? root.compositorRect.y : root.hostWindow.y) - Screen.virtualY Image { id: picture @@ -63,7 +124,7 @@ Item { autoPaddingEnabled: false blurEnabled: true blur: root.blur - blurMax: 48 + blurMax: 64 } Rectangle { anchors.fill: parent diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index 0f338c58a..a27649f15 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -292,6 +292,8 @@ mod local_media_info_qt; mod nav_qt; mod page_restore_qt; mod wallpaper_qt; +#[cfg(target_os = "linux")] +mod wallpaper_wayland_qt; mod now_playing; mod offline_fwd; mod output_labels; diff --git a/crates/qbz-qt/src/shell_bridge.rs b/crates/qbz-qt/src/shell_bridge.rs index 760aab970..f03202b3f 100644 --- a/crates/qbz-qt/src/shell_bridge.rs +++ b/crates/qbz-qt/src/shell_bridge.rs @@ -301,6 +301,11 @@ pub mod qbz_shell { /// (wallpaper_qt.rs). Empty until a mode that paints it resolves one. #[qproperty(QString, wallpaper_url)] #[qproperty(QString, wallpaper_atmosphere_url)] + /// This process's windows with their absolute geometry, as a Plasma + /// Wayland compositor reports them (`[{"x","y","w","h"}, …]`); + /// "[]" anywhere else. WallpaperField.qml picks the ApplicationWindow + /// out of it by size (wallpaper_wayland_qt.rs). + #[qproperty(QString, wallpaper_windows_json)] /// Is the kiosk touch shell the one currently mounted? /// /// The Qt counterpart of `ShellState.kiosk-profile` @@ -541,6 +546,10 @@ pub mod qbz_shell { /// Re-resolve the desktop wallpaper (window activation, a mode switch). #[qinvokable] fn refresh_wallpaper(self: Pin<&mut QbzShell>); + /// Start following this window's place on a Plasma Wayland desktop + /// (the Wallpaper mode on Wayland). Idempotent; a no-op elsewhere. + #[qinvokable] + fn track_window_position(self: Pin<&mut QbzShell>); #[qinvokable] fn navigate_forward(self: Pin<&mut QbzShell>); @@ -906,6 +915,7 @@ pub struct QbzShellRust { app_background_available: bool, wallpaper_url: QString, wallpaper_atmosphere_url: QString, + wallpaper_windows_json: QString, kiosk_profile: bool, kiosk_fullscreen_boot: bool, ambient_mode: i32, @@ -1023,6 +1033,7 @@ impl Default for QbzShellRust { app_background_available: crate::renderer_qt::gpu_tier(), wallpaper_url: QString::default(), wallpaper_atmosphere_url: QString::default(), + wallpaper_windows_json: QString::from("[]"), kiosk_profile: crate::kiosk_profile_qt::active(), kiosk_fullscreen_boot: crate::kiosk_profile_qt::active() && crate::kiosk_profile_qt::fullscreen_at_boot(), @@ -1449,6 +1460,10 @@ impl qbz_shell::QbzShell { crate::wallpaper_qt::refresh(); } + pub fn track_window_position(self: Pin<&mut Self>) { + crate::wallpaper_qt::track_window_position(); + } + pub fn navigate_forward(self: Pin<&mut Self>) { crate::nav_qt::forward(); } diff --git a/crates/qbz-qt/src/wallpaper_qt.rs b/crates/qbz-qt/src/wallpaper_qt.rs index b891b24ac..864760139 100644 --- a/crates/qbz-qt/src/wallpaper_qt.rs +++ b/crates/qbz-qt/src/wallpaper_qt.rs @@ -1,14 +1,16 @@ //! The desktop wallpaper as the app-wide background (2026-09-14). //! -//! Two `app_background` modes ride on it: "wallpaper" (3) shows the part of -//! the wallpaper that lies BEHIND the window, lightly blurred under the -//! translucent chrome (shell/WallpaperField.qml — the window's place on the -//! screen comes from Qt where the platform tells it: X11, macOS, Windows; -//! Wayland hides window positions, so a centred crop stands in until the -//! Plasma window-management protocol is wired); "wallpaper-blurred" (4) -//! passes the wallpaper through the same atmosphere pass Blurred art gives -//! the cover (atmosphere_qt::for_cover_blocking), so it reads like the album -//! blur with the desktop's colours. +//! Two `app_background` modes ride on it: "wallpaper" (3) makes the window +//! read as translucent to the desktop — ALWAYS the part of the wallpaper +//! that lies under the window, wherever it is moved, lightly blurred under +//! the half-alpha chrome (shell/WallpaperField.qml). The window's place on +//! the screen comes from Qt where the platform tells it (X11, macOS, +//! Windows) and from the compositor on KDE Plasma Wayland +//! (wallpaper_wayland_qt.rs, `org_kde_plasma_window_management`); where +//! neither can say, a centred crop stands in. "wallpaper-blurred" (4) is +//! Blurred art with the wallpaper in the cover's place: the same +//! ImmersiveAtmosphere, the same drift while the transport plays +//! (atmosphere_qt::for_cover_blocking builds its bitmap). //! //! The image is the system wallpaper, resolved per desktop — KDE Plasma's //! `plasma-org.kde.plasma.desktop-appletsrc` (the containment on screen 0 @@ -37,6 +39,13 @@ pub fn mode_uses_wallpaper(mode: i32) -> bool { mode >= MODE_WALLPAPER } +/// Start following the window's place on a Plasma Wayland desktop +/// (wallpaper_wayland_qt.rs). Idempotent; nothing to start elsewhere. +pub fn track_window_position() { + #[cfg(target_os = "linux")] + crate::wallpaper_wayland_qt::start(); +} + /// Resolve the wallpaper (the user's own image first) and publish its URL and /// its atmosphere bitmap on the shell bridge. No-op while the mode does not /// paint it, and quiet while the same file (same mtime) is already published. diff --git a/crates/qbz-qt/src/wallpaper_wayland_qt.rs b/crates/qbz-qt/src/wallpaper_wayland_qt.rs new file mode 100644 index 000000000..b6f86cc79 --- /dev/null +++ b/crates/qbz-qt/src/wallpaper_wayland_qt.rs @@ -0,0 +1,380 @@ +//! Where OUR window sits on a Plasma Wayland desktop (2026-09-14). +//! +//! The "Wallpaper" background (wallpaper_qt.rs, mode 3) paints the part of +//! the desktop picture that lies UNDER the window, so moving the window slides +//! the picture the way a translucent window would. That needs the window's +//! place on the screen, and Wayland hides it from clients by design: Qt's +//! `QWindow::position()` is a fiction there. KDE Plasma does tell a client +//! where every window is — through `org_kde_plasma_window_management`, the +//! protocol its own task manager and window switchers use — and this module +//! reads it back for the windows of this process. +//! +//! WHY A SEPARATE CONNECTION. Qt owns the process's Wayland display and reads +//! it from its own event loop; sharing that `wl_display` would mean a foreign +//! event queue dispatched from a thread Qt does not know about. A second +//! client connection is ordinary Wayland (any process may hold several), costs +//! one socket, and lives on its own thread that blocks in `dispatch` and only +//! wakes when KWin has something to say. It reports through the shell bridge +//! (`wallpaperWindowsJson`), a JSON array of this pid's windows with their +//! absolute geometry, republished only when that array changes; +//! `shell/WallpaperField.qml` picks the one whose size is the +//! ApplicationWindow's and turns the global origin into a per-screen offset. +//! Nothing else in the app touches this connection. +//! +//! WHO GETS THE PROTOCOL. KWin blacklists `org_kde_plasma_window_management` +//! (`kwin/src/wayland_server.cpp`, `interfacesBlackList`) and hands it only to +//! a client whose executable — `/proc//exe` — is named by a `.desktop` +//! file whose `Exec` (first word, canonical ABSOLUTE path) is that binary and +//! which lists the interface in `X-KDE-Wayland-Interfaces` +//! (`kwin/src/utils/serviceutils.h`). The packaged desktop entries carry both +//! (`packaging/linux/qbz.desktop`: `Exec=/usr/bin/qbz`); a development binary +//! run from the target directory gets the protocol only with a user-level +//! desktop file pointing at it. Installs whose running binary no desktop file +//! can name never see it: Flatpak and Snap (sandboxed), the AppImage (a +//! per-run mount point), Nix (the wrapper script is not the binary that +//! runs). When the global is absent the `bind` +//! fails, one line is logged, and the crop stays centred — the same picture as +//! before this module, never a broken one. Other Wayland compositors have no +//! equivalent protocol and take the same fallback. +//! +//! The tracker starts lazily (`start()`, first use of mode 3 on a Wayland +//! session, from QML which knows the QPA platform) and runs for the life of +//! the process: geometry events arrive only when a window moves or resizes, +//! so an idle tracker costs nothing. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; + +use cxx_qt_lib::QString; +use wayland_client::globals::{registry_queue_init, GlobalListContents}; +use wayland_client::protocol::wl_registry; +use wayland_client::{Connection, Dispatch, QueueHandle}; +use wayland_protocols_plasma::plasma_window_management::client::org_kde_plasma_window::{ + self, OrgKdePlasmaWindow, +}; +use wayland_protocols_plasma::plasma_window_management::client::org_kde_plasma_window_management::{ + self, OrgKdePlasmaWindowManagement, +}; + +/// `window_with_uuid` arrived with v13; `client_geometry` (the surface +/// without decorations, which is what the ApplicationWindow measures) with +/// v18. Everything in between is tolerated: a v13–v17 compositor reports the +/// frame geometry instead, which is the same rectangle for QBZ's frameless +/// window. +const MIN_VERSION: u32 = 13; +const MAX_VERSION: u32 = 18; + +static STARTED: AtomicBool = AtomicBool::new(false); + +/// One window of this process, as the compositor reports it. Absolute +/// (global, logical) coordinates. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct WindowRect { + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, +} + +/// Everything the compositor has said about one mapped window, until its +/// `pid` proves it is not ours (then the proxy is destroyed and the entry +/// dropped). +#[derive(Debug, Default)] +struct Tracked { + pid: Option, + /// Frame geometry (`geometry`), the v13–v17 fallback. + frame: Option<(i32, i32, u32, u32)>, + /// Surface geometry (`client_geometry`, v18) — preferred. + client: Option<(i32, i32, u32, u32)>, +} + +struct State { + me: u32, + windows: HashMap, + dirty: bool, + /// What the bridge holds now: a move that ends where it started, or a + /// geometry event that repeats the frame, republishes nothing. + published: String, +} + +/// Start the tracker once per process. Safe to call on every mode switch: +/// every call after the first returns immediately. Outside a Wayland session +/// there is nothing to track and nothing is started. +pub fn start() { + if std::env::var_os("WAYLAND_DISPLAY").is_none() { + return; + } + if STARTED.swap(true, Ordering::SeqCst) { + return; + } + if let Err(e) = std::thread::Builder::new() + .name("qbz-wl-windows".into()) + .spawn(run) + { + log::warn!("[qbz-qt] wallpaper: window tracker thread failed to start: {e}"); + STARTED.store(false, Ordering::SeqCst); + } +} + +fn run() { + let conn = match Connection::connect_to_env() { + Ok(c) => c, + Err(e) => { + log::warn!("[qbz-qt] wallpaper: no Wayland connection for the window tracker: {e}"); + return; + } + }; + let (globals, mut queue) = match registry_queue_init::(&conn) { + Ok(v) => v, + Err(e) => { + log::warn!("[qbz-qt] wallpaper: Wayland registry failed: {e}"); + return; + } + }; + let qh = queue.handle(); + let _manager: OrgKdePlasmaWindowManagement = + match globals.bind(&qh, MIN_VERSION..=MAX_VERSION, ()) { + Ok(m) => m, + Err(e) => { + log::info!( + "[qbz-qt] wallpaper: org_kde_plasma_window_management is not offered to this \ + binary ({e}); the crop stays centred (wallpaper_wayland_qt.rs)" + ); + return; + } + }; + let mut state = State { + me: std::process::id(), + windows: HashMap::new(), + dirty: false, + published: "[]".to_string(), + }; + loop { + if let Err(e) = queue.blocking_dispatch(&mut state) { + log::warn!("[qbz-qt] wallpaper: window tracker stopped: {e}"); + publish("[]".to_string()); + return; + } + if state.dirty { + state.dirty = false; + let json = to_json(&state.mine()); + if json != state.published { + state.published = json.clone(); + publish(json); + } + } + } +} + +impl State { + /// This process's windows, in a stable order (position, then size) so + /// two publishes of the same layout serialise identically. + fn mine(&self) -> Vec { + let mut out: Vec = self + .windows + .values() + .filter(|w| w.pid == Some(self.me)) + .filter_map(|w| { + let (x, y, width, height) = w.client.or(w.frame)?; + Some(WindowRect { + x, + y, + width, + height, + }) + }) + .collect(); + out.sort_by(|a, b| { + (a.x, a.y, a.width, a.height).cmp(&(b.x, b.y, b.width, b.height)) + }); + out + } +} + +/// The JSON the shell bridge carries: `[{"x","y","w","h"}, …]`. +pub(crate) fn to_json(rects: &[WindowRect]) -> String { + let items: Vec = rects + .iter() + .map(|r| { + serde_json::json!({ + "x": r.x, + "y": r.y, + "w": r.width, + "h": r.height, + }) + }) + .collect(); + serde_json::Value::Array(items).to_string() +} + +fn publish(json: String) { + crate::shell_bridge::ui(move |mut b| { + b.as_mut() + .set_wallpaper_windows_json(QString::from(json.as_str())); + }); +} + +// The registry: `registry_queue_init` collected the globals; nothing to do +// per event. +impl Dispatch for State { + fn event( + _state: &mut Self, + _proxy: &wl_registry::WlRegistry, + _event: wl_registry::Event, + _data: &GlobalListContents, + _conn: &Connection, + _qh: &QueueHandle, + ) { + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + manager: &OrgKdePlasmaWindowManagement, + event: org_kde_plasma_window_management::Event, + _data: &(), + _conn: &Connection, + qh: &QueueHandle, + ) { + // A mapped window: ask for its object, keyed by the uuid the + // compositor gave it. Every window on the desktop announces itself + // here; the `pid` event sorts ours from the rest. + if let org_kde_plasma_window_management::Event::WindowWithUuid { uuid, .. } = event { + manager.get_window_by_uuid(uuid.clone(), qh, uuid.clone()); + state.windows.insert(uuid, Tracked::default()); + } + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + window: &OrgKdePlasmaWindow, + event: org_kde_plasma_window::Event, + uuid: &String, + _conn: &Connection, + _qh: &QueueHandle, + ) { + use org_kde_plasma_window::Event; + let me = state.me; + let Some(tracked) = state.windows.get_mut(uuid) else { + return; + }; + match event { + Event::PidChanged { pid } => tracked.pid = Some(pid), + Event::Geometry { + x, + y, + width, + height, + } => { + tracked.frame = Some((x, y, width, height)); + state.dirty |= tracked.pid == Some(me); + } + Event::ClientGeometry { + x, + y, + width, + height, + } => { + tracked.client = Some((x, y, width, height)); + state.dirty |= tracked.pid == Some(me); + } + // The compositor has said what it knows about a new window + // (kwin sends `pid_changed` before this, `client_geometry` + // after it). Someone else's: let go of it now, so its later + // moves never reach this thread. + Event::InitialState => { + if tracked.pid != Some(me) { + state.windows.remove(uuid); + window.destroy(); + } else { + state.dirty = true; + } + } + Event::Unmapped => { + let mine = tracked.pid == Some(me); + state.windows.remove(uuid); + window.destroy(); + state.dirty |= mine; + } + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn json_carries_each_window_with_short_keys() { + let rects = vec![ + WindowRect { + x: 640, + y: 120, + width: 1720, + height: 980, + }, + WindowRect { + x: -10, + y: 0, + width: 320, + height: 96, + }, + ]; + let json = to_json(&rects); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + let arr = parsed.as_array().unwrap(); + assert_eq!(arr.len(), 2); + assert_eq!(arr[0]["x"], 640); + assert_eq!(arr[0]["y"], 120); + assert_eq!(arr[0]["w"], 1720); + assert_eq!(arr[0]["h"], 980); + assert_eq!(arr[1]["x"], -10); + } + + #[test] + fn empty_tracker_publishes_an_empty_array() { + assert_eq!(to_json(&[]), "[]"); + } + + #[test] + fn only_this_process_windows_are_reported_and_client_geometry_wins() { + let mut state = State { + me: 4242, + windows: HashMap::new(), + dirty: false, + published: String::new(), + }; + state.windows.insert( + "a".into(), + Tracked { + pid: Some(4242), + frame: Some((100, 100, 800, 600)), + client: Some((104, 130, 792, 566)), + }, + ); + state.windows.insert( + "b".into(), + Tracked { + pid: Some(7), + frame: Some((0, 0, 500, 500)), + client: None, + }, + ); + state.windows.insert( + "c".into(), + Tracked { + pid: Some(4242), + frame: None, + client: None, + }, + ); + let mine = state.mine(); + assert_eq!(mine.len(), 1); + assert_eq!(mine[0].x, 104); + assert_eq!(mine[0].width, 792); + } +} diff --git a/flake.nix b/flake.nix index 799a5ae6d..bb9487bfb 100644 --- a/flake.nix +++ b/flake.nix @@ -87,6 +87,10 @@ install -Dm755 qbz "$out/bin/qbz" install -Dm644 qbz.desktop \ "$out/share/applications/com.blitzfc.qbz.desktop" + # KWin matches the Exec path against /proc//exe before it + # offers org_kde_plasma_window_management (Wallpaper background). + substituteInPlace "$out/share/applications/com.blitzfc.qbz.desktop" \ + --replace-warn "Exec=/usr/bin/qbz" "Exec=$out/bin/qbz" if [ -d icons ]; then cp -r icons "$out/share/icons"; fi install -Dm644 LICENSE "$out/share/licenses/qbz/LICENSE" if [ -d licenses ]; then diff --git a/packaging/arch/com.blitzfc.qbz.desktop b/packaging/arch/com.blitzfc.qbz.desktop index 93bf12922..aef3da006 100644 --- a/packaging/arch/com.blitzfc.qbz.desktop +++ b/packaging/arch/com.blitzfc.qbz.desktop @@ -1,7 +1,7 @@ [Desktop Entry] Name=QBZ Comment=Native Qobuz client with hi-res audio support -Exec=qbz %u +Exec=/usr/bin/qbz %u Type=Application Icon=com.blitzfc.qbz Categories=AudioVideo;Audio;Music;Player; @@ -10,3 +10,7 @@ StartupWMClass=com.blitzfc.qbz StartupNotify=true Keywords=music;audio;qobuz;hifi;streaming;audiophile;flac; MimeType=x-scheme-handler/qobuzapp; +# KWin hands org_kde_plasma_window_management (where this window is, for the +# Wallpaper background on Plasma Wayland) only to a binary named by an +# ABSOLUTE Exec that declares the interface here. +X-KDE-Wayland-Interfaces=org_kde_plasma_window_management diff --git a/packaging/linux/qbz.desktop b/packaging/linux/qbz.desktop index f8ca2203a..58c964902 100644 --- a/packaging/linux/qbz.desktop +++ b/packaging/linux/qbz.desktop @@ -1,7 +1,7 @@ [Desktop Entry] Name=QBZ Comment=Native Qobuz client with hi-res audio support -Exec=qbz %u +Exec=/usr/bin/qbz %u Icon=qbz Terminal=false Type=Application @@ -10,3 +10,7 @@ Keywords=music;audio;qobuz;hifi;streaming;audiophile;flac; StartupWMClass=com.blitzfc.qbz StartupNotify=true MimeType=x-scheme-handler/qobuzapp; +# KWin hands org_kde_plasma_window_management (where this window is, for the +# Wallpaper background on Plasma Wayland) only to a binary named by an +# ABSOLUTE Exec that declares the interface here. +X-KDE-Wayland-Interfaces=org_kde_plasma_window_management