win-reveal 0.1.0

Windows-only: open a file's containing folder in Explorer and select the item
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
//! Reveal a path in Windows Explorer: open the containing folder and select the item.
//!
//! This crate is Windows-only by design. The shell call is [`SHOpenFolderAndSelectItems`],
//! the one documented Win32 entry point whose contract is exactly "open this folder and
//! select these items". Paths are handed to the shell as UTF-16 through the shell
//! namespace, never through a command line, so spaces, CJK text and commas in names
//! cannot be mis-parsed.
//!
//! # From inside another program
//!
//! ```no_run
//! use win_reveal::{Revealer, ShellRevealer};
//!
//! let target = std::env::current_exe().unwrap();
//! match ShellRevealer.reveal(&target) {
//!     Ok(()) => println!("Explorer is showing it"),
//!     Err(e) => eprintln!("could not reveal: {e}"),
//! }
//! ```
//!
//! Marked `no_run`: a doctest that opened an Explorer window on every `cargo test` would
//! make this crate a hostile dependency. [`ShellRevealer::reveal_with`] is the same call
//! with the tab policy passed in rather than read from the process environment.
//!
//! [`SHOpenFolderAndSelectItems`]: https://learn.microsoft.com/windows/win32/api/shlobj_core/nf-shlobj_core-shopenfolderandselectitems

#[cfg(not(windows))]
compile_error!("reveal is Windows-only; build with a Windows target");

mod automation;
mod ffi;

#[cfg(test)]
mod crosscheck;

use std::borrow::Cow;
use std::env;
use std::ffi::OsString;
use std::fmt;
use std::io::Write;
use std::os::windows::ffi::OsStrExt;
use std::path::{Component, Path, PathBuf, Prefix};
use std::ptr;
use std::sync::Mutex;
use std::time::Duration;

// The tab policy is a caller's choice, not an implementation detail: the CLI expresses it as
// `REVEAL_TAB_POLICY`, a library host passes it to [`ShellRevealer::reveal_with`].
pub use automation::Switch;

/// Process exit code when the reveal request was handed to the shell successfully.
pub const EXIT_OK: i32 = 0;
/// Process exit code when the shell call itself failed.
pub const EXIT_SHELL: i32 = 1;
/// Process exit code for bad command line input (wrong arg count, empty or missing path).
pub const EXIT_USAGE: i32 = 2;

const COINIT_APARTMENTTHREADED: u32 = 0x2;
/// `MAKE_HRESULT(SEVERITY_ERROR, FACILITY_RPC, 6)`: the thread already has an apartment in a
/// mode other than the one asked for.
const RPC_E_CHANGED_MODE: i32 = 0x8001_0106_u32 as i32;

/// Setting that chooses what happens when the target folder is already open in a tab the
/// user is not looking at. Accepted values: `newtab` (default) and `overwrite`.
const TAB_POLICY_VAR: &str = "REVEAL_TAB_POLICY";

/// Setting that swaps the shell call for a print of the resolved path, so the whole CLI can
/// be exercised without opening an Explorer window. Accepted values are listed in
/// [`dry_run_setting`]; the name is public because the binary reads it.
pub const DRY_RUN_VAR: &str = "REVEAL_DRY_RUN";

/// The one-line usage. Shared by the error path in [`run`] and the `--help` text, so the two
/// cannot drift apart.
pub const USAGE: &str = "usage: reveal <file-or-folder-path>";

