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, ShellExecuteW, FOFX_RECYCLEONDELETE, FOF_ALLOWUNDO,
18    FOF_NOCONFIRMATION, FOF_NOERRORUI, FOF_SILENT,
19};
20use windows::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL;
21
22use crate::error::{Error, Result};
23
24fn to_wide_os(value: &OsStr) -> Vec<u16> {
25    value.encode_wide().chain(std::iter::once(0)).collect()
26}
27
28fn to_wide_str(value: &str) -> Vec<u16> {
29    OsStr::new(value)
30        .encode_wide()
31        .chain(std::iter::once(0))
32        .collect()
33}
34
35fn normalize_url(url: &str) -> Result<&str> {
36    let trimmed = url.trim();
37
38    if trimmed.is_empty() {
39        return Err(Error::InvalidInput("url cannot be empty"));
40    }
41
42    if trimmed.contains('\0') {
43        return Err(Error::InvalidInput("url cannot contain NUL bytes"));
44    }
45
46    Ok(trimmed)
47}
48
49fn normalize_shell_verb(verb: &str) -> Result<&str> {
50    let trimmed = verb.trim();
51
52    if trimmed.is_empty() {
53        return Err(Error::InvalidInput("verb cannot be empty"));
54    }
55
56    if trimmed.contains('\0') {
57        return Err(Error::InvalidInput("verb cannot contain NUL bytes"));
58    }
59
60    Ok(trimmed)
61}
62
63struct ComApartment;
64
65impl ComApartment {
66    fn initialize_sta() -> Result<Self> {
67        let result = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) };
68
69        if result.is_ok() {
70            Ok(Self)
71        } else {
72            Err(Error::WindowsApi {
73                context: "CoInitializeEx",
74                code: result.0,
75            })
76        }
77    }
78}
79
80impl Drop for ComApartment {
81    fn drop(&mut self) {
82        unsafe {
83            CoUninitialize();
84        }
85    }
86}
87
88fn shell_execute_raw(verb: &str, target: &OsStr) -> Result<()> {
89    let operation = to_wide_str(verb);
90    let target_w = to_wide_os(target);
91
92    let result = unsafe {
93        ShellExecuteW(
94            Some(HWND::default()),
95            PCWSTR(operation.as_ptr()),
96            PCWSTR(target_w.as_ptr()),
97            PCWSTR::null(),
98            PCWSTR::null(),
99            SW_SHOWNORMAL,
100        )
101    };
102
103    let code = result.0 as isize;
104    if code <= 32 {
105        Err(Error::WindowsApi {
106            context: "ShellExecuteW",
107            code: code as i32,
108        })
109    } else {
110        Ok(())
111    }
112}
113
114fn shell_item_from_path(path: &Path) -> Result<IShellItem> {
115    let path_w = to_wide_os(path.as_os_str());
116
117    unsafe { SHCreateItemFromParsingName(PCWSTR(path_w.as_ptr()), None) }.map_err(|err| {
118        Error::WindowsApi {
119            context: "SHCreateItemFromParsingName",
120            code: err.code().0,
121        }
122    })
123}
124
125fn validate_recycle_path(path: &Path) -> Result<()> {
126    if path.as_os_str().is_empty() {
127        return Err(Error::InvalidInput("path cannot be empty"));
128    }
129
130    if !path.is_absolute() {
131        return Err(Error::PathNotAbsolute);
132    }
133
134    if !path.exists() {
135        return Err(Error::PathDoesNotExist);
136    }
137
138    Ok(())
139}
140
141fn collect_recycle_paths<I, P>(paths: I) -> Result<Vec<PathBuf>>
142where
143    I: IntoIterator<Item = P>,
144    P: AsRef<Path>,
145{
146    let mut collected = Vec::new();
147
148    for path in paths {
149        let path = path.as_ref();
150        validate_recycle_path(path)?;
151        collected.push(PathBuf::from(path));
152    }
153
154    if collected.is_empty() {
155        Err(Error::InvalidInput("paths cannot be empty"))
156    } else {
157        Ok(collected)
158    }
159}
160
161fn queue_recycle_item(operation: &IFileOperation, path: &Path) -> Result<()> {
162    let item = shell_item_from_path(path)?;
163
164    unsafe { operation.DeleteItem(&item, Option::<&IFileOperationProgressSink>::None) }.map_err(
165        |err| Error::WindowsApi {
166            context: "IFileOperation::DeleteItem",
167            code: err.code().0,
168        },
169    )
170}
171
172fn recycle_paths_in_sta(paths: &[PathBuf]) -> Result<()> {
173    let _com = ComApartment::initialize_sta()?;
174    let operation: IFileOperation = unsafe {
175        CoCreateInstance(&FileOperation, None, CLSCTX_INPROC_SERVER)
176    }
177    .map_err(|err| Error::WindowsApi {
178        context: "CoCreateInstance(FileOperation)",
179        code: err.code().0,
180    })?;
181
182    let flags =
183        FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_NOERRORUI | FOF_SILENT | FOFX_RECYCLEONDELETE;
184
185    unsafe { operation.SetOperationFlags(flags) }.map_err(|err| Error::WindowsApi {
186        context: "IFileOperation::SetOperationFlags",
187        code: err.code().0,
188    })?;
189
190    for path in paths {
191        queue_recycle_item(&operation, path)?;
192    }
193
194    unsafe { operation.PerformOperations() }.map_err(|err| Error::WindowsApi {
195        context: "IFileOperation::PerformOperations",
196        code: err.code().0,
197    })?;
198
199    let aborted =
200        unsafe { operation.GetAnyOperationsAborted() }.map_err(|err| Error::WindowsApi {
201            context: "IFileOperation::GetAnyOperationsAborted",
202            code: err.code().0,
203        })?;
204
205    if aborted.as_bool() {
206        Err(Error::WindowsApi {
207            context: "IFileOperation::PerformOperations aborted",
208            code: 0,
209        })
210    } else {
211        Ok(())
212    }
213}
214
215fn run_in_shell_sta<T, F>(work: F) -> Result<T>
216where
217    T: Send + 'static,
218    F: FnOnce() -> Result<T> + Send + 'static,
219{
220    match thread::spawn(work).join() {
221        Ok(result) => result,
222        Err(_) => Err(Error::Unsupported("shell STA worker thread panicked")),
223    }
224}
225
226/// Opens a file or directory with the user's default Windows handler.
227///
228/// # Errors
229///
230/// Returns [`Error::InvalidInput`] if `target` is empty.
231/// Returns [`Error::PathDoesNotExist`] if the target path does not exist.
232/// Returns [`Error::WindowsApi`] if `ShellExecuteW` reports failure.
233///
234/// # Examples
235///
236/// ```no_run
237/// win_desktop_utils::open_with_default(r"C:\Windows\notepad.exe")?;
238/// # Ok::<(), win_desktop_utils::Error>(())
239/// ```
240pub fn open_with_default(target: impl AsRef<Path>) -> Result<()> {
241    open_with_verb("open", target)
242}
243
244/// Opens a file or directory using a specific Windows shell verb.
245///
246/// Common verbs include `open`, `edit`, `print`, and `properties`, depending on
247/// what the target path's registered handler supports.
248///
249/// # Errors
250///
251/// Returns [`Error::InvalidInput`] if `verb` is empty, whitespace only, or contains
252/// NUL bytes, or if `target` is empty.
253/// Returns [`Error::PathDoesNotExist`] if the target path does not exist.
254/// Returns [`Error::WindowsApi`] if `ShellExecuteW` reports failure.
255///
256/// # Examples
257///
258/// ```no_run
259/// win_desktop_utils::open_with_verb("properties", r"C:\Windows\notepad.exe")?;
260/// # Ok::<(), win_desktop_utils::Error>(())
261/// ```
262pub fn open_with_verb(verb: &str, target: impl AsRef<Path>) -> Result<()> {
263    let verb = normalize_shell_verb(verb)?;
264    let path = target.as_ref();
265
266    if path.as_os_str().is_empty() {
267        return Err(Error::InvalidInput("target cannot be empty"));
268    }
269
270    if !path.exists() {
271        return Err(Error::PathDoesNotExist);
272    }
273
274    shell_execute_raw(verb, path.as_os_str())
275}
276
277/// Opens a URL with the user's default browser or registered handler.
278///
279/// Surrounding whitespace is trimmed before the URL is passed to the Windows shell.
280/// URL validation is otherwise delegated to the Windows shell.
281///
282/// # Errors
283///
284/// Returns [`Error::InvalidInput`] if `url` is empty, whitespace only, or contains NUL bytes.
285/// Returns [`Error::WindowsApi`] if `ShellExecuteW` reports failure.
286///
287/// # Examples
288///
289/// ```no_run
290/// win_desktop_utils::open_url("https://www.rust-lang.org")?;
291/// # Ok::<(), win_desktop_utils::Error>(())
292/// ```
293pub fn open_url(url: &str) -> Result<()> {
294    let url = normalize_url(url)?;
295    shell_execute_raw("open", OsStr::new(url))
296}
297
298/// Opens Explorer and selects the requested path.
299///
300/// This function starts `explorer.exe` with `/select,` and the provided path.
301///
302/// # Errors
303///
304/// Returns [`Error::InvalidInput`] if `path` is empty.
305/// Returns [`Error::PathDoesNotExist`] if the path does not exist.
306/// Returns [`Error::Io`] if spawning `explorer.exe` fails.
307///
308/// # Examples
309///
310/// ```no_run
311/// win_desktop_utils::reveal_in_explorer(r"C:\Windows\notepad.exe")?;
312/// # Ok::<(), win_desktop_utils::Error>(())
313/// ```
314pub fn reveal_in_explorer(path: impl AsRef<Path>) -> Result<()> {
315    let path = path.as_ref();
316
317    if path.as_os_str().is_empty() {
318        return Err(Error::InvalidInput("path cannot be empty"));
319    }
320
321    if !path.exists() {
322        return Err(Error::PathDoesNotExist);
323    }
324
325    Command::new("explorer.exe")
326        .arg("/select,")
327        .arg(path)
328        .spawn()?;
329
330    Ok(())
331}
332
333/// Sends a file or directory to the Windows Recycle Bin.
334///
335/// The path must be absolute and must exist.
336///
337/// This function uses `IFileOperation` on a dedicated STA thread so it can request
338/// recycle-bin behavior through the modern Shell API.
339///
340/// # Errors
341///
342/// Returns [`Error::InvalidInput`] if `path` is empty.
343/// Returns [`Error::PathNotAbsolute`] if the path is not absolute.
344/// Returns [`Error::PathDoesNotExist`] if the path does not exist.
345/// Returns [`Error::WindowsApi`] if the shell operation fails or is aborted.
346///
347/// # Examples
348///
349/// ```no_run
350/// let path = std::env::current_dir()?.join("temporary-file.txt");
351/// std::fs::write(&path, "temporary file")?;
352/// win_desktop_utils::move_to_recycle_bin(&path)?;
353/// # Ok::<(), Box<dyn std::error::Error>>(())
354/// ```
355pub fn move_to_recycle_bin(path: impl AsRef<Path>) -> Result<()> {
356    let path = path.as_ref();
357    validate_recycle_path(path)?;
358
359    let path = PathBuf::from(path);
360    run_in_shell_sta(move || recycle_paths_in_sta(std::slice::from_ref(&path)))
361}
362
363/// Sends multiple files or directories to the Windows Recycle Bin in one shell operation.
364///
365/// Every path must be absolute and must exist. All paths are validated before any shell
366/// operation is started.
367///
368/// This function uses `IFileOperation` on a dedicated STA thread so it can request
369/// recycle-bin behavior through the modern Shell API.
370///
371/// # Errors
372///
373/// Returns [`Error::InvalidInput`] if the path collection is empty or any path is empty.
374/// Returns [`Error::PathNotAbsolute`] if any path is not absolute.
375/// Returns [`Error::PathDoesNotExist`] if any path does not exist.
376/// Returns [`Error::WindowsApi`] if the shell operation fails or is aborted.
377///
378/// # Examples
379///
380/// ```no_run
381/// let first = std::env::current_dir()?.join("temporary-file-a.txt");
382/// let second = std::env::current_dir()?.join("temporary-file-b.txt");
383/// std::fs::write(&first, "temporary file")?;
384/// std::fs::write(&second, "temporary file")?;
385/// win_desktop_utils::move_paths_to_recycle_bin([&first, &second])?;
386/// # Ok::<(), Box<dyn std::error::Error>>(())
387/// ```
388pub fn move_paths_to_recycle_bin<I, P>(paths: I) -> Result<()>
389where
390    I: IntoIterator<Item = P>,
391    P: AsRef<Path>,
392{
393    let paths = collect_recycle_paths(paths)?;
394    run_in_shell_sta(move || recycle_paths_in_sta(&paths))
395}
396
397#[cfg(test)]
398mod tests {
399    use super::{collect_recycle_paths, normalize_shell_verb, normalize_url};
400    use std::path::PathBuf;
401
402    #[test]
403    fn normalize_url_rejects_empty_string() {
404        let result = normalize_url("");
405        assert!(matches!(
406            result,
407            Err(crate::Error::InvalidInput("url cannot be empty"))
408        ));
409    }
410
411    #[test]
412    fn normalize_url_rejects_whitespace_only() {
413        let result = normalize_url("   ");
414        assert!(matches!(
415            result,
416            Err(crate::Error::InvalidInput("url cannot be empty"))
417        ));
418    }
419
420    #[test]
421    fn normalize_url_trims_surrounding_whitespace() {
422        assert_eq!(
423            normalize_url("  https://example.com/docs  ").unwrap(),
424            "https://example.com/docs"
425        );
426    }
427
428    #[test]
429    fn normalize_url_rejects_nul_bytes() {
430        let result = normalize_url("https://example.com/\0hidden");
431        assert!(matches!(
432            result,
433            Err(crate::Error::InvalidInput("url cannot contain NUL bytes"))
434        ));
435    }
436
437    #[test]
438    fn normalize_shell_verb_rejects_empty_string() {
439        let result = normalize_shell_verb("");
440        assert!(matches!(
441            result,
442            Err(crate::Error::InvalidInput("verb cannot be empty"))
443        ));
444    }
445
446    #[test]
447    fn normalize_shell_verb_rejects_whitespace_only() {
448        let result = normalize_shell_verb("   ");
449        assert!(matches!(
450            result,
451            Err(crate::Error::InvalidInput("verb cannot be empty"))
452        ));
453    }
454
455    #[test]
456    fn normalize_shell_verb_trims_surrounding_whitespace() {
457        assert_eq!(
458            normalize_shell_verb("  properties  ").unwrap(),
459            "properties"
460        );
461    }
462
463    #[test]
464    fn normalize_shell_verb_rejects_nul_bytes() {
465        let result = normalize_shell_verb("pro\0perties");
466        assert!(matches!(
467            result,
468            Err(crate::Error::InvalidInput("verb cannot contain NUL bytes"))
469        ));
470    }
471
472    #[test]
473    fn collect_recycle_paths_rejects_empty_collection() {
474        let paths: [PathBuf; 0] = [];
475        let result = collect_recycle_paths(paths);
476        assert!(matches!(
477            result,
478            Err(crate::Error::InvalidInput("paths cannot be empty"))
479        ));
480    }
481}