win_desktop_utils/
shell.rs1use 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
226pub fn open_with_default(target: impl AsRef<Path>) -> Result<()> {
241 open_with_verb("open", target)
242}
243
244pub 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
277pub fn open_url(url: &str) -> Result<()> {
294 let url = normalize_url(url)?;
295 shell_execute_raw("open", OsStr::new(url))
296}
297
298pub 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
333pub 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
363pub 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}