/// Everything that can go wrong between the command line and the shell.
#[derive(Debug, PartialEq, Eq)]
pub enum Error {
    /// No path argument was given.
    MissingArgument,
    /// More than one path argument was given.
    TooManyArguments(usize),
    /// The path argument was the empty string.
    EmptyArgument,
    /// The resolved path does not exist on disk.
    NotFound(PathBuf),
    /// The path is a drive root or otherwise has no containing folder to open.
    NoParent(PathBuf),
    /// A setting holds a value this tool does not understand.
    BadSetting {
        variable: &'static str,
        value: OsString,
        /// The accepted spellings, quoted for someone who just typed the wrong one.
        allowed: &'static str,
    },
    /// A Win32 shell call returned a failure HRESULT.
    Shell { step: &'static str, hresult: u32 },
}

impl fmt::Display for Error {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Error::MissingArgument => write!(f, "missing file path argument"),
            Error::TooManyArguments(n) => {
                write!(f, "expected exactly 1 file path argument, got {n}")
            }
            Error::EmptyArgument => write!(f, "file path argument is empty"),
            Error::NotFound(p) => write!(f, "path does not exist: {}", p.display()),
            Error::NoParent(p) => write!(f, "path has no containing folder: {}", p.display()),
            Error::BadSetting { variable, value, allowed } => write!(
                f,
                "{variable} must be {allowed}, got '{}'",
                value.to_string_lossy()
            ),
            Error::Shell { step, hresult } => {
                write!(f, "{step} failed (HRESULT 0x{hresult:08X})")
            }
        }
    }
}

/// So a caller's own `fn …() -> Result<T, Box<dyn Error>>` or `anyhow` chain can take the
/// `?` from [`Revealer::reveal`] without wrapping.
impl std::error::Error for Error {}

/// The single injectable boundary between argument handling and the shell.
///
/// Production uses [`ShellRevealer`]; tests substitute a recorder so no Explorer
/// window is ever opened by the automated suite.
pub trait Revealer {
    /// Open the folder containing `path` and select `path`.
    fn reveal(&self, path: &Path) -> Result<(), Error>;
}

/// Reveals through the real Windows shell.
///
/// As a [`Revealer`] it takes the tab policy from the `REVEAL_TAB_POLICY` environment
/// variable, which is what a CLI wants. A library caller that would rather not put its
/// configuration into its own process environment calls [`ShellRevealer::reveal_with`] and
/// passes the policy as an argument instead.
///
/// Both forms initialize COM on the calling thread but *adopt* an apartment the thread already
/// has, including an MTA one — where `CoInitializeEx` answers `RPC_E_CHANGED_MODE`, a negative
/// HRESULT for a call that still works — and never uninitialize an apartment owned by the host.
/// They then block the calling thread while the shell catches up: view lookup can wait up to
/// 2 s and post-open selection up to 3 s, so a GUI host that cannot spare its UI thread makes
/// the call from a worker.
pub struct ShellRevealer;

impl ShellRevealer {
    /// Open the folder containing `path` and select it, with the tab policy given directly.
    ///
    /// `switch` decides what happens when that folder is already open in a tab the user is
    /// not looking at — see [`Switch`]. This is [`Revealer::reveal`] minus the environment
    /// lookup, and the entry point a GUI host should call.
    ///
    /// ```no_run
    /// use win_reveal::{ShellRevealer, Switch};
    ///
    /// // Any existing file or folder with a containing directory.
    /// let target = std::env::current_exe().unwrap();
    /// ShellRevealer::reveal_with(&target, Switch::Overwrite).unwrap();
    /// ```
    pub fn reveal_with(path: &Path, switch: Switch) -> Result<(), Error> {
        let wide: Vec<u16> = path.as_os_str().encode_wide().chain([0]).collect();
        // Every shell call below is COM-based and requires an initialized apartment;
        // a fresh CLI process has none, a library host usually has one already.
        let ours = init_apartment()?;
        let result = match select_in_already_open_view(path, switch) {
            Found::Done => Ok(()),
            // The folder is already in the user's window, only out of sight: bringing that
            // tab forward is enough, and asking the shell to open an open folder is what
            // produced a second tab for it.
            Found::Hidden => {
                ensure_selected(path, switch);
                Ok(())
            }
            Found::NotOpen => reveal_parsed(&wide).map(|()| ensure_selected(path, switch)),
        };
        if ours {
            unsafe { ffi::CoUninitialize() };
        }
        result
    }
}

impl Revealer for ShellRevealer {
    fn reveal(&self, path: &Path) -> Result<(), Error> {
        let switch = tab_policy(env::var_os(TAB_POLICY_VAR))?;
        ShellRevealer::reveal_with(path, switch)
    }
}

/// What the first look at the open Explorer tabs turned up.
enum Found {
    /// The item is selected in a tab the user can see.
    Done,
    /// A tab holds the folder but is not the one on screen, and the policy will not take
    /// over the visible tab.
    Hidden,
    /// No Explorer tab holds the folder, so it has to be opened.
    NotOpen,
}

