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