win_desktop_utils/
shell.rs1#[cfg(feature = "shell")]
4use std::ffi::OsStr;
5use std::path::Path;
6#[cfg(feature = "recycle-bin")]
7use std::path::PathBuf;
8#[cfg(feature = "shell")]
9use std::process::Command;
10
11#[cfg(feature = "recycle-bin")]
12use windows::core::PCWSTR;
13#[cfg(feature = "recycle-bin")]
14use windows::Win32::System::Com::{CoCreateInstance, CLSCTX_INPROC_SERVER};
15#[cfg(feature = "recycle-bin")]
16use windows::Win32::UI::Shell::{
17 FileOperation, IFileOperation, IFileOperationProgressSink, IShellItem,
18 SHCreateItemFromParsingName, SHEmptyRecycleBinW, FOFX_RECYCLEONDELETE, FOF_ALLOWUNDO,
19 FOF_NOCONFIRMATION, FOF_NOERRORUI, FOF_SILENT, SHERB_NOCONFIRMATION, SHERB_NOPROGRESSUI,
20 SHERB_NOSOUND,
21};
22
23use crate::error::{Error, Result};
24#[cfg(feature = "shell")]
25use crate::win::{normalize_nonempty_str, shell_execute};
26#[cfg(feature = "recycle-bin")]
27use crate::win::{run_in_sta, to_wide_os, ComApartment};
28
29#[cfg(feature = "shell")]
30fn normalize_url(url: &str) -> Result<&str> {
31 normalize_nonempty_str(url, "url cannot be empty", "url cannot contain NUL bytes")
32}
33
34#[cfg(feature = "shell")]
35fn normalize_shell_verb(verb: &str) -> Result<&str> {
36 normalize_nonempty_str(
37 verb,
38 "verb cannot be empty",
39 "verb cannot contain NUL bytes",
40 )
41}
42
43#[cfg(feature = "shell")]
44fn shell_execute_raw(verb: &str, target: &OsStr) -> Result<()> {
45 shell_execute(verb, target, None, "ShellExecuteW")
46}
47
48#[cfg(feature = "recycle-bin")]
49fn shell_item_from_path(path: &Path) -> Result<IShellItem> {
50 let path_w = to_wide_os(path.as_os_str());
51
52 unsafe { SHCreateItemFromParsingName(PCWSTR(path_w.as_ptr()), None) }.map_err(|err| {
53 Error::WindowsApi {
54 context: "SHCreateItemFromParsingName",
55 code: err.code().0,
56 }
57 })
58}
59
60#[cfg(feature = "recycle-bin")]
61fn validate_recycle_path(path: &Path) -> Result<()> {
62 if path.as_os_str().is_empty() {
63 return Err(Error::InvalidInput("path cannot be empty"));
64 }
65
66 if !path.is_absolute() {
67 return Err(Error::PathNotAbsolute);
68 }
69
70 if !path.exists() {
71 return Err(Error::PathDoesNotExist);
72 }
73
74 Ok(())
75}
76
77#[cfg(feature = "recycle-bin")]
78fn collect_recycle_paths<I, P>(paths: I) -> Result<Vec<PathBuf>>
79where
80 I: IntoIterator<Item = P>,
81 P: AsRef<Path>,
82{
83 let mut collected = Vec::new();
84
85 for path in paths {
86 let path = path.as_ref();
87 validate_recycle_path(path)?;
88 collected.push(PathBuf::from(path));
89 }
90
91 if collected.is_empty() {
92 Err(Error::InvalidInput("paths cannot be empty"))
93 } else {
94 Ok(collected)
95 }
96}
97
98#[cfg(feature = "recycle-bin")]
99fn queue_recycle_item(operation: &IFileOperation, path: &Path) -> Result<()> {
100 let item = shell_item_from_path(path)?;
101
102 unsafe { operation.DeleteItem(&item, Option::<&IFileOperationProgressSink>::None) }.map_err(
103 |err| Error::WindowsApi {
104 context: "IFileOperation::DeleteItem",
105 code: err.code().0,
106 },
107 )
108}
109
110#[cfg(feature = "recycle-bin")]
111fn recycle_paths_in_sta(paths: &[PathBuf]) -> Result<()> {
112 let _com = ComApartment::initialize_sta("CoInitializeEx")?;
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 for path in paths {
130 queue_recycle_item(&operation, path)?;
131 }
132
133 unsafe { operation.PerformOperations() }.map_err(|err| Error::WindowsApi {
134 context: "IFileOperation::PerformOperations",
135 code: err.code().0,
136 })?;
137
138 let aborted =
139 unsafe { operation.GetAnyOperationsAborted() }.map_err(|err| Error::WindowsApi {
140 context: "IFileOperation::GetAnyOperationsAborted",
141 code: err.code().0,
142 })?;
143
144 if aborted.as_bool() {
145 Err(Error::WindowsApi {
146 context: "IFileOperation::PerformOperations aborted",
147 code: 0,
148 })
149 } else {
150 Ok(())
151 }
152}
153
154#[cfg(feature = "recycle-bin")]
155fn empty_recycle_bin_raw(root_path: Option<&Path>) -> Result<()> {
156 let root_w = root_path.map(|path| to_wide_os(path.as_os_str()));
157 let root_ptr = root_w
158 .as_ref()
159 .map_or(PCWSTR::null(), |path| PCWSTR(path.as_ptr()));
160 let flags = SHERB_NOCONFIRMATION | SHERB_NOPROGRESSUI | SHERB_NOSOUND;
161
162 unsafe { SHEmptyRecycleBinW(None, root_ptr, flags) }.map_err(|err| Error::WindowsApi {
163 context: "SHEmptyRecycleBinW",
164 code: err.code().0,
165 })
166}
167
168#[cfg(feature = "recycle-bin")]
169fn run_in_shell_sta<T, F>(work: F) -> Result<T>
170where
171 T: Send + 'static,
172 F: FnOnce() -> Result<T> + Send + 'static,
173{
174 run_in_sta("shell STA worker thread panicked", work)
175}
176
177#[cfg(feature = "shell")]
192pub fn open_with_default(target: impl AsRef<Path>) -> Result<()> {
193 open_with_verb("open", target)
194}
195
196#[cfg(feature = "shell")]
215pub fn open_with_verb(verb: &str, target: impl AsRef<Path>) -> Result<()> {
216 let verb = normalize_shell_verb(verb)?;
217 let path = target.as_ref();
218
219 if path.as_os_str().is_empty() {
220 return Err(Error::InvalidInput("target cannot be empty"));
221 }
222
223 if !path.exists() {
224 return Err(Error::PathDoesNotExist);
225 }
226
227 shell_execute_raw(verb, path.as_os_str())
228}
229
230#[cfg(feature = "shell")]
248pub fn show_properties(target: impl AsRef<Path>) -> Result<()> {
249 open_with_verb("properties", target)
250}
251
252#[cfg(feature = "shell")]
270pub fn print_with_default(target: impl AsRef<Path>) -> Result<()> {
271 open_with_verb("print", target)
272}
273
274#[cfg(feature = "shell")]
291pub fn open_url(url: &str) -> Result<()> {
292 let url = normalize_url(url)?;
293 shell_execute_raw("open", OsStr::new(url))
294}
295
296#[cfg(feature = "shell")]
313pub fn reveal_in_explorer(path: impl AsRef<Path>) -> Result<()> {
314 let path = path.as_ref();
315
316 if path.as_os_str().is_empty() {
317 return Err(Error::InvalidInput("path cannot be empty"));
318 }
319
320 if !path.exists() {
321 return Err(Error::PathDoesNotExist);
322 }
323
324 Command::new("explorer.exe")
325 .arg("/select,")
326 .arg(path)
327 .spawn()?;
328
329 Ok(())
330}
331
332#[cfg(feature = "shell")]
347pub fn open_containing_folder(path: impl AsRef<Path>) -> Result<()> {
348 let path = path.as_ref();
349
350 if path.as_os_str().is_empty() {
351 return Err(Error::InvalidInput("path cannot be empty"));
352 }
353
354 if !path.exists() {
355 return Err(Error::PathDoesNotExist);
356 }
357
358 let parent = path.parent().ok_or(Error::InvalidInput(
359 "path does not have a containing folder",
360 ))?;
361
362 if parent.as_os_str().is_empty() {
363 return Err(Error::InvalidInput(
364 "path does not have a containing folder",
365 ));
366 }
367
368 open_with_default(parent)
369}
370
371#[cfg(feature = "recycle-bin")]
394pub fn move_to_recycle_bin(path: impl AsRef<Path>) -> Result<()> {
395 let path = path.as_ref();
396 validate_recycle_path(path)?;
397
398 let path = PathBuf::from(path);
399 run_in_shell_sta(move || recycle_paths_in_sta(std::slice::from_ref(&path)))
400}
401
402#[cfg(feature = "recycle-bin")]
428pub fn move_paths_to_recycle_bin<I, P>(paths: I) -> Result<()>
429where
430 I: IntoIterator<Item = P>,
431 P: AsRef<Path>,
432{
433 let paths = collect_recycle_paths(paths)?;
434 run_in_shell_sta(move || recycle_paths_in_sta(&paths))
435}
436
437#[cfg(feature = "recycle-bin")]
452pub fn empty_recycle_bin() -> Result<()> {
453 empty_recycle_bin_raw(None)
454}
455
456#[cfg(feature = "recycle-bin")]
474pub fn empty_recycle_bin_for_root(root_path: impl AsRef<Path>) -> Result<()> {
475 let root_path = root_path.as_ref();
476
477 if root_path.as_os_str().is_empty() {
478 return Err(Error::InvalidInput("root_path cannot be empty"));
479 }
480
481 if !root_path.is_absolute() {
482 return Err(Error::PathNotAbsolute);
483 }
484
485 if !root_path.exists() {
486 return Err(Error::PathDoesNotExist);
487 }
488
489 empty_recycle_bin_raw(Some(root_path))
490}
491
492#[cfg(test)]
493mod tests {
494 #[cfg(feature = "recycle-bin")]
495 use super::collect_recycle_paths;
496 #[cfg(feature = "shell")]
497 use super::{normalize_shell_verb, normalize_url};
498 #[cfg(feature = "recycle-bin")]
499 use std::path::PathBuf;
500
501 #[cfg(feature = "shell")]
502 #[test]
503 fn normalize_url_rejects_empty_string() {
504 let result = normalize_url("");
505 assert!(matches!(
506 result,
507 Err(crate::Error::InvalidInput("url cannot be empty"))
508 ));
509 }
510
511 #[cfg(feature = "shell")]
512 #[test]
513 fn normalize_url_rejects_whitespace_only() {
514 let result = normalize_url(" ");
515 assert!(matches!(
516 result,
517 Err(crate::Error::InvalidInput("url cannot be empty"))
518 ));
519 }
520
521 #[cfg(feature = "shell")]
522 #[test]
523 fn normalize_url_trims_surrounding_whitespace() {
524 assert_eq!(
525 normalize_url(" https://example.com/docs ").unwrap(),
526 "https://example.com/docs"
527 );
528 }
529
530 #[cfg(feature = "shell")]
531 #[test]
532 fn normalize_url_rejects_nul_bytes() {
533 let result = normalize_url("https://example.com/\0hidden");
534 assert!(matches!(
535 result,
536 Err(crate::Error::InvalidInput("url cannot contain NUL bytes"))
537 ));
538 }
539
540 #[cfg(feature = "shell")]
541 #[test]
542 fn normalize_shell_verb_rejects_empty_string() {
543 let result = normalize_shell_verb("");
544 assert!(matches!(
545 result,
546 Err(crate::Error::InvalidInput("verb cannot be empty"))
547 ));
548 }
549
550 #[cfg(feature = "shell")]
551 #[test]
552 fn normalize_shell_verb_rejects_whitespace_only() {
553 let result = normalize_shell_verb(" ");
554 assert!(matches!(
555 result,
556 Err(crate::Error::InvalidInput("verb cannot be empty"))
557 ));
558 }
559
560 #[cfg(feature = "shell")]
561 #[test]
562 fn normalize_shell_verb_trims_surrounding_whitespace() {
563 assert_eq!(
564 normalize_shell_verb(" properties ").unwrap(),
565 "properties"
566 );
567 }
568
569 #[cfg(feature = "shell")]
570 #[test]
571 fn normalize_shell_verb_rejects_nul_bytes() {
572 let result = normalize_shell_verb("pro\0perties");
573 assert!(matches!(
574 result,
575 Err(crate::Error::InvalidInput("verb cannot contain NUL bytes"))
576 ));
577 }
578
579 #[cfg(feature = "recycle-bin")]
580 #[test]
581 fn collect_recycle_paths_rejects_empty_collection() {
582 let paths: [PathBuf; 0] = [];
583 let result = collect_recycle_paths(paths);
584 assert!(matches!(
585 result,
586 Err(crate::Error::InvalidInput("paths cannot be empty"))
587 ));
588 }
589}