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