diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 011fba6..535a71f 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -53,13 +53,21 @@ jobs: echo "target flag is: ${{ env.TARGET_FLAGS }}" echo "target dir is: ${{ env.TARGET_DIR }}" - - name: cargo test + - name: cargo test (excluding main_thread) if: ${{ !startsWith(matrix.build, 'netbsd') }} - run: ${{ env.CARGO }} test --verbose ${{ env.TARGET_FLAGS }} + run: ${{ env.CARGO }} test --verbose ${{ env.TARGET_FLAGS }} -- --skip test_main_thread - - name: cargo test (without chrono) + - name: cargo test (only main_thread) if: ${{ !startsWith(matrix.build, 'netbsd') }} - run: ${{ env.CARGO }} test --verbose --no-default-features --features coinit_apartmentthreaded ${{ env.TARGET_FLAGS }} + run: ${{ env.CARGO }} test test_main_thread --verbose ${{ env.TARGET_FLAGS }} -- --test-threads=1 + + - name: cargo test (without chrono, excluding main_thread) + if: ${{ !startsWith(matrix.build, 'netbsd') }} + run: ${{ env.CARGO }} test --verbose --no-default-features --features coinit_apartmentthreaded ${{ env.TARGET_FLAGS }} -- --skip test_main_thread + + - name: cargo test (without chrono, only main_thread) + if: ${{ !startsWith(matrix.build, 'netbsd') }} + run: ${{ env.CARGO }} test test_main_thread --verbose --no-default-features --features coinit_apartmentthreaded ${{ env.TARGET_FLAGS }} -- --test-threads=1 - name: cargo build if: ${{ startsWith(matrix.build, 'netbsd') }} diff --git a/Cargo.toml b/Cargo.toml index 93dc38b..6237963 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ log = "0.4" [dev-dependencies] serial_test = { version = "2.0.0", default-features = false } +libtest-mimic = "0.8.1" chrono = { version = "0.4.31", default-features = false, features = ["clock"] } rand = "0.8.5" once_cell = "1.18.0" @@ -29,6 +30,11 @@ env_logger = "0.10.0" tempfile = "3.8.0" defer = "0.2.1" +[[test]] +name = "osakit" +path = "tests/osakit.rs" +harness = false + [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.5.1" @@ -39,6 +45,8 @@ objc2-foundation = { version = "0.2.0", features = [ "NSURL", ] } percent-encoding = "2.3.1" +chrono = { version = "0.4.31", optional = true, default-features = false, features = ["clock",] } +osakit = "0.2.4" [target.'cfg(all(unix, not(target_os = "macos"), not(target_os = "ios"), not(target_os = "android")))'.dependencies] chrono = { version = "0.4.31", optional = true, default-features = false, features = [ diff --git a/src/freedesktop.rs b/src/freedesktop.rs index a8bca87..0bc70c8 100644 --- a/src/freedesktop.rs +++ b/src/freedesktop.rs @@ -33,12 +33,17 @@ impl PlatformTrashContext { } } impl TrashContext { - pub(crate) fn delete_all_canonicalized(&self, full_paths: Vec) -> Result<(), Error> { + pub(crate) fn delete_all_canonicalized( + &self, + full_paths: Vec, + _with_info: bool, + ) -> Result>, Error> { let home_trash = home_trash()?; let sorted_mount_points = get_sorted_mount_points()?; let home_topdir = home_topdir(&sorted_mount_points)?; debug!("The home topdir is {:?}", home_topdir); let uid = unsafe { libc::getuid() }; + let mut items = Vec::with_capacity(full_paths.len()); for path in full_paths { debug!("Deleting {:?}", path); let topdir = get_first_topdir_containing_path(&path, &sorted_mount_points); @@ -47,18 +52,19 @@ impl TrashContext { debug!("The topdir was identical to the home topdir, so moving to the home trash."); // Note that the following function creates the trash folder // and its required subfolders in case they don't exist. - move_to_trash(path, &home_trash, topdir).map_err(|(p, e)| fs_error(p, e))?; + items.push(move_to_trash(path, &home_trash, topdir).map_err(|(p, e)| fs_error(p, e))?); } else if topdir.to_str() == Some("/var/home") && home_topdir.to_str() == Some("/") { debug!("The topdir is '/var/home' but the home_topdir is '/', moving to the home trash anyway."); - move_to_trash(path, &home_trash, topdir).map_err(|(p, e)| fs_error(p, e))?; + items.push(move_to_trash(path, &home_trash, topdir).map_err(|(p, e)| fs_error(p, e))?); } else { execute_on_mounted_trash_folders(uid, topdir, true, true, |trash_path| { - move_to_trash(&path, trash_path, topdir) + items.push(move_to_trash(&path, trash_path, topdir)?); + Ok(()) }) .map_err(|(p, e)| fs_error(p, e))?; } } - Ok(()) + Ok(Some(items)) } } @@ -450,7 +456,7 @@ fn move_to_trash( src: impl AsRef, trash_folder: impl AsRef, _topdir: impl AsRef, -) -> Result<(), FsError> { +) -> Result { let src = src.as_ref(); let trash_folder = trash_folder.as_ref(); let files_folder = trash_folder.join("files"); @@ -491,6 +497,7 @@ fn move_to_trash( info_name.push(".trashinfo"); let info_file_path = info_folder.join(&info_name); let info_result = OpenOptions::new().create_new(true).write(true).open(&info_file_path); + let mut time_deleted = -1; match info_result { Err(error) => { if error.kind() == std::io::ErrorKind::AlreadyExists { @@ -510,10 +517,12 @@ fn move_to_trash( #[cfg(feature = "chrono")] { let now = chrono::Local::now(); + time_deleted = now.timestamp(); writeln!(file, "DeletionDate={}", now.format("%Y-%m-%dT%H:%M:%S")) } #[cfg(not(feature = "chrono"))] { + time_deleted = -1; Ok(()) } }) @@ -537,12 +546,18 @@ fn move_to_trash( } Ok(_) => { // We did it! - break; + return Ok(TrashItem { + id: info_file_path.into(), + name: filename.into(), + original_parent: src + .parent() + .expect("Absolute path to trashed item should have a parent") + .to_path_buf(), + time_deleted, + }); } } } - - Ok(()) } /// An error may mean that a collision was found. diff --git a/src/lib.rs b/src/lib.rs index adccaea..b8ac6b4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -70,7 +70,7 @@ impl TrashContext { /// Removes a single file or directory. /// /// When a symbolic link is provided to this function, the symbolic link will be removed and the link - /// target will be kept intact. + /// target will be kept intact. Successful results will have always have None trash items. /// /// # Example /// @@ -81,14 +81,26 @@ impl TrashContext { /// trash::delete("delete_me").unwrap(); /// assert!(File::open("delete_me").is_err()); /// ``` - pub fn delete>(&self, path: T) -> Result<(), Error> { + pub fn delete>(&self, path: T) -> Result>, Error> { self.delete_all(&[path]) } + /// Same as `delete`, but returns `TrashItem` if available. + pub fn delete_with_info>(&self, path: T) -> Result, Error> { + match self.delete_all_with_info(&[path]) { + // Result>> + Ok(maybe_items) => match maybe_items { + Some(mut items) => Ok(items.pop()), // no need to check that vec.len=2? + None => Ok(None), + }, + Err(e) => Err(e), + } + } + /// Removes all files/directories specified by the collection of paths provided as an argument. /// /// When a symbolic link is provided to this function, the symbolic link will be removed and the link - /// target will be kept intact. + /// target will be kept intact. Successful results will have always have None trash items. /// /// # Example /// @@ -101,7 +113,19 @@ impl TrashContext { /// assert!(File::open("delete_me_1").is_err()); /// assert!(File::open("delete_me_2").is_err()); /// ``` - pub fn delete_all(&self, paths: I) -> Result<(), Error> + pub fn delete_all(&self, paths: I) -> Result>, Error> + where + I: IntoIterator, + T: AsRef, + { + trace!("Starting canonicalize_paths"); + let full_paths = canonicalize_paths(paths)?; + trace!("Finished canonicalize_paths"); + self.delete_all_canonicalized(full_paths, false) + } + + /// Same as `delete_all, but returns `TrashItem`s if available. + pub fn delete_all_with_info(&self, paths: I) -> Result>, Error> where I: IntoIterator, T: AsRef, @@ -109,21 +133,28 @@ impl TrashContext { trace!("Starting canonicalize_paths"); let full_paths = canonicalize_paths(paths)?; trace!("Finished canonicalize_paths"); - self.delete_all_canonicalized(full_paths) + self.delete_all_canonicalized(full_paths, true) } } /// Convenience method for `DEFAULT_TRASH_CTX.delete()`. /// /// See: [`TrashContext::delete`](TrashContext::delete) -pub fn delete>(path: T) -> Result<(), Error> { +pub fn delete>(path: T) -> Result>, Error> { DEFAULT_TRASH_CTX.delete(path) } +/// Convenience method for `DEFAULT_TRASH_CTX.delete_with_info()`. +/// +/// See: [`TrashContext::delete`](TrashContext::delete_with_info) +pub fn delete_with_info>(path: T) -> Result, Error> { + DEFAULT_TRASH_CTX.delete_with_info(path) +} + /// Convenience method for `DEFAULT_TRASH_CTX.delete_all()`. /// /// See: [`TrashContext::delete_all`](TrashContext::delete_all) -pub fn delete_all(paths: I) -> Result<(), Error> +pub fn delete_all(paths: I) -> Result>, Error> where I: IntoIterator, T: AsRef, @@ -131,6 +162,17 @@ where DEFAULT_TRASH_CTX.delete_all(paths) } +/// Convenience method for `DEFAULT_TRASH_CTX.delete_all_with_info()`. +/// +/// See: [`TrashContext::delete_all`](TrashContext::delete_all_with_info) +pub fn delete_all_with_info(paths: I) -> Result>, Error> +where + I: IntoIterator, + T: AsRef, +{ + DEFAULT_TRASH_CTX.delete_all_with_info(paths) +} + /// Provides information about an error. #[derive(Debug)] pub enum Error { @@ -270,10 +312,16 @@ pub struct TrashItem { /// /// On Linux it is an absolute path to the `.trashinfo` file associated with /// the item. + /// + /// On macOS it is the string returned by the `.path()` method on the `NSURL` item + /// returned by the `trashItemAtURL_resultingItemURL_error` call. pub id: OsString, /// The name of the item. For example if the folder '/home/user/New Folder' /// was deleted, its `name` is 'New Folder' + /// macOS: when trashing with DeleteMethod::Finder, files are passed to Finder in a single batch, + /// so if the size of the returned list of trashed paths is different from the list of items we sent + /// to trash, we can't match input to output, so will leave this field "" blank pub name: OsString, /// The path to the parent folder of this item before it was put inside the @@ -282,11 +330,20 @@ pub struct TrashItem { /// /// To get the full path to the file in its original location use the /// `original_path` function. + /// macOS: when trashing with DeleteMethod::Finder, files are passed to Finder in a single batch, + /// so if the size of the returned list of trashed paths is different from the list of items we sent + /// to trash, we can't match input to output, so will leave this field "" blank pub original_parent: PathBuf, /// The number of non-leap seconds elapsed between the UNIX Epoch and the /// moment the file was deleted. - /// Without the "chrono" feature, this will be a negative number on linux only. + /// Without the "chrono" feature, this will be a negative number on linux/macOS only. + /// macOS has the number, but there is no information on how to get it, + /// the usual 'kMDItemDateAdded' attribute doesn't exist for files @ trash + /// apple.stackexchange.com/questions/437475/how-can-i-find-out-when-a-file-had-been-moved-to-trash + /// stackoverflow.com/questions/53341670/access-the-file-date-added-in-terminal + /// macOS: when trashing with DeleteMethod::Finder, files are passed to Finder in a single batch, so the timing will be + /// set to the time before a batch is trashed and is the same for all files in a batch pub time_deleted: i64, } diff --git a/src/macos/mod.rs b/src/macos/mod.rs index 62027bd..f0c601e 100644 --- a/src/macos/mod.rs +++ b/src/macos/mod.rs @@ -1,13 +1,13 @@ -use std::{ - ffi::OsString, - path::{Path, PathBuf}, - process::Command, -}; +use crate::into_unknown; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::process::Command; -use log::trace; +use log::{trace, warn}; +use objc2::rc::Retained; use objc2_foundation::{NSFileManager, NSString, NSURL}; -use crate::{into_unknown, Error, TrashContext}; +use crate::{Error, TrashContext, TrashItem}; #[derive(Copy, Clone, Debug)] /// There are 2 ways to trash files: via the ≝Finder app or via the OS NsFileManager call @@ -15,6 +15,7 @@ use crate::{into_unknown, Error, TrashContext}; /// |
Feature |≝
Finder |
NsFileManager | /// |:-----------------------|:--------------:|:----------------:| /// |Undo via "Put back" | ✓ | ✗ | +/// |Get trashed paths | ✗ | ✓ | /// |Speed | ✗
Slower | ✓
Faster | /// |No sound | ✗ | ✓ | /// |No extra permissions | ✗ | ✓ | @@ -39,6 +40,7 @@ pub enum DeleteMethod { /// at: /// - /// - + /// - Allows getting the paths to the trashed items NsFileManager, } impl DeleteMethod { @@ -52,18 +54,48 @@ impl Default for DeleteMethod { Self::new() } } + +#[derive(Copy, Clone, Debug)] +/// There are 2 ways to ask Finder to trash files: ≝1. by calling the `osascript` binary or 2. calling directly into the `OSAKit` Framework. +/// The `OSAKit` method should be faster, but it MUST be run on the main thread, otherwise it can fail, stalling until the default 2 min +/// timeout expires. +/// +pub enum ScriptMethod { + /// Spawn a process calling the standalone `osascript` binary to run AppleScript. Slower, but more reliable. + /// + /// This is the default. + Cli, + + /// Call into `OSAKit` directly via ObjC-bindings. Faster, but MUST be run on the main thread, or it can fail, stalling for 2 min. + Osakit, +} +impl ScriptMethod { + /// Returns `ScriptMethod::Cli` + pub const fn new() -> Self { + ScriptMethod::Cli + } +} +impl Default for ScriptMethod { + fn default() -> Self { + Self::new() + } +} + #[derive(Clone, Default, Debug)] pub struct PlatformTrashContext { delete_method: DeleteMethod, + script_method: ScriptMethod, } impl PlatformTrashContext { pub const fn new() -> Self { - Self { delete_method: DeleteMethod::new() } + Self { delete_method: DeleteMethod::new(), script_method: ScriptMethod::new() } } } pub trait TrashContextExtMacos { fn set_delete_method(&mut self, method: DeleteMethod); fn delete_method(&self) -> DeleteMethod; + fn set_script_method(&mut self, method: ScriptMethod); + fn script_method(&self) -> ScriptMethod; } impl TrashContextExtMacos for TrashContext { fn set_delete_method(&mut self, method: DeleteMethod) { @@ -72,21 +104,36 @@ impl TrashContextExtMacos for TrashContext { fn delete_method(&self) -> DeleteMethod { self.platform_specific.delete_method } + fn set_script_method(&mut self, method: ScriptMethod) { + self.platform_specific.script_method = method; + } + fn script_method(&self) -> ScriptMethod { + self.platform_specific.script_method + } } impl TrashContext { - pub(crate) fn delete_all_canonicalized(&self, full_paths: Vec) -> Result<(), Error> { + pub(crate) fn delete_all_canonicalized( + &self, + full_paths: Vec, + with_info: bool, + ) -> Result>, Error> { match self.platform_specific.delete_method { - DeleteMethod::Finder => delete_using_finder(&full_paths), - DeleteMethod::NsFileManager => delete_using_file_mgr(&full_paths), + DeleteMethod::Finder => match self.platform_specific.script_method { + ScriptMethod::Cli => delete_using_finder(&full_paths, with_info, true), + ScriptMethod::Osakit => delete_using_finder(&full_paths, with_info, false), + }, + DeleteMethod::NsFileManager => delete_using_file_mgr(&full_paths, with_info), } } } -fn delete_using_file_mgr>(full_paths: &[P]) -> Result<(), Error> { +fn delete_using_file_mgr>(full_paths: &[P], with_info: bool) -> Result>, Error> { trace!("Starting delete_using_file_mgr"); let file_mgr = unsafe { NSFileManager::defaultManager() }; + let mut items = if with_info { Vec::with_capacity(full_paths.len()) } else { vec![] }; for path in full_paths { - let path = path.as_ref().as_os_str().as_encoded_bytes(); + let path_r = path.as_ref(); + let path = path_r.as_os_str().as_encoded_bytes(); let path = match std::str::from_utf8(path) { Ok(path_utf8) => NSString::from_str(path_utf8), // utf-8 path, use as is Err(_) => NSString::from_str(&percent_encode(path)), // binary path, %-encode it @@ -97,23 +144,66 @@ fn delete_using_file_mgr>(full_paths: &[P]) -> Result<(), Error> trace!("Finished fileURLWithPath"); trace!("Calling trashItemAtURL"); - let res = unsafe { file_mgr.trashItemAtURL_resultingItemURL_error(&url, None) }; + let mut out_res_nsurl: Option> = None; + let res = if with_info { + unsafe { file_mgr.trashItemAtURL_resultingItemURL_error(&url, Some(&mut out_res_nsurl)) } + } else { + unsafe { file_mgr.trashItemAtURL_resultingItemURL_error(&url, None) } + }; trace!("Finished trashItemAtURL"); if let Err(err) = res { return Err(Error::Unknown { - description: format!("While deleting '{:?}', `trashItemAtURL` failed: {err}", path.as_ref()), + description: format!("While deleting '{:?}', `trashItemAtURL` failed: {err}", path), }); + } else if with_info { + if let Some(out_nsurl) = out_res_nsurl { + #[allow(unused_assignments)] + let mut time_deleted = -1; + #[cfg(feature = "chrono")] + { + let now = chrono::Local::now(); + time_deleted = now.timestamp(); + } + #[cfg(not(feature = "chrono"))] + { + time_deleted = -1; + } + if let Some(nspath) = unsafe { out_nsurl.path() } { + // Option> + items.push(TrashItem { + id: nspath.to_string().into(), + name: path_r.file_name().expect("Item to be trashed should have a name").into(), + original_parent: path_r + .parent() + .expect("Item to be trashed should have a parent") + .to_path_buf(), + time_deleted, + }); + } else { + warn!("OS did not return path string from the URL of the trashed item '{:?}', originally located at: '{:?}'", out_nsurl, path); + } + } else { + warn!("OS did not return a path to the trashed file, originally located at: '{:?}'", path); + } } } - Ok(()) + if with_info { + Ok(Some(items)) + } else { + Ok(None) + } } -fn delete_using_finder>(full_paths: &[P]) -> Result<(), Error> { - // AppleScript command to move files (or directories) to Trash looks like - // osascript -e 'tell application "Finder" to delete { POSIX file "file1", POSIX "file2" }' - // The `-e` flag is used to execute only one line of AppleScript. - let mut command = Command::new("osascript"); +fn delete_using_finder + std::fmt::Debug>( + full_paths: &[P], + with_info: bool, + as_cli: bool, +) -> Result>, Error> { + // TODO: should we convert to trashing item by item instead of in batches to have a guaranteed match of input to output? + // which method is faster? + // what about with a lot of items? will a huge script combining all paths still work? + let mut items: Vec = if with_info { Vec::with_capacity(full_paths.len()) } else { vec![] }; let posix_files = full_paths .iter() .map(|p| { @@ -125,31 +215,200 @@ fn delete_using_finder>(full_paths: &[P]) -> Result<(), Error> { }) .collect::>() .join(", "); - let script = format!("tell application \"Finder\" to delete {{ {posix_files} }}"); - let argv: Vec = vec!["-e".into(), script.into()]; - command.args(argv); + const LIST_SEP: &str = " /// "; + let script_text = if with_info { + if as_cli { + // since paths can have any char, use " /// " triple path separator for parsing ouput of list paths + format!( + r#" + tell application "Finder" + set Trash_items to delete {{ {posix_files} }} + end tell + if (class of Trash_items) is not list then -- if only 1 file is deleted, returns a file, not a list + return (POSIX path of (Trash_items as alias)) + end if + repeat with aFile in Trash_items -- Finder reference + set contents of aFile to (POSIX path of (aFile as alias)) -- can't get paths of Finder reference, coersion to alias needed + end repeat + set text item delimiters to "{LIST_SEP}" -- hopefully no legal path can have this + return Trash_items as text -- coersion to text forces item delimiters for lists + "# + ) + } else { + format!( + r#" + tell application "Finder" + set Trash_items to delete {{ {posix_files} }} + end tell + if (class of Trash_items) is not list then -- if only 1 file is deleted, returns a file, not a list + return (POSIX path of (Trash_items as alias)) + end if + repeat with aFile in Trash_items -- Finder reference + set contents of aFile to (POSIX path of (aFile as alias)) -- can't get paths of Finder reference, coersion to alias needed + end repeat + return Trash_items + "# + ) + } + } else { + // no ouput parsing required, so script is the same for Cli and Osakit + format!( + r#"tell application "Finder" to delete {{ {posix_files} }} + return "" "# + ) + }; + use osakit::{Language, Script}; + if as_cli { + let mut command = Command::new("osascript"); + let argv: Vec = vec!["-e".into(), script_text.into()]; + command.args(argv); - // Execute command - let result = command.output().map_err(into_unknown)?; - if !result.status.success() { - let stderr = String::from_utf8_lossy(&result.stderr); - match result.status.code() { - None => { - return Err(Error::Unknown { - description: format!("The AppleScript exited with error. stderr: {}", stderr), - }) + // Execute command + let result = command.output().map_err(into_unknown)?; + if result.status.success() { + if with_info { + // parse stdout into a list of paths and convert to TrashItems + #[allow(unused_assignments)] + let mut time_deleted = -1; + #[cfg(feature = "chrono")] + { + let now = chrono::Local::now(); + time_deleted = now.timestamp(); + } + #[cfg(not(feature = "chrono"))] + { + time_deleted = -1; + } + let stdout = String::from_utf8_lossy(&result.stdout); // Finder's return paths should be utf8 (%-encoded?)? + let file_list = stdout.strip_suffix("\n").unwrap_or(&stdout).split(LIST_SEP).collect::>(); + + if !file_list.is_empty() { + let len_match = full_paths.len() == file_list.len(); + if !len_match { + warn!("AppleScript returned a list of trashed paths len {} ≠ {} expected items sent to be trashed, so trashed items will have empty names/original parents as we can't be certain which trash path matches which trashed item",full_paths.len(),file_list.len()); + } + for (i, file_path) in file_list.iter().enumerate() { + let path_r = if len_match { full_paths[i].as_ref() } else { Path::new("") }; + items.push(TrashItem { + id: file_path.into(), + name: if len_match { + path_r.file_name().expect("Item to be trashed should have a name").into() + } else { + "".into() + }, + original_parent: if len_match { + path_r.parent().expect("Item to be trashed should have a parent").to_path_buf() + } else { + "".into() + }, + time_deleted, + }); + } + return Ok(Some(items)); + } else { + let ss = if full_paths.len() > 1 { "s" } else { "" }; + warn!("AppleScript did not return a list of path{} to the trashed file{}, originally located at: {:?}",&ss,&ss,&full_paths); + } } + } else { + let stderr = String::from_utf8_lossy(&result.stderr); + match result.status.code() { + None => { + return Err(Error::Unknown { + description: format!("The AppleScript exited with error. stderr: {}", stderr), + }) + } + + Some(code) => { + return Err(Error::Os { + code, + description: format!("The AppleScript exited with error. stderr: {}", stderr), + }) + } + }; + } + } else { + // use Osakit + let mut script = Script::new_from_source(Language::AppleScript, &script_text); - Some(code) => { - return Err(Error::Os { - code, - description: format!("The AppleScript exited with error. stderr: {}", stderr), + // Compile and Execute script + match script.compile() { + Ok(_) => match script.execute() { + Ok(res) => { + if with_info { + #[allow(unused_assignments)] + let mut time_deleted = -1; + #[cfg(feature = "chrono")] + { + let now = chrono::Local::now(); + time_deleted = now.timestamp(); + } + #[cfg(not(feature = "chrono"))] + { + time_deleted = -1; + } + let res_arr = if let Some(file_path) = res.as_str() { + // convert a single value into an array for ease of handling later + vec![file_path].into() + } else { + res + }; + if let Some(file_list) = res_arr.as_array() { + let len_match = full_paths.len() == file_list.len(); + if !len_match { + warn!("AppleScript returned a list of trashed paths len {} ≠ {} expected items sent to be trashed, so trashed items will have empty names/original parents as we can't be certain which trash path matches which trashed item",full_paths.len(),file_list.len()); + } + for (i, posix_path) in file_list.iter().enumerate() { + if let Some(file_path) = posix_path.as_str() { + // Finder's return paths should be utf8 (%-encoded?)? + //let p=PathBuf::from(file_path); + //println!("✓converted posix_path:{} + // \nexists {} {:?}", posix_path, p.exists(),p); + let path_r = if len_match { full_paths[i].as_ref() } else { Path::new("") }; + items.push(TrashItem { + id: file_path.into(), + name: if len_match { + path_r.file_name().expect("Item to be trashed should have a name").into() + } else { + "".into() + }, + original_parent: if len_match { + path_r + .parent() + .expect("Item to be trashed should have a parent") + .to_path_buf() + } else { + "".into() + }, + time_deleted, + }); + } else { + warn!( + "Failed to parse AppleScript's returned path to the trashed file: {:?}", + &posix_path + ); + } + } + return Ok(Some(items)); + } else { + let ss = if full_paths.len() > 1 { "s" } else { "" }; + warn!("AppleScript did not return a list of path{} to the trashed file{}, originally located at: {:?}",&ss,&ss,&full_paths); + } + } + } + Err(e) => { + return Err(Error::Unknown { description: format!("The AppleScript failed with error: {}", e) }) + } + }, + Err(e) => { + return Err(Error::Unknown { + description: format!("The AppleScript failed to compile with error: {}", e), }) } - }; + } } - Ok(()) + Ok(None) } /// std's from_utf8_lossy, but non-utf8 byte sequences are %-encoded instead of being replaced by a special symbol. diff --git a/src/macos/tests.rs b/src/macos/tests.rs index 7d4fcf0..5cc0a14 100644 --- a/src/macos/tests.rs +++ b/src/macos/tests.rs @@ -1,5 +1,6 @@ use crate::{ - macos::{percent_encode, DeleteMethod, TrashContextExtMacos}, + canonicalize_paths, + macos::{percent_encode, DeleteMethod, ScriptMethod, TrashContextExtMacos}, tests::{get_unique_name, init_logging}, TrashContext, }; @@ -10,6 +11,70 @@ use std::os::unix::ffi::OsStrExt; use std::path::PathBuf; use std::process::Command; +#[test] +#[serial] +fn test_delete_with_finder_with_info() { + init_logging(); + let mut trash_ctx = TrashContext::default(); + trash_ctx.set_delete_method(DeleteMethod::Finder); + trash_ctx.set_script_method(ScriptMethod::Cli); + + let mut path1 = PathBuf::from(get_unique_name()); + let mut path2 = PathBuf::from(get_unique_name()); + path1.set_extension(r#"a"b,"#); + path2.set_extension(r#"x80=%80 slash=\ pc=% quote=" comma=,"#); + File::create_new(&path1).unwrap(); + File::create_new(&path2).unwrap(); + let trashed_items = trash_ctx.delete_all_with_info(&[path1.clone(), path2.clone()]).unwrap().unwrap(); //Ok + Some trashed paths + assert!(File::open(&path1).is_err()); // original files deleted + assert!(File::open(&path2).is_err()); + for item in trashed_items { + let trashed_path = item.id; + assert!(!File::open(&trashed_path).is_err()); // returned trash items exist + std::fs::remove_file(&trashed_path).unwrap(); // clean up + assert!(File::open(&trashed_path).is_err()); // cleaned up trash items + } + + // test a single file (in case returned paths aren't an array anymore) + let mut path3 = PathBuf::from(get_unique_name()); + path3.set_extension(r#"a"b,"#); + File::create_new(&path3).unwrap(); + let item = trash_ctx.delete_with_info(&path3).unwrap().unwrap(); //Ok + Some trashed paths + assert!(File::open(&path3).is_err()); // original files deleted + let trashed_path = item.id; + assert!(!File::open(&trashed_path).is_err()); // returned trash items exist + std::fs::remove_file(&trashed_path).unwrap(); // clean up + assert!(File::open(&trashed_path).is_err()); // cleaned up trash items +} + +#[test] +#[serial] +fn test_delete_binary_with_finder_with_info() { + init_logging(); + let (_cleanup, tmp) = create_hfs_volume().unwrap(); + let parent_fs_supports_binary = tmp.path(); + let mut trash_ctx = TrashContext::default(); + trash_ctx.set_delete_method(DeleteMethod::Finder); + + let mut path1 = parent_fs_supports_binary.join(get_unique_name()); + let mut path2 = parent_fs_supports_binary.join(get_unique_name()); + path1.set_extension(OsStr::from_bytes(b"\x80a\"b")); // \x80 = lone continuation byte (128) (invalid utf8) + path2.set_extension(OsStr::from_bytes(b"\x80=%80 slash=\\ pc=% quote=\" comma=,")); + File::create_new(&path1).unwrap(); + File::create_new(&path2).unwrap(); + assert!(&path1.exists()); + assert!(&path2.exists()); + let trashed_items = trash_ctx.delete_all_with_info(&[path1.clone(), path2.clone()]).unwrap().unwrap(); //Ok + Some trashed paths + assert!(File::open(&path1).is_err()); // original files deleted + assert!(File::open(&path2).is_err()); + for item in trashed_items { + let trashed_path = item.id; + assert!(!File::open(&trashed_path).is_err()); // returned trash items exist + std::fs::remove_file(&trashed_path).unwrap(); // clean up + assert!(File::open(&trashed_path).is_err()); // cleaned up trash items + } +} + #[test] #[serial] fn test_delete_with_finder_quoted_paths() { @@ -41,6 +106,43 @@ fn test_delete_with_ns_file_manager() { assert!(File::open(&path).is_err()); } +#[test] +#[serial] +fn test_delete_with_finder() { + init_logging(); + let mut trash_ctx = TrashContext::default(); + trash_ctx.set_delete_method(DeleteMethod::Finder); + + let path = PathBuf::from(get_unique_name()); + File::create_new(&path).unwrap(); + assert!(path.exists()); + trash_ctx.delete(&path).unwrap(); + assert!(!path.exists()); +} + +#[test] +#[serial] +fn test_delete_binary_path_with_ns_file_manager_with_info() { + init_logging(); + let mut trash_ctx = TrashContext::default(); + trash_ctx.set_delete_method(DeleteMethod::NsFileManager); + + let mut path = PathBuf::from(get_unique_name()); + let paths = canonicalize_paths(&[path]).unwrap(); // need full path to get parent + assert!(!paths.is_empty()); + path = paths[0].clone(); + let name = path.file_name().unwrap(); + let original_parent = path.parent().unwrap(); + + File::create_new(&path).unwrap(); + let trash_item = trash_ctx.delete_with_info(&path).unwrap().unwrap(); + assert!(File::open(&path).is_err()); + assert_eq!(name, trash_item.name); + assert_eq!(original_parent, trash_item.original_parent); + // TrashItem's date deleted not tested since we can't guarantee the date we calculate here will match the date calculated @ delete + // TrashItem's path@trash not tested since we can't guarantee the ours will be identical to the one FileManager decides to use (names change if they're dupe, also trash path is a bit tricky to get right as it changes depending on the user/admin) +} + #[test] #[serial] fn test_delete_binary_path_with_ns_file_manager() { diff --git a/src/windows.rs b/src/windows.rs index 8727706..e8434e0 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -36,7 +36,10 @@ impl PlatformTrashContext { } impl TrashContext { /// See https://docs.microsoft.com/en-us/windows/win32/api/shellapi/ns-shellapi-_shfileopstructa - pub(crate) fn delete_specified_canonicalized(&self, full_paths: Vec) -> Result<(), Error> { + pub(crate) fn delete_specified_canonicalized( + &self, + full_paths: Vec, + ) -> Result>, Error> { ensure_com_initialized(); unsafe { let pfo: IFileOperation = CoCreateInstance(&FileOperation as *const _, None, CLSCTX_ALL).unwrap(); @@ -66,14 +69,17 @@ impl TrashContext { // the list of HRESULT codes is not documented. return Err(Error::Unknown { description: "Some operations were aborted".into() }); } - Ok(()) + Ok(None) } } /// Removes all files and folder paths recursively. - pub(crate) fn delete_all_canonicalized(&self, full_paths: Vec) -> Result<(), Error> { - self.delete_specified_canonicalized(full_paths)?; - Ok(()) + pub(crate) fn delete_all_canonicalized( + &self, + full_paths: Vec, + _with_info: bool, + ) -> Result>, Error> { + self.delete_specified_canonicalized(full_paths) } } diff --git a/tests/osakit.rs b/tests/osakit.rs new file mode 100644 index 0000000..d9667c0 --- /dev/null +++ b/tests/osakit.rs @@ -0,0 +1,79 @@ +// Separate file to force running tests on the main thread, a must for macOS OSAkit APIs, which can otherwise fail after a 2-min stall +// Uses a custom libtest_mimic test harness for that since the default Cargo test doesn't support it. +// ADD "test_main_thread_" prefix to test names so that the main cargo test run filter them out with `--skip "test_main_thread_"` +fn main() { + #[cfg(target_os = "macos")] + test_main_thread_mac::main_mac() +} + +#[cfg(target_os = "macos")] +#[path = "../src/tests.rs"] +mod trash_tests; + +#[cfg(target_os = "macos")] +mod test_main_thread_mac { + use crate::trash_tests::{get_unique_name, init_logging}; + use serial_test::serial; + use std::{fs::File, path::PathBuf}; + use trash::{ + macos::{DeleteMethod, ScriptMethod, TrashContextExtMacos}, + TrashContext, + }; // not pub, so import directly + + pub(crate) fn main_mac() { + use libtest_mimic::{Arguments, Trial}; + let args = Arguments::from_args(); // Parse command line arguments + let tests = vec![ + // Create a list of tests and/or benchmarks + Trial::test("test_main_thread_delete_with_finder_osakit_with_info", || { + Ok(test_main_thread_delete_with_finder_osakit_with_info()) + }), + ]; + libtest_mimic::run(&args, tests).exit(); // Run all tests and exit the application appropriatly + } + + use std::thread; + #[serial] + pub fn test_main_thread_delete_with_finder_osakit_with_info() { + // OSAkit must be run on the main thread + if let Some("main") = thread::current().name() { + } else { + panic!("OSAkit is NOT thread-safe, so this test must run on the main thread, and it's not"); + }; + + init_logging(); + let mut trash_ctx = TrashContext::default(); + trash_ctx.set_delete_method(DeleteMethod::Finder); + trash_ctx.set_script_method(ScriptMethod::Osakit); + + let mut path1 = PathBuf::from(get_unique_name()); + let mut path2 = PathBuf::from(get_unique_name()); + path1.set_extension(r#"a"b,"#); + path2.set_extension(r#"x80=%80 slash=\ pc=% quote=" comma=,"#); + for _i in 1..10 { + // run a few times since previously threading issues didn't always appear on 1st run + File::create_new(&path1).unwrap(); + File::create_new(&path2).unwrap(); + let trashed_items = trash_ctx.delete_all_with_info(&[path1.clone(), path2.clone()]).unwrap().unwrap(); //Ok + Some trashed paths + assert!(File::open(&path1).is_err()); // original files deleted + assert!(File::open(&path2).is_err()); + for item in trashed_items { + let trashed_path = item.id; + assert!(!File::open(&trashed_path).is_err()); // returned trash items exist + std::fs::remove_file(&trashed_path).unwrap(); // clean up + assert!(File::open(&trashed_path).is_err()); // cleaned up trash items + } + } + + // test a single file (in case returned paths aren't an array anymore) + let mut path3 = PathBuf::from(get_unique_name()); + path3.set_extension(r#"a"b,"#); + File::create_new(&path3).unwrap(); + let item = trash_ctx.delete_with_info(&path3).unwrap().unwrap(); //Ok + Some trashed paths + assert!(File::open(&path3).is_err()); // original files deleted + let trashed_path = item.id; + assert!(!File::open(&trashed_path).is_err()); // returned trash items exist + std::fs::remove_file(&trashed_path).unwrap(); // clean up + assert!(File::open(&trashed_path).is_err()); // cleaned up trash items + } +}