From 32fd77f75e5f14ed5ab27b43b23639c1159ac3b8 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 14 Sep 2026 05:22:55 -0600 Subject: [PATCH] feat(qt): Wallpaper follows the window on Plasma Wayland; its blurred twin drifts "Wallpaper" now behaves like a window that sees straight through to the desktop: it always paints the part of the wallpaper that lies under the window, wherever the window is moved, with a stronger blur so nothing in the picture competes with the text. X11, macOS and Windows already told Qt where the window is. Wayland does not, so on KDE Plasma a tracker reads the window's geometry from org_kde_plasma_window_management on its own client connection and thread (wayland-client + the plasma protocol crate, pure Rust backend). It follows this process's windows only, drops every other window's object after its initial state, and republishes only when the geometry list changes; the field picks the window by size and ignores updates while another background is showing. Where the protocol is not offered the crop stays centred and one line is logged. KWin hands that protocol only to a binary whose desktop entry names it by an absolute Exec and lists it in X-KDE-Wayland-Interfaces, so the Linux desktop entries now carry both (the deb/rpm /usr/bin/qbz symlink resolves to the same binary). The AppImage step restores the bare Exec name that linuxdeploy resolves inside the AppDir, and the Nix package rewrites Exec to its store path. "Wallpaper, blurred" is Blurred art with the wallpaper in the cover's place, so it now drifts while the transport plays and holds still when it does not. cc --- .github/workflows/build-qt-linux.yml | 7 +- crates/Cargo.lock | 78 +++++ crates/qbz-qt/Cargo.toml | 5 + crates/qbz-qt/qml/shell/AppShell.qml | 22 +- crates/qbz-qt/qml/shell/WallpaperField.qml | 97 +++++- crates/qbz-qt/src/main.rs | 2 + crates/qbz-qt/src/shell_bridge.rs | 15 + crates/qbz-qt/src/wallpaper_qt.rs | 27 +- crates/qbz-qt/src/wallpaper_wayland_qt.rs | 380 +++++++++++++++++++++ flake.nix | 4 + packaging/arch/com.blitzfc.qbz.desktop | 6 +- packaging/linux/qbz.desktop | 6 +- 12 files changed, 610 insertions(+), 39 deletions(-) create mode 100644 crates/qbz-qt/src/wallpaper_wayland_qt.rs 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