/// Reads the tab policy out of an environment variable value.
///
/// Split out from [`ShellRevealer::reveal`] so the mapping is testable without process env.
fn tab_policy(raw: Option<OsString>) -> Result<Switch, Error> {
    let Some(value) = raw else {
        return Ok(Switch::NewTab);
    };
    match value.to_string_lossy().to_ascii_lowercase().as_str() {
        "newtab" => Ok(Switch::NewTab),
        "overwrite" => Ok(Switch::Overwrite),
        _ => Err(Error::BadSetting {
            variable: TAB_POLICY_VAR,
            value,
            allowed: "'newtab' or 'overwrite'",
        }),
    }
}

/// Reads the dry-run setting out of an environment variable value.
///
/// Split out from the binary so the mapping is testable without process env. Unset means off;
/// `0`, `false`, `no` and the empty string also mean off, because a value that only ever turns
/// a switch on is a footgun once it is exported into a shell session.
pub fn dry_run_setting(raw: Option<OsString>) -> Result<bool, Error> {
    let Some(value) = raw else { return Ok(false) };
    match value.to_string_lossy().to_ascii_lowercase().as_str() {
        "" | "0" | "false" | "no" => Ok(false),
        "1" | "true" | "yes" => Ok(true),
        _ => Err(Error::BadSetting {
            variable: DRY_RUN_VAR,
            value,
            allowed: "'1'/'0', 'true'/'false' or 'yes'/'no'",
        }),
    }
}

/// Selecting into a tab that is already on screen is preferred over asking the shell to
/// open the folder, because that is the one path which is both visible and free: it adds no
/// tab and no window. A folder that is only open in a background tab counts as "not on
/// screen"; what happens to it next is `switch`'s job — see
/// [`automation::select_in_open_view`].
fn select_in_already_open_view(path: &Path, switch: Switch) -> Found {
    let (Some(parent), Some(name)) = (path.parent(), path.file_name()) else {
        return Found::NotOpen;
    };
    // A view can exist but not list a just-created file yet; wait for Explorer's
    // asynchronous refresh before giving up and opening the folder anew.
    automation::poll_within(
        Duration::from_millis(2000),
        Found::NotOpen,
        || match automation::select_in_open_view(parent, name, switch) {
            Ok(hwnd) => {
                automation::bring_to_front(hwnd);
                Some(Found::Done)
            }
            // Only the item is missing, the folder is on screen: keep waiting, then fall
            // through to the open call, which is also what refreshes the item list.
            Err(automation::Miss::ItemNotListed(_)) => None,
            Err(automation::Miss::Hidden(_)) => Some(Found::Hidden),
            Err(automation::Miss::NoView) => Some(Found::NotOpen),
        },
    )
}

/// Some Windows 11 shells open the folder but drop the requested selection when they
/// have to create a new window or tab, and a window they create can land behind the
/// caller's own — either way the user sees nothing happen. Re-apply the selection through
/// the folder view of the window that is now open and raise it. With the new-tab policy,
/// also ask the shell to switch to the folder's tab when the open it produced is a
/// background one. Best effort: a failure here leaves the folder open, which is still the
/// documented result of the shell call above.
fn ensure_selected(path: &Path, switch: Switch) {
    let (Some(parent), Some(name)) = (path.parent(), path.file_name()) else {
        return;
    };
    let mut asked = false;
    // One pass over Shell.Windows per round: each miss already carries the frame to raise, so
    // the window lookup that used to run first would repeat the whole enumeration.
    automation::poll_within(Duration::from_millis(3000), (), || {
        match automation::select_in_open_view(parent, name, switch) {
            Ok(hwnd) => {
                automation::bring_to_front(hwnd);
                Some(())
            }
            // The window the shell just created can come up minimized (it restores the state of
            // the last closed one) or behind the caller's, so raise it as soon as it exists
            // rather than only once the item can be selected.
            Err(automation::Miss::ItemNotListed(hwnd)) => {
                automation::bring_to_front(hwnd);
                None
            }
            Err(automation::Miss::Hidden(hwnd)) => {
                automation::bring_to_front(hwnd);
                // Reached once, and only when the folder is open out of sight: the shell put it
                // in the user's existing window and left their own tab in front.
                if switch == Switch::NewTab && !asked {
                    asked = true;
                    automation::activate_tab_by_path(parent);
                }
                None
            }
            Err(automation::Miss::NoView) => None,
        }
    });
}

