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
49struct ComApartment;
50
51impl ComApartment {
52    fn initialize_sta() -> Result<Self> {
53        let result = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) };
54
55        if result.is_ok() {
56            Ok(Self)
57        } else {
58            Err(Error::WindowsApi {
59                context: "CoInitializeEx",
60                code: result.0,
61            })
62        }
63    }
64}
65
66impl Drop for ComApartment {
67    fn drop(&mut self) {
68        unsafe {
69            CoUninitialize();
70        }
71    }
72}
73
74fn shell_open_raw(target: &OsStr) -> Result<()> {
75    let operation = to_wide_str("open");
76    let target_w = to_wide_os(target);
77
78    let result = unsafe {
79        ShellExecuteW(
80            Some(HWND::default()),
81            PCWSTR(operation.as_ptr()),
82            PCWSTR(target_w.as_ptr()),
83            PCWSTR::null(),
84            PCWSTR::null(),
85            SW_SHOWNORMAL,
86        )
87    };
88
89    let code = result.0 as isize;
90    if code <= 32 {
91        Err(Error::WindowsApi {
92            context: "ShellExecuteW",
93            code: code as i32,
94        })
95    } else {
96        Ok(())
97    }
98}
99
100fn shell_item_from_path(path: &Path) -> Result<IShellItem> {
101    let path_w = to_wide_os(path.as_os_str());
102
103    unsafe { SHCreateItemFromParsingName(PCWSTR(path_w.as_ptr()), None) }.map_err(|err| {
104        Error::WindowsApi {
105            context: "SHCreateItemFromParsingName",
106            code: err.code().0,
107        }
108    })
109}
110
111fn recycle_path_in_sta(path: &Path) -> Result<()> {
112    let _com = ComApartment::initialize_sta()?;
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    let item = shell_item_from_path(path)?;
130
131    unsafe { operation.DeleteItem(&item, Option::<&IFileOperationProgressSink>::None) }.map_err(
132        |err| Error::WindowsApi {
133            context: "IFileOperation::DeleteItem",
134            code: err.code().0,
135        },
136    )?;
137
138    unsafe { operation.PerformOperations() }.map_err(|err| Error::WindowsApi {
139        context: "IFileOperation::PerformOperations",
140        code: err.code().0,
141    })?;
142
143    let aborted =
144        unsafe { operation.GetAnyOperationsAborted() }.map_err(|err| Error::WindowsApi {
145            context: "IFileOperation::GetAnyOperationsAborted",
146            code: err.code().0,
147        })?;
148
149    if aborted.as_bool() {
150        Err(Error::WindowsApi {
151            context: "IFileOperation::PerformOperations aborted",
152            code: 0,
153        })
154    } else {
155        Ok(())
156    }
157}
158
159fn run_in_shell_sta<T, F>(work: F) -> Result<T>
160where
161    T: Send + 'static,
162    F: FnOnce() -> Result<T> + Send + 'static,
163{
164    match thread::spawn(work).join() {
165        Ok(result) => result,
166        Err(_) => Err(Error::Unsupported("shell STA worker thread panicked")),
167    }
168}
169
170/// Opens a file or directory with the user's default Windows handler.
171///
172/// # Errors
173///
174/// Returns [`Error::InvalidInput`] if `target` is empty.
175/// Returns [`Error::PathDoesNotExist`] if the target path does not exist.
176/// Returns [`Error::WindowsApi`] if `ShellExecuteW` reports failure.
177///
178/// # Examples
179///
180/// ```no_run
181/// win_desktop_utils::open_with_default(r"C:\Windows\notepad.exe")?;
182/// # Ok::<(), win_desktop_utils::Error>(())
183/// ```
184pub fn open_with_default(target: impl AsRef<Path>) -> Result<()> {
185    let path = target.as_ref();
186
187    if path.as_os_str().is_empty() {
188        return Err(Error::InvalidInput("target cannot be empty"));
189    }
190
191    if !path.exists() {
192        return Err(Error::PathDoesNotExist);
193    }
194
195    shell_open_raw(path.as_os_str())
196}
197
198/// Opens a URL with the user's default browser or registered handler.
199///
200/// Surrounding whitespace is trimmed before the URL is passed to the Windows shell.
201/// URL validation is otherwise delegated to the Windows shell.
202///
203/// # Errors
204///
205/// Returns [`Error::InvalidInput`] if `url` is empty, whitespace only, or contains NUL bytes.
206/// Returns [`Error::WindowsApi`] if `ShellExecuteW` reports failure.
207///
208/// # Examples
209///
210/// ```no_run
211/// win_desktop_utils::open_url("https://www.rust-lang.org")?;
212/// # Ok::<(), win_desktop_utils::Error>(())
213/// ```
214pub fn open_url(url: &str) -> Result<()> {
215    let url = normalize_url(url)?;
216    shell_open_raw(OsStr::new(url))
217}
218
219/// Opens Explorer and selects the requested path.
220///
221/// This function starts `explorer.exe` with `/select,` and the provided path.
222///
223/// # Errors
224///
225/// Returns [`Error::InvalidInput`] if `path` is empty.
226/// Returns [`Error::PathDoesNotExist`] if the path does not exist.
227/// Returns [`Error::Io`] if spawning `explorer.exe` fails.
228///
229/// # Examples
230///
231/// ```no_run
232/// win_desktop_utils::reveal_in_explorer(r"C:\Windows\notepad.exe")?;
233/// # Ok::<(), win_desktop_utils::Error>(())
234/// ```
235pub fn reveal_in_explorer(path: impl AsRef<Path>) -> Result<()> {
236    let path = path.as_ref();
237
238    if path.as_os_str().is_empty() {
239        return Err(Error::InvalidInput("path cannot be empty"));
240    }
241
242    if !path.exists() {
243        return Err(Error::PathDoesNotExist);
244    }
245
246    Command::new("explorer.exe")
247        .arg("/select,")
248        .arg(path)
249        .spawn()?;
250
251    Ok(())
252}
253
254/// Sends a file or directory to the Windows Recycle Bin.
255///
256/// The path must be absolute and must exist.
257///
258/// This function uses `IFileOperation` on a dedicated STA thread so it can request
259/// recycle-bin behavior through the modern Shell API.
260///
261/// # Errors
262///
263/// Returns [`Error::InvalidInput`] if `path` is empty.
264/// Returns [`Error::PathNotAbsolute`] if the path is not absolute.
265/// Returns [`Error::PathDoesNotExist`] if the path does not exist.
266/// Returns [`Error::WindowsApi`] if the shell operation fails or is aborted.
267///
268/// # Examples
269///
270/// ```no_run
271/// let path = std::env::current_dir()?.join("temporary-file.txt");
272/// std::fs::write(&path, "temporary file")?;
273/// win_desktop_utils::move_to_recycle_bin(&path)?;
274/// # Ok::<(), Box<dyn std::error::Error>>(())
275/// ```
276pub fn move_to_recycle_bin(path: impl AsRef<Path>) -> Result<()> {
277    let path = path.as_ref();
278
279    if path.as_os_str().is_empty() {
280        return Err(Error::InvalidInput("path cannot be empty"));
281    }
282
283    if !path.is_absolute() {
284        return Err(Error::PathNotAbsolute);
285    }
286
287    if !path.exists() {
288        return Err(Error::PathDoesNotExist);
289    }
290
291    let path = PathBuf::from(path);
292    run_in_shell_sta(move || recycle_path_in_sta(&path))
293}
294
295#[cfg(test)]
296mod tests {
297    use super::normalize_url;
298
299    #[test]
300    fn normalize_url_rejects_empty_string() {
301        let result = normalize_url("");
302        assert!(matches!(
303            result,
304            Err(crate::Error::InvalidInput("url cannot be empty"))
305        ));
306    }
307
308    #[test]
309    fn normalize_url_rejects_whitespace_only() {
310        let result = normalize_url("   ");
311        assert!(matches!(
312            result,
313            Err(crate::Error::InvalidInput("url cannot be empty"))
314        ));
315    }
316
317    #[test]
318    fn normalize_url_trims_surrounding_whitespace() {
319        assert_eq!(
320            normalize_url("  https://example.com/docs  ").unwrap(),
321            "https://example.com/docs"
322        );
323    }
324
325    #[test]
326    fn normalize_url_rejects_nul_bytes() {
327        let result = normalize_url("https://example.com/\0hidden");
328        assert!(matches!(
329            result,
330            Err(crate::Error::InvalidInput("url cannot contain NUL bytes"))
331        ));
332    }
333}