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}