feat(updater): support non zipped updater (#1174)

pull/1267/head
Tony 1 year ago committed by GitHub
parent f39586bcb4
commit 1fa4d30eab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
"updater": "patch"
---
Add support for updating using non-zipped files on Windows and Linux.

48
Cargo.lock generated

@ -334,16 +334,6 @@ dependencies = [
"zbus", "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]] [[package]]
name = "async-broadcast" name = "async-broadcast"
version = "0.7.0" version = "0.7.0"
@ -1146,16 +1136,6 @@ dependencies = [
"winapi", "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]] [[package]]
name = "combine" name = "combine"
version = "4.6.6" version = "4.6.6"
@ -1893,7 +1873,7 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9f0c14694cbd524c8720dd69b0e3179344f04ebb5f90f2e4a440c6ea3b2f1ee" checksum = "d9f0c14694cbd524c8720dd69b0e3179344f04ebb5f90f2e4a440c6ea3b2f1ee"
dependencies = [ dependencies = [
"colored 1.9.4", "colored",
"log", "log",
] ]
@ -3469,24 +3449,6 @@ dependencies = [
"windows-sys 0.48.0", "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]] [[package]]
name = "muda" name = "muda"
version = "0.13.1" version = "0.13.1"
@ -5308,12 +5270,6 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27f6278552951f1f2b8cf9da965d10969b2efdea95a6ec47987ab46edfe263a" checksum = "f27f6278552951f1f2b8cf9da965d10969b2efdea95a6ec47987ab46edfe263a"
[[package]]
name = "similar"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32fea41aca09ee824cc9724996433064c89f7777e60762749a4170a14abbfa21"
[[package]] [[package]]
name = "single-instance-example" name = "single-instance-example"
version = "0.1.0" version = "0.1.0"
@ -6460,8 +6416,8 @@ dependencies = [
"flate2", "flate2",
"futures-util", "futures-util",
"http", "http",
"infer",
"minisign-verify", "minisign-verify",
"mockito",
"reqwest", "reqwest",
"semver", "semver",
"serde", "serde",

@ -12,8 +12,8 @@
isChecking = true; isChecking = true;
try { try {
const update = await check(); const update = await check();
onMessage(`Should update: ${update.response.available}`); onMessage(`Should update: ${update.available}`);
onMessage(update.response); onMessage(update);
newUpdate = update; newUpdate = update;
} catch (e) { } catch (e) {

@ -25,27 +25,33 @@ tokio = "1"
reqwest = { version = "0.12", default-features = false, features = [ "json", "stream" ] } reqwest = { version = "0.12", default-features = false, features = [ "json", "stream" ] }
url = { workspace = true } url = { workspace = true }
http = "1" http = "1"
dirs-next = "2"
minisign-verify = "0.2" minisign-verify = "0.2"
time = { version = "0.3", features = [ "parsing", "formatting" ] } time = { version = "0.3", features = [ "parsing", "formatting" ] }
base64 = "0.22" base64 = "0.22"
semver = { version = "1", features = [ "serde" ] } semver = { version = "1", features = [ "serde" ] }
futures-util = "0.3" futures-util = "0.3"
tempfile = "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] [target.'cfg(target_os = "linux")'.dependencies]
windows-sys = { version = "0.52.0", features = [ "Win32_Foundation", "Win32_UI_WindowsAndMessaging" ] } 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" tar = "0.4"
flate2 = "1" flate2 = "1"
[dev-dependencies]
mockito = "0.31"
[features] [features]
default = [ "rustls-tls" ] default = ["rustls-tls", "zip"]
native-tls = [ "reqwest/native-tls" ] zip = ["dep:zip", "dep:tar", "dep:flate2"]
native-tls-vendored = [ "reqwest/native-tls-vendored" ] native-tls = ["reqwest/native-tls"]
rustls-tls = [ "reqwest/rustls-tls" ] native-tls-vendored = ["reqwest/native-tls-vendored"]
rustls-tls = ["reqwest/rustls-tls"]

@ -54,6 +54,7 @@ pub enum Error {
/// UTF8 Errors in signature. /// 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.")] #[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), SignatureUtf8(String),
#[cfg(all(target_os = "windows", feature = "zip"))]
/// `zip` errors. /// `zip` errors.
#[error(transparent)] #[error(transparent)]
Extract(#[from] zip::result::ZipError), Extract(#[from] zip::result::ZipError),
@ -62,6 +63,8 @@ pub enum Error {
TempDirNotOnSameMountPoint, TempDirNotOnSameMountPoint,
#[error("binary for the current target not found in the archive")] #[error("binary for the current target not found in the archive")]
BinaryNotFoundInArchive, BinaryNotFoundInArchive,
#[error("invalid updater binary format")]
InvalidUpdaterFormat,
#[error(transparent)] #[error(transparent)]
Http(#[from] http::Error), Http(#[from] http::Error),
#[error(transparent)] #[error(transparent)]

@ -475,9 +475,8 @@ impl Update {
let mut stream = response.bytes_stream(); let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await { while let Some(chunk) = stream.next().await {
let chunk = chunk?; let chunk = chunk?;
let bytes = chunk.as_ref().to_vec(); on_chunk(chunk.len(), content_length);
on_chunk(bytes.len(), content_length); buffer.extend(chunk);
buffer.extend(bytes);
} }
on_download_finish(); on_download_finish();
@ -508,114 +507,167 @@ impl Update {
fn install_inner(&self, _bytes: Vec<u8>) -> Result<()> { fn install_inner(&self, _bytes: Vec<u8>) -> Result<()> {
Ok(()) Ok(())
} }
}
#[cfg(windows)]
enum WindowsUpdaterType {
Nsis,
Msi,
}
// Windows #[cfg(windows)]
// impl WindowsUpdaterType {
// ### Expected structure: fn extension(&self) -> &str {
// ├── [AppName]_[version]_x64.msi.zip # ZIP generated by tauri-bundler match self {
// │ └──[AppName]_[version]_x64.msi # Application MSI WindowsUpdaterType::Nsis => ".exe",
// ├── [AppName]_[version]_x64-setup.exe.zip # ZIP generated by tauri-bundler WindowsUpdaterType::Msi => ".msi",
// │ └──[AppName]_[version]_x64-setup.exe # NSIS installer }
// └── ... }
// }
// ## MSI
// Update server can provide a MSI for Windows. (Generated with tauri-bundler from *Wix*) #[cfg(windows)]
// To replace current version of the application. In later version we'll offer impl Config {
// incremental update to push specific binaries. fn install_mode(&self) -> crate::config::WindowsUpdateInstallMode {
// self.windows
// ## EXE .as_ref()
// Update server can provide a custom EXE (installer) who can run any task. .map(|w| w.install_mode.clone())
#[cfg(windows)] .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<u8>) -> Result<()> { fn install_inner(&self, bytes: Vec<u8>) -> Result<()> {
use std::fs;
use windows_sys::{ use windows_sys::{
w, w,
Win32::UI::{Shell::ShellExecuteW, WindowsAndMessaging::SW_SHOW}, Win32::UI::{Shell::ShellExecuteW, WindowsAndMessaging::SW_SHOW},
}; };
// FIXME: We need to create a memory buffer with the MSI and then run it. let (updater_type, path, _temp) = Self::extract(&bytes)?;
// (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 tmp_dir = tempfile::Builder::new().tempdir()?.into_path(); let install_mode = self.config.install_mode();
let archive = Cursor::new(bytes); let mut installer_args = self.installer_args();
let mut extractor = zip::ZipArchive::new(archive)?; match updater_type {
extractor.extract(&tmp_dir)?; 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 let file = encode_wide(path);
.config let parameters = encode_wide(installer_args.join(OsStr::new(" ")));
.windows unsafe {
.as_ref() ShellExecuteW(
.map(|w| w.install_mode.clone()) 0,
.unwrap_or_default(); w!("open"),
let mut installer_args = self file.as_ptr(),
.installer_args parameters.as_ptr(),
std::ptr::null(),
SW_SHOW,
)
};
std::process::exit(0);
}
fn installer_args(&self) -> Vec<&OsStr> {
self.installer_args
.iter() .iter()
.map(OsStr::new) .map(OsStr::new)
.collect::<Vec<_>>(); .collect::<Vec<_>>()
}
fn extract(bytes: &[u8]) -> Result<(WindowsUpdaterType, PathBuf, Option<tempfile::TempPath>)> {
#[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<tempfile::TempPath>)> {
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 { for path in paths {
let found_path = path?.path(); let found_path = path?.path();
// we support 2 type of files exe & msi for now let ext = found_path.extension();
// If it's an `exe` we expect an NSIS installer. if ext == Some(OsStr::new("exe")) {
if found_path.extension() == Some(OsStr::new("exe")) { return Ok((WindowsUpdaterType::Nsis, found_path, None));
installer_args.extend(install_mode.nsis_args().iter().map(OsStr::new)); } else if ext == Some(OsStr::new("msi")) {
} else if found_path.extension() == Some(OsStr::new("msi")) { return Ok((WindowsUpdaterType::Msi, found_path, None));
installer_args.extend(install_mode.msiexec_args().iter().map(OsStr::new));
installer_args.push(OsStr::new("/promptrestart"));
} else {
continue;
} }
}
if let Some(on_before_exit) = self.on_before_exit.as_ref() { Err(crate::Error::BinaryNotFoundInArchive)
on_before_exit(); }
}
let file = encode_wide(found_path.as_os_str()); fn extract_exe(
let parameters = encode_wide(installer_args.join(OsStr::new(" ")).as_os_str()); bytes: &[u8],
unsafe { ) -> Result<(WindowsUpdaterType, PathBuf, Option<tempfile::TempPath>)> {
ShellExecuteW( use std::io::Write;
0,
w!("open"),
file.as_ptr(),
parameters.as_ptr(),
std::ptr::null(),
SW_SHOW,
)
};
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) /// Linux (AppImage)
// #[cfg(any(
// ### Expected structure: target_os = "linux",
// ├── [AppName]_[version]_amd64.AppImage.tar.gz # GZ generated by tauri-bundler target_os = "dragonfly",
// │ └──[AppName]_[version]_amd64.AppImage # Application AppImage target_os = "freebsd",
// └── ... target_os = "netbsd",
// target_os = "openbsd"
// We should have an AppImage already installed to be able to copy and install ))]
// the extract_path is the current AppImage path impl Update {
// tmp_dir is where our new AppImage is found /// ### Expected structure:
#[cfg(any( /// ├── [AppName]_[version]_amd64.AppImage.tar.gz # GZ generated by tauri-bundler
target_os = "linux", /// │ └──[AppName]_[version]_amd64.AppImage # Application AppImage
target_os = "dragonfly", /// └── ...
target_os = "freebsd", ///
target_os = "netbsd", /// We should have an AppImage already installed to be able to copy and install
target_os = "openbsd" /// the extract_path is the current AppImage path
))] /// tmp_dir is where our new AppImage is found
fn install_inner(&self, bytes: Vec<u8>) -> Result<()> { fn install_inner(&self, bytes: Vec<u8>) -> Result<()> {
use flate2::read::GzDecoder;
use std::os::unix::fs::{MetadataExt, PermissionsExt}; use std::os::unix::fs::{MetadataExt, PermissionsExt};
let archive = Cursor::new(bytes);
let extract_path_metadata = self.extract_path.metadata()?; let extract_path_metadata = self.extract_path.metadata()?;
let tmp_dir_locations = vec![ let tmp_dir_locations = vec![
@ -641,43 +693,56 @@ impl Update {
// create a backup of our current app image // create a backup of our current app image
std::fs::rename(&self.extract_path, tmp_app_image)?; std::fs::rename(&self.extract_path, tmp_app_image)?;
// extract the buffer to the tmp_dir #[cfg(feature = "zip")]
// we extract our signed archive into our final directory without any temp file if infer::archive::is_gz(&bytes) {
let decoder = GzDecoder::new(archive); // extract the buffer to the tmp_dir
let mut archive = tar::Archive::new(decoder); // we extract our signed archive into our final directory without any temp file
for mut entry in archive.entries()?.flatten() { let archive = Cursor::new(bytes);
if let Ok(path) = entry.path() { let decoder = flate2::read::GzDecoder::new(archive);
if path.extension() == Some(OsStr::new("AppImage")) { let mut archive = tar::Archive::new(decoder);
// if something went wrong during the extraction, we should restore previous app for mut entry in archive.entries()?.flatten() {
if let Err(err) = entry.unpack(&self.extract_path) { if let Ok(path) = entry.path() {
std::fs::rename(tmp_app_image, &self.extract_path)?; if path.extension() == Some(OsStr::new("AppImage")) {
return Err(err.into()); // 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) Err(Error::TempDirNotOnSameMountPoint)
} }
}
// MacOS /// MacOS
// #[cfg(target_os = "macos")]
// ### Expected structure: impl Update {
// ├── [AppName]_[version]_x64.app.tar.gz # GZ generated by tauri-bundler /// ### Expected structure:
// │ └──[AppName].app # Main application /// ├── [AppName]_[version]_x64.app.tar.gz # GZ generated by tauri-bundler
// │ └── Contents # Application contents... /// │ └──[AppName].app # Main application
// │ └── ... /// │ └── Contents # Application contents...
// └── ... /// │ └── ...
#[cfg(target_os = "macos")] /// └── ...
fn install_inner(&self, bytes: Vec<u8>) -> Result<()> { fn install_inner(&self, bytes: Vec<u8>) -> Result<()> {
use flate2::read::GzDecoder; use flate2::read::GzDecoder;
@ -889,7 +954,7 @@ fn base64_to_string(base64_string: &str) -> Result<String> {
Ok(result) Ok(result)
} }
#[cfg(target_os = "windows")] #[cfg(windows)]
fn encode_wide(string: impl AsRef<OsStr>) -> Vec<u16> { fn encode_wide(string: impl AsRef<OsStr>) -> Vec<u16> {
use std::os::windows::ffi::OsStrExt; use std::os::windows::ffi::OsStrExt;

Loading…
Cancel
Save