From 1fa4d30eabb3768e1e97fa56f275408db2955b32 Mon Sep 17 00:00:00 2001 From: Tony <68118705+Legend-Master@users.noreply.github.com> Date: Tue, 30 Apr 2024 22:48:04 +0800 Subject: [PATCH] feat(updater): support non zipped updater (#1174) --- .changes/updater-non-zip.md | 5 + Cargo.lock | 48 +---- examples/api/src/views/Updater.svelte | 4 +- plugins/updater/Cargo.toml | 30 +-- plugins/updater/src/error.rs | 3 + plugins/updater/src/updater.rs | 291 ++++++++++++++++---------- 6 files changed, 208 insertions(+), 173 deletions(-) create mode 100644 .changes/updater-non-zip.md diff --git a/.changes/updater-non-zip.md b/.changes/updater-non-zip.md new file mode 100644 index 00000000..9fd014ff --- /dev/null +++ b/.changes/updater-non-zip.md @@ -0,0 +1,5 @@ +--- +"updater": "patch" +--- + +Add support for updating using non-zipped files on Windows and Linux. diff --git a/Cargo.lock b/Cargo.lock index 5a6b4071..07cceacf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -334,16 +334,6 @@ dependencies = [ "zbus", ] -[[package]] -name = "assert-json-diff" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "async-broadcast" version = "0.7.0" @@ -1146,16 +1136,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "colored" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbf2150cce219b664a8a70df7a1f933836724b503f8a413af9365b4dcc4d90b8" -dependencies = [ - "lazy_static", - "windows-sys 0.48.0", -] - [[package]] name = "combine" version = "4.6.6" @@ -1893,7 +1873,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9f0c14694cbd524c8720dd69b0e3179344f04ebb5f90f2e4a440c6ea3b2f1ee" dependencies = [ - "colored 1.9.4", + "colored", "log", ] @@ -3469,24 +3449,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "mockito" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80f9fece9bd97ab74339fe19f4bcaf52b76dcc18e5364c7977c1838f76b38de9" -dependencies = [ - "assert-json-diff", - "colored 2.1.0", - "httparse", - "lazy_static", - "log", - "rand 0.8.5", - "regex", - "serde_json", - "serde_urlencoded", - "similar", -] - [[package]] name = "muda" version = "0.13.1" @@ -5308,12 +5270,6 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27f6278552951f1f2b8cf9da965d10969b2efdea95a6ec47987ab46edfe263a" -[[package]] -name = "similar" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32fea41aca09ee824cc9724996433064c89f7777e60762749a4170a14abbfa21" - [[package]] name = "single-instance-example" version = "0.1.0" @@ -6460,8 +6416,8 @@ dependencies = [ "flate2", "futures-util", "http", + "infer", "minisign-verify", - "mockito", "reqwest", "semver", "serde", diff --git a/examples/api/src/views/Updater.svelte b/examples/api/src/views/Updater.svelte index 808e5868..819c65e0 100644 --- a/examples/api/src/views/Updater.svelte +++ b/examples/api/src/views/Updater.svelte @@ -12,8 +12,8 @@ isChecking = true; try { const update = await check(); - onMessage(`Should update: ${update.response.available}`); - onMessage(update.response); + onMessage(`Should update: ${update.available}`); + onMessage(update); newUpdate = update; } catch (e) { diff --git a/plugins/updater/Cargo.toml b/plugins/updater/Cargo.toml index ccbbb042..a18d8ce4 100644 --- a/plugins/updater/Cargo.toml +++ b/plugins/updater/Cargo.toml @@ -25,27 +25,33 @@ tokio = "1" reqwest = { version = "0.12", default-features = false, features = [ "json", "stream" ] } url = { workspace = true } http = "1" -dirs-next = "2" minisign-verify = "0.2" time = { version = "0.3", features = [ "parsing", "formatting" ] } base64 = "0.22" semver = { version = "1", features = [ "serde" ] } futures-util = "0.3" tempfile = "3" -zip = "0.6" +infer = "0.15" + +[target.'cfg(target_os = "windows")'.dependencies] +zip = { version = "0.6", optional = true } +windows-sys = { version = "0.52.0", features = [ + "Win32_Foundation", + "Win32_UI_WindowsAndMessaging", +] } -[target."cfg(target_os = \"windows\")".dependencies] -windows-sys = { version = "0.52.0", features = [ "Win32_Foundation", "Win32_UI_WindowsAndMessaging" ] } +[target.'cfg(target_os = "linux")'.dependencies] +dirs-next = "2" +tar = { version = "0.4", optional = true } +flate2 = { version = "1", optional = true } -[target."cfg(any(target_os = \"macos\", target_os = \"linux\"))".dependencies] +[target.'cfg(target_os = "macos")'.dependencies] tar = "0.4" flate2 = "1" -[dev-dependencies] -mockito = "0.31" - [features] -default = [ "rustls-tls" ] -native-tls = [ "reqwest/native-tls" ] -native-tls-vendored = [ "reqwest/native-tls-vendored" ] -rustls-tls = [ "reqwest/rustls-tls" ] +default = ["rustls-tls", "zip"] +zip = ["dep:zip", "dep:tar", "dep:flate2"] +native-tls = ["reqwest/native-tls"] +native-tls-vendored = ["reqwest/native-tls-vendored"] +rustls-tls = ["reqwest/rustls-tls"] diff --git a/plugins/updater/src/error.rs b/plugins/updater/src/error.rs index 43c0d2cb..2bfadc3f 100644 --- a/plugins/updater/src/error.rs +++ b/plugins/updater/src/error.rs @@ -54,6 +54,7 @@ pub enum Error { /// UTF8 Errors in signature. #[error("The signature {0} could not be decoded, please check if it is a valid base64 string. The signature must be the contents of the `.sig` file generated by the Tauri bundler, as a string.")] SignatureUtf8(String), + #[cfg(all(target_os = "windows", feature = "zip"))] /// `zip` errors. #[error(transparent)] Extract(#[from] zip::result::ZipError), @@ -62,6 +63,8 @@ pub enum Error { TempDirNotOnSameMountPoint, #[error("binary for the current target not found in the archive")] BinaryNotFoundInArchive, + #[error("invalid updater binary format")] + InvalidUpdaterFormat, #[error(transparent)] Http(#[from] http::Error), #[error(transparent)] diff --git a/plugins/updater/src/updater.rs b/plugins/updater/src/updater.rs index 27749bc3..916a04d8 100644 --- a/plugins/updater/src/updater.rs +++ b/plugins/updater/src/updater.rs @@ -475,9 +475,8 @@ impl Update { let mut stream = response.bytes_stream(); while let Some(chunk) = stream.next().await { let chunk = chunk?; - let bytes = chunk.as_ref().to_vec(); - on_chunk(bytes.len(), content_length); - buffer.extend(bytes); + on_chunk(chunk.len(), content_length); + buffer.extend(chunk); } on_download_finish(); @@ -508,114 +507,167 @@ impl Update { fn install_inner(&self, _bytes: Vec) -> Result<()> { Ok(()) } +} + +#[cfg(windows)] +enum WindowsUpdaterType { + Nsis, + Msi, +} - // Windows - // - // ### Expected structure: - // ├── [AppName]_[version]_x64.msi.zip # ZIP generated by tauri-bundler - // │ └──[AppName]_[version]_x64.msi # Application MSI - // ├── [AppName]_[version]_x64-setup.exe.zip # ZIP generated by tauri-bundler - // │ └──[AppName]_[version]_x64-setup.exe # NSIS installer - // └── ... - // - // ## MSI - // Update server can provide a MSI for Windows. (Generated with tauri-bundler from *Wix*) - // To replace current version of the application. In later version we'll offer - // incremental update to push specific binaries. - // - // ## EXE - // Update server can provide a custom EXE (installer) who can run any task. - #[cfg(windows)] +#[cfg(windows)] +impl WindowsUpdaterType { + fn extension(&self) -> &str { + match self { + WindowsUpdaterType::Nsis => ".exe", + WindowsUpdaterType::Msi => ".msi", + } + } +} + +#[cfg(windows)] +impl Config { + fn install_mode(&self) -> crate::config::WindowsUpdateInstallMode { + self.windows + .as_ref() + .map(|w| w.install_mode.clone()) + .unwrap_or_default() + } +} + +/// Windows +#[cfg(windows)] +impl Update { + /// ### Expected structure: + /// ├── [AppName]_[version]_x64.msi # Application MSI + /// ├── [AppName]_[version]_x64-setup.exe # NSIS installer + /// ├── [AppName]_[version]_x64.msi.zip # ZIP generated by tauri-bundler + /// │ └──[AppName]_[version]_x64.msi # Application MSI + /// ├── [AppName]_[version]_x64-setup.exe.zip # ZIP generated by tauri-bundler + /// │ └──[AppName]_[version]_x64-setup.exe # NSIS installer + /// └── ... fn install_inner(&self, bytes: Vec) -> Result<()> { - use std::fs; use windows_sys::{ w, Win32::UI::{Shell::ShellExecuteW, WindowsAndMessaging::SW_SHOW}, }; - // FIXME: We need to create a memory buffer with the MSI and then run it. - // (instead of extracting the MSI to a temp path) - // - // The tricky part is the MSI need to be exposed and spawned so the memory allocation - // shouldn't drop but we should be able to pass the reference so we can drop it once the installation - // is done, otherwise we have a huge memory leak. + let (updater_type, path, _temp) = Self::extract(&bytes)?; - let tmp_dir = tempfile::Builder::new().tempdir()?.into_path(); - let archive = Cursor::new(bytes); - let mut extractor = zip::ZipArchive::new(archive)?; - extractor.extract(&tmp_dir)?; + let install_mode = self.config.install_mode(); + let mut installer_args = self.installer_args(); + match updater_type { + WindowsUpdaterType::Nsis => { + installer_args.extend(install_mode.nsis_args().iter().map(OsStr::new)); + } + WindowsUpdaterType::Msi => { + installer_args.extend(install_mode.msiexec_args().iter().map(OsStr::new)); + installer_args.push(OsStr::new("/promptrestart")); + } + }; - let paths = fs::read_dir(&tmp_dir)?; + if let Some(on_before_exit) = self.on_before_exit.as_ref() { + on_before_exit(); + } - let install_mode = self - .config - .windows - .as_ref() - .map(|w| w.install_mode.clone()) - .unwrap_or_default(); - let mut installer_args = self - .installer_args + let file = encode_wide(path); + let parameters = encode_wide(installer_args.join(OsStr::new(" "))); + unsafe { + ShellExecuteW( + 0, + w!("open"), + file.as_ptr(), + parameters.as_ptr(), + std::ptr::null(), + SW_SHOW, + ) + }; + + std::process::exit(0); + } + + fn installer_args(&self) -> Vec<&OsStr> { + self.installer_args .iter() .map(OsStr::new) - .collect::>(); + .collect::>() + } + + fn extract(bytes: &[u8]) -> Result<(WindowsUpdaterType, PathBuf, Option)> { + #[cfg(feature = "zip")] + if infer::archive::is_zip(bytes) { + return Self::extract_zip(bytes); + } + + Self::extract_exe(bytes) + } + + #[cfg(feature = "zip")] + fn extract_zip( + bytes: &[u8], + ) -> Result<(WindowsUpdaterType, PathBuf, Option)> { + let tmp_dir = tempfile::Builder::new().tempdir()?.into_path(); + + let archive = Cursor::new(bytes); + let mut extractor = zip::ZipArchive::new(archive)?; + extractor.extract(&tmp_dir)?; + let paths = std::fs::read_dir(&tmp_dir)?; for path in paths { let found_path = path?.path(); - // we support 2 type of files exe & msi for now - // If it's an `exe` we expect an NSIS installer. - if found_path.extension() == Some(OsStr::new("exe")) { - installer_args.extend(install_mode.nsis_args().iter().map(OsStr::new)); - } else if found_path.extension() == Some(OsStr::new("msi")) { - installer_args.extend(install_mode.msiexec_args().iter().map(OsStr::new)); - installer_args.push(OsStr::new("/promptrestart")); - } else { - continue; + let ext = found_path.extension(); + if ext == Some(OsStr::new("exe")) { + return Ok((WindowsUpdaterType::Nsis, found_path, None)); + } else if ext == Some(OsStr::new("msi")) { + return Ok((WindowsUpdaterType::Msi, found_path, None)); } + } - if let Some(on_before_exit) = self.on_before_exit.as_ref() { - on_before_exit(); - } + Err(crate::Error::BinaryNotFoundInArchive) + } - let file = encode_wide(found_path.as_os_str()); - let parameters = encode_wide(installer_args.join(OsStr::new(" ")).as_os_str()); - unsafe { - ShellExecuteW( - 0, - w!("open"), - file.as_ptr(), - parameters.as_ptr(), - std::ptr::null(), - SW_SHOW, - ) - }; + fn extract_exe( + bytes: &[u8], + ) -> Result<(WindowsUpdaterType, PathBuf, Option)> { + use std::io::Write; - std::process::exit(0); - } + let updater_type = if infer::app::is_exe(bytes) { + WindowsUpdaterType::Nsis + } else if infer::archive::is_msi(bytes) { + WindowsUpdaterType::Msi + } else { + return Err(crate::Error::InvalidUpdaterFormat); + }; - Ok(()) + let ext = updater_type.extension(); + + let mut temp_file = tempfile::Builder::new().suffix(ext).tempfile()?; + temp_file.write_all(bytes)?; + let temp_path = temp_file.into_temp_path(); + + Ok((updater_type, temp_path.to_path_buf(), Some(temp_path))) } +} - // Linux (AppImage) - // - // ### Expected structure: - // ├── [AppName]_[version]_amd64.AppImage.tar.gz # GZ generated by tauri-bundler - // │ └──[AppName]_[version]_amd64.AppImage # Application AppImage - // └── ... - // - // We should have an AppImage already installed to be able to copy and install - // the extract_path is the current AppImage path - // tmp_dir is where our new AppImage is found - #[cfg(any( - target_os = "linux", - target_os = "dragonfly", - target_os = "freebsd", - target_os = "netbsd", - target_os = "openbsd" - ))] +/// Linux (AppImage) +#[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" +))] +impl Update { + /// ### Expected structure: + /// ├── [AppName]_[version]_amd64.AppImage.tar.gz # GZ generated by tauri-bundler + /// │ └──[AppName]_[version]_amd64.AppImage # Application AppImage + /// └── ... + /// + /// We should have an AppImage already installed to be able to copy and install + /// the extract_path is the current AppImage path + /// tmp_dir is where our new AppImage is found fn install_inner(&self, bytes: Vec) -> Result<()> { - use flate2::read::GzDecoder; use std::os::unix::fs::{MetadataExt, PermissionsExt}; - let archive = Cursor::new(bytes); let extract_path_metadata = self.extract_path.metadata()?; let tmp_dir_locations = vec![ @@ -641,43 +693,56 @@ impl Update { // create a backup of our current app image std::fs::rename(&self.extract_path, tmp_app_image)?; - // extract the buffer to the tmp_dir - // we extract our signed archive into our final directory without any temp file - let decoder = GzDecoder::new(archive); - let mut archive = tar::Archive::new(decoder); - for mut entry in archive.entries()?.flatten() { - if let Ok(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) = entry.unpack(&self.extract_path) { - std::fs::rename(tmp_app_image, &self.extract_path)?; - return Err(err.into()); + #[cfg(feature = "zip")] + if infer::archive::is_gz(&bytes) { + // extract the buffer to the tmp_dir + // we extract our signed archive into our final directory without any temp file + let archive = Cursor::new(bytes); + let decoder = flate2::read::GzDecoder::new(archive); + let mut archive = tar::Archive::new(decoder); + for mut entry in archive.entries()?.flatten() { + if let Ok(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) = entry.unpack(&self.extract_path) { + std::fs::rename(tmp_app_image, &self.extract_path)?; + return Err(err.into()); + } + // early finish we have everything we need here + return Ok(()); } - // early finish we have everything we need here - return Ok(()); } } + // if we have not returned early we should restore the backup + std::fs::rename(tmp_app_image, &self.extract_path)?; + return Err(Error::BinaryNotFoundInArchive); } - // if we have not returned early we should restore the backup - std::fs::rename(tmp_app_image, &self.extract_path)?; - return Err(Error::BinaryNotFoundInArchive); + return match std::fs::write(&self.extract_path, bytes) { + Err(err) => { + // if something went wrong during the extraction, we should restore previous app + std::fs::rename(tmp_app_image, &self.extract_path)?; + Err(err.into()) + } + Ok(_) => Ok(()), + }; } } } Err(Error::TempDirNotOnSameMountPoint) } +} - // MacOS - // - // ### Expected structure: - // ├── [AppName]_[version]_x64.app.tar.gz # GZ generated by tauri-bundler - // │ └──[AppName].app # Main application - // │ └── Contents # Application contents... - // │ └── ... - // └── ... - #[cfg(target_os = "macos")] +/// MacOS +#[cfg(target_os = "macos")] +impl Update { + /// ### Expected structure: + /// ├── [AppName]_[version]_x64.app.tar.gz # GZ generated by tauri-bundler + /// │ └──[AppName].app # Main application + /// │ └── Contents # Application contents... + /// │ └── ... + /// └── ... fn install_inner(&self, bytes: Vec) -> Result<()> { use flate2::read::GzDecoder; @@ -889,7 +954,7 @@ fn base64_to_string(base64_string: &str) -> Result { Ok(result) } -#[cfg(target_os = "windows")] +#[cfg(windows)] fn encode_wide(string: impl AsRef) -> Vec { use std::os::windows::ffi::OsStrExt;