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(any(feature = "recycle-bin", feature = "shell"))]
25use crate::win::path_contains_nul;
26#[cfg(feature = "shell")]
27use crate::win::{normalize_nonempty_str, shell_execute};
28#[cfg(feature = "recycle-bin")]
29use crate::win::{run_in_sta, to_wide_os, ComApartment};
30
31#[cfg(feature = "shell")]
32fn normalize_url(url: &str) -> Result<&str> {
33 normalize_nonempty_str(url, "url cannot be empty", "url cannot contain NUL bytes")
34}
35
36#[cfg(feature = "shell")]
37fn normalize_shell_verb(verb: &str) -> Result<&str> {
38 normalize_nonempty_str(
39 verb,
40 "verb cannot be empty",
41 "verb cannot contain NUL bytes",
42 )
43}
44
45#[cfg(feature = "shell")]
46fn shell_execute_raw(verb: &str, target: &OsStr) -> Result<()> {
47 shell_execute(verb, target, None, "ShellExecuteW")
48}
49
50#[cfg(feature = "recycle-bin")]
51fn shell_item_from_path(path: &Path) -> Result<IShellItem> {
52 let path_w = to_wide_os(path.as_os_str());
53
54 unsafe { SHCreateItemFromParsingName(PCWSTR(path_w.as_ptr()), None) }.map_err(|err| {
55 Error::WindowsApi {
56 context: "SHCreateItemFromParsingName",
57 code: err.code().0,
58 }
59 })
60}
61
62#[cfg(feature = "recycle-bin")]
63fn validate_recycle_path(path: &Path) -> Result<()> {
64 if path.as_os_str().is_empty() {
65 return Err(Error::InvalidInput("path cannot be empty"));
66 }
67
68 if path_contains_nul(path) {
69 return Err(Error::InvalidInput("path cannot contain NUL bytes"));
70 }
71
72 if !path.is_absolute() {
73 return Err(Error::PathNotAbsolute);
74 }
75
76 if !path.exists() {
77 return Err(Error::PathDoesNotExist);
78 }
79
80 Ok(())
81}
82
83#[cfg(feature = "recycle-bin")]
84fn collect_recycle_paths<I, P>(paths: I) -> Result<Vec<PathBuf>>
85where
86 I: IntoIterator<Item = P>,
87 P: AsRef<Path>,
88{
89 let mut collected = Vec::new();
90
91 for path in paths {
92 let path = path.as_ref();
93 validate_recycle_path(path)?;
94 collected.push(PathBuf::from(path));
95 }
96
97 if collected.is_empty() {
98 Err(Error::InvalidInput("paths cannot be empty"))
99 } else {
100 Ok(collected)
101 }
102}
103
104#[cfg(feature = "recycle-bin")]
105fn queue_recycle_item(operation: &IFileOperation, path: &Path) -> Result<()> {
106 let item = shell_item_from_path(path)?;
107
108 unsafe { operation.DeleteItem(&item, Option::<&IFileOperationProgressSink>::None) }.map_err(
109 |err| Error::WindowsApi {
110 context: "IFileOperation::DeleteItem",
111 code: err.code().0,
112 },
113 )
114}
115
116#[cfg(feature = "recycle-bin")]
117fn recycle_paths_in_sta(paths: &[PathBuf]) -> Result<()> {
118 let _com = ComApartment::initialize_sta("CoInitializeEx")?;
119 let operation: IFileOperation = unsafe {
120 CoCreateInstance(&FileOperation, None, CLSCTX_INPROC_SERVER)
121 }
122 .map_err(|err| Error::WindowsApi {
123 context: "CoCreateInstance(FileOperation)",
124 code: err.code().0,
125 })?;
126
127 let flags =
128 FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_NOERRORUI | FOF_SILENT | FOFX_RECYCLEONDELETE;
129
130 unsafe { operation.SetOperationFlags(flags) }.map_err(|err| Error::WindowsApi {
131 context: "IFileOperation::SetOperationFlags",
132 code: err.code().0,
133 })?;
134
135 for path in paths {
136 queue_recycle_item(&operation, path)?;
137 }
138
139 unsafe { operation.PerformOperations() }.map_err(|err| Error::WindowsApi {
140 context: "IFileOperation::PerformOperations",
141 code: err.code().0,
142 })?;
143
144 let aborted =
145 unsafe { operation.GetAnyOperationsAborted() }.map_err(|err| Error::WindowsApi {
146 context: "IFileOperation::GetAnyOperationsAborted",
147 code: err.code().0,
148 })?;
149
150 if aborted.as_bool() {
151 Err(Error::WindowsApi {
152 context: "IFileOperation::PerformOperations aborted",
153 code: 0,
154 })
155 } else {
156 Ok(())
157 }
158}
159
160#[cfg(feature = "recycle-bin")]
161fn empty_recycle_bin_raw(root_path: Option<&Path>) -> Result<()> {
162 let root_w = root_path.map(|path| to_wide_os(path.as_os_str()));
163 let root_ptr = root_w
164 .as_ref()
165 .map_or(PCWSTR::null(), |path| PCWSTR(path.as_ptr()));
166 let flags = SHERB_NOCONFIRMATION | SHERB_NOPROGRESSUI | SHERB_NOSOUND;
167
168 unsafe { SHEmptyRecycleBinW(None, root_ptr, flags) }.map_err(|err| Error::WindowsApi {
169 context: "SHEmptyRecycleBinW",
170 code: err.code().0,
171 })
172}
173
174#[cfg(feature = "recycle-bin")]
175fn run_in_shell_sta<T, F>(work: F) -> Result<T>
176where
177 T: Send + 'static,
178 F: FnOnce() -> Result<T> + Send + 'static,
179{
180 run_in_sta("shell STA worker thread panicked", work)
181}
182
183#[cfg(feature = "shell")]
198pub fn open_with_default(target: impl AsRef<Path>) -> Result<()> {
199 open_with_verb("open", target)
200}
201
202#[cfg(feature = "shell")]
221pub fn open_with_verb(verb: &str, target: impl AsRef<Path>) -> Result<()> {
222 let verb = normalize_shell_verb(verb)?;
223 let path = target.as_ref();
224
225 if path.as_os_str().is_empty() {
226 return Err(Error::InvalidInput("target cannot be empty"));
227 }
228
229 if path_contains_nul(path) {
230 return Err(Error::InvalidInput("target cannot contain NUL bytes"));
231 }
232
233 if !path.exists() {
234 return Err(Error::PathDoesNotExist);
235 }
236
237 shell_execute_raw(verb, path.as_os_str())
238}
239
240#[cfg(feature = "shell")]
258pub fn show_properties(target: impl AsRef<Path>) -> Result<()> {
259 open_with_verb("properties", target)
260}
261
262#[cfg(feature = "shell")]
280pub fn print_with_default(target: impl AsRef<Path>) -> Result<()> {
281 open_with_verb("print", target)
282}
283
284#[cfg(feature = "shell")]
301pub fn open_url(url: &str) -> Result<()> {
302 let url = normalize_url(url)?;
303 shell_execute_raw("open", OsStr::new(url))
304}
305
306#[cfg(feature = "shell")]
323pub fn reveal_in_explorer(path: impl AsRef<Path>) -> Result<()> {
324 let path = path.as_ref();
325
326 if path.as_os_str().is_empty() {
327 return Err(Error::InvalidInput("path cannot be empty"));
328 }
329
330 if path_contains_nul(path) {
331 return Err(Error::InvalidInput("path cannot contain NUL bytes"));
332 }
333
334 if !path.exists() {
335 return Err(Error::PathDoesNotExist);
336 }
337
338 Command::new("explorer.exe")
339 .arg("/select,")
340 .arg(path)
341 .spawn()?;
342
343 Ok(())
344}
345
346#[cfg(feature = "shell")]
362pub fn open_containing_folder(path: impl AsRef<Path>) -> Result<()> {
363 let path = path.as_ref();
364
365 if path.as_os_str().is_empty() {
366 return Err(Error::InvalidInput("path cannot be empty"));
367 }
368
369 if path_contains_nul(path) {
370 return Err(Error::InvalidInput("path cannot contain NUL bytes"));
371 }
372
373 if !path.exists() {
374 return Err(Error::PathDoesNotExist);
375 }
376
377 let parent = path.parent().ok_or(Error::InvalidInput(
378 "path does not have a containing folder",
379 ))?;
380
381 if parent.as_os_str().is_empty() {
382 return Err(Error::InvalidInput(
383 "path does not have a containing folder",
384 ));
385 }
386
387 open_with_default(parent)
388}
389
390#[cfg(feature = "recycle-bin")]
413pub fn move_to_recycle_bin(path: impl AsRef<Path>) -> Result<()> {
414 let path = path.as_ref();
415 validate_recycle_path(path)?;
416
417 let path = PathBuf::from(path);
418 run_in_shell_sta(move || recycle_paths_in_sta(std::slice::from_ref(&path)))
419}
420
421#[cfg(feature = "recycle-bin")]
448pub fn move_paths_to_recycle_bin<I, P>(paths: I) -> Result<()>
449where
450 I: IntoIterator<Item = P>,
451 P: AsRef<Path>,
452{
453 let paths = collect_recycle_paths(paths)?;
454 run_in_shell_sta(move || recycle_paths_in_sta(&paths))
455}
456
457#[cfg(feature = "recycle-bin")]
472pub fn empty_recycle_bin() -> Result<()> {
473 empty_recycle_bin_raw(None)
474}
475
476#[cfg(feature = "recycle-bin")]
494pub fn empty_recycle_bin_for_root(root_path: impl AsRef<Path>) -> Result<()> {
495 let root_path = root_path.as_ref();
496
497 if root_path.as_os_str().is_empty() {
498 return Err(Error::InvalidInput("root_path cannot be empty"));
499 }
500
501 if path_contains_nul(root_path) {
502 return Err(Error::InvalidInput("root_path cannot contain NUL bytes"));
503 }
504
505 if !root_path.is_absolute() {
506 return Err(Error::PathNotAbsolute);
507 }
508
509 if !root_path.exists() {
510 return Err(Error::PathDoesNotExist);
511 }
512
513 empty_recycle_bin_raw(Some(root_path))
514}
515
516#[cfg(test)]
517mod tests {
518 #[cfg(feature = "recycle-bin")]
519 use super::collect_recycle_paths;
520 #[cfg(feature = "shell")]
521 use super::{normalize_shell_verb, normalize_url};
522 #[cfg(feature = "recycle-bin")]
523 use std::path::PathBuf;
524
525 #[cfg(feature = "shell")]
526 #[test]
527 fn normalize_url_rejects_empty_string() {
528 let result = normalize_url("");
529 assert!(matches!(
530 result,
531 Err(crate::Error::InvalidInput("url cannot be empty"))
532 ));
533 }
534
535 #[cfg(feature = "shell")]
536 #[test]
537 fn normalize_url_rejects_whitespace_only() {
538 let result = normalize_url(" ");
539 assert!(matches!(
540 result,
541 Err(crate::Error::InvalidInput("url cannot be empty"))
542 ));
543 }
544
545 #[cfg(feature = "shell")]
546 #[test]
547 fn normalize_url_trims_surrounding_whitespace() {
548 assert_eq!(
549 normalize_url(" https://example.com/docs ").unwrap(),
550 "https://example.com/docs"
551 );
552 }
553
554 #[cfg(feature = "shell")]
555 #[test]
556 fn normalize_url_rejects_nul_bytes() {
557 let result = normalize_url("https://example.com/\0hidden");
558 assert!(matches!(
559 result,
560 Err(crate::Error::InvalidInput("url cannot contain NUL bytes"))
561 ));
562 }
563
564 #[cfg(feature = "shell")]
565 #[test]
566 fn normalize_shell_verb_rejects_empty_string() {
567 let result = normalize_shell_verb("");
568 assert!(matches!(
569 result,
570 Err(crate::Error::InvalidInput("verb cannot be empty"))
571 ));
572 }
573
574 #[cfg(feature = "shell")]
575 #[test]
576 fn normalize_shell_verb_rejects_whitespace_only() {
577 let result = normalize_shell_verb(" ");
578 assert!(matches!(
579 result,
580 Err(crate::Error::InvalidInput("verb cannot be empty"))
581 ));
582 }
583
584 #[cfg(feature = "shell")]
585 #[test]
586 fn normalize_shell_verb_trims_surrounding_whitespace() {
587 assert_eq!(
588 normalize_shell_verb(" properties ").unwrap(),
589 "properties"
590 );
591 }
592
593 #[cfg(feature = "shell")]
594 #[test]
595 fn normalize_shell_verb_rejects_nul_bytes() {
596 let result = normalize_shell_verb("pro\0perties");
597 assert!(matches!(
598 result,
599 Err(crate::Error::InvalidInput("verb cannot contain NUL bytes"))
600 ));
601 }
602
603 #[cfg(feature = "recycle-bin")]
604 #[test]
605 fn collect_recycle_paths_rejects_empty_collection() {
606 let paths: [PathBuf; 0] = [];
607 let result = collect_recycle_paths(paths);
608 assert!(matches!(
609 result,
610 Err(crate::Error::InvalidInput("paths cannot be empty"))
611 ));
612 }
613}