Skip to main content

win_desktop_utils/
shell.rs

1//! Shell-facing helpers for opening files, URLs, Explorer selections, and the Recycle Bin.
2
3#[cfg(feature = "shell")]
4use std::ffi::OsStr;
5use std::path::Path;
6#[cfg(feature = "recycle-bin")]
7use std::path::PathBuf;
8#[cfg(feature = "shell")]
9use std::process::Command;
10
11#[cfg(feature = "recycle-bin")]
12use windows::core::PCWSTR;
13#[cfg(feature = "recycle-bin")]
14use windows::Win32::System::Com::{CoCreateInstance, CLSCTX_INPROC_SERVER};
15#[cfg(feature = "recycle-bin")]
16use windows::Win32::UI::Shell::{
17    FileOperation, IFileOperation, IFileOperationProgressSink, IShellItem,
18    SHCreateItemFromParsingName, SHEmptyRecycleBinW, FOFX_RECYCLEONDELETE, FOF_ALLOWUNDO,
19    FOF_NOCONFIRMATION, FOF_NOERRORUI, FOF_SILENT, SHERB_NOCONFIRMATION, SHERB_NOPROGRESSUI,
20    SHERB_NOSOUND,
21};
22
23use crate::error::{Error, Result};
24#[cfg(any(feature = "recycle-bin", feature = "shell"))]
25use crate::win::path_contains_nul;
26#[cfg(feature = "shell")]
27use crate::win::{normalize_nonempty_str, shell_execute};
28#[cfg(feature = "recycle-bin")]
29use crate::win::{run_in_sta, to_wide_os, ComApartment};
30
31#[cfg(feature = "shell")]
32fn normalize_url(url: &str) -> Result<&str> {
33    normalize_nonempty_str(url, "url cannot be empty", "url cannot contain NUL bytes")
34}
35
36#[cfg(feature = "shell")]
37fn normalize_shell_verb(verb: &str) -> Result<&str> {
38    normalize_nonempty_str(
39        verb,
40        "verb cannot be empty",
41        "verb cannot contain NUL bytes",
42    )
43}
44
45#[cfg(feature = "shell")]
46fn shell_execute_raw(verb: &str, target: &OsStr) -> Result<()> {
47    shell_execute(verb, target, None, "ShellExecuteW")
48}
49
50#[cfg(feature = "recycle-bin")]
51fn shell_item_from_path(path: &Path) -> Result<IShellItem> {
52    let path_w = to_wide_os(path.as_os_str());
53
54    unsafe { SHCreateItemFromParsingName(PCWSTR(path_w.as_ptr()), None) }.map_err(|err| {
55        Error::WindowsApi {
56            context: "SHCreateItemFromParsingName",
57            code: err.code().0,
58        }
59    })
60}
61
62#[cfg(feature = "recycle-bin")]
63fn validate_recycle_path(path: &Path) -> Result<()> {
64    if path.as_os_str().is_empty() {
65        return Err(Error::InvalidInput("path cannot be empty"));
66    }
67
68    if path_contains_nul(path) {
69        return Err(Error::InvalidInput("path cannot contain NUL bytes"));
70    }
71
72    if !path.is_absolute() {
73        return Err(Error::PathNotAbsolute);
74    }
75
76    if !path.exists() {
77        return Err(Error::PathDoesNotExist);
78    }
79
80    Ok(())
81}
82
83#[cfg(feature = "recycle-bin")]
84fn collect_recycle_paths<I, P>(paths: I) -> Result<Vec<PathBuf>>
85where
86    I: IntoIterator<Item = P>,
87    P: AsRef<Path>,
88{
89    let mut collected = Vec::new();
90
91    for path in paths {
92        let path = path.as_ref();
93        validate_recycle_path(path)?;
94        collected.push(PathBuf::from(path));
95    }
96
97    if collected.is_empty() {
98        Err(Error::InvalidInput("paths cannot be empty"))
99    } else {
100        Ok(collected)
101    }
102}
103
104#[cfg(feature = "recycle-bin")]
105fn queue_recycle_item(operation: &IFileOperation, path: &Path) -> Result<()> {
106    let item = shell_item_from_path(path)?;
107
108    unsafe { operation.DeleteItem(&item, Option::<&IFileOperationProgressSink>::None) }.map_err(
109        |err| Error::WindowsApi {
110            context: "IFileOperation::DeleteItem",
111            code: err.code().0,
112        },
113    )
114}
115
116#[cfg(feature = "recycle-bin")]
117fn recycle_paths_in_sta(paths: &[PathBuf]) -> Result<()> {
118    let _com = ComApartment::initialize_sta("CoInitializeEx")?;
119    let operation: IFileOperation = unsafe {
120        CoCreateInstance(&FileOperation, None, CLSCTX_INPROC_SERVER)
121    }
122    .map_err(|err| Error::WindowsApi {
123        context: "CoCreateInstance(FileOperation)",
124        code: err.code().0,
125    })?;
126
127    let flags =
128        FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_NOERRORUI | FOF_SILENT | FOFX_RECYCLEONDELETE;
129
130    unsafe { operation.SetOperationFlags(flags) }.map_err(|err| Error::WindowsApi {
131        context: "IFileOperation::SetOperationFlags",
132        code: err.code().0,
133    })?;
134
135    for path in paths {
136        queue_recycle_item(&operation, path)?;
137    }
138
139    unsafe { operation.PerformOperations() }.map_err(|err| Error::WindowsApi {
140        context: "IFileOperation::PerformOperations",
141        code: err.code().0,
142    })?;
143
144    let aborted =
145        unsafe { operation.GetAnyOperationsAborted() }.map_err(|err| Error::WindowsApi {
146            context: "IFileOperation::GetAnyOperationsAborted",
147            code: err.code().0,
148        })?;
149
150    if aborted.as_bool() {
151        Err(Error::WindowsApi {
152            context: "IFileOperation::PerformOperations aborted",
153            code: 0,
154        })
155    } else {
156        Ok(())
157    }
158}
159
160#[cfg(feature = "recycle-bin")]
161fn empty_recycle_bin_raw(root_path: Option<&Path>) -> Result<()> {
162    let root_w = root_path.map(|path| to_wide_os(path.as_os_str()));
163    let root_ptr = root_w
164        .as_ref()
165        .map_or(PCWSTR::null(), |path| PCWSTR(path.as_ptr()));
166    let flags = SHERB_NOCONFIRMATION | SHERB_NOPROGRESSUI | SHERB_NOSOUND;
167
168    unsafe { SHEmptyRecycleBinW(None, root_ptr, flags) }.map_err(|err| Error::WindowsApi {
169        context: "SHEmptyRecycleBinW",
170        code: err.code().0,
171    })
172}
173
174#[cfg(feature = "recycle-bin")]
175fn run_in_shell_sta<T, F>(work: F) -> Result<T>
176where
177    T: Send + 'static,
178    F: FnOnce() -> Result<T> + Send + 'static,
179{
180    run_in_sta("shell STA worker thread panicked", work)
181}
182
183/// Opens a file or directory with the user's default Windows handler.
184///
185/// # Errors
186///
187/// Returns [`Error::InvalidInput`] if `target` is empty or contains NUL bytes.
188/// Returns [`Error::PathDoesNotExist`] if the target path does not exist.
189/// Returns [`Error::WindowsApi`] if `ShellExecuteW` reports failure.
190///
191/// # Examples
192///
193/// ```no_run
194/// win_desktop_utils::open_with_default(r"C:\Windows\notepad.exe")?;
195/// # Ok::<(), win_desktop_utils::Error>(())
196/// ```
197#[cfg(feature = "shell")]
198pub fn open_with_default(target: impl AsRef<Path>) -> Result<()> {
199    open_with_verb("open", target)
200}
201
202/// Opens a file or directory using a specific Windows shell verb.
203///
204/// Common verbs include `open`, `edit`, `print`, and `properties`, depending on
205/// what the target path's registered handler supports.
206///
207/// # Errors
208///
209/// Returns [`Error::InvalidInput`] if `verb` is empty, whitespace only, or contains
210/// NUL bytes, or if `target` is empty or contains NUL bytes.
211/// Returns [`Error::PathDoesNotExist`] if the target path does not exist.
212/// Returns [`Error::WindowsApi`] if `ShellExecuteW` reports failure.
213///
214/// # Examples
215///
216/// ```no_run
217/// win_desktop_utils::open_with_verb("properties", r"C:\Windows\notepad.exe")?;
218/// # Ok::<(), win_desktop_utils::Error>(())
219/// ```
220#[cfg(feature = "shell")]
221pub fn open_with_verb(verb: &str, target: impl AsRef<Path>) -> Result<()> {
222    let verb = normalize_shell_verb(verb)?;
223    let path = target.as_ref();
224
225    if path.as_os_str().is_empty() {
226        return Err(Error::InvalidInput("target cannot be empty"));
227    }
228
229    if path_contains_nul(path) {
230        return Err(Error::InvalidInput("target cannot contain NUL bytes"));
231    }
232
233    if !path.exists() {
234        return Err(Error::PathDoesNotExist);
235    }
236
237    shell_execute_raw(verb, path.as_os_str())
238}
239
240/// Opens the Windows Properties sheet for a file or directory.
241///
242/// This is a convenience wrapper around [`open_with_verb`] using the `properties`
243/// shell verb.
244///
245/// # Errors
246///
247/// Returns [`Error::InvalidInput`] if `target` is empty or contains NUL bytes.
248/// Returns [`Error::PathDoesNotExist`] if the target path does not exist.
249/// Returns [`Error::WindowsApi`] if `ShellExecuteW` reports failure.
250///
251/// # Examples
252///
253/// ```no_run
254/// win_desktop_utils::show_properties(r"C:\Windows\notepad.exe")?;
255/// # Ok::<(), win_desktop_utils::Error>(())
256/// ```
257#[cfg(feature = "shell")]
258pub fn show_properties(target: impl AsRef<Path>) -> Result<()> {
259    open_with_verb("properties", target)
260}
261
262/// Prints a file using its registered default print shell verb.
263///
264/// Not every file type has a registered `print` verb; unsupported handlers are
265/// reported as Windows shell errors.
266///
267/// # Errors
268///
269/// Returns [`Error::InvalidInput`] if `target` is empty or contains NUL bytes.
270/// Returns [`Error::PathDoesNotExist`] if the target path does not exist.
271/// Returns [`Error::WindowsApi`] if `ShellExecuteW` reports failure.
272///
273/// # Examples
274///
275/// ```no_run
276/// win_desktop_utils::print_with_default(r"C:\Users\Public\Documents\sample.txt")?;
277/// # Ok::<(), win_desktop_utils::Error>(())
278/// ```
279#[cfg(feature = "shell")]
280pub fn print_with_default(target: impl AsRef<Path>) -> Result<()> {
281    open_with_verb("print", target)
282}
283
284/// Opens a URL with the user's default browser or registered handler.
285///
286/// Surrounding whitespace is trimmed before the URL is passed to the Windows shell.
287/// URL validation is otherwise delegated to the Windows shell.
288///
289/// # Errors
290///
291/// Returns [`Error::InvalidInput`] if `url` is empty, whitespace only, or contains NUL bytes.
292/// Returns [`Error::WindowsApi`] if `ShellExecuteW` reports failure.
293///
294/// # Examples
295///
296/// ```no_run
297/// win_desktop_utils::open_url("https://www.rust-lang.org")?;
298/// # Ok::<(), win_desktop_utils::Error>(())
299/// ```
300#[cfg(feature = "shell")]
301pub fn open_url(url: &str) -> Result<()> {
302    let url = normalize_url(url)?;
303    shell_execute_raw("open", OsStr::new(url))
304}
305
306/// Opens Explorer and selects the requested path.
307///
308/// This function starts `explorer.exe` with `/select,` and the provided path.
309///
310/// # Errors
311///
312/// Returns [`Error::InvalidInput`] if `path` is empty or contains NUL bytes.
313/// Returns [`Error::PathDoesNotExist`] if the path does not exist.
314/// Returns [`Error::Io`] if spawning `explorer.exe` fails.
315///
316/// # Examples
317///
318/// ```no_run
319/// win_desktop_utils::reveal_in_explorer(r"C:\Windows\notepad.exe")?;
320/// # Ok::<(), win_desktop_utils::Error>(())
321/// ```
322#[cfg(feature = "shell")]
323pub fn reveal_in_explorer(path: impl AsRef<Path>) -> Result<()> {
324    let path = path.as_ref();
325
326    if path.as_os_str().is_empty() {
327        return Err(Error::InvalidInput("path cannot be empty"));
328    }
329
330    if path_contains_nul(path) {
331        return Err(Error::InvalidInput("path cannot contain NUL bytes"));
332    }
333
334    if !path.exists() {
335        return Err(Error::PathDoesNotExist);
336    }
337
338    Command::new("explorer.exe")
339        .arg("/select,")
340        .arg(path)
341        .spawn()?;
342
343    Ok(())
344}
345
346/// Opens the directory containing an existing file or directory.
347///
348/// # Errors
349///
350/// Returns [`Error::InvalidInput`] if `path` is empty, contains NUL bytes, or has
351/// no containing directory.
352/// Returns [`Error::PathDoesNotExist`] if the path does not exist.
353/// Returns [`Error::WindowsApi`] if `ShellExecuteW` reports failure.
354///
355/// # Examples
356///
357/// ```no_run
358/// win_desktop_utils::open_containing_folder(r"C:\Windows\notepad.exe")?;
359/// # Ok::<(), win_desktop_utils::Error>(())
360/// ```
361#[cfg(feature = "shell")]
362pub fn open_containing_folder(path: impl AsRef<Path>) -> Result<()> {
363    let path = path.as_ref();
364
365    if path.as_os_str().is_empty() {
366        return Err(Error::InvalidInput("path cannot be empty"));
367    }
368
369    if path_contains_nul(path) {
370        return Err(Error::InvalidInput("path cannot contain NUL bytes"));
371    }
372
373    if !path.exists() {
374        return Err(Error::PathDoesNotExist);
375    }
376
377    let parent = path.parent().ok_or(Error::InvalidInput(
378        "path does not have a containing folder",
379    ))?;
380
381    if parent.as_os_str().is_empty() {
382        return Err(Error::InvalidInput(
383            "path does not have a containing folder",
384        ));
385    }
386
387    open_with_default(parent)
388}
389
390/// Sends a file or directory to the Windows Recycle Bin.
391///
392/// The path must be absolute and must exist.
393///
394/// This function uses `IFileOperation` on a dedicated STA thread so it can request
395/// recycle-bin behavior through the modern Shell API.
396///
397/// # Errors
398///
399/// Returns [`Error::InvalidInput`] if `path` is empty or contains NUL bytes.
400/// Returns [`Error::PathNotAbsolute`] if the path is not absolute.
401/// Returns [`Error::PathDoesNotExist`] if the path does not exist.
402/// Returns [`Error::WindowsApi`] if the shell operation fails or is aborted.
403///
404/// # Examples
405///
406/// ```no_run
407/// let path = std::env::current_dir()?.join("temporary-file.txt");
408/// std::fs::write(&path, "temporary file")?;
409/// win_desktop_utils::move_to_recycle_bin(&path)?;
410/// # Ok::<(), Box<dyn std::error::Error>>(())
411/// ```
412#[cfg(feature = "recycle-bin")]
413pub fn move_to_recycle_bin(path: impl AsRef<Path>) -> Result<()> {
414    let path = path.as_ref();
415    validate_recycle_path(path)?;
416
417    let path = PathBuf::from(path);
418    run_in_shell_sta(move || recycle_paths_in_sta(std::slice::from_ref(&path)))
419}
420
421/// Sends multiple files or directories to the Windows Recycle Bin in one shell operation.
422///
423/// Every path must be absolute and must exist. All paths are validated before any shell
424/// operation is started.
425///
426/// This function uses `IFileOperation` on a dedicated STA thread so it can request
427/// recycle-bin behavior through the modern Shell API.
428///
429/// # Errors
430///
431/// Returns [`Error::InvalidInput`] if the path collection is empty or any path is
432/// empty or contains NUL bytes.
433/// Returns [`Error::PathNotAbsolute`] if any path is not absolute.
434/// Returns [`Error::PathDoesNotExist`] if any path does not exist.
435/// Returns [`Error::WindowsApi`] if the shell operation fails or is aborted.
436///
437/// # Examples
438///
439/// ```no_run
440/// let first = std::env::current_dir()?.join("temporary-file-a.txt");
441/// let second = std::env::current_dir()?.join("temporary-file-b.txt");
442/// std::fs::write(&first, "temporary file")?;
443/// std::fs::write(&second, "temporary file")?;
444/// win_desktop_utils::move_paths_to_recycle_bin([&first, &second])?;
445/// # Ok::<(), Box<dyn std::error::Error>>(())
446/// ```
447#[cfg(feature = "recycle-bin")]
448pub fn move_paths_to_recycle_bin<I, P>(paths: I) -> Result<()>
449where
450    I: IntoIterator<Item = P>,
451    P: AsRef<Path>,
452{
453    let paths = collect_recycle_paths(paths)?;
454    run_in_shell_sta(move || recycle_paths_in_sta(&paths))
455}
456
457/// Permanently empties the Recycle Bin for all drives without shell UI.
458///
459/// This operation cannot be undone through this crate.
460///
461/// # Errors
462///
463/// Returns [`Error::WindowsApi`] if `SHEmptyRecycleBinW` reports failure.
464///
465/// # Examples
466///
467/// ```no_run
468/// win_desktop_utils::empty_recycle_bin()?;
469/// # Ok::<(), win_desktop_utils::Error>(())
470/// ```
471#[cfg(feature = "recycle-bin")]
472pub fn empty_recycle_bin() -> Result<()> {
473    empty_recycle_bin_raw(None)
474}
475
476/// Permanently empties the Recycle Bin for a specific drive root without shell UI.
477///
478/// `root_path` should be an existing absolute drive root such as `C:\`.
479///
480/// # Errors
481///
482/// Returns [`Error::InvalidInput`] if `root_path` is empty or contains NUL bytes.
483/// Returns [`Error::PathNotAbsolute`] if `root_path` is not absolute.
484/// Returns [`Error::PathDoesNotExist`] if `root_path` does not exist.
485/// Returns [`Error::WindowsApi`] if `SHEmptyRecycleBinW` reports failure.
486///
487/// # Examples
488///
489/// ```no_run
490/// win_desktop_utils::empty_recycle_bin_for_root(r"C:\")?;
491/// # Ok::<(), win_desktop_utils::Error>(())
492/// ```
493#[cfg(feature = "recycle-bin")]
494pub fn empty_recycle_bin_for_root(root_path: impl AsRef<Path>) -> Result<()> {
495    let root_path = root_path.as_ref();
496
497    if root_path.as_os_str().is_empty() {
498        return Err(Error::InvalidInput("root_path cannot be empty"));
499    }
500
501    if path_contains_nul(root_path) {
502        return Err(Error::InvalidInput("root_path cannot contain NUL bytes"));
503    }
504
505    if !root_path.is_absolute() {
506        return Err(Error::PathNotAbsolute);
507    }
508
509    if !root_path.exists() {
510        return Err(Error::PathDoesNotExist);
511    }
512
513    empty_recycle_bin_raw(Some(root_path))
514}
515
516#[cfg(test)]
517mod tests {
518    #[cfg(feature = "recycle-bin")]
519    use super::collect_recycle_paths;
520    #[cfg(feature = "shell")]
521    use super::{normalize_shell_verb, normalize_url};
522    #[cfg(feature = "recycle-bin")]
523    use std::path::PathBuf;
524
525    #[cfg(feature = "shell")]
526    #[test]
527    fn normalize_url_rejects_empty_string() {
528        let result = normalize_url("");
529        assert!(matches!(
530            result,
531            Err(crate::Error::InvalidInput("url cannot be empty"))
532        ));
533    }
534
535    #[cfg(feature = "shell")]
536    #[test]
537    fn normalize_url_rejects_whitespace_only() {
538        let result = normalize_url("   ");
539        assert!(matches!(
540            result,
541            Err(crate::Error::InvalidInput("url cannot be empty"))
542        ));
543    }
544
545    #[cfg(feature = "shell")]
546    #[test]
547    fn normalize_url_trims_surrounding_whitespace() {
548        assert_eq!(
549            normalize_url("  https://example.com/docs  ").unwrap(),
550            "https://example.com/docs"
551        );
552    }
553
554    #[cfg(feature = "shell")]
555    #[test]
556    fn normalize_url_rejects_nul_bytes() {
557        let result = normalize_url("https://example.com/\0hidden");
558        assert!(matches!(
559            result,
560            Err(crate::Error::InvalidInput("url cannot contain NUL bytes"))
561        ));
562    }
563
564    #[cfg(feature = "shell")]
565    #[test]
566    fn normalize_shell_verb_rejects_empty_string() {
567        let result = normalize_shell_verb("");
568        assert!(matches!(
569            result,
570            Err(crate::Error::InvalidInput("verb cannot be empty"))
571        ));
572    }
573
574    #[cfg(feature = "shell")]
575    #[test]
576    fn normalize_shell_verb_rejects_whitespace_only() {
577        let result = normalize_shell_verb("   ");
578        assert!(matches!(
579            result,
580            Err(crate::Error::InvalidInput("verb cannot be empty"))
581        ));
582    }
583
584    #[cfg(feature = "shell")]
585    #[test]
586    fn normalize_shell_verb_trims_surrounding_whitespace() {
587        assert_eq!(
588            normalize_shell_verb("  properties  ").unwrap(),
589            "properties"
590        );
591    }
592
593    #[cfg(feature = "shell")]
594    #[test]
595    fn normalize_shell_verb_rejects_nul_bytes() {
596        let result = normalize_shell_verb("pro\0perties");
597        assert!(matches!(
598            result,
599            Err(crate::Error::InvalidInput("verb cannot contain NUL bytes"))
600        ));
601    }
602
603    #[cfg(feature = "recycle-bin")]
604    #[test]
605    fn collect_recycle_paths_rejects_empty_collection() {
606        let paths: [PathBuf; 0] = [];
607        let result = collect_recycle_paths(paths);
608        assert!(matches!(
609            result,
610            Err(crate::Error::InvalidInput("paths cannot be empty"))
611        ));
612    }
613}