Skip to main content

rdi_platform_windows/
backend.rs

1//! [`WindowsBackend`] — concrete [`DesktopBackend`] implementation.
2//!
3//! The backend lives on `rdi-core`'s worker thread and holds:
4//!
5//! * a [`ComContext`] with the cached `IFolderView2`,
6//! * a [`HashMap<IconId, OwnedPidl>`] populated by `list_icons` and
7//!   consulted by `set_positions`.
8//!
9//! On any COM error from a folder-view call the cached view is dropped
10//! via [`ComContext::invalidate`] — the next call re-acquires it.
11//! This mirrors the legacy `with_folder_view` retry helper.
12
13use std::collections::HashMap;
14use std::path::PathBuf;
15use std::time::{Duration as StdDuration, Instant};
16
17use rdi_core::{
18    DesktopBackend, DesktopError, FinalCommitOutcome, IconId, IconLabel, IconRenderPlan,
19    IconSnapshot, MonitorInfo, OverlayRenderOptions, Point, SnapshotFrame,
20};
21use windows::Win32::Foundation::{HWND, LPARAM, MAX_PATH, POINT, WPARAM};
22use windows::Win32::UI::Controls::LVM_GETITEMSPACING;
23use windows::Win32::UI::Shell::Common::{ITEMIDLIST, STRRET};
24use windows::Win32::UI::Shell::{
25    FOLDERVIEWMODE, IEnumIDList, IShellFolder, SHGDN_NORMAL, SHGetPathFromIDListW,
26    SVGIO_ALLVIEW, SVSI_POSITIONITEM, StrRetToStrW,
27};
28use windows::Win32::UI::WindowsAndMessaging::{
29    FindWindowExW, IsWindowVisible, SendMessageTimeoutW, ShowWindow, SMTO_ABORTIFHUNG, SW_HIDE, SW_SHOW,
30};
31use windows::core::{PCWSTR, PWSTR, w};
32
33use crate::com::ComContext;
34use crate::ids::build_icon_id;
35use crate::imaging::extract_icon_bitmap;
36use crate::layout::{dpi_for_scale, IconLayout};
37use crate::monitors::enumerate_monitors;
38use crate::overlay::OverlaySession;
39use crate::pidl::{CoTaskMemWStr, OwnedPidl, wcslen_bounded};
40use crate::text::label_metrics_for_dpi;
41use tracing::{debug, info, trace, warn};
42
43/// `FWF_HIDEICONS` from `<shobjidl_core.h>` — set on the desktop
44/// folder view to make Explorer stop drawing the real icons while
45/// the overlay owns the visible pixels. Cleared before
46/// `finalize_overlay_session` returns.
47const FWF_HIDEICONS: u32 = 0x0000_0200;
48
49/// How long to wait for `IFolderView2::GetItemPosition` to confirm
50/// that the shell has moved at least one icon to its new position.
51///
52/// Explorer typically updates its internal layout within a few
53/// milliseconds of `SelectAndPositionItems` returning; 250 ms is
54/// generous enough that a slow SysListView32 doesn't leave the overlay
55/// up until it becomes noticeable.
56const SHELL_CONFIRM_TIMEOUT: StdDuration = StdDuration::from_millis(250);
57
58/// Sleep between `GetItemPosition` polls while waiting for the shell
59/// to confirm the final commit. 5 ms is generous — most confirmations
60/// happen in one poll.
61const SHELL_CONFIRM_POLL: StdDuration = StdDuration::from_millis(5);
62
63/// How long the overlay stays visible AFTER restoring the real
64/// icons but BEFORE being destroyed, so the shell + DWM composition
65/// pipeline has a chance to push a fresh frame containing the real
66/// icons at their final positions. Without this, the overlay's DComp
67/// visual is torn down before DWM composites one frame of "shell has
68/// updated" pixels, producing a single-frame blank flash at the
69/// reveal.
70///
71/// **Empirical**: 50 ms is not enough — the blank flash is still
72/// occasionally visible on a 250 % Windows 11 desktop. 300 ms gives a
73/// clean reveal. Callers with faster hardware could reasonably want
74/// this as a tunable option.
75const SHELL_REVEAL_SETTLE: StdDuration = StdDuration::from_millis(300);
76
77/// Windows-native `DesktopBackend` driving Explorer's desktop folder view.
78pub struct WindowsBackend {
79    com: ComContext,
80    /// PIDLs cached from the most recent `list_icons` call. The engine
81    /// calls `list_icons` at the start of every animation, so the
82    /// animation's ticks reuse these instead of re-enumerating the
83    /// desktop.
84    pidl_cache: HashMap<IconId, OwnedPidl>,
85    /// Display names cached alongside `pidl_cache`. Populated by the
86    /// same enumeration pass; used by
87    /// [`Self::enrich_plans`](WindowsBackend::enrich_plans) to build
88    /// `IconLabel`s without a second Shell round-trip.
89    display_name_cache: HashMap<IconId, String>,
90    overlay: OverlayState,
91}
92
93/// The overlay animation lifecycle, as a state machine.
94///
95/// Transitions are one-way and each is owned by exactly one method:
96/// `begin_overlay_session` builds `Prepared`, the *first*
97/// `commit_overlay_frame` promotes it to `Live`, and
98/// `finalize_overlay_session` (or `Drop`) consumes it back to `Idle`.
99enum OverlayState {
100    /// No animation in flight. The real desktop icons are visible and
101    /// owned entirely by Explorer.
102    Idle,
103    /// Session built and the first frame pre-rendered, but the overlay
104    /// window is still hidden and the Shell has not been touched — the
105    /// real icons are visible at their *source* positions.
106    Prepared {
107        session: OverlaySession,
108        /// Icon targets to hand the Shell on the first commit.
109        pending_final_positions: Vec<(IconId, Point)>,
110    },
111    /// Overlay visible; the real icons have been teleported to their
112    /// final positions and hidden. Holding a [`ShellHideGuard`] here is
113    /// what makes "the icons always come back" a property of the type
114    /// rather than of every error path.
115    Live {
116        session: OverlaySession,
117        hide: ShellHideGuard,
118        /// Non-empty only when the first-frame teleport failed and
119        /// `finalize_overlay_session` still has to retry it.
120        pending_final_positions: Vec<(IconId, Point)>,
121    },
122}
123
124/// Owns the two Shell-side actions that hide the real desktop icons
125/// for the duration of an overlay animation.
126///
127/// Restoring is split because the two halves have different
128/// requirements: `ShowWindow(SW_SHOW)` needs nothing but the HWND, so
129/// `Drop` can always do it, while clearing `FWF_HIDEICONS` needs
130/// `IFolderView2` and therefore has to go through
131/// [`WindowsBackend::restore_real_icons`].
132///
133/// The two invariants this enforces at the type level: `FWF_HIDEICONS`
134/// is always cleared again, and the `SysListView32` is always reshown.
135struct ShellHideGuard {
136    /// The `SysListView32` that was hidden, if one was found.
137    hidden_syslistview: Option<HWND>,
138    /// Whether `FWF_HIDEICONS` was set by this guard. Stays `true`
139    /// if clearing it fails, so the `Drop` backstop can see it.
140    flag_set: bool,
141}
142
143impl ShellHideGuard {
144    /// Restore `SysListView32` visibility. Idempotent.
145    fn show_syslistview(&mut self) {
146        if let Some(hwnd) = self.hidden_syslistview.take() {
147            // SAFETY: `ShowWindow` is safe on any HWND value.
148            let _ = unsafe { ShowWindow(hwnd, SW_SHOW) };
149        }
150    }
151}
152
153impl Drop for ShellHideGuard {
154    fn drop(&mut self) {
155        self.show_syslistview();
156        if self.flag_set {
157            warn!(
158                "ShellHideGuard dropped with FWF_HIDEICONS still set — the next \
159                 begin_overlay_session will clear it"
160            );
161        }
162    }
163}
164
165// SAFETY: `IFolderView2` (and every COM interface it transitively holds)
166// is apartment-threaded, not `Sync`. `WindowsBackend` is owned
167// exclusively by the engine's dedicated worker thread — every method
168// call on the trait happens on the same thread that constructed it.
169// `Send` is asserted so `DesktopController::new` can move the backend
170// onto that worker thread. The type is deliberately NOT `Sync`: no
171// path in `rdi-core` shares a `&Backend` across threads.
172unsafe impl Send for WindowsBackend {}
173
174impl Drop for WindowsBackend {
175    /// Best-effort cleanup for the "worker thread panicked mid-
176    /// animation" case. Dropping while the overlay is still `Live`
177    /// means the normal `finalize_overlay_session` path was skipped;
178    /// mirroring it here keeps the desktop from being left with
179    /// hidden icons after a panic.
180    ///
181    /// The body is wrapped in `catch_unwind` because this runs during
182    /// unwinding: `restore_real_icons` makes a COM call, and a panic
183    /// inside a `Drop` that is itself unwinding aborts the process,
184    /// skipping the very cleanup this impl exists to perform.
185    ///
186    /// This does **not** cover `abort()` or `TerminateProcess`,
187    /// which don't run `Drop`. Startup cleanup in
188    /// `begin_overlay_session` handles those.
189    fn drop(&mut self) {
190        let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
191            // Taking the state first means a panic below still leaves
192            // `self.overlay` in a coherent `Idle`.
193            if let OverlayState::Live { session, hide, .. } =
194                std::mem::replace(&mut self.overlay, OverlayState::Idle)
195            {
196                self.restore_real_icons(hide);
197                // Explicit: the DComp visual must be gone before the
198                // COM interfaces in `self.com` are released (Rust
199                // drops fields in declaration order).
200                drop(session);
201            }
202        }));
203        if outcome.is_err() {
204            // Deliberately not `warn!`: the desktop is visibly broken
205            // at this point.
206            tracing::error!(
207                "panic while restoring desktop state during Drop; desktop icons \
208                 may remain hidden until the next animation"
209            );
210        }
211    }
212}
213
214impl WindowsBackend {
215    /// Construct a new backend. COM is initialised lazily — no work is
216    /// done until the first trait method call. Never fails; errors
217    /// surface at that first call instead.
218    ///
219    /// Also opts the current process into Per-Monitor V2 DPI
220    /// awareness (idempotent; failure is silently accepted for
221    /// processes whose awareness was already fixed by manifest).
222    /// This MUST run before any DPI-sensitive Shell call — in
223    /// particular before [`Self::list_monitors`] and
224    /// [`Self::begin_overlay_session`] — otherwise
225    /// `GetDpiForMonitor` and `SPI_GETICONMETRICS` return virtualised
226    /// 96-DPI values and the overlay renders icons + labels at
227    /// ~1/scale of their real physical size on high-DPI displays.
228    pub fn new() -> Self {
229        crate::overlay::ensure_dpi_awareness();
230        Self {
231            com: ComContext::new(),
232            pidl_cache: HashMap::new(),
233            display_name_cache: HashMap::new(),
234            overlay: OverlayState::Idle,
235        }
236    }
237
238    /// Refresh [`Self::pidl_cache`] by walking `IEnumIDList` and storing
239    /// every (id, pidl) pair. Returns the collected [`IconSnapshot`]s.
240    fn enumerate(&mut self) -> Result<Vec<IconSnapshot>, DesktopError> {
241        let view = self.com.folder_view()?;
242
243        let result = (|| -> Result<Vec<IconSnapshot>, DesktopError> {
244            // SAFETY: the folder view was just acquired successfully;
245            // its v-table is valid. The two calls below either yield
246            // valid interfaces or a propagated error.
247            let (folder, enumerator) = unsafe {
248                let folder: IShellFolder = view.GetFolder().map_err(|e| {
249                    DesktopError::BackendUnavailable(format!(
250                        "IFolderView2::GetFolder failed: {e}"
251                    ))
252                })?;
253                let enumerator: IEnumIDList = view.Items(SVGIO_ALLVIEW).map_err(|e| {
254                    DesktopError::BackendUnavailable(format!(
255                        "IFolderView2::Items(SVGIO_ALLVIEW) failed: {e}"
256                    ))
257                })?;
258                (folder, enumerator)
259            };
260
261            let mut snapshots = Vec::new();
262            let mut fresh_cache = HashMap::new();
263            let mut fresh_names: HashMap<IconId, String> = HashMap::new();
264
265            loop {
266                let mut pidl_raw: *mut ITEMIDLIST = std::ptr::null_mut();
267                let mut fetched: u32 = 0;
268                // SAFETY: `IEnumIDList::Next` writes exactly one PIDL
269                // pointer into the stack slot when it returns S_OK
270                // with `fetched == 1`.
271                let hr = unsafe {
272                    enumerator.Next(
273                        std::slice::from_mut(&mut pidl_raw),
274                        Some(&mut fetched as *mut u32),
275                    )
276                };
277                if hr.is_err() || fetched == 0 || pidl_raw.is_null() {
278                    break;
279                }
280                let pidl = OwnedPidl::from_raw(pidl_raw);
281
282                // SAFETY: `pidl` was just returned by the shell; the
283                // helpers below only read from it.
284                let (path_utf16, display_utf16, position) = unsafe {
285                    let path = path_of(pidl.as_ptr());
286                    let display = display_name_of(&folder, pidl.as_ptr())?;
287                    let pt = view.GetItemPosition(pidl.as_ptr()).map_err(|e| {
288                        DesktopError::BackendUnavailable(format!(
289                            "IFolderView2::GetItemPosition failed: name={:?}, \
290                             path={:?}, HRESULT=0x{:08X}: {}",
291                            String::from_utf16_lossy(&display),
292                            if path.is_empty() {
293                                "<unavailable>".to_owned()
294                            } else {
295                                String::from_utf16_lossy(&path)
296                            },
297                            e.code().0 as u32,
298                            e.message(),
299                        ))
300                    })?;
301                    (path, display, pt)
302                };
303
304                let is_virtual = path_utf16.is_empty();
305                let id = build_icon_id(&path_utf16, &display_utf16);
306                let display_string = String::from_utf16_lossy(&display_utf16);
307
308                let path_pb = if is_virtual {
309                    None
310                } else {
311                    Some(PathBuf::from(String::from_utf16_lossy(&path_utf16)))
312                };
313
314                snapshots.push(IconSnapshot::new(
315                    id.clone(),
316                    display_string.clone(),
317                    path_pb,
318                    is_virtual,
319                    Point::new(position.x, position.y),
320                ));
321                // Last write wins if two icons hash to the same id — in
322                // practice that never happens because shell paths are
323                // unique and virtual display names are unique too.
324                fresh_names.insert(id.clone(), display_string);
325                fresh_cache.insert(id, pidl);
326            }
327
328            self.pidl_cache = fresh_cache;
329            self.display_name_cache = fresh_names;
330            Ok(snapshots)
331        })();
332
333        if result.is_err() {
334            // A folder-view failure invalidates the cached interface.
335            self.com.invalidate();
336            self.pidl_cache.clear();
337            self.display_name_cache.clear();
338        }
339        result
340    }
341}
342
343impl Default for WindowsBackend {
344    fn default() -> Self {
345        Self::new()
346    }
347}
348
349impl DesktopBackend for WindowsBackend {
350    fn list_icons(&mut self) -> Result<Vec<IconSnapshot>, DesktopError> {
351        self.enumerate()
352    }
353
354    fn get_flags(&mut self) -> Result<u32, DesktopError> {
355        let view = self.com.folder_view()?;
356        // SAFETY: `view` is a valid `IFolderView2` interface pointer.
357        let result = unsafe { view.GetCurrentFolderFlags() };
358        match result {
359            Ok(flags) => Ok(flags),
360            Err(e) => {
361                self.com.invalidate();
362                Err(DesktopError::BackendUnavailable(format!(
363                    "IFolderView2::GetCurrentFolderFlags failed: {e}"
364                )))
365            }
366        }
367    }
368
369    fn apply_flags(&mut self, mask: u32, values: u32) -> Result<(), DesktopError> {
370        let view = self.com.folder_view()?;
371        // SAFETY: `view` is a valid `IFolderView2` interface pointer.
372        // `SetCurrentFolderFlags(mask, values)` applies the same
373        // masked-update semantics documented on the trait
374        // (`new = (old & !mask) | (values & mask)`), so no translation
375        // is needed.
376        let result = unsafe { view.SetCurrentFolderFlags(mask, values) };
377        match result {
378            Ok(()) => Ok(()),
379            Err(e) => {
380                self.com.invalidate();
381                Err(DesktopError::BackendUnavailable(format!(
382                    "IFolderView2::SetCurrentFolderFlags failed: {e}"
383                )))
384            }
385        }
386    }
387
388    fn set_positions(
389        &mut self,
390        moves: &[(IconId, Point)],
391    ) -> Result<Vec<IconId>, DesktopError> {
392        if moves.is_empty() {
393            return Ok(Vec::new());
394        }
395
396        // If any id in the batch isn't in the cache, refresh — this
397        // covers the "direct positioning without a preceding
398        // list_icons" flow. `enumerate` also refreshes the folder view
399        // if needed.
400        let has_uncached = moves.iter().any(|(id, _)| !self.pidl_cache.contains_key(id));
401        if has_uncached {
402            let _ = self.enumerate()?;
403        }
404
405        let view = self.com.folder_view()?;
406
407        // Partition into (resolvable, missing).
408        let mut pidl_ptrs: Vec<*const ITEMIDLIST> = Vec::with_capacity(moves.len());
409        let mut points: Vec<POINT> = Vec::with_capacity(moves.len());
410        let mut missing: Vec<IconId> = Vec::new();
411
412        for (id, pt) in moves {
413            match self.pidl_cache.get(id) {
414                Some(pidl) => {
415                    pidl_ptrs.push(pidl.as_ptr());
416                    points.push(POINT { x: pt.x, y: pt.y });
417                }
418                None => missing.push(id.clone()),
419            }
420        }
421
422        if pidl_ptrs.is_empty() {
423            return Ok(missing);
424        }
425
426        // SAFETY: `pidl_ptrs` and `points` are both `pidl_ptrs.len()`
427        // elements long and outlive the call. Every `*const ITEMIDLIST`
428        // was obtained from `IEnumIDList::Next` on this backend and is
429        // still owned by `self.pidl_cache`.
430        let hr = unsafe {
431            view.SelectAndPositionItems(
432                pidl_ptrs.len() as u32,
433                pidl_ptrs.as_ptr(),
434                Some(points.as_ptr()),
435                SVSI_POSITIONITEM.0 as u32,
436            )
437        };
438        if let Err(e) = hr {
439            self.com.invalidate();
440            self.pidl_cache.clear();
441            self.display_name_cache.clear();
442            return Err(DesktopError::BackendUnavailable(format!(
443                "IFolderView2::SelectAndPositionItems failed: {e}"
444            )));
445        }
446
447        // Explorer's `SysListView32` accepts the new item positions
448        // but defers painting them — its message loop batches
449        // invalidations and only flushes them when it goes idle, which
450        // never happens while frames are committed back-to-back. The
451        // synchronous repaint is what keeps the desktop from looking
452        // frozen until the tick loop ends. See
453        // `ComContext::redraw_folder_view`.
454        self.com.redraw_folder_view();
455
456        Ok(missing)
457    }
458
459    fn list_monitors(&mut self) -> Result<Vec<MonitorInfo>, DesktopError> {
460        // Defensive: `enumerate_monitors` uses `GetDpiForMonitor`,
461        // which returns virtualised 96 DPI if the process is DPI
462        // unaware. `WindowsBackend::new` already opts into PMv2, but
463        // callers that construct the trait object via other paths
464        // (mocks, tests, `mem::replace`) may bypass that constructor.
465        // The call is idempotent.
466        crate::overlay::ensure_dpi_awareness();
467        // Pure GDI enumeration — does not touch `IFolderView2`, so
468        // there is nothing to invalidate on failure.
469        enumerate_monitors()
470    }
471
472    fn desktop_info(&mut self, icons: &[IconSnapshot]) -> Result<rdi_core::DesktopInfo, DesktopError> {
473        let monitors = self.list_monitors()?;
474        let view = self.com.folder_view()?;
475        let mut mode = FOLDERVIEWMODE(0);
476        let mut logical_size = 0;
477        // SAFETY: the STA-owned view is valid and both output pointers are stack locals.
478        if let Err(error) = unsafe { view.GetViewModeAndIconSize(&mut mode, &mut logical_size) } {
479            self.com.invalidate();
480            return Err(DesktopError::BackendUnavailable(format!("icon size query failed: {error}")));
481        }
482        if logical_size <= 0 {
483            return Err(DesktopError::BackendUnavailable("invalid Shell icon size".into()));
484        }
485        let list = self.find_syslistview32().ok_or_else(||
486            DesktopError::BackendUnavailable("desktop ListView is unavailable".into()))?;
487        let mut packed = 0usize;
488        // SAFETY: this pointer-free message returns packed dimensions; the output
489        // is written by user32 locally, never dereferenced by the Explorer process.
490        let result = unsafe { SendMessageTimeoutW(list, LVM_GETITEMSPACING, WPARAM(0),
491            LPARAM(0), SMTO_ABORTIFHUNG, 1000, Some(&mut packed)) };
492        let spacing = Point::new((packed & 0xffff) as i32, ((packed >> 16) & 0xffff) as i32);
493        if result.0 == 0 || spacing.x <= 0 || spacing.y <= 0 {
494            self.com.invalidate();
495            return Err(DesktopError::BackendUnavailable("live desktop grid spacing is unavailable".into()));
496        }
497        let mut grids = Vec::with_capacity(monitors.len());
498        for monitor in &monitors {
499            let positions: Vec<_> = icons.iter().filter(|icon| monitor.bounds.contains(icon.position))
500                .map(|icon| icon.position).collect();
501            let origin = rdi_core::IconGrid::infer_origin(monitor.work_area, spacing, &positions);
502            let metrics = label_metrics_for_dpi(dpi_for_scale(monitor.scale_factor));
503            let layout = IconLayout::resolve((logical_size as u32, logical_size as u32),
504                monitor.scale_factor, &metrics);
505            grids.push(rdi_core::IconGrid::new(monitor.id.clone(), monitor.work_area,
506                Point::new(layout.size_px.0 as i32, layout.size_px.1 as i32), spacing,
507                origin, true)?);
508        }
509        let bounds = monitors.iter().map(|monitor| monitor.bounds).reduce(|left, right| {
510            rdi_core::Rect::new(left.left.min(right.left), left.top.min(right.top),
511                left.right.max(right.right), left.bottom.max(right.bottom))
512        }).ok_or_else(|| DesktopError::BackendUnavailable("no desktop monitors".into()))?;
513        Ok(rdi_core::DesktopInfo { bounds, monitors, grids })
514    }
515
516    fn begin_overlay_session(
517        &mut self,
518        plans: &[IconRenderPlan],
519        render_options: OverlayRenderOptions,
520    ) -> Result<(), DesktopError> {
521        if !matches!(self.overlay, OverlayState::Idle) {
522            return Err(DesktopError::OverlayUnavailable(
523                "an overlay session is already open on this backend".into(),
524            ));
525        }
526
527        // Stale-state recovery: `FWF_HIDEICONS` already set when a new
528        // session opens must be a leftover from a previous run that
529        // crashed before it could clean up. Legitimate callers never
530        // set this flag on the desktop folder view, so clearing it is
531        // safe.
532        if let Ok(current) = self.get_flags() {
533            if current & FWF_HIDEICONS != 0 {
534                warn!(
535                    flags = format_args!("0x{current:08x}"),
536                    "found stale FWF_HIDEICONS at overlay session start — assuming \
537                     a prior session crashed; clearing"
538                );
539                let _ = self.apply_flags(FWF_HIDEICONS, 0);
540            }
541        }
542
543        // Enrich the engine-supplied plans with Shell-sourced
544        // metadata (real icon bitmap, label, refined size, selection
545        // state) before handing them to the overlay renderer.
546        // Failures here degrade gracefully: bitmap missing → placeholder;
547        // label missing → no text; selection unknown → drawn as
548        // unselected.
549        //
550        // `render_options` gates the individual decoration passes
551        // (labels, shortcut arrow overlay, admin shield overlay).
552        // A disabled decoration skips both its rendering and its
553        // Shell COM probe — see
554        // [`extract_icon_bitmap`](crate::imaging::extract_icon_bitmap)
555        // and the `if render_options.draw_labels` check in
556        // `enrich_plans`.
557        let enriched = self.enrich_plans(plans, render_options);
558
559        // Cache the icon → final-position map so the first
560        // `commit_overlay_frame` can teleport the real Shell icons
561        // to their targets before hiding them.
562        let pending_final_positions = enriched
563            .iter()
564            .filter(|plan| !plan.stationary)
565            .map(|p| (p.id.clone(), p.final_position))
566            .collect();
567
568        // Build the overlay window + Direct2D + DirectComposition
569        // resources; render the first frame at each icon's source
570        // position while the window is still hidden.
571        let session = OverlaySession::new(&enriched)?;
572        self.overlay = OverlayState::Prepared {
573            session,
574            pending_final_positions,
575        };
576        Ok(())
577    }
578
579    fn commit_overlay_frame(
580        &mut self,
581        positions: &[(IconId, Point)],
582    ) -> Result<(), DesktopError> {
583        // ---- Cancellation watchdog -------------------------
584        //
585        // Pump any queued messages so the overlay WNDPROC has a
586        // chance to observe broadcast messages
587        // (`TaskbarCreated`, `WM_DISPLAYCHANGE`, `WM_DPICHANGED`),
588        // then read-and-clear the cancel atom. Also cross-check the
589        // cached shell view HWND via `IsWindow` for the case where
590        // the shell view silently vanishes without emitting
591        // `TaskbarCreated`.
592        //
593        // A `DesktopError::OverlayCancelled` here is caught by the
594        // engine's tick loop and results in a graceful
595        // `FinishReason::Stopped(TeleportToTarget)` — the real icons
596        // are already at their final positions from the first-frame
597        // teleport, so this outcome is correct.
598        self.poll_overlay_session()?;
599
600        // Render + present. Scoped so the `session` borrow ends before
601        // the Shell calls below, which need `&mut self`.
602        match &mut self.overlay {
603            OverlayState::Idle => {
604                return Err(DesktopError::OverlayUnavailable(
605                    "commit_overlay_frame called with no active overlay session".into(),
606                ));
607            }
608            OverlayState::Prepared { session, .. } | OverlayState::Live { session, .. } => {
609                // `trace!`: this runs once per tick (100 Hz default).
610                trace!(icons = positions.len(), "overlay frame");
611                session.commit_frame(positions)?;
612            }
613        }
614
615        // First-frame housekeeping — done AFTER the overlay is
616        // guaranteed to have presented at least one frame. This is the
617        // one and only `Prepared → Live` transition:
618        //   1. Teleport the real Shell icons to their final positions
619        //      (they'll be behind the overlay for the rest of the
620        //      animation).
621        //   2. Hide the Shell's own drawing of them, and take
622        //      ownership of undoing that via `ShellHideGuard`.
623        let previous = std::mem::replace(&mut self.overlay, OverlayState::Idle);
624        self.overlay = match previous {
625            OverlayState::Prepared {
626                session,
627                pending_final_positions,
628            } => {
629                let pending = self.teleport_real_icons(pending_final_positions);
630                let hide = self.hide_real_icons();
631                OverlayState::Live {
632                    session,
633                    hide,
634                    pending_final_positions: pending,
635                }
636            }
637            already_live => already_live,
638        };
639        Ok(())
640    }
641
642    fn commit_visual_frame(&mut self, frame: &[rdi_core::IconFrame]) -> Result<(), DesktopError> {
643        match &mut self.overlay {
644            OverlayState::Prepared { session, .. } | OverlayState::Live { session, .. } => session.set_visual_frame(frame),
645            OverlayState::Idle => {},
646        }
647        let positions: Vec<_> = frame.iter().map(|entry| (entry.id.clone(), entry.position)).collect();
648        self.commit_overlay_frame(&positions)
649    }
650
651    fn discard_overlay_session(&mut self) {
652        if matches!(self.overlay, OverlayState::Prepared { .. }) {
653            self.overlay = OverlayState::Idle;
654        }
655    }
656
657    fn validate_prepared_session(&mut self) -> Result<(), DesktopError> {
658        self.poll_overlay_session()
659    }
660
661    fn set_real_icons_visible(&mut self, visible: bool) -> Result<(), DesktopError> {
662        if !matches!(self.overlay, OverlayState::Live { .. }) {
663            return Err(DesktopError::BackendUnavailable("real-icon visibility requires a live overlay".into()));
664        }
665        let hwnd = self.find_syslistview32().ok_or_else(||
666            DesktopError::BackendUnavailable("desktop ListView is unavailable".into()))?;
667        self.apply_flags(FWF_HIDEICONS, if visible { 0 } else { FWF_HIDEICONS })?;
668        if let OverlayState::Live { hide, .. } = &mut self.overlay {
669            hide.flag_set = true;
670            hide.hidden_syslistview = Some(hwnd);
671        }
672        self.com.redraw_folder_view();
673        // SAFETY: hwnd is the current Shell ListView; visibility calls borrow no memory.
674        unsafe { let _ = ShowWindow(hwnd, if visible { SW_SHOW } else { SW_HIDE }); }
675        // SAFETY: IsWindowVisible only queries the current Shell ListView handle.
676        let window_visible = unsafe { IsWindowVisible(hwnd).as_bool() };
677        if window_visible != visible {
678            return Err(DesktopError::BackendUnavailable("desktop ListView visibility did not change as requested".into()));
679        }
680        if (self.get_flags()? & FWF_HIDEICONS == 0) != visible {
681            return Err(DesktopError::BackendUnavailable("desktop icon flag did not change as requested".into()));
682        }
683        if visible {
684            if let OverlayState::Live { hide, .. } = &mut self.overlay {
685                hide.hidden_syslistview = None;
686                hide.flag_set = false;
687            }
688        }
689        Ok(())
690    }
691
692    fn poll_overlay_session(&mut self) -> Result<(), DesktopError> {
693        crate::overlay::pump_pending_messages();
694        if let Some(reason) = crate::overlay::take_cancel_reason() {
695            return Err(DesktopError::OverlayCancelled(reason.description().into()));
696        }
697        if !self.com.is_shell_view_valid() {
698            crate::overlay::signal_cancel(crate::overlay::CancelReason::ShellViewLost);
699            let reason = crate::overlay::take_cancel_reason()
700                .unwrap_or(crate::overlay::CancelReason::ShellViewLost);
701            return Err(DesktopError::OverlayCancelled(reason.description().into()));
702        }
703        Ok(())
704    }
705
706    fn finalize_overlay_session(
707        &mut self,
708        final_positions: &[(IconId, Point)],
709    ) -> Result<FinalCommitOutcome, DesktopError> {
710        // Take the whole state up front: every path below leaves the
711        // backend `Idle`, including the ones that bail early.
712        let (mut session, hide, pending) =
713            match std::mem::replace(&mut self.overlay, OverlayState::Idle) {
714                OverlayState::Idle => (None, None, Vec::new()),
715                OverlayState::Prepared {
716                    session,
717                    ..
718                } => {
719                    drop(session);
720                    return Ok(FinalCommitOutcome::default());
721                }
722                OverlayState::Live {
723                    session,
724                    hide,
725                    pending_final_positions,
726                } => (Some(session), Some(hide), pending_final_positions),
727            };
728
729        // The engine's `final_positions` is the authoritative end state
730        // and is NOT always what the first-frame teleport applied: a
731        // `StopMode::LeaveInPlace` stop leaves icons mid-flight, so
732        // committing only `pending` would snap them to their targets.
733        let mut commit: Vec<(IconId, Point)> = final_positions.to_vec();
734        // `pending` is non-empty only when the first-frame teleport
735        // failed. Ids the engine no longer tracks still need the retry.
736        for (id, pt) in pending {
737            if !commit.iter().any(|(existing, _)| existing == &id) {
738                commit.push((id, pt));
739            }
740        }
741
742        let missing = if commit.is_empty() {
743            Vec::new()
744        } else {
745            self.set_positions(&commit).unwrap_or_else(|e| {
746                warn!(
747                    error = %e,
748                    icons = commit.len(),
749                    "finalize: final set_positions failed; continuing with cleanup"
750                );
751                Vec::new()
752            })
753        };
754
755        // Poll GetItemPosition to confirm the Shell has settled at
756        // the final positions. Cheap on the happy path — the commit
757        // above is usually a no-op repeat of the first-frame teleport,
758        // so confirmation lands on the very first poll.
759        let moved = self.await_shell_confirmation(final_positions, &missing);
760
761        if let Some(session) = &mut session {
762            let _ = session.clean_frame(final_positions);
763        }
764        if let Some(hide) = hide {
765            self.restore_real_icons(hide);
766        }
767
768        // `RedrawWindow` on a cross-process HWND posts messages
769        // synchronously via `SendMessage`, but DWM still needs one
770        // composition frame to push the shell's fresh pixels to the
771        // display — the overlay must stay on screen for that frame or
772        // a single blank frame shows between overlay removal and shell
773        // paint.
774        self.com.redraw_folder_view();
775        std::thread::sleep(SHELL_REVEAL_SETTLE);
776
777        // Destroy the overlay window last so the real icons have a
778        // chance to paint before the overlay disappears.
779        drop(session);
780
781        info!(
782            moved = moved.len(),
783            missing = missing.len(),
784            "overlay session finalized"
785        );
786        Ok(FinalCommitOutcome {
787            moved_ids: moved,
788            missing_ids: missing,
789        })
790    }
791
792    fn prepare_scene_renderer(&mut self, canvas: rdi_core::Canvas, plans: &[IconRenderPlan], options: OverlayRenderOptions) -> Result<Box<dyn rdi_core::SceneRenderer>, DesktopError> {
793        canvas.validate()?;
794        let positions: Vec<_> = plans.iter().map(|plan| (plan.id.clone(), plan.source_position)).collect();
795        let mut enriched = self.build_snapshot_plans_with_size(canvas.dpi_scale, &positions, options, Some(canvas.icon_size));
796        for (plan, source) in enriched.iter_mut().zip(plans) {
797            plan.effect = source.effect.clone();
798            plan.final_position = source.final_position;
799            plan.stationary = source.stationary;
800            if plan.image.is_none() {
801                return Err(DesktopError::BackendUnavailable(format!("artwork unavailable for {}", plan.id.as_str())));
802            }
803        }
804        Ok(Box::new(crate::overlay::SceneRenderer::new(canvas, &enriched)?))
805    }
806
807    fn capture_overlay(&mut self, seconds: f64) -> Result<rdi_core::CapturedFrame, DesktopError> {
808        match &mut self.overlay {
809            OverlayState::Live { session, .. } => session.capture(seconds),
810            _ => Err(DesktopError::BackendUnavailable("no live overlay".into())),
811        }
812    }
813
814    fn render_overlay_snapshot(
815        &mut self,
816        width_px: u32,
817        height_px: u32,
818        dpi_scale: f32,
819        positions: &[(IconId, Point)],
820        render_options: OverlayRenderOptions,
821    ) -> Result<SnapshotFrame, DesktopError> {
822        // The caller-supplied `dpi_scale` replaces the live
823        // per-monitor scale factor, which is what makes a snapshot
824        // resolution- and DPI-independent. Positions are already
825        // overlay-local, and a snapshot is a single frame, so
826        // `source_position == final_position`.
827        let plans = self.build_snapshot_plans(dpi_scale, positions, render_options);
828        let width = width_px.max(1);
829        let height = height_px.max(1);
830        let (pixels, geometry) = crate::overlay::render_snapshot(
831            width, height, dpi_scale, &plans, positions,
832        )?;
833        Ok(SnapshotFrame {
834            width,
835            height,
836            pixels,
837            geometry,
838        })
839    }
840}
841
842// ---------------------------------------------------------------------------
843// Shell helpers — used only from the `unsafe` blocks above.
844// ---------------------------------------------------------------------------
845
846/// Get the shell display name of `pidl` as UTF-16 (no trailing NUL).
847///
848/// # Safety
849/// * `folder` must be the `IShellFolder` that owns `pidl`.
850/// * `pidl` must be a valid PIDL relative to `folder`.
851unsafe fn display_name_of(
852    folder: &IShellFolder,
853    pidl: *const ITEMIDLIST,
854) -> Result<Vec<u16>, DesktopError> {
855    // SAFETY forwarded to the caller — see the doc comment above. The
856    // individual `unsafe {}` blocks below only exist because
857    // `unsafe_op_in_unsafe_fn` is a hard error under Edition 2024.
858    let mut strret: STRRET = unsafe { std::mem::zeroed() };
859    unsafe {
860        folder
861            .GetDisplayNameOf(pidl, SHGDN_NORMAL, &mut strret)
862            .map_err(|e| {
863                DesktopError::BackendUnavailable(format!(
864                    "IShellFolder::GetDisplayNameOf failed: {e}"
865                ))
866            })?;
867    }
868    let mut out_ptr = PWSTR::null();
869    unsafe {
870        StrRetToStrW(&mut strret, Some(pidl), &mut out_ptr).map_err(|e| {
871            DesktopError::BackendUnavailable(format!("StrRetToStrW failed: {e}"))
872        })?;
873    }
874    let owned = CoTaskMemWStr::from_raw(out_ptr);
875    Ok(owned.to_vec())
876}
877
878/// Get the filesystem path of `pidl` as UTF-16 (no trailing NUL).
879/// Returns an empty vec for virtual items (matching the legacy behaviour
880/// when `SHGetPathFromIDListW` fails or returns an empty string).
881///
882/// # Safety
883/// `pidl` must be a valid absolute PIDL.
884unsafe fn path_of(pidl: *const ITEMIDLIST) -> Vec<u16> {
885    let mut buf = [0u16; MAX_PATH as usize];
886    // SAFETY: forwarded to the caller — `pidl` is a valid absolute PIDL.
887    let ok = unsafe { SHGetPathFromIDListW(pidl, &mut buf) };
888    if !ok.as_bool() {
889        return Vec::new();
890    }
891    let len = wcslen_bounded(&buf, buf.len());
892    buf[..len].to_vec()
893}
894
895// ---------------------------------------------------------------------------
896// Overlay-session support methods on WindowsBackend.
897// ---------------------------------------------------------------------------
898
899impl WindowsBackend {
900    /// Enrich a slice of engine-supplied `IconRenderPlan`s with Shell
901    /// data before handing them to `OverlaySession`.
902    ///
903    /// * `size_px` — refined via [`Self::query_icon_size_px`].
904    /// * `image` — filled via [`extract_icon_bitmap`] on the cached
905    ///   PIDL; `None` if extraction fails (renderer falls back to a
906    ///   solid placeholder).
907    /// * `label` — pulled from `display_name_cache` (populated by the
908    ///   most recent `list_icons`).
909    ///
910    /// The whole method is best-effort: any per-icon step that fails
911    /// leaves the corresponding field at its default and the animation
912    /// still runs.
913    fn enrich_plans(
914        &mut self,
915        plans: &[IconRenderPlan],
916        render_options: OverlayRenderOptions,
917    ) -> Vec<IconRenderPlan> {
918        let base_size_dip = self.query_icon_size_px().unwrap_or((48, 48));
919
920        // Per-monitor DPI. `IFolderView2::GetViewModeAndIconSize`
921        // reports in Explorer's DPI-awareness context, so crossing COM
922        // into this PMv2 process it arrives at the 96-DPI baseline; the
923        // scale factor is what turns it back into physical pixels. See
924        // [`IconLayout::resolve`](crate::layout::IconLayout::resolve)
925        // for the full rationale.
926        let monitors = self.list_monitors().unwrap_or_default();
927
928        plans
929            .iter()
930            .map(|plan| {
931                let mut enriched = plan.clone();
932
933                // Icons that fall outside every reported monitor
934                // (extremely rare — e.g. an icon at (-99999, 0)) fall
935                // back to the primary's DPI so they still render at
936                // *some* plausible size.
937                let icon_scale = monitors
938                    .iter()
939                    .find(|m| m.bounds.contains(plan.source_position))
940                    .or_else(|| monitors.iter().find(|m| m.is_primary))
941                    .map(|m| m.scale_factor.max(0.01))
942                    .unwrap_or(1.0);
943
944                // Metrics at the icon's own monitor DPI, so wrap sizing
945                // tracks Explorer's paint there without a secondary
946                // rescale.
947                let metrics = label_metrics_for_dpi(dpi_for_scale(icon_scale));
948                let layout = IconLayout::resolve(base_size_dip, icon_scale, &metrics);
949
950                enriched.size_px = layout.size_px;
951                enriched.render_offset_px = layout.render_offset_px;
952
953                // Icon bitmap + optional shortcut-arrow overlay —
954                // request the base at the per-icon rendered size so
955                // `IShellItemImageFactory` returns a bitmap sharp at
956                // that scale (SIIGBF_BIGGERSIZEOK lets the Shell
957                // round up to the nearest available image). The
958                // arrow is kept as a separate bitmap so the overlay
959                // renderer can anchor it to the icon slot.
960                let extracted = self.pidl_cache.get(&plan.id).and_then(|pidl| unsafe {
961                    extract_icon_bitmap(
962                        pidl.as_ptr(),
963                        enriched.size_px.0,
964                        render_options.draw_shortcut_overlay,
965                        render_options.draw_shield_overlay,
966                    )
967                });
968                if let Some(bundle) = extracted {
969                    enriched.image = Some(bundle.base);
970                    enriched.shortcut_arrow_image = bundle.shortcut_arrow;
971                } else {
972                    enriched.image = None;
973                    enriched.shortcut_arrow_image = None;
974                }
975
976                enriched.label = if render_options.draw_labels {
977                    self.display_name_cache
978                        .get(&plan.id)
979                        .cloned()
980                        .map(|text| IconLabel {
981                            text,
982                            bounds_px: layout.label_bounds_px,
983                        })
984                } else {
985                    None
986                };
987
988                // `final_position` was set by the engine when it built
989                // the placeholder plan; pass it through unchanged so
990                // `begin_overlay_session` can cache it for the
991                // first-commit teleport.
992                enriched.final_position = plan.final_position;
993
994                enriched
995            })
996            .collect()
997    }
998
999    /// Build enriched `IconRenderPlan`s for the off-screen snapshot
1000    /// path. Mirrors [`Self::enrich_plans`] but uses a caller-supplied
1001    /// `dpi_scale` uniformly instead of resolving each icon's
1002    /// per-monitor scale, so the snapshot is entirely display-mode-
1003    /// independent — the caller can iterate `(width, height,
1004    /// dpi_scale)` combos without changing the real display mode.
1005    ///
1006    /// The PIDL / display-name caches are read from `self`, so the
1007    /// caller must have run `list_icons` first for icon bitmaps and
1008    /// labels to appear. Missing entries render as placeholder tiles
1009    /// (existing fallback behaviour).
1010    fn build_snapshot_plans(
1011        &mut self,
1012        dpi_scale: f32,
1013        positions: &[(IconId, Point)],
1014        render_options: OverlayRenderOptions,
1015    ) -> Vec<IconRenderPlan> {
1016        self.build_snapshot_plans_with_size(dpi_scale, positions, render_options, None)
1017    }
1018
1019    fn build_snapshot_plans_with_size(
1020        &mut self, dpi_scale: f32, positions: &[(IconId, Point)],
1021        render_options: OverlayRenderOptions, icon_size: Option<u32>,
1022    ) -> Vec<IconRenderPlan> {
1023        // Identical geometry to the live path by construction — same
1024        // `IconLayout::resolve` call, same inputs, differing only in
1025        // where `icon_scale` comes from (caller-supplied here, resolved
1026        // per-monitor in `enrich_plans`). That equality is the entire
1027        // reason a snapshot can be used as an oracle for what the live
1028        // overlay draws. Do not inline this math back into either
1029        // caller.
1030        let base_size_dip = icon_size.map(|size| (size, size)).unwrap_or_else(|| self.query_icon_size_px().unwrap_or((48, 48)));
1031        let icon_scale = dpi_scale.max(0.01);
1032        let metrics = label_metrics_for_dpi(dpi_for_scale(icon_scale));
1033        let layout = IconLayout::resolve(base_size_dip, icon_scale, &metrics);
1034
1035        positions
1036            .iter()
1037            .map(|(id, pt)| {
1038                let extracted = self.pidl_cache.get(id).and_then(|pidl| unsafe {
1039                    extract_icon_bitmap(
1040                        pidl.as_ptr(),
1041                        layout.size_px.0,
1042                        render_options.draw_shortcut_overlay,
1043                        render_options.draw_shield_overlay,
1044                    )
1045                });
1046                let (image, shortcut_arrow_image) = match extracted {
1047                    Some(bundle) => (Some(bundle.base), bundle.shortcut_arrow),
1048                    None => (None, None),
1049                };
1050                let label = if render_options.draw_labels {
1051                    self.display_name_cache
1052                        .get(id)
1053                        .cloned()
1054                        .map(|text| IconLabel {
1055                            text,
1056                            bounds_px: layout.label_bounds_px,
1057                        })
1058                } else {
1059                    None
1060                };
1061                IconRenderPlan {
1062                    effect: None,
1063                    stationary: false,
1064                    id: id.clone(),
1065                    source_position: *pt,
1066                    final_position: *pt,
1067                    size_px: layout.size_px,
1068                    image,
1069                    label,
1070                    render_offset_px: layout.render_offset_px,
1071                    shortcut_arrow_image,
1072                }
1073            })
1074            .collect()
1075    }
1076
1077    /// Query the current desktop icon size in physical pixels via
1078    /// `IFolderView2::GetViewModeAndIconSize`.
1079    ///
1080    /// Returns `None` if the folder view is unreachable or the shell
1081    /// reports an implausible size — in that case callers should fall
1082    /// back to `(48, 48)`.
1083    fn query_icon_size_px(&mut self) -> Option<(u32, u32)> {
1084        let view = self.com.folder_view().ok()?;
1085        // SAFETY: valid `IFolderView2` interface pointer; out params
1086        // are stack-local.
1087        let mut mode = FOLDERVIEWMODE(0);
1088        let mut px: i32 = 0;
1089        let hr = unsafe { view.GetViewModeAndIconSize(&mut mode, &mut px) };
1090        if hr.is_err() || px <= 0 {
1091            return None;
1092        }
1093        // Clamp so a corrupt shell can't report an absurd size.
1094        let sz = (px as u32).clamp(16, 256);
1095        Some((sz, sz))
1096    }
1097
1098    /// Locate the `SysListView32` child of the cached
1099    /// `SHELLDLL_DefView` window. Returns `None` if the shell view
1100    /// HWND isn't cached yet or the child isn't findable (which
1101    /// happens on non-standard shells).
1102    ///
1103    /// Used by [`commit_overlay_frame`](Self::commit_overlay_frame)
1104    /// as a `ShowWindow(SW_HIDE)` backup when `FWF_HIDEICONS` on the
1105    /// desktop folder view doesn't take effect (Windows 11 quirk).
1106    fn find_syslistview32(&self) -> Option<HWND> {
1107        let parent = self.com.shell_view_hwnd()?;
1108        // SAFETY: `FindWindowExW` takes a parent HWND and a class
1109        // name; it returns a child HWND or an error (child not
1110        // present). Both branches are safe.
1111        let class_name = w!("SysListView32");
1112        match unsafe { FindWindowExW(Some(parent), None, class_name, PCWSTR::null()) } {
1113            Ok(hwnd) if !hwnd.is_invalid() => Some(hwnd),
1114            _ => None,
1115        }
1116    }
1117
1118    /// Teleport the real Shell icons to their final positions, ahead of
1119    /// hiding them. Returns the positions still awaiting a commit —
1120    /// empty on success, the untouched input on failure so
1121    /// `finalize_overlay_session` can retry.
1122    fn teleport_real_icons(
1123        &mut self,
1124        pending: Vec<(IconId, Point)>,
1125    ) -> Vec<(IconId, Point)> {
1126        if pending.is_empty() {
1127            return pending;
1128        }
1129        match self.set_positions(&pending) {
1130            Ok(_) => {
1131                debug!(icons = pending.len(), "teleported real icons to targets");
1132                Vec::new()
1133            }
1134            Err(e) => {
1135                warn!(
1136                    error = %e,
1137                    icons = pending.len(),
1138                    "first-frame set_positions failed; real icons will pop into \
1139                     place at animation end instead"
1140                );
1141                pending
1142            }
1143        }
1144    }
1145
1146    /// Stop Explorer drawing the real icons, two ways, and return the
1147    /// guard that owns undoing both.
1148    ///
1149    /// `FWF_HIDEICONS` on the desktop folder view is documented as
1150    /// unreliable on Windows 11, so the `SysListView32` that actually
1151    /// paints the icons is hidden too. Either mechanism alone is
1152    /// enough; the guard restores whichever stuck.
1153    fn hide_real_icons(&mut self) -> ShellHideGuard {
1154        let mut guard = ShellHideGuard {
1155            hidden_syslistview: None,
1156            flag_set: false,
1157        };
1158
1159        match self.apply_flags(FWF_HIDEICONS, FWF_HIDEICONS) {
1160            Ok(()) => guard.flag_set = true,
1161            Err(e) => warn!(
1162                error = %e,
1163                "failed to set FWF_HIDEICONS; falling back to SysListView32 hide only"
1164            ),
1165        }
1166        // Force Explorer to actually process the flag change.
1167        self.com.redraw_folder_view();
1168
1169        if let Some(hwnd) = self.find_syslistview32() {
1170            // SAFETY: `ShowWindow` is safe on any HWND value.
1171            let _ = unsafe { ShowWindow(hwnd, SW_HIDE) };
1172            guard.hidden_syslistview = Some(hwnd);
1173        }
1174        debug!(
1175            flag_set = guard.flag_set,
1176            syslistview_hidden = guard.hidden_syslistview.is_some(),
1177            "real icons hidden"
1178        );
1179        guard
1180    }
1181
1182    /// Undo everything [`Self::hide_real_icons`] did, consuming the guard.
1183    ///
1184    /// `SysListView32` is restored BEFORE `FWF_HIDEICONS` is cleared,
1185    /// so if the flag was the mechanism that actually hid the icons,
1186    /// the window restore happens under still-hidden semantics and
1187    /// cannot flash the old positions.
1188    ///
1189    /// `flag_set` is cleared **only** when the Shell call succeeds. A
1190    /// failed clear therefore stays visible to the guard's `Drop` and
1191    /// to the stale-flag sweep in `begin_overlay_session`, instead of
1192    /// being silently forgotten.
1193    fn restore_real_icons(&mut self, mut hide: ShellHideGuard) {
1194        hide.show_syslistview();
1195
1196        if hide.flag_set {
1197            match self.apply_flags(FWF_HIDEICONS, 0) {
1198                Ok(()) => hide.flag_set = false,
1199                // Left `true` on purpose so the guard's Drop warns and
1200                // the next session's stale-flag sweep picks it up.
1201                Err(e) => tracing::error!(
1202                    error = %e,
1203                    "failed to clear FWF_HIDEICONS — desktop icons may stay hidden"
1204                ),
1205            }
1206        }
1207        debug!("real icons restored");
1208    }
1209
1210    /// Poll `IFolderView2::GetItemPosition` until at least one of the
1211    /// moved icons reports a position within `CONFIRM_TOLERANCE_PX` of
1212    /// its target, or `SHELL_CONFIRM_TIMEOUT` expires. Returns the
1213    /// confirmed ids.
1214    ///
1215    /// The shell almost always updates its internal layout within a
1216    /// few milliseconds of `SelectAndPositionItems` returning, but
1217    /// that is not guaranteed.
1218    fn await_shell_confirmation(
1219        &mut self,
1220        final_positions: &[(IconId, Point)],
1221        missing: &[IconId],
1222    ) -> Vec<IconId> {
1223        let missing_set: std::collections::HashSet<&IconId> = missing.iter().collect();
1224        let view = match self.com.folder_view() {
1225            Ok(v) => v,
1226            Err(_) => return Vec::new(),
1227        };
1228
1229        let deadline = Instant::now() + SHELL_CONFIRM_TIMEOUT;
1230        let mut confirmed: Vec<IconId> = Vec::new();
1231        const CONFIRM_TOLERANCE_PX: i32 = 4;
1232
1233        loop {
1234            for (id, target) in final_positions {
1235                if missing_set.contains(id) {
1236                    continue;
1237                }
1238                if confirmed.iter().any(|c| c == id) {
1239                    continue;
1240                }
1241                let Some(pidl) = self.pidl_cache.get(id) else {
1242                    continue;
1243                };
1244                // SAFETY: `pidl` is owned by the PIDL cache and `view`
1245                // is a clone of the cached `IFolderView2`; both stay
1246                // valid for the duration of this call.
1247                let pos = unsafe { view.GetItemPosition(pidl.as_ptr()) };
1248                if let Ok(p) = pos {
1249                    let dx = (p.x - target.x).abs();
1250                    let dy = (p.y - target.y).abs();
1251                    if dx <= CONFIRM_TOLERANCE_PX && dy <= CONFIRM_TOLERANCE_PX {
1252                        confirmed.push(id.clone());
1253                        return confirmed;
1254                    }
1255                }
1256            }
1257            if Instant::now() >= deadline {
1258                break;
1259            }
1260            std::thread::sleep(SHELL_CONFIRM_POLL);
1261        }
1262        confirmed
1263    }
1264}