diff --git a/.changes/archive-format-plain-breaking-changes.md b/.changes/archive-format-plain-breaking-changes.md new file mode 100644 index 000000000..754e5459e --- /dev/null +++ b/.changes/archive-format-plain-breaking-changes.md @@ -0,0 +1,5 @@ +--- +"tauri": patch +--- + +**Breaking change:** Removed `tauri::api::file::ArchiveFormat::Plain`. diff --git a/.changes/extract-file-breaking.change.md b/.changes/extract-file-breaking.change.md new file mode 100644 index 000000000..3247644b3 --- /dev/null +++ b/.changes/extract-file-breaking.change.md @@ -0,0 +1,5 @@ +--- +"tauri": patch +--- + +**Breaking change:** The `tauri::api::file::Extract#extract_file` function has been moved to `tauri::api::file::Entry#extract`. diff --git a/.changes/extract-files-breaking-change.md b/.changes/extract-files-breaking-change.md new file mode 100644 index 000000000..e120bc09f --- /dev/null +++ b/.changes/extract-files-breaking-change.md @@ -0,0 +1,5 @@ +--- +"tauri": patch +--- + +**Breaking change:** The `tauri::api::file::Extract#files` function has been renamed to `with_files` for performance reasons. diff --git a/.changes/extract-performance.md b/.changes/extract-performance.md new file mode 100644 index 000000000..3e80b993e --- /dev/null +++ b/.changes/extract-performance.md @@ -0,0 +1,5 @@ +--- +"tauri": patch +--- + +Improved the performance of the `tauri::api::fs::Extract` API. diff --git a/core/tauri/src/api/error.rs b/core/tauri/src/api/error.rs index 518759808..f518eeb2b 100644 --- a/core/tauri/src/api/error.rs +++ b/core/tauri/src/api/error.rs @@ -9,9 +9,6 @@ pub enum Error { /// Command error. #[error("Command Error: {0}")] Command(String), - /// The extract archive error. - #[error("Extract Error: {0}")] - Extract(String), /// The path operation error. #[error("Path Error: {0}")] Path(String), @@ -69,6 +66,10 @@ pub enum Error { #[cfg(feature = "fs-extract-api")] #[error(transparent)] Zip(#[from] zip::result::ZipError), + /// Extract error. + #[cfg(feature = "fs-extract-api")] + #[error("Failed to extract: {0}")] + Extract(String), /// Notification error. #[cfg(notification_all)] #[error(transparent)] diff --git a/core/tauri/src/api/file/extract.rs b/core/tauri/src/api/file/extract.rs index 3b19a0328..6b3cd1143 100644 --- a/core/tauri/src/api/file/extract.rs +++ b/core/tauri/src/api/file/extract.rs @@ -2,21 +2,46 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -use either::{self, Either}; use std::{ + borrow::Cow, fs, - io::{self, Read, Seek}, + io::{self, Cursor, Read, Seek}, path::{self, Path, PathBuf}, }; +/// The archive reader. +#[derive(Debug)] +pub enum ArchiveReader { + /// A plain reader. + Plain(R), + /// A GZ- compressed reader (decoder). + GzCompressed(flate2::read::GzDecoder), +} + +impl Read for ArchiveReader { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + match self { + Self::Plain(r) => r.read(buf), + Self::GzCompressed(decoder) => decoder.read(buf), + } + } +} + +impl ArchiveReader { + fn get_mut(&mut self) -> &mut R { + match self { + Self::Plain(r) => r, + Self::GzCompressed(decoder) => decoder.get_mut(), + } + } +} + /// The supported archive formats. #[derive(Debug, Clone, Copy, PartialEq)] #[non_exhaustive] pub enum ArchiveFormat { /// Tar archive. Tar(Option), - /// Plain archive. - Plain(Option), /// Zip archive. Zip, } @@ -29,112 +54,182 @@ pub enum Compression { Gz, } -/// The extract manager to retrieve files from archives. -#[derive(Debug)] -pub struct Extract { - reader: R, - archive_format: ArchiveFormat, +/// The zip entry. +pub struct ZipEntry { + path: PathBuf, + is_dir: bool, + file_contents: Vec, } -impl Extract { - /// Create archive from reader. - pub fn from_cursor(mut reader: R, archive_format: ArchiveFormat) -> Extract { - if reader.seek(io::SeekFrom::Start(0)).is_err() { - #[cfg(debug_assertions)] - eprintln!("Could not seek to start of the file"); - } - Extract { - reader, - archive_format, +/// A read-only view into an entry of an archive. +#[non_exhaustive] +pub enum Entry<'a, R: Read> { + /// An entry of a tar archive. + #[non_exhaustive] + Tar(Box>), + /// An entry of a zip archive. + #[non_exhaustive] + Zip(ZipEntry), +} + +impl<'a, R: Read> Entry<'a, R> { + /// The entry path. + pub fn path(&self) -> crate::api::Result> { + match self { + Self::Tar(e) => e.path().map_err(Into::into), + Self::Zip(e) => Ok(Cow::Borrowed(&e.path)), } } - /// Get the archive content. - pub fn files(&mut self) -> crate::api::Result> { - let reader = &mut self.reader; - let mut all_files = Vec::new(); - if reader.seek(io::SeekFrom::Start(0)).is_err() { - #[cfg(debug_assertions)] - eprintln!("Could not seek to start of the file"); - } - match self.archive_format { - ArchiveFormat::Plain(compression) | ArchiveFormat::Tar(compression) => { - let reader = Self::get_archive_reader(reader, compression); - match self.archive_format { - ArchiveFormat::Tar(_) => { - let mut archive = tar::Archive::new(reader); - for entry in archive.entries()?.flatten() { - if let Ok(path) = entry.path() { - all_files.push(path.to_path_buf()); + /// Extract this entry into `into_path`. + /// If it's a directory, the target will be created, if it's a file, it'll be extracted at this location. + /// Note: You need to include the complete path, with file name and extension. + pub fn extract(self, into_path: &path::Path) -> crate::api::Result<()> { + match self { + Self::Tar(mut entry) => { + // determine if it's a file or a directory + if entry.header().entry_type() == tar::EntryType::Directory { + // this is a directory, lets create it + match fs::create_dir_all(into_path) { + Ok(_) => (), + Err(e) => { + if e.kind() != io::ErrorKind::AlreadyExists { + return Err(e.into()); } } } - _ => unreachable!(), - }; - } + } else { + let mut out_file = fs::File::create(into_path)?; + io::copy(&mut entry, &mut out_file)?; - ArchiveFormat::Zip => { - let archive = zip::ZipArchive::new(reader)?; - for entry in archive.file_names() { - all_files.push(PathBuf::from(entry)); + // make sure we set permissions + if let Ok(mode) = entry.header().mode() { + set_perms(into_path, Some(&mut out_file), mode, true)?; + } + } + } + Self::Zip(entry) => { + if entry.is_dir { + // this is a directory, lets create it + match fs::create_dir_all(into_path) { + Ok(_) => (), + Err(e) => { + if e.kind() != io::ErrorKind::AlreadyExists { + return Err(e.into()); + } + } + } + } else { + let mut out_file = fs::File::create(into_path)?; + io::copy(&mut Cursor::new(entry.file_contents), &mut out_file)?; } } } - Ok(all_files) + Ok(()) } +} - // Get the reader based on the compression type. - fn get_archive_reader( - source: &mut R, - compression: Option, - ) -> Either<&mut R, flate2::read::GzDecoder<&mut R>> { - if source.seek(io::SeekFrom::Start(0)).is_err() { +/// The extract manager to retrieve files from archives. +pub struct Extract<'a, R: Read + Seek> { + reader: ArchiveReader, + archive_format: ArchiveFormat, + tar_archive: Option>>, +} + +impl<'a, R: std::fmt::Debug + Read + Seek> std::fmt::Debug for Extract<'a, R> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Extract") + .field("reader", &self.reader) + .field("archive_format", &self.archive_format) + .finish() + } +} + +impl<'a, R: Read + Seek> Extract<'a, R> { + /// Create archive from reader. + pub fn from_cursor(mut reader: R, archive_format: ArchiveFormat) -> Extract<'a, R> { + if reader.seek(io::SeekFrom::Start(0)).is_err() { #[cfg(debug_assertions)] eprintln!("Could not seek to start of the file"); } - match compression { - Some(Compression::Gz) => Either::Right(flate2::read::GzDecoder::new(source)), - None => Either::Left(source), + let compression = if let ArchiveFormat::Tar(compression) = archive_format { + compression + } else { + None + }; + Extract { + reader: match compression { + Some(Compression::Gz) => ArchiveReader::GzCompressed(flate2::read::GzDecoder::new(reader)), + _ => ArchiveReader::Plain(reader), + }, + archive_format, + tar_archive: None, } } + /// Reads the archive content. + pub fn with_files< + E: Into, + F: FnMut(Entry<'_, &mut ArchiveReader>) -> std::result::Result, + >( + &'a mut self, + mut f: F, + ) -> crate::api::Result<()> { + match self.archive_format { + ArchiveFormat::Tar(_) => { + let archive = tar::Archive::new(&mut self.reader); + self.tar_archive.replace(archive); + for entry in self.tar_archive.as_mut().unwrap().entries()? { + let entry = entry?; + if entry.path().is_ok() { + let stop = f(Entry::Tar(Box::new(entry))).map_err(Into::into)?; + if stop { + break; + } + } + } + } + + ArchiveFormat::Zip => { + let mut archive = zip::ZipArchive::new(self.reader.get_mut())?; + let file_names = archive + .file_names() + .map(|f| f.to_string()) + .collect::>(); + for path in file_names { + let mut zip_file = archive.by_name(&path)?; + let is_dir = zip_file.is_dir(); + let mut file_contents = Vec::new(); + zip_file.read_to_end(&mut file_contents)?; + let stop = f(Entry::Zip(ZipEntry { + path: path.into(), + is_dir, + file_contents, + })) + .map_err(Into::into)?; + if stop { + break; + } + } + } + } + + Ok(()) + } + /// Extract an entire source archive into a specified path. If the source is a single compressed /// file and not an archive, it will be extracted into a file with the same name inside of /// `into_dir`. pub fn extract_into(&mut self, into_dir: &path::Path) -> crate::api::Result<()> { - let reader = &mut self.reader; - if reader.seek(io::SeekFrom::Start(0)).is_err() { - #[cfg(debug_assertions)] - eprintln!("Could not seek to start of the file"); - } match self.archive_format { - ArchiveFormat::Plain(compression) | ArchiveFormat::Tar(compression) => { - let mut reader = Self::get_archive_reader(reader, compression); - match self.archive_format { - ArchiveFormat::Plain(_) => { - match fs::create_dir_all(into_dir) { - Ok(_) => (), - Err(e) => { - if e.kind() != io::ErrorKind::AlreadyExists { - return Err(e.into()); - } - } - } - - let mut out_file = fs::File::create(&into_dir)?; - io::copy(&mut reader, &mut out_file)?; - } - ArchiveFormat::Tar(_) => { - let mut archive = tar::Archive::new(reader); - archive.unpack(into_dir)?; - } - _ => unreachable!(), - }; + ArchiveFormat::Tar(_) => { + let mut archive = tar::Archive::new(&mut self.reader); + archive.unpack(into_dir)?; } ArchiveFormat::Zip => { - let mut archive = zip::ZipArchive::new(reader)?; + let mut archive = zip::ZipArchive::new(self.reader.get_mut())?; for i in 0..archive.len() { let mut file = archive.by_index(i)?; // Decode the file name from raw bytes instead of using file.name() directly. @@ -165,100 +260,6 @@ impl Extract { } Ok(()) } - - /// Extract a single file from a source and extract it `into_path`. - /// If it's a directory, the target will be created, if it's a file, it'll be extracted at this location. - /// Note: You need to include the complete path, with file name and extension. - pub fn extract_file>( - &mut self, - into_path: &path::Path, - file_to_extract: T, - ) -> crate::api::Result<()> { - let file_to_extract = file_to_extract.as_ref(); - let reader = &mut self.reader; - - match self.archive_format { - ArchiveFormat::Plain(compression) | ArchiveFormat::Tar(compression) => { - let mut reader = Self::get_archive_reader(reader, compression); - match self.archive_format { - ArchiveFormat::Plain(_) => { - match fs::create_dir_all(into_path) { - Ok(_) => (), - Err(e) => { - if e.kind() != io::ErrorKind::AlreadyExists { - return Err(e.into()); - } - } - } - let mut out_file = fs::File::create(into_path)?; - io::copy(&mut reader, &mut out_file)?; - } - ArchiveFormat::Tar(_) => { - let mut archive = tar::Archive::new(reader); - let mut entry = archive - .entries()? - .filter_map(|e| e.ok()) - .find(|e| e.path().ok().filter(|p| p == file_to_extract).is_some()) - .ok_or_else(|| { - crate::api::Error::Extract(format!( - "Could not find the required path in the archive: {:?}", - file_to_extract - )) - })?; - - // determine if it's a file or a directory - if entry.header().entry_type() == tar::EntryType::Directory { - // this is a directory, lets create it - match fs::create_dir_all(into_path) { - Ok(_) => (), - Err(e) => { - if e.kind() != io::ErrorKind::AlreadyExists { - return Err(e.into()); - } - } - } - } else { - let mut out_file = fs::File::create(into_path)?; - io::copy(&mut entry, &mut out_file)?; - - // make sure we set permissions - if let Ok(mode) = entry.header().mode() { - set_perms(into_path, Some(&mut out_file), mode, true)?; - } - } - } - _ => { - panic!("Unreasonable code"); - } - }; - } - ArchiveFormat::Zip => { - let mut archive = zip::ZipArchive::new(reader)?; - let mut file = archive.by_name( - file_to_extract - .to_str() - .expect("Could not convert file to str"), - )?; - - if file.is_dir() { - // this is a directory, lets create it - match fs::create_dir_all(into_path) { - Ok(_) => (), - Err(e) => { - if e.kind() != io::ErrorKind::AlreadyExists { - return Err(e.into()); - } - } - } - } else { - let mut out_file = fs::File::create(into_path)?; - io::copy(&mut file, &mut out_file)?; - } - } - } - - Ok(()) - } } fn set_perms( diff --git a/core/tauri/src/updater/core.rs b/core/tauri/src/updater/core.rs index 5a06a56f1..f2c2a2a33 100644 --- a/core/tauri/src/updater/core.rs +++ b/core/tauri/src/updater/core.rs @@ -643,17 +643,19 @@ fn copy_files_and_run(archive_buffer: R, extract_path: &Path) -> let mut extractor = Extract::from_cursor(archive_buffer, ArchiveFormat::Tar(Some(Compression::Gz))); - for file in extractor.files()? { - if file.extension() == Some(OsStr::new("AppImage")) { + extractor.with_files(|entry| { + let path = entry.path()?; + if path.extension() == Some(OsStr::new("AppImage")) { // if something went wrong during the extraction, we should restore previous app - if let Err(err) = extractor.extract_file(extract_path, &file) { + if let Err(err) = entry.extract(extract_path) { Move::from_source(tmp_app_image).to_dest(extract_path)?; - return Err(Error::Extract(err.to_string())); + return Err(crate::api::Error::Extract(err.to_string())); } // early finish we have everything we need here - return Ok(()); + return Ok(true); } - } + Ok(false) + })?; Ok(()) } @@ -785,7 +787,6 @@ fn copy_files_and_run(archive_buffer: R, extract_path: &Path) -> Extract::from_cursor(archive_buffer, ArchiveFormat::Tar(Some(Compression::Gz))); // the first file in the tar.gz will always be // /Contents - let all_files = extractor.files()?; let tmp_dir = tempfile::Builder::new() .prefix("tauri_current_app") .tempdir()?; @@ -794,14 +795,15 @@ fn copy_files_and_run(archive_buffer: R, extract_path: &Path) -> Move::from_source(extract_path).to_dest(tmp_dir.path())?; // extract all the files - for file in all_files { + extractor.with_files(|entry| { + let path = entry.path()?; // skip the first folder (should be the app name) - let collected_path: PathBuf = file.iter().skip(1).collect(); + let collected_path: PathBuf = path.iter().skip(1).collect(); let extraction_path = extract_path.join(collected_path); // if something went wrong during the extraction, we should restore previous app - if let Err(err) = extractor.extract_file(&extraction_path, &file) { - for file in extracted_files { + if let Err(err) = entry.extract(&extraction_path) { + for file in &extracted_files { // delete all the files we extracted if file.is_dir() { std::fs::remove_dir(file)?; @@ -810,11 +812,13 @@ fn copy_files_and_run(archive_buffer: R, extract_path: &Path) -> } } Move::from_source(tmp_dir.path()).to_dest(extract_path)?; - return Err(Error::Extract(err.to_string())); + return Err(crate::api::Error::Extract(err.to_string())); } extracted_files.push(extraction_path); - } + + Ok(false) + })?; Ok(()) }