Skip to main content

win_desktop_utils/
shell.rs

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