/// Records every path it is asked to reveal instead of touching the shell.
///
/// `Send + Sync` on purpose: a host's test suite hands the double to whatever thread runs the
/// call, often behind `Arc<dyn Revealer + Send + Sync>`.
#[derive(Default)]
pub struct RecordingRevealer(pub Mutex<Vec<PathBuf>>);

impl RecordingRevealer {
    pub fn recorded(&self) -> Vec<PathBuf> {
        self.0.lock().unwrap().clone()
    }
}

impl Revealer for RecordingRevealer {
    fn reveal(&self, path: &Path) -> Result<(), Error> {
        self.0.lock().unwrap().push(path.to_path_buf());
        Ok(())
    }
}

/// Spell the argument the way the shell namespace means it, when it differs from Win32.
///
/// Three spellings need it, all measured on build 26100:
///
/// * `\\?\C:\a.txt` is a valid Win32 path, so the existence check below accepts it, but
///   `SHParseDisplayName` answers `E_INVALIDARG` for it — without the rewrite the tool would
///   report a shell failure for a path it just verified exists. Device-namespace forms
///   (`\\?\Device\...`) have no shell equivalent and are left alone: they fail where they
///   fail, with the HRESULT that says so.
/// * `C:a.txt` means "relative to the current directory *of drive C:*", which Explorer takes
///   from its own process, not from the caller's. Anchoring it to the drive root keeps one
///   path meaning one file.
/// * `\\server\share` needs nothing, and neither does an already ordinary absolute path.
fn normalize(path: &Path) -> Cow<'_, Path> {
    let mut components = path.components();
    let head = match components.next() {
        Some(Component::Prefix(prefix)) if !path.has_root() => {
            // A drive letter or UNC root without a `\` after it: rebuild the rooted form.
            match prefix.kind() {
                Prefix::Disk(disk) | Prefix::VerbatimDisk(disk) => {
                    Some(OsString::from(format!("{}:\\", disk as char)))
                }
                _ => None,
            }
        }
        Some(Component::Prefix(prefix)) => match prefix.kind() {
            Prefix::VerbatimDisk(disk) => Some(OsString::from(format!("{}:\\", disk as char))),
            Prefix::VerbatimUNC(server, share) => {
                let mut head = OsString::from(r"\\");
                head.push(server);
                head.push("\\");
                head.push(share);
                head.push("\\");
                Some(head)
            }
            _ => None,
        },
        _ => None,
    };
    let Some(mut rewritten) = head else {
        return Cow::Borrowed(path);
    };
    // The head already carries its separator, so drop the remainder's leading one to keep a
    // single separator at the join instead of `C:\\Windows`.
    let rest = components.as_path();
    let rest = rest.strip_prefix("\\").unwrap_or(rest);
    rewritten.push(rest.as_os_str());
    Cow::Owned(PathBuf::from(rewritten))
}

/// Resolve the raw command line argument to the absolute path that will be revealed.
///
/// Relative input is resolved against `cwd` because Explorer would otherwise resolve it
/// against its own process directory, which is unrelated to the caller's.
pub fn resolve(raw: &std::ffi::OsStr, cwd: &Path) -> Result<PathBuf, Error> {
    if raw.is_empty() {
        return Err(Error::EmptyArgument);
    }
    let path = normalize(Path::new(raw));
    let joined = if path.is_relative() {
        cwd.join(&*path)
    } else {
        path.to_path_buf()
    };
    // Lexical only: drops trailing separators and `.` components, which would otherwise
    // make a trailing-separator file path fail the existence check below.
    let abs: PathBuf = joined.components().collect();
    if !abs.exists() {
        return Err(Error::NotFound(abs));
    }
    match abs.parent() {
        Some(parent) if !parent.as_os_str().is_empty() => Ok(abs),
        _ => Err(Error::NoParent(abs)),
    }
}

