diff --git a/i18n/en/cosmic_files.ftl b/i18n/en/cosmic_files.ftl index c7179ac..ea05939 100644 --- a/i18n/en/cosmic_files.ftl +++ b/i18n/en/cosmic_files.ftl @@ -39,6 +39,9 @@ resume = Resume ## Compress Dialog create-archive = Create archive +## Extract Dialog +extract-password-required = Password required + ## Empty Trash Dialog empty-trash = Empty trash empty-trash-warning = Are you sure you want to permanently delete all the items in Trash? diff --git a/src/app.rs b/src/app.rs index 20d5c04..2f49153 100644 --- a/src/app.rs +++ b/src/app.rs @@ -71,6 +71,7 @@ use crate::{ spawn_detached::spawn_detached, tab::{self, HeadingOptions, ItemMetadata, Location, Tab, HOVER_DURATION}, }; +use crate::operation::{OperationError, OperationErrorType}; #[derive(Clone, Debug)] pub enum Mode { @@ -319,7 +320,7 @@ pub enum Message { PendingCancelAll, PendingComplete(u64, OperationSelection), PendingDismiss, - PendingError(u64, String), + PendingError(u64, OperationError), PendingPause(u64, bool), PendingPauseAll(bool), Preview(Option), @@ -417,9 +418,14 @@ pub enum DialogPage { to: PathBuf, name: String, archive_type: ArchiveType, + password: Option }, EmptyTrash, FailedOperation(u64), + ExtractPassword { + id: u64, + password: String + }, MountError { mounter_key: MounterKey, item: MounterItem, @@ -1884,6 +1890,7 @@ impl Application for App { to, name, archive_type, + password: None }); return widget::text_input::focus(self.dialog_text_input.clone()); } @@ -1961,6 +1968,7 @@ impl Application for App { to, name, archive_type, + password } => { let extension = archive_type.extension(); let name = format!("{}{}", name, extension); @@ -1969,6 +1977,7 @@ impl Application for App { paths, to, archive_type, + password }) } DialogPage::EmptyTrash => { @@ -1977,6 +1986,21 @@ impl Application for App { DialogPage::FailedOperation(id) => { log::warn!("TODO: retry operation {}", id); } + DialogPage::ExtractPassword { + id, + password + } => { + let (operation, _, _err) = self.failed_operations.get(&id).unwrap(); + let new_op = match &operation { + Operation::Extract { to, paths, .. } => Operation::Extract { + to: to.clone(), + paths: paths.clone(), + password: Some(password) + }, + _ => unreachable!() + }; + self.operation(new_op); + } DialogPage::MountError { mounter_key, item, @@ -2093,6 +2117,7 @@ impl Application for App { self.operation(Operation::Extract { paths, to: destination, + password: None }); } } @@ -2573,11 +2598,19 @@ impl Application for App { if let Some((op, controller)) = self.pending_operations.remove(&id) { // Only show dialog if not cancelled if !controller.is_cancelled() { - self.dialog_pages.push_back(DialogPage::FailedOperation(id)); + self.dialog_pages.push_back( + match err.kind { + OperationErrorType::Generic(_) => DialogPage::FailedOperation(id), + OperationErrorType::PasswordRequired => DialogPage::ExtractPassword { + id: id, + password: String::from("") + } + } + ); } // Remove from progress self.progress_operations.remove(&id); - self.failed_operations.insert(id, (op, controller, err)); + self.failed_operations.insert(id, (op, controller, err.to_string())); } // Close progress notification if all relavent operations are finished if !self @@ -3572,6 +3605,7 @@ impl Application for App { to, name, archive_type, + password } => { let mut dialog = widget::dialog().title(fl!("create-archive")); @@ -3604,7 +3638,7 @@ impl Application for App { let archive_types = ArchiveType::all(); let selected = archive_types.iter().position(|&x| x == *archive_type); - dialog + dialog = dialog .primary_action( widget::button::suggested(fl!("create")) .on_press_maybe(complete_maybe.clone()), @@ -3624,9 +3658,10 @@ impl Application for App { to: to.clone(), name: name.clone(), archive_type: *archive_type, + password: password.clone(), }) }) - .on_submit_maybe(complete_maybe) + .on_submit_maybe(complete_maybe.clone()) .into(), widget::dropdown(archive_types, selected, move |index| { Message::DialogUpdate(DialogPage::Compress { @@ -3634,6 +3669,7 @@ impl Application for App { to: to.clone(), name: name.clone(), archive_type: archive_types[index], + password: password.clone(), }) }) .into(), @@ -3643,7 +3679,29 @@ impl Application for App { .into(), ]) .spacing(space_xxs), - ) + ); + + if *archive_type == ArchiveType::Zip { + let password_unwrapped = password.clone().unwrap_or_else(String::default); + dialog = dialog.control( + widget::column::with_children(vec![ + widget::text::body(fl!("password")).into(), + widget::text_input("", password_unwrapped).password().on_input(move |password_unwrapped| { + Message::DialogUpdate(DialogPage::Compress { + paths: paths.clone(), + to: to.clone(), + name: name.clone(), + archive_type: *archive_type, + password: Some(password_unwrapped), + }) + }) + .on_submit_maybe(complete_maybe) + .into(), + ]) + ); + } + + dialog } DialogPage::EmptyTrash => widget::dialog() .title(fl!("empty-trash")) @@ -3668,6 +3726,26 @@ impl Application for App { widget::button::standard(fl!("cancel")).on_press(Message::DialogCancel), ) } + DialogPage::ExtractPassword { + id, + password + } => { + widget::dialog() + .title(fl!("extract-password-required")) + .icon(widget::icon::from_name("dialog-error").size(64)) + .control(widget::text_input("", password).password().on_input(move |password| { + Message::DialogUpdate(DialogPage::ExtractPassword { + id: *id, + password + }) + })) + .primary_action( + widget::button::suggested(fl!("extract-here")).on_press(Message::DialogComplete), + ) + .secondary_action( + widget::button::standard(fl!("cancel")).on_press(Message::DialogCancel), + ) + } DialogPage::MountError { mounter_key: _, item: _, @@ -4697,8 +4775,9 @@ impl Application for App { let _ = msg_tx .lock() .await - .send(Message::PendingError(id, err.to_string())) + .send(Message::PendingError(id, err)) .await; + } } diff --git a/src/operation/mod.rs b/src/operation/mod.rs index 0fe2d39..6bc4e5b 100644 --- a/src/operation/mod.rs +++ b/src/operation/mod.rs @@ -6,13 +6,16 @@ use std::{ path::{Path, PathBuf}, sync::Arc, }; +use std::collections::VecDeque; +use std::fmt::{Formatter}; use tokio::sync::{mpsc, Mutex as TokioMutex}; use walkdir::WalkDir; - +use zip::AesMode::Aes256; +use zip::result::ZipError; use crate::{ app::{ArchiveType, DialogPage, Message}, config::IconSizes, - err_str, fl, + fl, mime_icon::mime_for_path, spawn_detached::spawn_detached, tab, @@ -91,6 +94,7 @@ fn zip_extract>( archive: &mut zip::ZipArchive, directory: P, controller: Controller, + password: Option ) -> zip::result::ZipResult<()> { use std::{ffi::OsString, fs}; use zip::result::ZipError; @@ -111,10 +115,13 @@ fn zip_extract>( Ok(()) } + #[cfg(unix)] let mut files_by_unix_mode = Vec::new(); let mut buffer = vec![0; 4 * 1024 * 1024]; let total_files = archive.len(); + let mut pending_directory_creates = VecDeque::new(); + for i in 0..total_files { controller .check() @@ -122,7 +129,10 @@ fn zip_extract>( controller.set_progress((i as f32) / total_files as f32); - let mut file = archive.by_index(i)?; + let mut file = match &password { + None => archive.by_index(i), + Some(pwd) => archive.by_index_decrypt(i, pwd.as_bytes()) + }.map_err(|e| e)?; let filepath = file .enclosed_name() .ok_or(ZipError::InvalidArchive("Invalid file path"))?; @@ -130,7 +140,7 @@ fn zip_extract>( let outpath = directory.as_ref().join(filepath); if file.is_dir() { - make_writable_dir_all(&outpath)?; + pending_directory_creates.push_back(outpath.clone()); continue; } let symlink_target = if file.is_symlink() && (cfg!(unix) || cfg!(windows)) { @@ -141,10 +151,16 @@ fn zip_extract>( None }; drop(file); - if let Some(p) = outpath.parent() { - make_writable_dir_all(p)?; - } if let Some(target) = symlink_target { + // create all pending dirs + while let Some(pending_dir) = pending_directory_creates.pop_front() { + make_writable_dir_all(pending_dir)?; + } + + if let Some(p) = outpath.parent() { + make_writable_dir_all(p)?; + } + #[cfg(unix)] { use std::os::unix::ffi::OsStringExt; @@ -175,7 +191,20 @@ fn zip_extract>( } continue; } - let mut file = archive.by_index(i)?; + let mut file = match &password { + None => archive.by_index(i), + Some(pwd) => archive.by_index_decrypt(i, pwd.as_bytes()) + }.map_err(|e| e)?; + + // create all pending dirs + while let Some(pending_dir) = pending_directory_creates.pop_front() { + make_writable_dir_all(pending_dir)?; + } + + if let Some(p) = outpath.parent() { + make_writable_dir_all(p)?; + } + let total = file.size(); let mut outfile = fs::File::create(&outpath)?; let mut current = 0; @@ -236,9 +265,9 @@ async fn copy_or_move( moving: bool, msg_tx: &Arc>>, controller: Controller, -) -> Result { +) -> Result { let msg_tx = msg_tx.clone(); - tokio::task::spawn_blocking(move || -> Result { + tokio::task::spawn_blocking(move || -> Result { log::info!( "{} {:?} to {:?}", if moving { "Move" } else { "Copy" }, @@ -293,13 +322,13 @@ async fn copy_or_move( }); } - context.recursive_copy_or_move(from_to_pairs, moving)?; + context.recursive_copy_or_move(from_to_pairs, moving).map_err(OperationError::from_str)?; Ok(context.op_sel) }) .await - .map_err(err_str)? - .map_err(err_str) + .map_err(OperationError::from_str)? + //.map_err(OperationError::from_str) } fn copy_unique_path(from: &Path, to: &Path) -> PathBuf { @@ -417,6 +446,7 @@ pub enum Operation { paths: Vec, to: PathBuf, archive_type: ArchiveType, + password: Option }, /// Copy items Copy { @@ -433,6 +463,7 @@ pub enum Operation { Extract { paths: Vec, to: PathBuf, + password: Option }, /// Move items Move { @@ -459,6 +490,33 @@ pub enum Operation { }, } +#[derive(Clone, Debug)] +pub enum OperationErrorType { + Generic(String), + PasswordRequired +} +#[derive(Clone, Debug)] +pub struct OperationError { + pub kind: OperationErrorType, +} + +impl OperationError { + pub fn from_str(err: T) -> Self { + OperationError { + kind: OperationErrorType::Generic(err.to_string()), + } + } +} + +impl std::fmt::Display for OperationError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match &self.kind { + OperationErrorType::Generic(s) => s.fmt(f), + OperationErrorType::PasswordRequired => f.write_str("Password required") + } + } +} + impl Operation { pub fn pending_text(&self, ratio: f32, state: ControllerState) -> String { let percent = (ratio * 100.0) as i32; @@ -490,7 +548,7 @@ impl Operation { progress = progress() ), Self::EmptyTrash => fl!("emptying-trash", progress = progress()), - Self::Extract { paths, to } => fl!( + Self::Extract { paths, to, password: _ } => fl!( "extracting", items = paths.len(), from = paths_parent_name(paths), @@ -545,7 +603,7 @@ impl Operation { to = fl!("trash") ), Self::EmptyTrash => fl!("emptied-trash"), - Self::Extract { paths, to } => fl!( + Self::Extract { paths, to, password: _ } => fl!( "extracted", items = paths.len(), from = paths_parent_name(paths), @@ -607,7 +665,7 @@ impl Operation { self, msg_tx: &Arc>>, controller: Controller, - ) -> Result { + ) -> Result { let controller_clone = controller.clone(); //TODO: IF ERROR, RETURN AN Operation THAT CAN UNDO THE CURRENT STATE @@ -616,10 +674,11 @@ impl Operation { paths, to, archive_type, + password } => { - tokio::task::spawn_blocking(move || -> Result { + tokio::task::spawn_blocking(move || -> Result { let Some(relative_root) = to.parent() else { - return Err(format!("path {:?} has no parent directory", to)); + return Err(OperationError::from_str(format!("path {:?} has no parent directory", to))); }; let op_sel = OperationSelection { @@ -632,7 +691,7 @@ impl Operation { if path.is_dir() { let new_paths_it = WalkDir::new(path).into_iter(); for entry in new_paths_it.skip(1) { - let entry = entry.map_err(err_str)?; + let entry = entry.map_err(OperationError::from_str)?; paths.push(entry.into_path()); } } @@ -646,45 +705,48 @@ impl Operation { flate2::write::GzEncoder::new(w, flate2::Compression::default()) }) .map(tar::Builder::new) - .map_err(err_str)?; + .map_err(OperationError::from_str)?; let total_paths = paths.len(); for (i, path) in paths.iter().enumerate() { - controller.check()?; + controller.check().map_err(OperationError::from_str)?; controller.set_progress((i as f32) / total_paths as f32); if let Some(relative_path) = - path.strip_prefix(relative_root).map_err(err_str)?.to_str() + path.strip_prefix(relative_root).map_err(OperationError::from_str)?.to_str() { archive .append_path_with_name(path, relative_path) - .map_err(err_str)?; + .map_err(OperationError::from_str)?; } } - archive.finish().map_err(err_str)?; + archive.finish().map_err(OperationError::from_str)?; } ArchiveType::Zip => { let mut archive = fs::File::create(&to) .map(io::BufWriter::new) .map(zip::ZipWriter::new) - .map_err(err_str)?; + .map_err(OperationError::from_str)?; let total_paths = paths.len(); let mut buffer = vec![0; 4 * 1024 * 1024]; for (i, path) in paths.iter().enumerate() { - controller.check()?; + controller.check().map_err(OperationError::from_str)?; controller.set_progress((i as f32) / total_paths as f32); let mut zip_options = zip::write::SimpleFileOptions::default(); + if password.is_some() { + zip_options = zip_options.with_aes_encryption(Aes256, password.as_deref().unwrap()); + } if let Some(relative_path) = - path.strip_prefix(relative_root).map_err(err_str)?.to_str() + path.strip_prefix(relative_root).map_err(OperationError::from_str)?.to_str() { if path.is_file() { - let mut file = fs::File::open(path).map_err(err_str)?; - let metadata = file.metadata().map_err(err_str)?; + let mut file = fs::File::open(path).map_err(OperationError::from_str)?; + let metadata = file.metadata().map_err(OperationError::from_str)?; let total = metadata.len(); if total >= 4 * 1024 * 1024 * 1024 { // The large file option must be enabled for files above 4 GiB @@ -698,16 +760,16 @@ impl Operation { } archive .start_file(relative_path, zip_options) - .map_err(err_str)?; + .map_err(OperationError::from_str)?; let mut current = 0; loop { - controller.check()?; + controller.check().map_err(OperationError::from_str)?; - let count = file.read(&mut buffer).map_err(err_str)?; + let count = file.read(&mut buffer).map_err(OperationError::from_str)?; if count == 0 { break; } - archive.write_all(&buffer[..count]).map_err(err_str)?; + archive.write_all(&buffer[..count]).map_err(OperationError::from_str)?; current += count; let file_progress = current as f32 / total as f32; @@ -718,36 +780,36 @@ impl Operation { } else { archive .add_directory(relative_path, zip_options) - .map_err(err_str)?; + .map_err(OperationError::from_str)?; } } } - archive.finish().map_err(err_str)?; + archive.finish().map_err(OperationError::from_str)?; } } Ok(op_sel) }) .await - .map_err(err_str)? - .map_err(err_str)? + .map_err(OperationError::from_str)? + //.map_err(|e| e)? } - Self::Copy { paths, to } => copy_or_move(paths, to, false, msg_tx, controller).await?, + Self::Copy { paths, to } => copy_or_move(paths, to, false, msg_tx, controller).await, Self::Delete { paths } => { let total = paths.len(); for (i, path) in paths.into_iter().enumerate() { - controller.check()?; + controller.check().map_err(OperationError::from_str)?; controller.set_progress((i as f32) / (total as f32)); let _items_opt = tokio::task::spawn_blocking(|| trash::delete(path)) .await - .map_err(err_str)? - .map_err(err_str)?; + .map_err(OperationError::from_str)? + .map_err(OperationError::from_str)?; //TODO: items_opt allows for easy restore } - OperationSelection::default() + Ok(OperationSelection::default()) } Self::EmptyTrash => { #[cfg(any( @@ -760,29 +822,29 @@ impl Operation { ) ))] { - tokio::task::spawn_blocking(move || -> Result<(), String> { - let items = trash::os_limited::list().map_err(err_str)?; + tokio::task::spawn_blocking(move || -> Result<(), OperationError> { + let items = trash::os_limited::list().map_err(OperationError::from_str)?; let count = items.len(); for (i, item) in items.into_iter().enumerate() { - controller.check()?; + controller.check().map_err(OperationError::from_str)?; controller.set_progress(i as f32 / count as f32); - trash::os_limited::purge_all([item]).map_err(err_str)?; + trash::os_limited::purge_all([item]).map_err(OperationError::from_str)?; } Ok(()) }) .await - .map_err(err_str)??; + .map_err(OperationError::from_str)??; } - OperationSelection::default() + Ok(OperationSelection::default()) } - Self::Extract { paths, to } => { - tokio::task::spawn_blocking(move || -> Result { + Self::Extract { paths, to, password } => { + tokio::task::spawn_blocking(move || -> Result { let total_paths = paths.len(); let mut op_sel = OperationSelection::default(); for (i, path) in paths.iter().enumerate() { - controller.check()?; + controller.check().map_err(OperationError::from_str)?; controller.set_progress((i as f32) / total_paths as f32); @@ -801,6 +863,7 @@ impl Operation { let controller = controller.clone(); let mime = mime_for_path(path); + let password = password.clone(); match mime.essence_str() { "application/gzip" | "application/x-compressed-tar" => { OpReader::new(path, controller) @@ -808,21 +871,29 @@ impl Operation { .map(flate2::read::GzDecoder::new) .map(tar::Archive::new) .and_then(|mut archive| archive.unpack(&new_dir)) - .map_err(err_str)? + .map_err(OperationError::from_str)? } "application/x-tar" => OpReader::new(path, controller) .map(io::BufReader::new) .map(tar::Archive::new) .and_then(|mut archive| archive.unpack(&new_dir)) - .map_err(err_str)?, + .map_err(OperationError::from_str)?, "application/zip" => fs::File::open(path) .map(io::BufReader::new) .map(zip::ZipArchive::new) - .map_err(err_str)? + .map_err(OperationError::from_str)? .and_then(move |mut archive| { - zip_extract(&mut archive, &new_dir, controller) + zip_extract(&mut archive, &new_dir, controller, password) }) - .map_err(err_str)?, + .map_err(|e| match e { + ZipError::UnsupportedArchive(ZipError::PASSWORD_REQUIRED) | + ZipError::InvalidPassword => { + OperationError { + kind: OperationErrorType::PasswordRequired, + } + }, + _ => OperationError::from_str(e) + })?, #[cfg(feature = "bzip2")] "application/x-bzip" | "application/x-bzip-compressed-tar" => { OpReader::new(path, controller) @@ -830,7 +901,7 @@ impl Operation { .map(bzip2::read::BzDecoder::new) .map(tar::Archive::new) .and_then(|mut archive| archive.unpack(&new_dir)) - .map_err(err_str)? + .map_err(OperationError::from_str)? } #[cfg(feature = "liblzma")] "application/x-xz" | "application/x-xz-compressed-tar" => { @@ -839,9 +910,9 @@ impl Operation { .map(liblzma::read::XzDecoder::new) .map(tar::Archive::new) .and_then(|mut archive| archive.unpack(&new_dir)) - .map_err(err_str)? + .map_err(OperationError::from_str)? } - _ => Err(format!("unsupported mime type {:?}", mime))?, + _ => Err(OperationError::from_str(format!("unsupported mime type {:?}", mime)))?, } } } @@ -849,45 +920,45 @@ impl Operation { Ok(op_sel) }) .await - .map_err(err_str)? - .map_err(err_str)? + .map_err(OperationError::from_str)? + //.map_err(OperationError::from_str)? } - Self::Move { paths, to } => copy_or_move(paths, to, true, msg_tx, controller).await?, + Self::Move { paths, to } => copy_or_move(paths, to, true, msg_tx, controller).await, Self::NewFolder { path } => { - tokio::task::spawn_blocking(move || -> Result { - controller.check()?; - fs::create_dir(&path).map_err(err_str)?; + tokio::task::spawn_blocking(move || -> Result { + controller.check().map_err(OperationError::from_str)?; + fs::create_dir(&path).map_err(OperationError::from_str)?; Ok(OperationSelection { ignored: Vec::new(), selected: vec![path], }) }) .await - .map_err(err_str)?? + .map_err(OperationError::from_str)? } Self::NewFile { path } => { - tokio::task::spawn_blocking(move || -> Result { - controller.check()?; - fs::File::create(&path).map_err(err_str)?; + tokio::task::spawn_blocking(move || -> Result { + controller.check().map_err(OperationError::from_str)?; + fs::File::create(&path).map_err(OperationError::from_str)?; Ok(OperationSelection { ignored: Vec::new(), selected: vec![path], }) }) .await - .map_err(err_str)?? + .map_err(OperationError::from_str)? } Self::Rename { from, to } => { - tokio::task::spawn_blocking(move || -> Result { - controller.check()?; - fs::rename(&from, &to).map_err(err_str)?; + tokio::task::spawn_blocking(move || -> Result { + controller.check().map_err(OperationError::from_str)?; + fs::rename(&from, &to).map_err(OperationError::from_str)?; Ok(OperationSelection { ignored: vec![from], selected: vec![to], }) }) .await - .map_err(err_str)?? + .map_err(OperationError::from_str)? } #[cfg(target_os = "macos")] Self::Restore { .. } => { @@ -899,7 +970,7 @@ impl Operation { let total = items.len(); let mut paths = Vec::with_capacity(total); for (i, item) in items.into_iter().enumerate() { - controller.check()?; + controller.check().map_err(OperationError::from_str)?; controller.set_progress((i as f32) / (total as f32)); @@ -907,47 +978,47 @@ impl Operation { tokio::task::spawn_blocking(|| trash::os_limited::restore_all([item])) .await - .map_err(err_str)? - .map_err(err_str)?; + .map_err(OperationError::from_str)? + .map_err(OperationError::from_str)?; } - OperationSelection { + Ok(OperationSelection { ignored: Vec::new(), selected: paths, - } + }) } Self::SetExecutableAndLaunch { path } => { - tokio::task::spawn_blocking(move || -> Result<(), String> { + tokio::task::spawn_blocking(move || -> Result<(), OperationError> { //TODO: what to do on non-Unix systems? #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - controller.check()?; + controller.check().map_err(OperationError::from_str)?; - let mut perms = fs::metadata(&path).map_err(err_str)?.permissions(); + let mut perms = fs::metadata(&path).map_err(OperationError::from_str)?.permissions(); let current_mode = perms.mode(); let new_mode = current_mode | 0o111; perms.set_mode(new_mode); - fs::set_permissions(&path, perms).map_err(err_str)?; + fs::set_permissions(&path, perms).map_err(OperationError::from_str)?; } - controller.check()?; + controller.check().map_err(OperationError::from_str)?; let mut command = std::process::Command::new(path); - spawn_detached(&mut command).map_err(err_str)?; + spawn_detached(&mut command).map_err(OperationError::from_str)?; Ok(()) }) .await - .map_err(err_str)? - .map_err(err_str)?; - OperationSelection::default() + .map_err(OperationError::from_str)? + .map_err(|e| e)?; + Ok(OperationSelection::default()) } }; controller_clone.set_progress(100.0); - Ok(paths) + paths } }