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
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
170pub 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
198pub fn open_url(url: &str) -> Result<()> {
215 let url = normalize_url(url)?;
216 shell_open_raw(OsStr::new(url))
217}
218
219pub 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
254pub 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}