/// Full CLI entry point: parse `args`, reveal, and report to `stderr`.
/// Returns the process exit code.
///
/// The `revealer` and the writer are parameters rather than `ShellRevealer` and
/// [`std::io::stderr`], so a host can run the whole CLI over its own argument list without
/// an Explorer window appearing:
///
/// ```
/// use std::path::Path;
/// use win_reveal::{run, RecordingRevealer, EXIT_OK, EXIT_USAGE};
///
/// let path = std::env::current_exe().unwrap().as_os_str().to_owned();
/// let recorder = RecordingRevealer::default();
/// let mut messages: Vec<u8> = Vec::new();
///
/// // One argument: the recorder stands in for the shell, so nothing opens.
/// let code = run(&[path.clone()], Path::new("."), &recorder, &mut messages);
/// assert_eq!(code, EXIT_OK);
/// assert_eq!(recorder.recorded().len(), 1);
///
/// // Two arguments: a usage error, reported into the writer that was handed in.
/// let code = run(&[path.clone(), path], Path::new("."), &recorder, &mut messages);
/// assert_eq!(code, EXIT_USAGE);
/// assert_eq!(recorder.recorded().len(), 1, "the rejected call must not reveal anything");
/// assert!(String::from_utf8_lossy(&messages).contains("expected exactly 1 file path argument"));
/// ```
pub fn run(
    args: &[OsString],
    cwd: &Path,
    revealer: &dyn Revealer,
    stderr: &mut dyn Write,
) -> i32 {
    let result = match args.len() {
        0 => Err(Error::MissingArgument),
        1 => resolve(&args[0], cwd).and_then(|p| revealer.reveal(&p).map(|()| p)),
        n => Err(Error::TooManyArguments(n)),
    };
    match result {
        Ok(_) => EXIT_OK,
        Err(e) => {
            let code = match e {
                Error::Shell { .. } => EXIT_SHELL,
                _ => EXIT_USAGE,
            };
            let _ = writeln!(stderr, "reveal: {e}");
            if code == EXIT_USAGE {
                let _ = writeln!(stderr, "{USAGE}");
            }
            code
        }
    }
}

/// Initialize COM on the calling thread and report whether *this call* owns the matching
/// `CoUninitialize`.
///
/// A CLI process starts with no apartment, but a library host usually already has one:
/// `CoInitializeEx` then answers `S_FALSE` (count incremented, so we must decrement) or, when
/// the host initialized the thread in another mode, `RPC_E_CHANGED_MODE` — a negative HRESULT
/// for a call that is perfectly usable. Treating that as a shell failure would deny a GUI host
/// the one thing it asked for, so the apartment stays the host's and we never uninitialize it.
fn init_apartment() -> Result<bool, Error> {
    let hr = unsafe { ffi::CoInitializeEx(ptr::null(), COINIT_APARTMENTTHREADED) };
    if hr == RPC_E_CHANGED_MODE {
        Ok(false)
    } else if hr >= 0 {
        Ok(true)
    } else {
        Err(Error::Shell {
            step: "CoInitializeEx",
            hresult: hr as u32,
        })
    }
}

fn reveal_parsed(wide: &[u16]) -> Result<(), Error> {
    unsafe {
        let mut pidl = ptr::null_mut();
        let hr = ffi::SHParseDisplayName(wide.as_ptr(), ptr::null(), &mut pidl, 0, ptr::null_mut());
        if hr < 0 || pidl.is_null() {
            return Err(Error::Shell {
                step: "SHParseDisplayName",
                hresult: hr as u32,
            });
        }
        // Split the parsed item into (parent folder, child) as the API requires:
        // truncate the full IDLIST in place to the parent and keep the last element
        // as the relative child to select.
        let child = ffi::ILFindLastID(pidl);
        ffi::ILRemoveLastID(pidl);
        let child_ptr: *const std::os::raw::c_void = child;
        let hr = ffi::SHOpenFolderAndSelectItems(pidl, 1, &child_ptr, 0);
        ffi::ILFree(pidl);
        if hr < 0 {
            return Err(Error::Shell {
                step: "SHOpenFolderAndSelectItems",
                hresult: hr as u32,
            });
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests;