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 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..3d9a861 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,197 @@ 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; } - // 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) + // A new child is linked at the bottom of its siblings, which inside + // Progman is behind the icons. + let made = Made { window, holder }; + 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. + 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) } -/// Put `window` back inside `host` and over `rect`. +/// Put `window` directly below the icons, or at the top without them. #[allow(unsafe_code)] -pub(crate) fn reparent(window: Handle, host: Handle) { - if !alive(window) || !alive(host) { +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, + ); + } +} + +/// 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 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 +963,167 @@ 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_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); + 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..1c4d720 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 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 @@ -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 |