use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::thread;
use windows::core::PCWSTR;
use windows::Win32::Foundation::HWND;
use windows::Win32::System::Com::{
CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_INPROC_SERVER,
COINIT_APARTMENTTHREADED,
};
use windows::Win32::UI::Shell::{
FileOperation, IFileOperation, IFileOperationProgressSink, IShellItem,
SHCreateItemFromParsingName, ShellExecuteW, FOFX_RECYCLEONDELETE, FOF_ALLOWUNDO,
FOF_NOCONFIRMATION, FOF_NOERRORUI, FOF_SILENT,
};
use windows::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL;
use crate::error::{Error, Result};
fn to_wide_os(value: &OsStr) -> Vec<u16> {
value.encode_wide().chain(std::iter::once(0)).collect()
}
fn to_wide_str(value: &str) -> Vec<u16> {
OsStr::new(value)
.encode_wide()
.chain(std::iter::once(0))
.collect()
}
fn normalize_url(url: &str) -> Result<&str> {
let trimmed = url.trim();
if trimmed.is_empty() {
return Err(Error::InvalidInput("url cannot be empty"));
}
if trimmed.contains('\0') {
return Err(Error::InvalidInput("url cannot contain NUL bytes"));
}
Ok(trimmed)
}
struct ComApartment;
impl ComApartment {
fn initialize_sta() -> Result<Self> {
let result = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) };
if result.is_ok() {
Ok(Self)
} else {
Err(Error::WindowsApi {
context: "CoInitializeEx",
code: result.0,
})
}
}
}
impl Drop for ComApartment {
fn drop(&mut self) {
unsafe {
CoUninitialize();
}
}
}
fn shell_open_raw(target: &OsStr) -> Result<()> {
let operation = to_wide_str("open");
let target_w = to_wide_os(target);
let result = unsafe {
ShellExecuteW(
Some(HWND::default()),
PCWSTR(operation.as_ptr()),
PCWSTR(target_w.as_ptr()),
PCWSTR::null(),
PCWSTR::null(),
SW_SHOWNORMAL,
)
};
let code = result.0 as isize;
if code <= 32 {
Err(Error::WindowsApi {
context: "ShellExecuteW",
code: code as i32,
})
} else {
Ok(())
}
}
fn shell_item_from_path(path: &Path) -> Result<IShellItem> {
let path_w = to_wide_os(path.as_os_str());
unsafe { SHCreateItemFromParsingName(PCWSTR(path_w.as_ptr()), None) }.map_err(|err| {
Error::WindowsApi {
context: "SHCreateItemFromParsingName",
code: err.code().0,
}
})
}
fn recycle_path_in_sta(path: &Path) -> Result<()> {
let _com = ComApartment::initialize_sta()?;
let operation: IFileOperation = unsafe {
CoCreateInstance(&FileOperation, None, CLSCTX_INPROC_SERVER)
}
.map_err(|err| Error::WindowsApi {
context: "CoCreateInstance(FileOperation)",
code: err.code().0,
})?;
let flags =
FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_NOERRORUI | FOF_SILENT | FOFX_RECYCLEONDELETE;
unsafe { operation.SetOperationFlags(flags) }.map_err(|err| Error::WindowsApi {
context: "IFileOperation::SetOperationFlags",
code: err.code().0,
})?;
let item = shell_item_from_path(path)?;
unsafe { operation.DeleteItem(&item, Option::<&IFileOperationProgressSink>::None) }.map_err(
|err| Error::WindowsApi {
context: "IFileOperation::DeleteItem",
code: err.code().0,
},
)?;
unsafe { operation.PerformOperations() }.map_err(|err| Error::WindowsApi {
context: "IFileOperation::PerformOperations",
code: err.code().0,
})?;
let aborted =
unsafe { operation.GetAnyOperationsAborted() }.map_err(|err| Error::WindowsApi {
context: "IFileOperation::GetAnyOperationsAborted",
code: err.code().0,
})?;
if aborted.as_bool() {
Err(Error::WindowsApi {
context: "IFileOperation::PerformOperations aborted",
code: 0,
})
} else {
Ok(())
}
}
fn run_in_shell_sta<T, F>(work: F) -> Result<T>
where
T: Send + 'static,
F: FnOnce() -> Result<T> + Send + 'static,
{
match thread::spawn(work).join() {
Ok(result) => result,
Err(_) => Err(Error::Unsupported("shell STA worker thread panicked")),
}
}
pub fn open_with_default(target: impl AsRef<Path>) -> Result<()> {
let path = target.as_ref();
if path.as_os_str().is_empty() {
return Err(Error::InvalidInput("target cannot be empty"));
}
if !path.exists() {
return Err(Error::PathDoesNotExist);
}
shell_open_raw(path.as_os_str())
}
pub fn open_url(url: &str) -> Result<()> {
let url = normalize_url(url)?;
shell_open_raw(OsStr::new(url))
}
pub fn reveal_in_explorer(path: impl AsRef<Path>) -> Result<()> {
let path = path.as_ref();
if path.as_os_str().is_empty() {
return Err(Error::InvalidInput("path cannot be empty"));
}
if !path.exists() {
return Err(Error::PathDoesNotExist);
}
Command::new("explorer.exe")
.arg("/select,")
.arg(path)
.spawn()?;
Ok(())
}
pub fn move_to_recycle_bin(path: impl AsRef<Path>) -> Result<()> {
let path = path.as_ref();
if path.as_os_str().is_empty() {
return Err(Error::InvalidInput("path cannot be empty"));
}
if !path.is_absolute() {
return Err(Error::PathNotAbsolute);
}
if !path.exists() {
return Err(Error::PathDoesNotExist);
}
let path = PathBuf::from(path);
run_in_shell_sta(move || recycle_path_in_sta(&path))
}
#[cfg(test)]
mod tests {
use super::normalize_url;
#[test]
fn normalize_url_rejects_empty_string() {
let result = normalize_url("");
assert!(matches!(
result,
Err(crate::Error::InvalidInput("url cannot be empty"))
));
}
#[test]
fn normalize_url_rejects_whitespace_only() {
let result = normalize_url(" ");
assert!(matches!(
result,
Err(crate::Error::InvalidInput("url cannot be empty"))
));
}
#[test]
fn normalize_url_trims_surrounding_whitespace() {
assert_eq!(
normalize_url(" https://example.com/docs ").unwrap(),
"https://example.com/docs"
);
}
#[test]
fn normalize_url_rejects_nul_bytes() {
let result = normalize_url("https://example.com/\0hidden");
assert!(matches!(
result,
Err(crate::Error::InvalidInput("url cannot contain NUL bytes"))
));
}
}