From dea9974447c518d9ed8cfc25d0eab8e4f6bbc694 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 03:09:38 +0000 Subject: [PATCH 1/3] Put the wallpaper under the icons on Windows 11 24H2, and draw on the screen's GPU On Windows 11 24H2 and 25H2 the desktop is shaped differently: the icons stay a child of Progman, which has no redirection surface, and Explorer's wallpaper layer is another child of it, below the icons. kirie only knew the classic layout, so it fell back to Progman and its window landed on top of the icons. - Read Explorer's window tree once and decide the layout from it (desktop_tree, tested on Linux): classic split, raised, or not split yet. 0x052C is sent as 0xD/0x1, and only when the tree lacks what it makes. - On a raised desktop each wallpaper window sits inside a layered child of Progman, placed directly below SHELLDLL_DefView and above Explorer's layer, which is what Microsoft asks for there. The order is checked every poll and repaired only when Explorer changed it. KIRIE_NO_LAYERED_HOST skips the layered window. - Embed a manifest declaring Windows 8 to 11 in the MSVC build, which layered child windows need. - Pick the GPU driving the screen (the first DXGI adapter) instead of the integrated one, Vulkan first then DX12, and honor --gpu / KIRIE_GPU on Windows, which was parsed and dropped. WGPU_BACKEND and WGPU_DX12_PRESENTATION_SYSTEM are honored for testing. - Log the desktop tree, every adapter offered, and a screen that never gets its first frame. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QJnt7JtdtiqnVCP9GYqUhK --- crates/kirie-platform/src/backend.rs | 5 + crates/kirie-platform/src/desktop_tree.rs | 379 ++++++++++++ crates/kirie-platform/src/gpu.rs | 206 +++++++ crates/kirie-platform/src/lib.rs | 4 + crates/kirie-platform/src/win32.rs | 642 +++++++++++++++++---- crates/kirie-platform/src/windows.rs | 204 +++++-- crates/kirie/build.rs | 21 + crates/kirie/kirie.exe.manifest | 19 + crates/kirie/src/compat/args.rs | 14 +- crates/kirie/src/compat/desktop_present.rs | 1 + docs/COMMANDS.md | 8 +- 11 files changed, 1357 insertions(+), 146 deletions(-) create mode 100644 crates/kirie-platform/src/desktop_tree.rs create mode 100644 crates/kirie/kirie.exe.manifest diff --git a/crates/kirie-platform/src/backend.rs b/crates/kirie-platform/src/backend.rs index cf48e08..50889fd 100644 --- a/crates/kirie-platform/src/backend.rs +++ b/crates/kirie-platform/src/backend.rs @@ -22,6 +22,10 @@ pub struct PresentOptions { pub activity_paused: Option>, pub pointer: bool, pub take_clicks: bool, + /// The GPU to draw on, as a word from `kirie gpus`. Only Windows reads it; + /// elsewhere `--gpu` has already pinned the Vulkan driver by the time the + /// platform starts. + pub gpu: Option, } impl Default for PresentOptions { @@ -38,6 +42,7 @@ impl Default for PresentOptions { activity_paused: None, pointer: true, take_clicks: false, + gpu: None, } } } diff --git a/crates/kirie-platform/src/desktop_tree.rs b/crates/kirie-platform/src/desktop_tree.rs new file mode 100644 index 0000000..2b8e7a2 --- /dev/null +++ b/crates/kirie-platform/src/desktop_tree.rs @@ -0,0 +1,379 @@ +//! Working out where a wallpaper belongs among Explorer's desktop windows. +//! +//! Windows has had two shapes of desktop since Windows 7, and a wallpaper goes +//! somewhere different in each: +//! +//! - **Classic** (Windows 10, Windows 11 up to 23H2). Message `0x052C` splits +//! Progman into two top-level `WorkerW` windows: one holding +//! `SHELLDLL_DefView`, which is the icons, and an empty one behind it. The +//! empty one is ours, and our windows are children of it. +//! - **Raised** (Windows 11 24H2 servicing and 25H2). Progman keeps the icons +//! as a direct child, is made with `WS_EX_NOREDIRECTIONBITMAP`, and `0x052C` +//! makes a `WorkerW` *child* of Progman, below the icons, which draws the +//! Windows wallpaper. Nothing is ours there: Microsoft's advice, as Lively +//! quotes it, is to add a layered child of Progman of our own, below +//! `SHELLDLL_DefView` and above that `WorkerW`. +//! +//! The decision is kept apart from the Win32 calls that gather what it looks +//! at, so that it can be tested on any machine. `W` is a window handle on +//! Windows and a plain number in the tests. + +/// The class of the window holding the desktop icons. +pub(crate) const ICONS: &str = "SHELLDLL_DefView"; + +/// The class of the windows `0x052C` makes. +pub(crate) const WORKER: &str = "WorkerW"; + +/// One window, as much of it as the decision needs. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Seen { + pub(crate) window: W, + pub(crate) class: String, + /// Whether it has a `SHELLDLL_DefView` as a direct child. + pub(crate) has_icons: bool, +} + +/// Explorer's desktop windows at one moment. +#[derive(Clone, Debug)] +pub(crate) struct Snapshot { + pub(crate) progman: W, + /// Progman has `WS_EX_NOREDIRECTIONBITMAP`, which is how Windows marks the + /// raised desktop. It is set whether or not the `WorkerW` exists yet, so it + /// is the one thing that tells "raised, not split yet" from "classic, not + /// split yet". + pub(crate) raised_style: bool, + /// Progman's direct children, topmost first. + pub(crate) progman_children: Vec>, + /// The top-level `WorkerW` windows, topmost first. + pub(crate) top_workers: Vec>, +} + +/// Where the wallpaper windows go. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Layout { + /// Classic: inside the empty `WorkerW` behind the icons. + Behind { worker: W }, + /// Raised: children of Progman, below `icons` and above `layer`, Explorer's + /// own wallpaper. Either can be missing: the icons when the user hid them, + /// the layer until Explorer has made it. + UnderIcons { + progman: W, + icons: Option, + layer: Option, + }, + /// Classic but not split. There is no room behind the icons, because the + /// icon view draws the wallpaper itself; `0x052C` is what makes some. + Unsplit { progman: W }, +} + +impl Layout { + /// Whether asking Explorer to split the desktop could give a better answer. + pub(crate) fn wants_split(&self) -> bool { + matches!(self, Self::Unsplit { .. } | Self::UnderIcons { layer: None, .. }) + } + + /// The window our windows are made inside. + pub(crate) fn parent(&self) -> W { + match *self { + Self::Behind { worker } => worker, + Self::UnderIcons { progman, .. } | Self::Unsplit { progman } => progman, + } + } +} + +pub(crate) fn choose(snapshot: &Snapshot) -> Layout { + let child = |class: &str| { + snapshot + .progman_children + .iter() + .find(|seen| seen.class == class) + .map(|seen| seen.window) + }; + let (icons, layer) = (child(ICONS), child(WORKER)); + + // Both inside Progman, in either order, is the raised desktop even on the + // builds that made it before they started setting the style bit. + if snapshot.raised_style || (icons.is_some() && layer.is_some()) { + return Layout::UnderIcons { + progman: snapshot.progman, + icons, + layer, + }; + } + + // Only a `WorkerW` holding the icons counts. Anything else with a + // `SHELLDLL_DefView` inside, such as an old-style file dialog, is not the + // desktop, and the `WorkerW` after one of those can be the icons' own. + let holding = snapshot.top_workers.iter().position(|seen| seen.has_icons); + if let Some(at) = holding + && let Some(behind) = snapshot + .top_workers + .iter() + .skip(at + 1) + .find(|seen| !seen.has_icons) + { + return Layout::Behind { + worker: behind.window, + }; + } + + Layout::Unsplit { + progman: snapshot.progman, + } +} + +/// What to move so that `ours` sit below the icons and above Explorer's layer. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Restack { + /// Ours that are anywhere else, including not among Progman's children at + /// all. Each goes directly below the icons, or to the top without them. + pub(crate) lift: Vec, + /// Explorer's layer is above the icons, where it hides them and anything + /// below them, and goes to the bottom. + pub(crate) sink_layer: bool, +} + +impl Restack { + pub(crate) fn is_needed(&self) -> bool { + !self.lift.is_empty() || self.sink_layer + } +} + +/// Compare Progman's children, topmost first, with where ours should be. +/// +/// Nothing is moved that is already in place: moving windows every poll is +/// what makes a desktop flicker. +pub(crate) fn restack( + children: &[W], + ours: &[W], + icons: Option, + layer: Option, +) -> Restack { + let at = |window: W| children.iter().position(|child| *child == window); + let icons_at = icons.and_then(at); + let layer_at = layer.and_then(at); + + // A layer above the icons goes to the bottom, so being below it for now + // does not count against ours. + let sink_layer = matches!((layer_at, icons_at), (Some(layer), Some(icons)) if layer < icons); + let lift = ours + .iter() + .copied() + .filter(|window| match at(*window) { + None => true, + Some(mine) => { + icons_at.is_some_and(|icons| mine < icons) + || (!sink_layer && layer_at.is_some_and(|layer| mine > layer)) + } + }) + .collect(); + + Restack { lift, sink_layer } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn seen(window: u32, class: &str, has_icons: bool) -> Seen { + Seen { + window, + class: class.to_owned(), + has_icons, + } + } + + fn snapshot( + raised_style: bool, + progman_children: Vec>, + top_workers: Vec>, + ) -> Snapshot { + Snapshot { + progman: 1, + raised_style, + progman_children, + top_workers, + } + } + + #[test] + fn a_classic_split_desktop_puts_the_wallpaper_in_the_empty_worker() { + let tree = snapshot( + false, + vec![], + vec![seen(10, WORKER, true), seen(11, WORKER, false)], + ); + assert_eq!(choose(&tree), Layout::Behind { worker: 11 }); + } + + #[test] + fn a_classic_desktop_before_the_split_asks_for_one() { + let tree = snapshot(false, vec![seen(20, ICONS, false)], vec![]); + let layout = choose(&tree); + assert_eq!(layout, Layout::Unsplit { progman: 1 }); + assert!(layout.wants_split()); + } + + #[test] + fn only_the_worker_right_behind_the_icons_is_ours() { + // Only `WorkerW` windows are looked at, so an old-style file dialog + // with a DefView of its own never is; and a stray empty `WorkerW` + // above the real pair is not the one behind the icons. + let tree = snapshot( + false, + vec![], + vec![ + seen(9, WORKER, false), + seen(10, WORKER, true), + seen(11, WORKER, false), + ], + ); + assert_eq!(choose(&tree), Layout::Behind { worker: 11 }); + } + + #[test] + fn the_icons_own_worker_is_never_chosen() { + let tree = snapshot(false, vec![], vec![seen(10, WORKER, true)]); + assert_eq!(choose(&tree), Layout::Unsplit { progman: 1 }); + } + + #[test] + fn a_raised_desktop_goes_under_the_icons_and_over_the_layer() { + let tree = snapshot( + true, + vec![seen(20, ICONS, false), seen(21, WORKER, false)], + vec![], + ); + let layout = choose(&tree); + assert_eq!( + layout, + Layout::UnderIcons { + progman: 1, + icons: Some(20), + layer: Some(21) + } + ); + assert!(!layout.wants_split()); + assert_eq!(layout.parent(), 1); + } + + #[test] + fn a_raised_desktop_with_its_layer_on_top_is_still_raised() { + let tree = snapshot( + true, + vec![seen(21, WORKER, false), seen(20, ICONS, false)], + vec![], + ); + assert_eq!( + choose(&tree), + Layout::UnderIcons { + progman: 1, + icons: Some(20), + layer: Some(21) + } + ); + } + + #[test] + fn a_raised_desktop_without_its_layer_yet_never_goes_over_the_icons() { + let tree = snapshot(true, vec![seen(20, ICONS, false)], vec![]); + let layout = choose(&tree); + assert_eq!( + layout, + Layout::UnderIcons { + progman: 1, + icons: Some(20), + layer: None + } + ); + assert!(layout.wants_split()); + } + + #[test] + fn a_raised_desktop_with_the_icons_hidden_still_counts() { + let tree = snapshot(true, vec![seen(21, WORKER, false)], vec![]); + assert_eq!( + choose(&tree), + Layout::UnderIcons { + progman: 1, + icons: None, + layer: Some(21) + } + ); + } + + #[test] + fn both_inside_progman_is_raised_without_the_style_bit() { + let tree = snapshot( + false, + vec![seen(20, ICONS, false), seen(21, WORKER, false)], + vec![], + ); + assert!(matches!(choose(&tree), Layout::UnderIcons { .. })); + } + + #[test] + fn the_style_bit_wins_over_a_stale_classic_pair() { + let tree = snapshot( + true, + vec![seen(20, ICONS, false)], + vec![seen(10, WORKER, true), seen(11, WORKER, false)], + ); + assert!(matches!(choose(&tree), Layout::UnderIcons { .. })); + } + + #[test] + fn nothing_moves_when_everything_is_in_place() { + let fix = restack(&[20, 30, 31, 21], &[30, 31], Some(20), Some(21)); + assert!(!fix.is_needed(), "{fix:?}"); + } + + #[test] + fn a_new_window_above_the_icons_is_lifted_under_them() { + let fix = restack(&[30, 20, 21], &[30], Some(20), Some(21)); + assert_eq!(fix.lift, vec![30]); + assert!(!fix.sink_layer); + } + + #[test] + fn a_new_window_below_the_layer_is_lifted_above_it() { + // Lifted to just below the icons is above a layer that is below them. + let fix = restack(&[20, 21, 30], &[30], Some(20), Some(21)); + assert_eq!(fix.lift, vec![30]); + assert!(!fix.sink_layer); + } + + #[test] + fn a_layer_explorer_made_again_on_top_is_sunk() { + let fix = restack(&[21, 20, 30], &[30], Some(20), Some(21)); + assert!(fix.lift.is_empty(), "{fix:?}"); + assert!(fix.sink_layer); + } + + #[test] + fn a_layer_on_top_and_a_window_over_the_icons_both_move() { + let fix = restack(&[21, 30, 20], &[30], Some(20), Some(21)); + assert_eq!(fix.lift, vec![30]); + assert!(fix.sink_layer); + } + + #[test] + fn a_window_that_left_progman_is_put_back() { + let fix = restack(&[20, 21], &[30], Some(20), Some(21)); + assert_eq!(fix.lift, vec![30]); + } + + #[test] + fn without_icons_ours_only_have_to_be_above_the_layer() { + assert!(!restack(&[30, 21], &[30], None, Some(21)).is_needed()); + let fix = restack(&[21, 30], &[30], None, Some(21)); + assert_eq!(fix.lift, vec![30]); + assert!(!fix.sink_layer); + } + + #[test] + fn without_a_layer_ours_only_have_to_be_below_the_icons() { + assert!(!restack(&[20, 30], &[30], Some(20), None).is_needed()); + assert_eq!(restack(&[30, 20], &[30], Some(20), None).lift, vec![30]); + } +} diff --git a/crates/kirie-platform/src/gpu.rs b/crates/kirie-platform/src/gpu.rs index 3a44e39..a44b118 100644 --- a/crates/kirie-platform/src/gpu.rs +++ b/crates/kirie-platform/src/gpu.rs @@ -20,6 +20,113 @@ pub fn power_preference() -> wgpu::PowerPreference { } } +/// One adapter wgpu offered on Windows, as much of it as choosing needs. +#[cfg(any(windows, test))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Offer { + pub(crate) backend: wgpu::Backend, + /// PCI ids, which are the same whichever backend reports the GPU, and so + /// what tells one GPU's Vulkan and DX12 adapters apart from another's. + pub(crate) vendor: u32, + pub(crate) device: u32, + pub(crate) software: bool, +} + +/// Which GPU `preference` put first, and why. +#[cfg(any(windows, test))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Aim { + /// The one `--gpu` or `KIRIE_GPU` named. + Asked, + /// `--gpu` named a GPU this machine does not have, so the screen's. + AskedButAbsent, + /// The GPU driving the screen. + Screen, + /// No DX12 adapter to say which GPU drives the screen, which happens when + /// `WGPU_BACKEND` leaves DX12 out, so wgpu's own order. + Anything, +} + +/// The order to try `offers` in, best first, for a Windows desktop. +/// +/// The GPU to use is the one driving the screen, not the most frugal one: a +/// wallpaper drawn anywhere else has to be copied across to it every frame, +/// and on a desktop with the monitor on the graphics card and the integrated +/// GPU still enabled, that copy is where frames went missing. DXGI lists that +/// GPU first -- it is also where Windows' own per-app graphics setting moves +/// whichever GPU the user picked -- and wgpu keeps DXGI's order for its DX12 +/// adapters, so the first DX12 adapter names it. `--gpu` overrides it with a +/// word from `kirie gpus`. +/// +/// On the chosen GPU Vulkan comes before DX12, because Vulkan is what kirie's +/// shaders are written and tested against; DX12 is there for a GPU without a +/// Vulkan driver. Every other adapter follows as a fallback, software last. +#[cfg(any(windows, test))] +pub(crate) fn preference(offers: &[Offer], wanted: Option<&str>) -> (Vec, Aim) { + let key = |offer: &Offer| (offer.vendor, offer.device); + let screen = offers + .iter() + .find(|offer| offer.backend == wgpu::Backend::Dx12 && !offer.software) + .map(key); + + let wanted = wanted + .map(str::trim) + .filter(|word| !word.is_empty() && !word.eq_ignore_ascii_case("auto")); + let asked = wanted.and_then(|word| { + let named: Vec<&Offer> = offers.iter().filter(|offer| names(word, offer)).collect(); + // Of two GPUs from one vendor, the one on the screen, or else the one + // DXGI puts first. + named + .iter() + .find(|offer| Some(key(offer)) == screen) + .or_else(|| named.iter().find(|offer| offer.backend == wgpu::Backend::Dx12)) + .or_else(|| named.first()) + .map(|offer| key(offer)) + }); + let (target, aim) = match (asked, wanted, screen) { + (Some(asked), _, _) => (Some(asked), Aim::Asked), + (None, Some(_), _) => (screen, Aim::AskedButAbsent), + (None, None, Some(screen)) => (Some(screen), Aim::Screen), + (None, None, None) => (None, Aim::Anything), + }; + + let mut order: Vec = (0..offers.len()).collect(); + order.sort_by_key(|at| { + let Some(offer) = offers.get(*at) else { + return (u8::MAX, u8::MAX, *at); + }; + let tier = if target.is_some_and(|target| key(offer) == target) { + 0 + } else if offer.software { + 2 + } else { + 1 + }; + let backend = match offer.backend { + wgpu::Backend::Vulkan => 0, + wgpu::Backend::Dx12 => 1, + _ => 2, + }; + (tier, backend, *at) + }); + (order, aim) +} + +/// Whether a `--gpu` word, as `kirie gpus` lists them, names this adapter. +#[cfg(any(windows, test))] +fn names(word: &str, offer: &Offer) -> bool { + let word = word.to_ascii_lowercase(); + match word.as_str() { + "lvp" | "software" | "cpu" => offer.software, + _ if offer.software => false, + "nvidia" => offer.vendor == 0x10DE, + "amd" => matches!(offer.vendor, 0x1002 | 0x1022), + "intel" => offer.vendor == 0x8086, + "virtio" => offer.vendor == 0x1AF4, + _ => false, + } +} + #[cfg(any(target_os = "linux", target_os = "macos", windows))] pub(crate) struct Gpu { pub instance: wgpu::Instance, @@ -221,3 +328,102 @@ fn create_wgpu_surface( Ok(surface) } + +#[cfg(test)] +mod tests { + use super::*; + use wgpu::Backend::{Dx12, Gl, Vulkan}; + + const NVIDIA: (u32, u32) = (0x10DE, 0x2484); + const INTEL: (u32, u32) = (0x8086, 0x4680); + const WARP: (u32, u32) = (0x1414, 0x008C); + + fn offer(backend: wgpu::Backend, (vendor, device): (u32, u32)) -> Offer { + Offer { + backend, + vendor, + device, + software: (vendor, device) == WARP, + } + } + + /// A desktop with its monitor on the graphics card and the integrated GPU + /// still enabled, listed the way wgpu lists them: Vulkan, then DX12 in + /// DXGI's order. + fn desktop() -> Vec { + vec![ + offer(Vulkan, INTEL), + offer(Vulkan, NVIDIA), + offer(Dx12, NVIDIA), + offer(Dx12, INTEL), + offer(Dx12, WARP), + ] + } + + #[test] + fn the_gpu_driving_the_screen_comes_first_on_vulkan() { + let (order, aim) = preference(&desktop(), None); + assert_eq!(order, vec![1, 2, 0, 3, 4]); + assert_eq!(aim, Aim::Screen); + } + + #[test] + fn a_laptop_panel_on_the_integrated_gpu_keeps_it() { + let laptop = vec![ + offer(Vulkan, INTEL), + offer(Vulkan, NVIDIA), + offer(Dx12, INTEL), + offer(Dx12, NVIDIA), + offer(Dx12, WARP), + ]; + assert_eq!(preference(&laptop, None).0, vec![0, 2, 1, 3, 4]); + } + + #[test] + fn gpu_names_the_vendor_whichever_gpu_drives_the_screen() { + let (order, aim) = preference(&desktop(), Some("intel")); + assert_eq!(order, vec![0, 3, 1, 2, 4]); + assert_eq!(aim, Aim::Asked); + assert_eq!(preference(&desktop(), Some(" NVIDIA ")).0.first(), Some(&1)); + } + + #[test] + fn auto_and_nothing_mean_the_screen() { + for word in [None, Some(""), Some("auto"), Some("Auto")] { + assert_eq!( + preference(&desktop(), word), + preference(&desktop(), None), + "{word:?}" + ); + } + } + + #[test] + fn a_gpu_this_machine_lacks_falls_back_to_the_screen() { + for word in ["amd", "banana"] { + let (order, aim) = preference(&desktop(), Some(word)); + assert_eq!(order.first(), Some(&1), "{word}"); + assert_eq!(aim, Aim::AskedButAbsent, "{word}"); + } + } + + #[test] + fn software_is_last_unless_asked_for() { + assert_eq!(preference(&desktop(), None).0.last(), Some(&4)); + assert_eq!(preference(&desktop(), Some("lvp")).0.first(), Some(&4)); + } + + #[test] + fn without_dx12_the_order_is_wgpus_own() { + let vulkan_only = vec![offer(Vulkan, NVIDIA), offer(Vulkan, INTEL), offer(Gl, NVIDIA)]; + let (order, aim) = preference(&vulkan_only, None); + assert_eq!(order, vec![0, 1, 2]); + assert_eq!(aim, Aim::Anything); + } + + #[test] + fn a_gpu_without_vulkan_still_gets_dx12() { + let no_vulkan = vec![offer(Vulkan, INTEL), offer(Dx12, NVIDIA), offer(Dx12, INTEL)]; + assert_eq!(preference(&no_vulkan, None).0, vec![1, 0, 2]); + } +} diff --git a/crates/kirie-platform/src/lib.rs b/crates/kirie-platform/src/lib.rs index 7e8e23c..e5d440e 100644 --- a/crates/kirie-platform/src/lib.rs +++ b/crates/kirie-platform/src/lib.rs @@ -2,6 +2,10 @@ #[cfg(any(target_os = "linux", target_os = "macos", windows))] mod backend; +// Pure, so it is built for tests everywhere; only Windows uses it. +#[cfg(any(windows, test))] +#[cfg_attr(not(windows), allow(dead_code))] +mod desktop_tree; mod error; mod gpu; #[cfg(target_os = "macos")] diff --git a/crates/kirie-platform/src/win32.rs b/crates/kirie-platform/src/win32.rs index 01a403f..824d3a5 100644 --- a/crates/kirie-platform/src/win32.rs +++ b/crates/kirie-platform/src/win32.rs @@ -24,14 +24,18 @@ use windows_sys::Win32::UI::HiDpi::{ }; use windows_sys::Win32::UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_LBUTTON}; use windows_sys::Win32::UI::WindowsAndMessaging::{ - CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, EnumWindows, FindWindowExW, - FindWindowW, GetCursorPos, GetForegroundWindow, GetSystemMetrics, GetWindowRect, - GetWindowThreadProcessId, IsWindow, MSG, PM_REMOVE, PeekMessageW, RegisterClassW, SM_CXVIRTUALSCREEN, - SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, SMTO_NORMAL, SW_SHOWNA, SWP_NOACTIVATE, - SWP_NOZORDER, SendMessageTimeoutW, SetParent, SetWindowPos, ShowWindow, WM_ERASEBKGND, WNDCLASSW, - WS_CHILD, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, WS_EX_TRANSPARENT, WS_VISIBLE, + CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, FindWindowExW, FindWindowW, GW_CHILD, + GW_HWNDNEXT, GWL_EXSTYLE, GetClassNameW, GetCursorPos, GetForegroundWindow, GetSystemMetrics, GetWindow, + GetWindowLongW, GetWindowRect, GetWindowThreadProcessId, HWND_BOTTOM, HWND_TOP, IsWindow, LWA_ALPHA, MSG, + PM_REMOVE, PeekMessageW, RegisterClassW, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, + SM_YVIRTUALSCREEN, SMTO_NORMAL, SW_SHOWNA, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, + SendMessageTimeoutW, SetLayeredWindowAttributes, SetParent, SetWindowPos, ShowWindow, WM_ERASEBKGND, + WNDCLASSW, WS_CHILD, WS_CLIPCHILDREN, WS_CLIPSIBLINGS, WS_EX_LAYERED, WS_EX_NOACTIVATE, + WS_EX_NOREDIRECTIONBITMAP, WS_EX_TOOLWINDOW, WS_EX_TRANSPARENT, }; +use crate::desktop_tree::{self, Layout, Seen, Snapshot}; + /// A window on the desktop, kept as a raw handle because that is what both /// Win32 and wgpu want to be handed. pub(crate) type Handle = HWND; @@ -188,97 +192,188 @@ pub(crate) fn virtual_screen() -> Rect { } } -/// The window to put wallpapers inside, which is the same one Wallpaper Engine -/// and Lively use. +/// Where on the desktop the wallpaper windows go, with the windows found there. +pub(crate) type Desktop = Layout; + +/// Find where the wallpaper goes, the way Wallpaper Engine and Lively do. /// -/// The desktop is drawn by Explorer's `Progman`, and behind the icons there is -/// room for exactly one more window. Progman only creates that window -- -/// a `WorkerW` -- when it is asked, and the way to ask is an undocumented -/// message, `0x052C`, that has been the way since Windows 7. Once it exists, -/// Explorer has two `WorkerW` windows: the one holding `SHELLDLL_DefView`, -/// which is the icons, and a sibling behind it, which is ours to draw in. +/// The desktop is Explorer's `Progman`, and there is only room for a wallpaper +/// behind its icons once it has been asked to make some. The way to ask is an +/// undocumented message, `0x052C`, which has been the way since Windows 7. +/// What it makes changed in Windows 11 24H2 (`desktop_tree` has both shapes), +/// so the message is only sent when what is there is not enough, and then +/// Explorer is given a moment: it can make the new window lazily. /// -/// Every part of this can fail on a Windows that changed its mind -- the -/// message can do nothing, the sibling can be absent -- so the fallback is -/// Progman itself. Drawing there works; it just puts the wallpaper in front of -/// the icons rather than behind them. -#[allow(unsafe_code)] -pub(crate) fn wallpaper_host() -> Option { - // SAFETY: FindWindowW takes a null-terminated class name and no parent. - let progman = unsafe { FindWindowW(wide("Progman").as_ptr(), std::ptr::null()) }; - if progman.is_null() { - tracing::warn!("no Progman window; is Explorer running?"); - return None; +/// A classic desktop that will not split is the one case left over. The +/// wallpaper then goes on Progman in front of the icons, which is visible and +/// wrong rather than invisible, and says so in the log. +pub(crate) fn find_desktop() -> Option { + let mut seen = snapshot()?; + let mut layout = desktop_tree::choose(&seen); + if layout.wants_split() { + split(seen.progman); + for _ in 0..SPLIT_POLLS { + std::thread::sleep(SPLIT_POLL); + let Some(again) = snapshot() else { + break; + }; + seen = again; + layout = desktop_tree::choose(&seen); + if !layout.wants_split() { + break; + } + } + } + + let classes = |windows: &[Seen]| { + windows + .iter() + .map(|window| { + format!( + "{}{}", + window.class, + if window.has_icons { "(icons)" } else { "" } + ) + }) + .collect::>() + .join(" ") + }; + tracing::info!( + raised = seen.raised_style, + progman_children = %classes(&seen.progman_children), + top_level_workers = %classes(&seen.top_workers), + ?layout, + "found the desktop" + ); + match layout { + Layout::Unsplit { .. } => tracing::warn!( + "Explorer did not make a window behind the desktop icons; drawing in front of them instead" + ), + Layout::UnderIcons { layer: None, .. } => tracing::warn!( + "Explorer has not made its wallpaper layer, so the icons may still be drawing the wallpaper over ours" + ), + Layout::UnderIcons { .. } | Layout::Behind { .. } => {} } + Some(layout) +} + +/// The message that makes Progman split the desktop. Undocumented; `0xD, 1` +/// is what Lively, Seelen and FeatherWall send, and unlike `0, 0` it does not +/// depend on "Animate controls and elements inside windows" being on. +const SPLIT_DESKTOP: u32 = 0x052C; + +const SPLIT_TIMEOUT_MS: u32 = 1_000; +/// How often, and how many times, to look again after asking for the split. +const SPLIT_POLL: std::time::Duration = std::time::Duration::from_millis(100); +const SPLIT_POLLS: u32 = 20; + +#[allow(unsafe_code)] +fn split(progman: Handle) { // SAFETY: a message send with a timeout, to a window we just found, whose // result we do not read. unsafe { SendMessageTimeoutW( progman, - SPAWN_WORKERW, - WPARAM::default(), - LPARAM::default(), + SPLIT_DESKTOP, + 0xD, + 0x1, SMTO_NORMAL, - SPAWN_TIMEOUT_MS, + SPLIT_TIMEOUT_MS, std::ptr::null_mut(), ); } +} - let mut worker: Handle = std::ptr::null_mut(); - // SAFETY: the callback matches EnumWindows's signature, and the lparam is - // the address of `worker`, which outlives the call. - unsafe { - EnumWindows(Some(find_worker), std::ptr::from_mut(&mut worker) as LPARAM); +/// Explorer's desktop windows as they are now. +#[allow(unsafe_code)] +fn snapshot() -> Option> { + // SAFETY: FindWindowW takes a null-terminated class name and no title. + let progman = unsafe { FindWindowW(wide("Progman").as_ptr(), std::ptr::null()) }; + if progman.is_null() { + tracing::warn!("no Progman window; is Explorer running?"); + return None; } + Some(snapshot_of(progman)) +} - if worker.is_null() { - tracing::warn!( - "Explorer did not make a window behind the desktop icons; drawing in front of them instead" - ); - return Some(progman); +#[allow(unsafe_code)] +fn snapshot_of(progman: Handle) -> Snapshot { + // The top-level `WorkerW` windows in z-order: each search starts after the + // one before, and a null parent keeps it to top-level windows. + let class = wide(desktop_tree::WORKER); + let mut top_workers = Vec::new(); + let mut after: Handle = std::ptr::null_mut(); + while top_workers.len() < MAX_WINDOWS { + // SAFETY: `after` is null or a window the previous search returned, and + // `class` is a null-terminated string that outlives the call. + let next = unsafe { FindWindowExW(std::ptr::null_mut(), after, class.as_ptr(), std::ptr::null()) }; + if next.is_null() { + break; + } + top_workers.push(seen(next)); + after = next; + } + + Snapshot { + progman, + raised_style: ex_style(progman) & WS_EX_NOREDIRECTIONBITMAP != 0, + progman_children: children(progman).into_iter().map(seen).collect(), + top_workers, } - Some(worker) } -/// The message that makes Progman split the desktop in two. Undocumented, and -/// the same value every Windows since 7 has answered to. -const SPAWN_WORKERW: u32 = 0x052C; +/// No desktop has this many, so a search that gets here is going round in +/// circles on windows that are being made and destroyed under it. +const MAX_WINDOWS: usize = 256; -const SPAWN_TIMEOUT_MS: u32 = 1_000; +fn seen(window: Handle) -> Seen { + Seen { + window, + class: class_of(window), + has_icons: has_icons(window), + } +} +/// A window's direct children, topmost first. #[allow(unsafe_code)] -unsafe extern "system" fn find_worker(window: HWND, out: LPARAM) -> i32 { - // SAFETY: `window` comes from EnumWindows and is valid for this call. - let shell_view = unsafe { - FindWindowExW( - window, - std::ptr::null_mut(), - wide("SHELLDLL_DefView").as_ptr(), - std::ptr::null(), - ) - }; - if shell_view.is_null() { - return 1; +fn children(parent: Handle) -> Vec { + let mut found = Vec::new(); + // SAFETY: GetWindow is defined for any handle, and answers null for a + // window that has gone. + let mut next = unsafe { GetWindow(parent, GW_CHILD) }; + while !next.is_null() && found.len() < MAX_WINDOWS { + found.push(next); + // SAFETY: as above. + next = unsafe { GetWindow(next, GW_HWNDNEXT) }; } + found +} - // The icons live here, so the window we want is the next WorkerW after it. - // SAFETY: searching top-level windows starting after `window`. - let sibling = unsafe { - FindWindowExW( - std::ptr::null_mut(), - window, - wide("WorkerW").as_ptr(), - std::ptr::null(), - ) - }; - if sibling.is_null() { - return 1; - } +#[allow(unsafe_code)] +fn class_of(window: Handle) -> String { + let mut name = [0u16; 256]; + // SAFETY: the buffer's real length is passed, and the call writes at most + // that many characters including the terminator. + let length = unsafe { GetClassNameW(window, name.as_mut_ptr(), name.len() as i32) }; + let length = usize::try_from(length).unwrap_or(0).min(name.len()); + String::from_utf16_lossy(name.get(..length).unwrap_or_default()) +} + +#[allow(unsafe_code)] +fn has_icons(window: Handle) -> bool { + let class = wide(desktop_tree::ICONS); + // SAFETY: a search of `window`'s direct children for a null-terminated + // class name that outlives the call; a dead `window` finds nothing. + let icons = unsafe { FindWindowExW(window, std::ptr::null_mut(), class.as_ptr(), std::ptr::null()) }; + !icons.is_null() +} - // SAFETY: the lparam is the `&mut Handle` wallpaper_host() passed in. - unsafe { *(out as *mut Handle) = sibling }; - 0 +#[allow(unsafe_code)] +fn ex_style(window: Handle) -> u32 { + // SAFETY: reading a window's extended style, which is defined for any + // handle and answers 0 for a dead one. + (unsafe { GetWindowLongW(window, GWL_EXSTYLE) }) as u32 } /// Whether a window handle still names a live window. @@ -291,16 +386,48 @@ pub(crate) fn alive(window: Handle) -> bool { unsafe { IsWindow(window) != 0 } } -/// Make a borderless child window covering `rect`, parented into the desktop. +/// A screen's wallpaper window, and the layered window it sits in on a raised +/// desktop. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Made { + pub(crate) window: Handle, + pub(crate) holder: Option, +} + +impl Made { + /// The window that is a child of the desktop, and so the one to move. + pub(crate) fn outer(self) -> Handle { + self.holder.unwrap_or(self.window) + } + + pub(crate) fn alive(self) -> bool { + alive(self.window) && self.holder.is_none_or(alive) + } +} + +/// Make a borderless child window covering `rect`, inside the desktop. /// -/// `rect` is in virtual-screen coordinates; the host's client area starts at +/// `rect` is in virtual-screen coordinates; the desktop's client area starts at /// the virtual screen's own origin, so the offset between the two is what turns /// one into the other. +/// +/// On a raised desktop the window goes inside a layered window of its own, +/// which is what Microsoft asks for there: Progman has no redirection surface, +/// so a plain child of it has nowhere for a copied (blt) present to land, and +/// only presents that bypass it would show. The layered window gives every +/// present somewhere to go, and the wallpaper window inside it stays an +/// ordinary child for wgpu or a web view to draw into. If Windows will not make +/// one, the wallpaper window goes straight into Progman, which works for the +/// presents that do not need it. `KIRIE_NO_LAYERED_HOST` asks for that. #[allow(unsafe_code)] -pub(crate) fn desktop_window(host: Handle, rect: Rect, take_clicks: bool) -> Option { +pub(crate) fn desktop_window(desktop: &Desktop, rect: Rect, take_clicks: bool) -> Option { register_class()?; let origin = virtual_screen(); + let at = ( + rect.left.saturating_sub(origin.left), + rect.top.saturating_sub(origin.top), + ); let style_ex = if take_clicks { WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW } else { @@ -309,6 +436,19 @@ pub(crate) fn desktop_window(host: Handle, rect: Rect, take_clicks: bool) -> Opt WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW | WS_EX_TRANSPARENT }; + let holder = match *desktop { + Layout::UnderIcons { progman, .. } if std::env::var_os("KIRIE_NO_LAYERED_HOST").is_none() => { + layered_holder(progman, at, rect, style_ex) + } + _ => None, + }; + let (parent, at) = match holder { + Some(holder) => (holder, (0, 0)), + None => (desktop.parent(), at), + }; + + // Made hidden, so that it is never seen in front of the icons while it is + // still being put behind them. // SAFETY: the class is registered above, the parent is a live window, and // every pointer is either null or a null-terminated string that outlives // the call. @@ -317,12 +457,12 @@ pub(crate) fn desktop_window(host: Handle, rect: Rect, take_clicks: bool) -> Opt style_ex, wide(CLASS_NAME).as_ptr(), wide("kirie").as_ptr(), - WS_CHILD | WS_VISIBLE, - rect.left.saturating_sub(origin.left), - rect.top.saturating_sub(origin.top), + WS_CHILD | WS_CLIPSIBLINGS, + at.0, + at.1, rect.width() as i32, rect.height() as i32, - host, + parent, std::ptr::null_mut(), module_handle(), std::ptr::null(), @@ -330,53 +470,190 @@ pub(crate) fn desktop_window(host: Handle, rect: Rect, take_clicks: bool) -> Opt }; if window.is_null() { tracing::error!("could not create the wallpaper window"); + if let Some(holder) = holder { + destroy(holder); + } + return None; + } + + let made = Made { window, holder }; + if let Layout::UnderIcons { icons, .. } = *desktop { + under_icons(made.outer(), icons); + } + // SAFETY: showing windows we just made, without taking focus from whatever + // the user is doing. The holder, when there is one, shows its child with it. + unsafe { + ShowWindow(window, SW_SHOWNA); + if let Some(holder) = holder { + ShowWindow(holder, SW_SHOWNA); + } + } + Some(made) +} + +/// A hidden, opaque, layered child of Progman over `rect`, or `None` when +/// Windows will not make one. +/// +/// A layered child needs Windows 8 and an executable whose manifest says it +/// knows about Windows 8, which kirie's build embeds; without it Windows can +/// quietly make an ordinary window instead, so the style is read back. A +/// layered window also stays invisible until its opacity is set. +#[allow(unsafe_code)] +fn layered_holder(progman: Handle, at: (i32, i32), rect: Rect, style_ex: u32) -> Option { + // SAFETY: as for the wallpaper window in `desktop_window`. + let holder = unsafe { + CreateWindowExW( + style_ex | WS_EX_LAYERED, + wide(CLASS_NAME).as_ptr(), + wide("kirie").as_ptr(), + WS_CHILD | WS_CLIPSIBLINGS | WS_CLIPCHILDREN, + at.0, + at.1, + rect.width() as i32, + rect.height() as i32, + progman, + std::ptr::null_mut(), + module_handle(), + std::ptr::null(), + ) + }; + if holder.is_null() { + tracing::warn!("Windows would not make a layered window on the desktop; drawing without one"); + return None; + } + let layered = ex_style(holder) & WS_EX_LAYERED != 0; + // SAFETY: a live window this process just made, with no pointer arguments. + let opaque = layered && unsafe { SetLayeredWindowAttributes(holder, 0, 255, LWA_ALPHA) } != 0; + if !opaque { + tracing::warn!( + layered, + "Windows would not make a layered window on the desktop; drawing without one" + ); + destroy(holder); return None; } + Some(holder) +} - // CreateWindowExW already took `host` as the parent, but Explorer restarting - // orphans the window, and re-parenting is how it is put back; doing it here - // too keeps one path for both. - reparent(window, host); - // SAFETY: showing a window we just made, without taking focus from whatever - // the user is doing. - unsafe { ShowWindow(window, SW_SHOWNA) }; - Some(window) +/// Put `window` directly below the icons, or at the top without them. +#[allow(unsafe_code)] +fn under_icons(window: Handle, icons: Option) { + // SAFETY: a z-order change with no pointer arguments; a dead handle on + // either side makes it fail, and the next `keep_under_icons` tries again. + unsafe { + SetWindowPos( + window, + icons.unwrap_or(HWND_TOP), + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE, + ); + } } -/// Put `window` back inside `host` and over `rect`. +/// Keep `ours` below the icons and above Explorer's wallpaper layer on a +/// raised desktop, answering whether anything had to move. +/// +/// Explorer makes its layer again whenever the wallpaper or the slideshow +/// changes, and a new window can land on top of ours, so the children are +/// read fresh each time, and only what is out of place is moved. #[allow(unsafe_code)] -pub(crate) fn reparent(window: Handle, host: Handle) { - if !alive(window) || !alive(host) { +pub(crate) fn keep_under_icons(desktop: &mut Desktop, ours: &[Handle]) -> bool { + let Layout::UnderIcons { + progman, + icons, + layer, + } = desktop + else { + return false; + }; + let children = children(*progman); + let classes: Vec = children.iter().map(|child| class_of(*child)).collect(); + let first = |wanted: &str| { + children + .iter() + .zip(&classes) + .find(|(_, class)| class.as_str() == wanted) + .map(|(child, _)| *child) + }; + *icons = first(desktop_tree::ICONS); + *layer = first(desktop_tree::WORKER); + + let fix = desktop_tree::restack(&children, ours, *icons, *layer); + for window in &fix.lift { + under_icons(*window, *icons); + } + if fix.sink_layer + && let Some(layer) = *layer + { + // SAFETY: as in `under_icons`. + unsafe { + SetWindowPos( + layer, + HWND_BOTTOM, + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE, + ); + } + } + fix.is_needed() +} + +/// Put `window` back inside `parent`. +/// +/// This only moves the window between parents; on a raised desktop the caller +/// follows it with `keep_under_icons`, because a window given a parent lands on +/// top of its new siblings, in front of the icons. +#[allow(unsafe_code)] +pub(crate) fn reparent(window: Handle, parent: Handle) { + if !alive(window) || !alive(parent) { return; } // SAFETY: both handles are live, checked immediately above. - unsafe { SetParent(window, host) }; + unsafe { SetParent(window, parent) }; } /// Move and resize a wallpaper window to cover `rect`. /// /// The z-order argument is null because `SWP_NOZORDER` is set, and that flag /// tells Windows to ignore whatever is passed there. Keeping the order is what -/// is wanted: the wallpaper windows are children of the desktop host, one per -/// monitor, so they never overlap each other, and sinking one to the bottom of -/// that sibling list would gain nothing while risking a reshuffle every poll. +/// is wanted: where a wallpaper sits among Explorer's windows is +/// `keep_under_icons`'s business, and a move is not a reason to change it. #[allow(unsafe_code)] -pub(crate) fn place(window: Handle, rect: Rect) { - if !alive(window) { - return; - } +pub(crate) fn place(made: Made, rect: Rect) { let origin = virtual_screen(); - // SAFETY: a live window, and a placement with no pointer arguments. - unsafe { - SetWindowPos( - window, - std::ptr::null_mut(), - rect.left.saturating_sub(origin.left), - rect.top.saturating_sub(origin.top), - rect.width() as i32, - rect.height() as i32, - SWP_NOACTIVATE | SWP_NOZORDER, - ); + let at = ( + rect.left.saturating_sub(origin.left), + rect.top.saturating_sub(origin.top), + ); + let resize = |window: Handle, (x, y): (i32, i32)| { + if !alive(window) { + return; + } + // SAFETY: a live window, and a placement with no pointer arguments. + unsafe { + SetWindowPos( + window, + std::ptr::null_mut(), + x, + y, + rect.width() as i32, + rect.height() as i32, + SWP_NOACTIVATE | SWP_NOZORDER, + ); + } + }; + match made.holder { + Some(holder) => { + resize(holder, at); + resize(made.window, (0, 0)); + } + None => resize(made.window, at), } } @@ -679,4 +956,151 @@ mod tests { fn a_string_going_to_win32_is_terminated() { assert_eq!(wide("ab"), vec![b'a' as u16, b'b' as u16, 0]); } + + // The tests below make real windows standing in for Explorer's, so that + // the calls `desktop_tree`'s decisions go through are checked too: which + // way round `GetWindow` lists children, where `SetWindowPos` puts a window, + // and that a restack leaves the order it meant to. + + use windows_sys::Win32::UI::WindowsAndMessaging::WS_POPUP; + + /// A hidden window of `class`, registering the class on first use. + #[allow(unsafe_code)] + fn stand_in(class: &str, parent: Handle, style_ex: u32) -> Handle { + let name = wide(class); + let registration = WNDCLASSW { + style: 0, + lpfnWndProc: Some(wallpaper_proc), + cbClsExtra: 0, + cbWndExtra: 0, + hInstance: module_handle(), + hIcon: std::ptr::null_mut(), + hCursor: std::ptr::null_mut(), + hbrBackground: std::ptr::null_mut(), + lpszMenuName: std::ptr::null(), + lpszClassName: name.as_ptr(), + }; + let style = if parent.is_null() { WS_POPUP } else { WS_CHILD }; + // SAFETY: as in `register_class` and `desktop_window`; registering a + // class twice fails harmlessly, which the second test to get here does. + let window = unsafe { + RegisterClassW(std::ptr::from_ref(®istration)); + CreateWindowExW( + style_ex, + name.as_ptr(), + name.as_ptr(), + style, + 0, + 0, + 64, + 64, + parent, + std::ptr::null_mut(), + module_handle(), + std::ptr::null(), + ) + }; + assert!(!window.is_null(), "could not make a stand-in {class}"); + window + } + + #[allow(unsafe_code)] + fn put_after(window: Handle, after: Handle) { + // SAFETY: a z-order change between live windows, no pointer arguments. + unsafe { + SetWindowPos( + window, + after, + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE, + ) + }; + } + + const SMALL: Rect = Rect { + left: 0, + top: 0, + right: 32, + bottom: 32, + }; + + #[test] + fn a_raised_desktop_gets_the_wallpaper_between_the_icons_and_the_layer() { + let progman = stand_in( + "kirie-test-progman", + std::ptr::null_mut(), + WS_EX_NOREDIRECTIONBITMAP, + ); + let icons = stand_in(desktop_tree::ICONS, progman, 0); + let layer = stand_in(desktop_tree::WORKER, progman, 0); + put_after(layer, icons); + assert_eq!( + children(progman), + vec![icons, layer], + "children are listed topmost first" + ); + + let mut desktop = desktop_tree::choose(&snapshot_of(progman)); + assert_eq!( + desktop, + Layout::UnderIcons { + progman, + icons: Some(icons), + layer: Some(layer) + } + ); + let made = desktop_window(&desktop, SMALL, false).expect("a wallpaper window"); + let ours = made.outer(); + assert_eq!(children(progman), vec![icons, ours, layer]); + if let Some(holder) = made.holder { + assert!(ex_style(holder) & WS_EX_LAYERED != 0, "the holder is layered"); + assert_eq!(children(holder), vec![made.window]); + } + + // Explorer makes its layer again, and the new one lands on top. + put_after(layer, HWND_TOP); + assert_eq!(children(progman), vec![layer, icons, ours]); + assert!( + keep_under_icons(&mut desktop, &[ours]), + "the layer was out of place" + ); + assert_eq!(children(progman), vec![icons, ours, layer]); + assert!( + !keep_under_icons(&mut desktop, &[ours]), + "nothing moves once in place" + ); + + // And a window that ends up over the icons goes back under them. + put_after(ours, HWND_TOP); + assert!(keep_under_icons(&mut desktop, &[ours])); + assert_eq!(children(progman), vec![icons, ours, layer]); + + destroy(progman); + assert!(!made.alive(), "children go with their parent"); + } + + #[test] + fn a_classic_desktop_gets_the_wallpaper_in_the_worker_behind_the_icons() { + let front = stand_in(desktop_tree::WORKER, std::ptr::null_mut(), 0); + let _icons = stand_in(desktop_tree::ICONS, front, 0); + let behind = stand_in(desktop_tree::WORKER, std::ptr::null_mut(), 0); + put_after(behind, front); + let progman = stand_in("kirie-test-progman", std::ptr::null_mut(), 0); + + let desktop = desktop_tree::choose(&snapshot_of(progman)); + assert_eq!(desktop, Layout::Behind { worker: behind }); + let made = desktop_window(&desktop, SMALL, false).expect("a wallpaper window"); + assert!( + made.holder.is_none(), + "only a raised desktop needs a layered window" + ); + assert_eq!(children(behind), vec![made.window]); + + for window in [front, behind, progman] { + destroy(window); + } + } } diff --git a/crates/kirie-platform/src/windows.rs b/crates/kirie-platform/src/windows.rs index 738a38b..40b179e 100644 --- a/crates/kirie-platform/src/windows.rs +++ b/crates/kirie-platform/src/windows.rs @@ -4,9 +4,10 @@ //! surface on each, and a loop that draws them and paces itself. What differs //! is where the windows go. Windows has exactly one place a wallpaper can //! live -- inside Explorer's own desktop window, behind the icons -- so -//! `win32::wallpaper_host` finds it and every window is a child of it. The +//! `win32::find_desktop` finds it and every window is a child of it. The //! same place also has to be found again whenever Explorer restarts, which it -//! does on its own schedule and takes our windows' parent with it. +//! does on its own schedule and takes our windows' parent with it, and on a +//! raised desktop our windows have to be kept in order among Explorer's own. use std::time::{Duration, Instant}; @@ -20,6 +21,8 @@ use crate::win32::{self, Rect}; struct WinOutput { window: win32::Handle, + /// The layered window `window` sits in on a raised desktop. + holder: Option, name: String, rect: Rect, format: wgpu::TextureFormat, @@ -30,6 +33,9 @@ struct WinOutput { drawn_at: Option, due_at: Option, first_frame_presented: bool, + /// Frames in a row the surface would not give out, so that a screen that + /// never gets to draw says so rather than staying blank in silence. + skipped: u32, covered: bool, /// A web page drawing this screen instead of a renderer. While there is /// one the window has no wgpu surface: the two cannot share a window. @@ -40,6 +46,13 @@ struct WinOutput { } impl WinOutput { + fn made(&self) -> win32::Made { + win32::Made { + window: self.window, + holder: self.holder, + } + } + fn size(&self) -> SurfaceSize { SurfaceSize { width: self.rect.width(), @@ -58,7 +71,7 @@ struct PauseConfig { pub struct WindowsPlatform { outputs: Vec, gpu: Gpu, - host: win32::Handle, + desktop: win32::Desktop, make_renderer: RendererFactory, frame_interval: Duration, pointer: bool, @@ -82,7 +95,7 @@ impl WindowsPlatform { win32::announce_dpi_awareness(); win32::enter_apartment(); - let host = win32::wallpaper_host().ok_or(PlatformError::NoDesktopHost)?; + let mut desktop = win32::find_desktop().ok_or(PlatformError::NoDesktopHost)?; let chosen = chosen_monitors(&options.screen_roots); if chosen.is_empty() { return Err(PlatformError::NoCrtcs); @@ -90,7 +103,7 @@ impl WindowsPlatform { let mut made = Vec::with_capacity(chosen.len()); for monitor in chosen { - match win32::desktop_window(host, monitor.rect, options.take_clicks) { + match win32::desktop_window(&desktop, monitor.rect, options.take_clicks) { Some(window) => made.push((window, monitor)), None => tracing::error!(output = %monitor.name, "no wallpaper window for this screen"), } @@ -98,12 +111,15 @@ impl WindowsPlatform { let Some((first, _)) = made.first() else { return Err(PlatformError::NoCrtcs); }; + let ours: Vec = made.iter().map(|(window, _)| window.outer()).collect(); + win32::keep_under_icons(&mut desktop, &ours); - let (gpu, first_surface) = bring_up_gpu(*first)?; + let (gpu, first_surface) = bring_up_gpu(first.window, options.gpu.as_deref())?; let mut first_surface = Some(first_surface); let mut outputs = Vec::with_capacity(made.len()); - for (index, (window, monitor)) in made.into_iter().enumerate() { + for (index, (made, monitor)) in made.into_iter().enumerate() { + let window = made.window; let wgpu_surface = if index == 0 { first_surface.take() } else { @@ -117,6 +133,7 @@ impl WindowsPlatform { }; outputs.push(WinOutput { window, + holder: made.holder, name: monitor.name, rect: monitor.rect, format: wgpu::TextureFormat::Bgra8UnormSrgb, @@ -127,6 +144,7 @@ impl WindowsPlatform { drawn_at: None, due_at: None, first_frame_presented: false, + skipped: 0, covered: false, page: None, make_page: None, @@ -137,7 +155,7 @@ impl WindowsPlatform { let mut platform = Self { outputs, gpu, - host, + desktop, make_renderer, frame_interval: frame_interval(options.fps), pointer: options.pointer, @@ -395,33 +413,49 @@ impl WindowsPlatform { /// it, which is ordinary rather than exceptional: it happens on a crash, on /// a settings change, and whenever the user restarts it by hand. Finding /// the host again and re-parenting is all that is needed. + /// + /// While nothing died, a raised desktop still needs watching: Explorer + /// makes its wallpaper layer again when the wallpaper changes, and the new + /// one can land on top of ours. fn keep_host(&mut self) { - if win32::alive(self.host) && self.outputs.iter().all(|output| win32::alive(output.window)) { + if win32::alive(self.desktop.parent()) && self.outputs.iter().all(|output| output.made().alive()) { + let ours = self.ours(); + if win32::keep_under_icons(&mut self.desktop, &ours) { + tracing::info!("Explorer moved its desktop windows; put the wallpaper back under the icons"); + } return; } - let Some(host) = win32::wallpaper_host() else { + let Some(desktop) = win32::find_desktop() else { tracing::warn!("the desktop has no host window right now; waiting for Explorer"); return; }; - self.host = host; + self.desktop = desktop; tracing::info!("the desktop window came back; putting the wallpapers back into it"); for index in 0..self.outputs.len() { let Some(output) = self.outputs.get(index) else { continue; }; - if win32::alive(output.window) { - win32::reparent(output.window, host); - win32::place(output.window, output.rect); + let made = output.made(); + if made.alive() { + win32::reparent(made.outer(), self.desktop.parent()); + win32::place(made, output.rect); continue; } - self.rebuild_window(index, host); + self.rebuild_window(index); } + let ours = self.ours(); + win32::keep_under_icons(&mut self.desktop, &ours); + } + + /// The windows that are children of the desktop, one per screen. + fn ours(&self) -> Vec { + self.outputs.iter().map(|output| output.made().outer()).collect() } /// Make a screen's window again after Explorer destroyed it, and give it a /// new surface. The renderer itself survives; only its canvas was lost. - fn rebuild_window(&mut self, index: usize, host: win32::Handle) { + fn rebuild_window(&mut self, index: usize) { let Some(output) = self.outputs.get_mut(index) else { return; }; @@ -429,16 +463,25 @@ impl WindowsPlatform { // it before anything else can ask it for another frame. output.wgpu_surface = None; output.configured = false; + // Whichever half survived goes too, or it would be left on the desktop. + output.page = None; + win32::destroy(output.window); + if let Some(holder) = output.holder.take() { + win32::destroy(holder); + } let (rect, name) = (output.rect, output.name.clone()); - let Some(window) = win32::desktop_window(host, rect, self.take_clicks) else { + let Some(made) = win32::desktop_window(&self.desktop, rect, self.take_clicks) else { tracing::error!(output = %name, "could not make the wallpaper window again"); return; }; + let window = made.window; + if let Some(output) = self.outputs.get_mut(index) { + output.holder = made.holder; + } if let Some(output) = self.outputs.get_mut(index) && let Some(make) = output.make_page.as_ref() { // The old page died with its window; make it again in the new one. - output.page = None; output.page = make(win32::address(window), output.size()); output.window = window; return; @@ -455,6 +498,7 @@ impl WindowsPlatform { output.wgpu_surface = surface; output.configured = false; output.first_frame_presented = false; + output.skipped = 0; } self.configure_swapchain(index); } @@ -473,11 +517,11 @@ impl WindowsPlatform { continue; } let rect = monitor.rect; - let window = output.window; + let made = output.made(); if let Some(output) = self.outputs.get_mut(index) { output.rect = rect; } - win32::place(window, rect); + win32::place(made, rect); if let Some(output) = self.outputs.get_mut(index) { let size = output.size(); if let Some(page) = output.page.as_mut() { @@ -648,10 +692,20 @@ impl WindowsPlatform { let texture = match texture { wgpu::CurrentSurfaceTexture::Success(t) | wgpu::CurrentSurfaceTexture::Suboptimal(t) => t, other => { - tracing::debug!(output = %output.name, status = ?other, "skipping frame"); + output.skipped = output.skipped.saturating_add(1); + if !output.first_frame_presented && output.skipped == SKIPS_WORTH_A_WARNING { + tracing::warn!( + output = %output.name, + status = ?other, + "this screen still has not been given a frame to draw" + ); + } else { + tracing::debug!(output = %output.name, status = ?other, "skipping frame"); + } return false; } }; + output.skipped = 0; let view = texture .texture @@ -770,6 +824,9 @@ impl Drop for WindowsPlatform { output.page = None; output.wgpu_surface = None; win32::destroy(output.window); + if let Some(holder) = output.holder { + win32::destroy(holder); + } } } } @@ -821,6 +878,10 @@ const HIDDEN_FRAME: Duration = Duration::from_secs(1); const SCREEN_POLL: Duration = Duration::from_secs(2); +/// Tries at a first frame before a screen that never got one is worth a +/// warning: a few seconds of them, more than any driver needs to warm up. +const SKIPS_WORTH_A_WARNING: u32 = 60; + fn settled(renderer: Option<&dyn Renderer>) -> bool { let Some(renderer) = renderer else { return false; @@ -835,28 +896,103 @@ fn frame_interval(fps: Option) -> Duration { } } -fn bring_up_gpu(window: win32::Handle) -> Result<(Gpu, wgpu::Surface<'static>), PlatformError> { - // DX12 first because it is what a Windows driver is tuned for; Vulkan is - // there for the machines whose DX12 driver is the worse of the two. - let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { - backends: wgpu::Backends::DX12 | wgpu::Backends::VULKAN, - ..wgpu::InstanceDescriptor::new_without_display_handle() - }); - +/// Bring up wgpu on the GPU driving the screen, or the one `wanted` names. +/// +/// `crate::gpu::preference` says which and why. `WGPU_BACKEND` and +/// `WGPU_DX12_PRESENTATION_SYSTEM` still apply on top of it, so that a backend +/// or a presentation path can be tried on a machine without a new build. +fn bring_up_gpu( + window: win32::Handle, + wanted: Option<&str>, +) -> Result<(Gpu, wgpu::Surface<'static>), PlatformError> { + let instance = wgpu::Instance::new( + wgpu::InstanceDescriptor { + backends: wgpu::Backends::VULKAN | wgpu::Backends::DX12, + ..wgpu::InstanceDescriptor::new_without_display_handle() + } + .with_env(), + ); let surface = create_surface(&instance, window)?; + + let adapters: Vec = pollster::block_on(instance.enumerate_adapters(wgpu::Backends::all())) + .into_iter() + .filter(|adapter| { + let info = adapter.get_info(); + let presents = adapter.is_surface_supported(&surface); + tracing::info!( + backend = %info.backend, + adapter = %info.name, + vendor = format_args!("{:#06x}", info.vendor), + kind = ?info.device_type, + presents, + "gpu adapter offered" + ); + presents + }) + .collect(); + let offers: Vec = adapters + .iter() + .map(|adapter| { + let info = adapter.get_info(); + crate::gpu::Offer { + backend: info.backend, + vendor: info.vendor, + device: info.device, + software: info.device_type == wgpu::DeviceType::Cpu, + } + }) + .collect(); + + let wanted = wanted + .map(str::to_owned) + .or_else(|| std::env::var("KIRIE_GPU").ok()); + let (order, aim) = crate::gpu::preference(&offers, wanted.as_deref()); + if aim == crate::gpu::Aim::AskedButAbsent { + tracing::warn!( + gpu = wanted.as_deref().unwrap_or_default(), + "no adapter matched --gpu; using the GPU driving the screen" + ); + } + + let mut failed = None; + for adapter in order.iter().filter_map(|at| adapters.get(*at)) { + let info = adapter.get_info(); + match pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + label: Some("kirie-platform-windows"), + ..wgpu::DeviceDescriptor::default() + })) { + Ok((device, queue)) => { + tracing::info!(backend = %info.backend, adapter = %info.name, why = ?aim, "selected gpu adapter"); + return Ok(( + Gpu { + instance, + adapter: adapter.clone(), + device, + queue, + }, + surface, + )); + } + Err(err) => { + tracing::warn!(backend = %info.backend, adapter = %info.name, %err, "this gpu would not start"); + failed = Some(err); + } + } + } + if let Some(err) = failed { + return Err(err.into()); + } + + // Nothing could draw to the window. Asking wgpu the ordinary way gets its + // own explanation of why, which is the error worth reporting. let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { compatible_surface: Some(&surface), - power_preference: crate::gpu::power_preference(), ..wgpu::RequestAdapterOptions::default() }))?; - - let info = adapter.get_info(); - tracing::info!(backend = %info.backend, adapter = %info.name, "selected gpu adapter"); let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { label: Some("kirie-platform-windows"), ..wgpu::DeviceDescriptor::default() }))?; - Ok(( Gpu { instance, diff --git a/crates/kirie/build.rs b/crates/kirie/build.rs index 0f8eb7c..9a14c77 100644 --- a/crates/kirie/build.rs +++ b/crates/kirie/build.rs @@ -30,6 +30,7 @@ fn main() { println!("cargo:rustc-env=KIRIE_WEBVIEWHOST_BLOB={}", blob.display()); link_media_foundation(); + embed_manifest(); } // vcpkg's ffmpeg builds avcodec with the Media Foundation encoder, so avcodec.lib @@ -48,3 +49,23 @@ fn link_media_foundation() { println!("cargo:rustc-link-lib=strmiids"); } } + +// kirie.exe says which Windows versions it knows about (kirie.exe.manifest has +// why). The release build is MSVC, whose linker embeds a manifest given these +// two flags. A build without one still runs; it only loses the layered window +// on a raised desktop and draws straight into Progman instead. +fn embed_manifest() { + let windows = std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows"); + let msvc = std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc"); + if !(windows && msvc) { + return; + } + let manifest = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")) + .join("kirie.exe.manifest"); + println!("cargo:rerun-if-changed={}", manifest.display()); + println!("cargo:rustc-link-arg-bin=kirie=/MANIFEST:EMBED"); + println!( + "cargo:rustc-link-arg-bin=kirie=/MANIFESTINPUT:{}", + manifest.display() + ); +} diff --git a/crates/kirie/kirie.exe.manifest b/crates/kirie/kirie.exe.manifest new file mode 100644 index 0000000..f86bd9c --- /dev/null +++ b/crates/kirie/kirie.exe.manifest @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + diff --git a/crates/kirie/src/compat/args.rs b/crates/kirie/src/compat/args.rs index 1d83924..9e763b0 100644 --- a/crates/kirie/src/compat/args.rs +++ b/crates/kirie/src/compat/args.rs @@ -142,6 +142,9 @@ pub struct CompatArgs { pub disable_particles: bool, pub disable_mouse: bool, pub interactive: bool, + /// `--gpu`, which Windows reads when it picks an adapter. Linux and macOS + /// act on it earlier, by re-running with the Vulkan driver pinned. + pub gpu: Option, pub disable_parallax: bool, pub list_properties: bool, pub list_properties_json: bool, @@ -185,6 +188,7 @@ impl Default for CompatArgs { disable_particles: false, disable_mouse: false, interactive: false, + gpu: None, disable_parallax: false, list_properties: false, list_properties_json: false, @@ -612,7 +616,7 @@ fn parse_with( out.screenshot_delay = n.clamp(0, u32::MAX as i64) as u32; } "--assets-dir" => out.assets_dir = Some(PathBuf::from(value()?)), - "--gpu" => drop(value()?), + "--gpu" => out.gpu = Some(value()?), "--release-hidden-after" => { let n = scan_int("--release-hidden-after", &value()?)?; out.release_hidden_after = (n > 0).then_some(n as u64); @@ -818,6 +822,14 @@ mod tests { assert_eq!(args.fps, 45, "the argument after an empty --bg is still read"); } + #[test] + fn the_gpu_haru_picked_is_kept_for_windows() { + let args = parse(&os(&["kirie", "--gpu=nvidia"])).expect("--gpu parses"); + assert_eq!(args.gpu.as_deref(), Some("nvidia")); + let args = parse(&os(&["kirie", "--gpu", "intel"])).expect("--gpu parses"); + assert_eq!(args.gpu.as_deref(), Some("intel")); + } + #[test] fn a_blank_background_is_ignored_too() { let args = parse(&os(&["kirie", "--bg", " "])).expect("blank is the same as absent"); diff --git a/crates/kirie/src/compat/desktop_present.rs b/crates/kirie/src/compat/desktop_present.rs index a39942b..e2e68ec 100644 --- a/crates/kirie/src/compat/desktop_present.rs +++ b/crates/kirie/src/compat/desktop_present.rs @@ -72,6 +72,7 @@ pub fn present(args: &CompatArgs) -> ExitCode { playback_speed: args.playback_speed, pointer: !args.disable_mouse, take_clicks: args.interactive, + gpu: args.gpu.clone(), ..PresentOptions::default() }; diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index ab569f8..76c6175 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -162,7 +162,9 @@ kirie gpus --json kirie --gpu nvidia --screen-root HDMI-A-1 --bg 1388331347 ``` -`auto` is always listed first and means "no pinning". +`auto` is always listed first and means "no pinning". On Windows it means the +GPU driving the screen, which is where Windows' own per-app graphics setting +also points. ### `kirie extract` @@ -451,7 +453,7 @@ unknown name fails with the list of names that do exist. | `--playback-speed ` | `1.0` | Animation speed multiplier (alias `--clock`) | | `--render-scale ` | `1.0` | Render below or above native, then scale | | `--fit-render-to-output` | off | Render at the output size rather than the scene's | -| `--gpu ` | `auto` | Pin to one Vulkan adapter, from `kirie gpus` | +| `--gpu ` | `auto` | Draw on one GPU, from `kirie gpus` (on Windows, `auto` is the GPU driving the screen) | ```sh kirie --screen-root HDMI-A-1 --bg 1388331347 --fps 60 @@ -669,6 +671,8 @@ kirie ask workshop subscribe 1388331347 | `KIRIE_NO_PIPELINE_CACHE` | Skip the on-disk pipeline cache | | `KIRIE_SHADER_DUMP`, `KIRIE_SHADER_DUMP_ALL` | Write translated shaders to disk | | `KIRIE_WEB_CONSOLE` | Forward web-wallpaper console output to the log | +| `KIRIE_NO_LAYERED_HOST` | Windows 11 24H2 and later: draw straight into the desktop instead of inside a layered window under the icons | +| `WGPU_BACKEND` | Windows: limit the GPU backends kirie tries, e.g. `dx12` or `vulkan` | | `KIRIE_CORPUS` | Wallpaper corpus directory used by the tests | | `RUST_LOG` | Tracing filter, e.g. `RUST_LOG=debug` | | `XDG_RUNTIME_DIR` | Where the default control socket lives | From 9468c5ff6bbb4b08becbf8c935326b4187f7448a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 03:11:00 +0000 Subject: [PATCH 2/3] Move a rebuilt draft's target to the commit it was built from A draft release has no tag until Publish, which tags the draft's target. The update-in-place branch kept the old target, so re-running the release for a version whose draft already existed attached new binaries to a draft that would still tag the commit before them. haru's release.yml had the same bug and the same fix. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QJnt7JtdtiqnVCP9GYqUhK --- .github/workflows/release.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a633319..0ceb53f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -518,12 +518,19 @@ jobs: # --prerelease on its own only sets the flag on a still-unpublished # draft. Nothing here goes the other way: a release that is already # published is never pushed back to a draft. + # + # A draft has no tag yet: Publish makes it from the draft's target. + # Rebuilding a draft from a newer commit has to move the target + # too, or Publish tags the commit before the change while the + # binaries were built after it. + target="" + if [ "$state" = "--draft" ]; then target="--target $GITHUB_SHA"; fi if [ "${{ inputs.prerelease }}" = "true" ] && [ "$state" = "--draft" ]; then echo "release $V is a draft and a prerelease was asked for — publishing it" state="--prerelease --draft=false" fi echo "release $V exists ($state) — updating in place (not deleting)" - gh release edit "$V" $state --title "$V" --notes-file RELEASE_NOTES.md + gh release edit "$V" $state $target --title "$V" --notes-file RELEASE_NOTES.md # Clear each asset by name first. `--clobber` alone is not enough: # a run cancelled mid-upload leaves a half-created asset, and the # API then answers 400 for a fresh upload of that name rather than From ddaa242074bcae92917871052ca52bc01c671308 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 03:55:26 +0000 Subject: [PATCH 3/3] Draw in front of the icons when Explorer will not split the desktop A new child of Progman is linked at the bottom of its siblings, which on an unsplit desktop is behind the icon view, and the icon view paints the wallpaper itself, so the fallback window never showed. Put it on top of Progman's children instead, as kirie did before. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QJnt7JtdtiqnVCP9GYqUhK --- crates/kirie-platform/src/win32.rs | 27 +++++++++++++++++++++++++-- docs/COMMANDS.md | 2 +- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/crates/kirie-platform/src/win32.rs b/crates/kirie-platform/src/win32.rs index 824d3a5..3d9a861 100644 --- a/crates/kirie-platform/src/win32.rs +++ b/crates/kirie-platform/src/win32.rs @@ -476,9 +476,16 @@ pub(crate) fn desktop_window(desktop: &Desktop, rect: Rect, take_clicks: bool) - return None; } + // A new child is linked at the bottom of its siblings, which inside + // Progman is behind the icons. let made = Made { window, holder }; - if let Layout::UnderIcons { icons, .. } = *desktop { - under_icons(made.outer(), icons); + match *desktop { + Layout::UnderIcons { icons, .. } => under_icons(made.outer(), icons), + // On an unsplit desktop the icon view draws the wallpaper itself, so + // behind it nothing shows; the fallback is in front of it. + Layout::Unsplit { .. } => under_icons(made.outer(), None), + // Nothing else is in the worker behind the icons. + Layout::Behind { .. } => {} } // SAFETY: showing windows we just made, without taking focus from whatever // the user is doing. The holder, when there is one, shows its child with it. @@ -1082,6 +1089,22 @@ mod tests { assert!(!made.alive(), "children go with their parent"); } + #[test] + fn a_desktop_that_will_not_split_gets_the_wallpaper_in_front_of_the_icons() { + let progman = stand_in("kirie-test-progman", std::ptr::null_mut(), 0); + let icons = stand_in(desktop_tree::ICONS, progman, 0); + + let desktop = desktop_tree::choose(&snapshot_of(progman)); + assert_eq!(desktop, Layout::Unsplit { progman }); + let made = desktop_window(&desktop, SMALL, false).expect("a wallpaper window"); + assert!(made.holder.is_none()); + // Behind the icon view it would be hidden: that is where the wallpaper + // Windows draws is on an unsplit desktop. + assert_eq!(children(progman), vec![made.window, icons]); + + destroy(progman); + } + #[test] fn a_classic_desktop_gets_the_wallpaper_in_the_worker_behind_the_icons() { let front = stand_in(desktop_tree::WORKER, std::ptr::null_mut(), 0); diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 76c6175..1c4d720 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -453,7 +453,7 @@ unknown name fails with the list of names that do exist. | `--playback-speed ` | `1.0` | Animation speed multiplier (alias `--clock`) | | `--render-scale ` | `1.0` | Render below or above native, then scale | | `--fit-render-to-output` | off | Render at the output size rather than the scene's | -| `--gpu ` | `auto` | Draw on one GPU, from `kirie gpus` (on Windows, `auto` is the GPU driving the screen) | +| `--gpu ` | `auto` | Draw on one GPU, from `kirie gpus` (on Windows it applies to the desktop wallpaper, and `auto` is the GPU driving the screen) | ```sh kirie --screen-root HDMI-A-1 --bg 1388331347 --fps 60