// Copyright 2019-2021 Tauri Programme within The Commons Conservancy // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT use super::error::{Error, Result}; use crate::api::{ file::{ArchiveFormat, Extract, Move}, http::{ClientBuilder, HttpRequestBuilder}, version, }; use base64::decode; use http::StatusCode; use minisign_verify::{PublicKey, Signature}; use tauri_utils::{platform::current_exe, Env}; use std::{ collections::HashMap, env, io::{Cursor, Read, Seek}, path::{Path, PathBuf}, str::from_utf8, }; #[cfg(not(target_os = "macos"))] use std::ffi::OsStr; #[cfg(not(target_os = "windows"))] use crate::api::file::Compression; #[cfg(target_os = "windows")] use std::{ fs::read_dir, process::{exit, Command}, }; #[derive(Debug)] pub struct RemoteRelease { /// Version to install pub version: String, /// Release date pub date: String, /// Download URL for current platform pub download_url: String, /// Update short description pub body: Option, /// Optional signature for the current platform pub signature: Option, #[cfg(target_os = "windows")] /// Optional: Windows only try to use elevated task pub with_elevated_task: bool, } impl RemoteRelease { // Read JSON and confirm this is a valid Schema fn from_release(release: &serde_json::Value, target: &str) -> Result { // Version or name is required for static and dynamic JSON // if `version` is not announced, we fallback to `name` (can be the tag name example v1.0.0) let version = match release.get("version") { Some(version) => version .as_str() .ok_or_else(|| { Error::RemoteMetadata("Unable to extract `version` from remote server".into()) })? .trim_start_matches('v') .to_string(), None => release .get("name") .ok_or_else(|| Error::RemoteMetadata("Release missing `name` and `version`".into()))? .as_str() .ok_or_else(|| { Error::RemoteMetadata("Unable to extract `name` from remote server`".into()) })? .trim_start_matches('v') .to_string(), }; // pub_date is required default is: `N/A` if not provided by the remote JSON let date = release .get("pub_date") .and_then(|v| v.as_str()) .unwrap_or("N/A") .to_string(); // body is optional to build our update let body = release .get("notes") .map(|notes| notes.as_str().unwrap_or("").to_string()); // signature is optional to build our update let mut signature = release .get("signature") .map(|signature| signature.as_str().unwrap_or("").to_string()); let download_url; #[cfg(target_os = "windows")] let with_elevated_task; match release.get("platforms") { // // Did we have a platforms field? // If we did, that mean it's a static JSON. // The main difference with STATIC and DYNAMIC is static announce ALL platforms // and dynamic announce only the current platform. // // This could be used if you do NOT want an update server and use // a GIST, S3 or any static JSON file to announce your updates. // // Notes: // Dynamic help to reduce bandwidth usage or to intelligently update your clients // based on the request you give. The server can remotely drive behaviors like // rolling back or phased rollouts. // Some(platforms) => { // make sure we have our target available if let Some(current_target_data) = platforms.get(target) { // use provided signature if available signature = current_target_data .get("signature") .map(|found_signature| found_signature.as_str().unwrap_or("").to_string()); // Download URL is required download_url = current_target_data .get("url") .ok_or_else(|| Error::RemoteMetadata("Release missing `url`".into()))? .as_str() .ok_or_else(|| { Error::RemoteMetadata("Unable to extract `url` from remote server`".into()) })? .to_string(); #[cfg(target_os = "windows")] { with_elevated_task = current_target_data .get("with_elevated_task") .and_then(|v| v.as_bool()) .unwrap_or_default(); } } else { // make sure we have an available platform from the static return Err(Error::RemoteMetadata("Platform not available".into())); } } // We don't have the `platforms` field announced, let's assume our // download URL is at the root of the JSON. None => { download_url = release .get("url") .ok_or_else(|| Error::RemoteMetadata("Release missing `url`".into()))? .as_str() .ok_or_else(|| { Error::RemoteMetadata("Unable to extract `url` from remote server`".into()) })? .to_string(); #[cfg(target_os = "windows")] { with_elevated_task = match release.get("with_elevated_task") { Some(with_elevated_task) => with_elevated_task.as_bool().unwrap_or(false), None => false, }; } } } // Return our formatted release Ok(RemoteRelease { version, date, download_url, body, signature, #[cfg(target_os = "windows")] with_elevated_task, }) } } #[derive(Debug)] pub struct UpdateBuilder<'a> { /// Environment information. pub env: Env, /// Current version we are running to compare with announced version pub current_version: &'a str, /// The URLs to checks updates. We suggest at least one fallback on a different domain. pub urls: Vec, /// The platform the updater will check and install the update. Default is from `get_updater_target` pub target: Option, /// The current executable path. Default is automatically extracted. pub executable_path: Option, } // Create new updater instance and return an Update impl<'a> UpdateBuilder<'a> { pub fn new(env: Env) -> Self { UpdateBuilder { env, urls: Vec::new(), target: None, executable_path: None, current_version: env!("CARGO_PKG_VERSION"), } } #[allow(dead_code)] pub fn url(mut self, url: String) -> Self { self.urls.push( percent_encoding::percent_decode(url.as_bytes()) .decode_utf8_lossy() .to_string(), ); self } /// Add multiple URLS at once inside a Vec for future reference pub fn urls(mut self, urls: &[String]) -> Self { let mut formatted_vec: Vec = Vec::new(); for url in urls { formatted_vec.push( percent_encoding::percent_decode(url.as_bytes()) .decode_utf8_lossy() .to_string(), ); } self.urls = formatted_vec; self } /// Set the current app version, used to compare against the latest available version. /// The `cargo_crate_version!` macro can be used to pull the version from your `Cargo.toml` pub fn current_version(mut self, ver: &'a str) -> Self { self.current_version = ver; self } /// Set the target (os) /// win32, win64, darwin and linux are currently supported #[allow(dead_code)] pub fn target(mut self, target: &str) -> Self { self.target = Some(target.to_owned()); self } /// Set the executable path #[allow(dead_code)] pub fn executable_path>(mut self, executable_path: A) -> Self { self.executable_path = Some(PathBuf::from(executable_path.as_ref())); self } pub async fn build(self) -> Result { let mut remote_release: Option = None; // make sure we have at least one url if self.urls.is_empty() { return Err(Error::Builder( "Unable to check update, `url` is required.".into(), )); }; // set current version if not set let current_version = self.current_version; // If no executable path provided, we use current_exe from tauri_utils let executable_path = self.executable_path.unwrap_or(current_exe()?); // Did the target is provided by the config? // Should be: linux, darwin, win32 or win64 let target = self .target .or_else(get_updater_target) .ok_or(Error::UnsupportedPlatform)?; // Get the extract_path from the provided executable_path let extract_path = extract_path_from_executable(&self.env, &executable_path); // Set SSL certs for linux if they aren't available. // We do not require to recheck in the download_and_install as we use // ENV variables, we can expect them to be set for the second call. #[cfg(target_os = "linux")] { if env::var_os("SSL_CERT_FILE").is_none() { env::set_var("SSL_CERT_FILE", "/etc/ssl/certs/ca-certificates.crt"); } if env::var_os("SSL_CERT_DIR").is_none() { env::set_var("SSL_CERT_DIR", "/etc/ssl/certs"); } } // Allow fallback if more than 1 urls is provided let mut last_error: Option = None; for url in &self.urls { // replace {{current_version}} and {{target}} in the provided URL // this is usefull if we need to query example // https://releases.myapp.com/update/{{target}}/{{current_version}} // will be transleted into -> // https://releases.myapp.com/update/darwin/1.0.0 // The main objective is if the update URL is defined via the Cargo.toml // the URL will be generated dynamicly let fixed_link = str::replace( &str::replace(url, "{{current_version}}", current_version), "{{target}}", &target, ); // we want JSON only let mut headers = HashMap::new(); headers.insert("Accept".into(), "application/json".into()); let resp = ClientBuilder::new() .build()? .send( HttpRequestBuilder::new("GET", &fixed_link)? .headers(headers) // wait 20sec for the firewall .timeout(20), ) .await; // If we got a success, we stop the loop // and we set our remote_release variable if let Ok(res) = resp { let res = res.read().await?; // got status code 2XX if StatusCode::from_u16(res.status) .map_err(|e| Error::Builder(e.to_string()))? .is_success() { // if we got 204 if StatusCode::NO_CONTENT.as_u16() == res.status { // return with `UpToDate` error // we should catch on the client return Err(Error::UpToDate); }; // Convert the remote result to our local struct let built_release = RemoteRelease::from_release(&res.data, &target); // make sure all went well and the remote data is compatible // with what we need locally match built_release { Ok(release) => { last_error = None; remote_release = Some(release); break; } Err(err) => last_error = Some(err), } } // if status code is not 2XX we keep loopin' our urls } } // Last error is cleaned on success -- shouldn't be triggered if // we have a successful call if let Some(error) = last_error { return Err(Error::Network(error.to_string())); } // Extracted remote metadata let final_release = remote_release.ok_or_else(|| { Error::RemoteMetadata("Unable to extract update metadata from the remote server.".into()) })?; // did the announced version is greated than our current one? let should_update = version::is_greater(current_version, &final_release.version).unwrap_or(false); // create our new updater Ok(Update { env: self.env, target, extract_path, should_update, version: final_release.version, date: final_release.date, current_version: self.current_version.to_owned(), download_url: final_release.download_url, body: final_release.body, signature: final_release.signature, #[cfg(target_os = "windows")] with_elevated_task: final_release.with_elevated_task, }) } } pub fn builder<'a>(env: Env) -> UpdateBuilder<'a> { UpdateBuilder::new(env) } #[derive(Debug, Clone)] pub struct Update { /// Environment information. pub env: Env, /// Update description pub body: Option, /// Should we update or not pub should_update: bool, /// Version announced pub version: String, /// Running version pub current_version: String, /// Update publish date pub date: String, /// Target #[allow(dead_code)] target: String, /// Extract path extract_path: PathBuf, /// Download URL announced download_url: String, /// Signature announced signature: Option, #[cfg(target_os = "windows")] /// Optional: Windows only try to use elevated task /// Default to false with_elevated_task: bool, } impl Update { // Download and install our update // @todo(lemarier): Split into download and install (two step) but need to be thread safe pub async fn download_and_install(&self, pub_key: String) -> Result { // download url for selected release let url = self.download_url.as_str(); // extract path let extract_path = &self.extract_path; // make sure we can install the update on linux // We fail here because later we can add more linux support // actually if we use APPIMAGE, our extract path should already // be set with our APPIMAGE env variable, we don't need to do // anythin with it yet #[cfg(target_os = "linux")] if self.env.appimage.is_none() { return Err(Error::UnsupportedPlatform); } // set our headers let mut headers = HashMap::new(); headers.insert("Accept".into(), "application/octet-stream".into()); headers.insert("User-Agent".into(), "tauri/updater".into()); // Create our request let resp = ClientBuilder::new() .build()? .send( HttpRequestBuilder::new("GET", url)? .headers(headers) // wait 20sec for the firewall .timeout(20), ) .await? .bytes() .await?; // make sure it's success if !StatusCode::from_u16(resp.status) .map_err(|e| Error::Network(e.to_string()))? .is_success() { return Err(Error::Network(format!( "Download request failed with status: {}", resp.status ))); } // create memory buffer from our archive (Seek + Read) let mut archive_buffer = Cursor::new(resp.data); // We need an announced signature by the server // if there is no signature, bail out. if let Some(signature) = &self.signature { // we make sure the archive is valid and signed with the private key linked with the publickey verify_signature(&mut archive_buffer, signature, &pub_key)?; } else { // We have a public key inside our source file, but not announced by the server, // we assume this update is NOT valid. return Err(Error::MissingUpdaterSignature); } // we copy the files depending of the operating system // we run the setup, appimage re-install or overwrite the // macos .app #[cfg(target_os = "windows")] copy_files_and_run(archive_buffer, extract_path, self.with_elevated_task)?; #[cfg(not(target_os = "windows"))] copy_files_and_run(archive_buffer, extract_path)?; // We are done! Ok(()) } } // 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(target_os = "linux")] fn copy_files_and_run(archive_buffer: R, extract_path: &Path) -> Result { let tmp_dir = tempfile::Builder::new() .prefix("tauri_current_app") .tempdir()?; let tmp_app_image = &tmp_dir.path().join("current_app.AppImage"); // create a backup of our current app image Move::from_source(extract_path).to_dest(tmp_app_image)?; // extract the buffer to the tmp_dir // we extract our signed archive into our final directory without any temp file 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")) { // if something went wrong during the extraction, we should restore previous app if let Err(err) = extractor.extract_file(extract_path, &file) { Move::from_source(tmp_app_image).to_dest(extract_path)?; return Err(Error::Extract(err.to_string())); } // early finish we have everything we need here return Ok(()); } } Ok(()) } // Windows // ### Expected structure: // ├── [AppName]_[version]_x64.msi.zip # ZIP generated by tauri-bundler // │ └──[AppName]_[version]_x64.msi # Application MSI // └── ... // ## 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(target_os = "windows")] #[allow(clippy::unnecessary_wraps)] fn copy_files_and_run( archive_buffer: R, _extract_path: &Path, with_elevated_task: bool, ) -> Result { // 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 tmp_dir = tempfile::Builder::new().tempdir()?.into_path(); // extract the buffer to the tmp_dir // we extract our signed archive into our final directory without any temp file let mut extractor = Extract::from_cursor(archive_buffer, ArchiveFormat::Zip); // extract the msi extractor.extract_into(&tmp_dir)?; let paths = read_dir(&tmp_dir)?; // This consumes the TempDir without deleting directory on the filesystem, // meaning that the directory will no longer be automatically deleted. 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 installer not a runtime. if found_path.extension() == Some(OsStr::new("exe")) { // Run the EXE Command::new(found_path) .spawn() .expect("installer failed to start"); exit(0); } else if found_path.extension() == Some(OsStr::new("msi")) { if with_elevated_task { if let Some(bin_name) = current_exe() .ok() .and_then(|pb| pb.file_name().map(|s| s.to_os_string())) .and_then(|s| s.into_string().ok()) { let product_name = bin_name.replace(".exe", ""); // Check if there is a task that enables the updater to skip the UAC prompt let update_task_name = format!("Update {} - Skip UAC", product_name); if let Ok(status) = Command::new("schtasks") .arg("/QUERY") .arg("/TN") .arg(update_task_name.clone()) .status() { if status.success() { // Rename the MSI to the match file name the Skip UAC task is expecting it to be let temp_msi = tmp_dir.with_file_name(bin_name).with_extension("msi"); Move::from_source(&found_path) .to_dest(&temp_msi) .expect("Unable to move update MSI"); let exit_status = Command::new("schtasks") .arg("/RUN") .arg("/TN") .arg(update_task_name) .status() .expect("failed to start updater task"); if exit_status.success() { // Successfully launched task that skips the UAC prompt exit(0); } } // Failed to run update task. Following UAC Path } } } // restart should be handled by WIX as we exit the process Command::new("msiexec.exe") .arg("/i") .arg(found_path) // quiet basic UI with prompt at the end .arg("/qb+") .spawn() .expect("installer failed to start"); exit(0); } } Ok(()) } // 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")] fn copy_files_and_run(archive_buffer: R, extract_path: &Path) -> Result { let mut extracted_files: Vec = Vec::new(); // extract the buffer to the tmp_dir // we extract our signed archive into our final directory without any temp file let mut extractor = 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()?; // create backup of our current app Move::from_source(extract_path).to_dest(tmp_dir.path())?; // extract all the files for file in all_files { // skip the first folder (should be the app name) let collected_path: PathBuf = file.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 { // delete all the files we extracted if file.is_dir() { std::fs::remove_dir(file)?; } else { std::fs::remove_file(file)?; } } Move::from_source(tmp_dir.path()).to_dest(extract_path)?; return Err(Error::Extract(err.to_string())); } extracted_files.push(extraction_path); } Ok(()) } /// Returns a target os /// We do not use a helper function like the target_triple /// from tauri-utils because this function return `None` if /// the updater do not support the platform. /// /// Available target: `linux, darwin, win32, win64` pub fn get_updater_target() -> Option { if cfg!(target_os = "linux") { Some("linux".into()) } else if cfg!(target_os = "macos") { Some("darwin".into()) } else if cfg!(target_os = "windows") { if cfg!(target_pointer_width = "32") { Some("win32".into()) } else { Some("win64".into()) } } else { None } } /// Get the extract_path from the provided executable_path #[allow(unused_variables)] pub fn extract_path_from_executable(env: &Env, executable_path: &Path) -> PathBuf { // Return the path of the current executable by default // Example C:\Program Files\My App\ let extract_path = executable_path .parent() .map(PathBuf::from) .expect("Can't determine extract path"); // MacOS example binary is in /Applications/TestApp.app/Contents/MacOS/myApp // We need to get /Applications/.app // todo(lemarier): Need a better way here // Maybe we could search for <*.app> to get the right path #[cfg(target_os = "macos")] if extract_path .display() .to_string() .contains("Contents/MacOS") { return extract_path .parent() .map(PathBuf::from) .expect("Unable to find the extract path") .parent() .map(PathBuf::from) .expect("Unable to find the extract path"); } // We should use APPIMAGE exposed env variable // This is where our APPIMAGE should sit and should be replaced #[cfg(target_os = "linux")] if let Some(app_image_path) = &env.appimage { return PathBuf::from(app_image_path); } extract_path } // Convert base64 to string and prevent failing fn base64_to_string(base64_string: &str) -> Result { let decoded_string = &decode(base64_string)?; let result = from_utf8(decoded_string)?.to_string(); Ok(result) } // Validate signature // need to be public because its been used // by our tests in the bundler // // NOTE: The buffer position is not reset. pub fn verify_signature( archive_reader: &mut R, release_signature: &str, pub_key: &str, ) -> Result where R: Read, { // we need to convert the pub key let pub_key_decoded = base64_to_string(pub_key)?; let public_key = PublicKey::decode(&pub_key_decoded)?; let signature_base64_decoded = base64_to_string(release_signature)?; let signature = Signature::decode(&signature_base64_decoded)?; // read all bytes until EOF in the buffer let mut data = Vec::new(); archive_reader.read_to_end(&mut data)?; // Validate signature or bail out public_key.verify(&data, &signature, true)?; Ok(true) } #[cfg(test)] mod test { use super::*; #[cfg(target_os = "macos")] use std::fs::File; macro_rules! block { ($e:expr) => { tokio_test::block_on($e) }; } fn generate_sample_raw_json() -> String { r#"{ "version": "v2.0.0", "notes": "Test version !", "pub_date": "2020-06-22T19:25:57Z", "platforms": { "darwin": { "signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUldUTE5QWWxkQnlZOVJZVGdpKzJmRWZ0SkRvWS9TdFpqTU9xcm1mUmJSSG5OWVlwSklrWkN1SFpWbmh4SDlBcTU3SXpjbm0xMmRjRkphbkpVeGhGcTdrdzlrWGpGVWZQSWdzPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNTkyOTE1MDU3CWZpbGU6L1VzZXJzL3J1bm5lci9ydW5uZXJzLzIuMjYzLjAvd29yay90YXVyaS90YXVyaS90YXVyaS9leGFtcGxlcy9jb21tdW5pY2F0aW9uL3NyYy10YXVyaS90YXJnZXQvZGVidWcvYnVuZGxlL29zeC9hcHAuYXBwLnRhci5negp4ZHFlUkJTVnpGUXdDdEhydTE5TGgvRlVPeVhjTnM5RHdmaGx3c0ZPWjZXWnFwVDRNWEFSbUJTZ1ZkU1IwckJGdmlwSzJPd00zZEZFN2hJOFUvL1FDZz09Cg==", "url": "https://github.com/lemarier/tauri-test/releases/download/v1.0.0/app.app.tar.gz" }, "linux": { "signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUldUTE5QWWxkQnlZOWZSM29hTFNmUEdXMHRoOC81WDFFVVFRaXdWOUdXUUdwT0NlMldqdXkyaWVieXpoUmdZeXBJaXRqSm1YVmczNXdRL1Brc0tHb1NOTzhrL1hadFcxdmdnPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNTkyOTE3MzQzCWZpbGU6L2hvbWUvcnVubmVyL3dvcmsvdGF1cmkvdGF1cmkvdGF1cmkvZXhhbXBsZXMvY29tbXVuaWNhdGlvbi9zcmMtdGF1cmkvdGFyZ2V0L2RlYnVnL2J1bmRsZS9hcHBpbWFnZS9hcHAuQXBwSW1hZ2UudGFyLmd6CmRUTUM2bWxnbEtTbUhOZGtERUtaZnpUMG5qbVo5TGhtZWE1SFNWMk5OOENaVEZHcnAvVW0zc1A2ajJEbWZUbU0yalRHT0FYYjJNVTVHOHdTQlYwQkF3PT0K", "url": "https://github.com/lemarier/tauri-test/releases/download/v1.0.0/app.AppImage.tar.gz" }, "win64": { "signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUldUTE5QWWxkQnlZOVJHMWlvTzRUSlQzTHJOMm5waWpic0p0VVI2R0hUNGxhQVMxdzBPRndlbGpXQXJJakpTN0toRURtVzBkcm15R0VaNTJuS1lZRWdzMzZsWlNKUVAzZGdJPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNTkyOTE1NTIzCWZpbGU6RDpcYVx0YXVyaVx0YXVyaVx0YXVyaVxleGFtcGxlc1xjb21tdW5pY2F0aW9uXHNyYy10YXVyaVx0YXJnZXRcZGVidWdcYXBwLng2NC5tc2kuemlwCitXa1lQc3A2MCs1KzEwZnVhOGxyZ2dGMlZqbjBaVUplWEltYUdyZ255eUF6eVF1dldWZzFObStaVEQ3QU1RS1lzcjhDVU4wWFovQ1p1QjJXbW1YZUJ3PT0K", "url": "https://github.com/lemarier/tauri-test/releases/download/v1.0.0/app.x64.msi.zip" } } }"#.into() } fn generate_sample_platform_json( version: &str, public_signature: &str, download_url: &str, ) -> String { format!( r#" {{ "name": "v{}", "notes": "This is the latest version! Once updated you shouldn't see this prompt.", "pub_date": "2020-06-25T14:14:19Z", "signature": "{}", "url": "{}" }} "#, version, public_signature, download_url ) } fn generate_sample_with_elevated_task_platform_json( version: &str, public_signature: &str, download_url: &str, with_elevated_task: bool, ) -> String { format!( r#" {{ "name": "v{}", "notes": "This is the latest version! Once updated you shouldn't see this prompt.", "pub_date": "2020-06-25T14:14:19Z", "signature": "{}", "url": "{}", "with_elevated_task": "{}" }} "#, version, public_signature, download_url, with_elevated_task ) } fn generate_sample_bad_json() -> String { r#"{ "version": "v0.0.3", "notes": "Blablaa", "date": "2020-02-20T15:41:00Z", "download_link": "https://github.com/lemarier/tauri-test/releases/download/v0.0.1/update3.tar.gz" }"#.into() } #[test] fn simple_http_updater() { let _m = mockito::mock("GET", "/") .with_status(200) .with_header("content-type", "application/json") .with_body(generate_sample_raw_json()) .create(); let check_update = block!(builder(Default::default()) .current_version("0.0.0") .url(mockito::server_url()) .build()); assert!(check_update.is_ok()); let updater = check_update.expect("Can't check update"); assert!(updater.should_update); } #[test] fn simple_http_updater_raw_json() { let _m = mockito::mock("GET", "/") .with_status(200) .with_header("content-type", "application/json") .with_body(generate_sample_raw_json()) .create(); let check_update = block!(builder(Default::default()) .current_version("0.0.0") .url(mockito::server_url()) .build()); assert!(check_update.is_ok()); let updater = check_update.expect("Can't check update"); assert!(updater.should_update); } #[test] fn simple_http_updater_raw_json_win64() { let _m = mockito::mock("GET", "/") .with_status(200) .with_header("content-type", "application/json") .with_body(generate_sample_raw_json()) .create(); let check_update = block!(builder(Default::default()) .current_version("0.0.0") .target("win64") .url(mockito::server_url()) .build()); assert!(check_update.is_ok()); let updater = check_update.expect("Can't check update"); assert!(updater.should_update); assert_eq!(updater.version, "2.0.0"); assert_eq!(updater.signature, Some("dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUldUTE5QWWxkQnlZOVJHMWlvTzRUSlQzTHJOMm5waWpic0p0VVI2R0hUNGxhQVMxdzBPRndlbGpXQXJJakpTN0toRURtVzBkcm15R0VaNTJuS1lZRWdzMzZsWlNKUVAzZGdJPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNTkyOTE1NTIzCWZpbGU6RDpcYVx0YXVyaVx0YXVyaVx0YXVyaVxleGFtcGxlc1xjb21tdW5pY2F0aW9uXHNyYy10YXVyaVx0YXJnZXRcZGVidWdcYXBwLng2NC5tc2kuemlwCitXa1lQc3A2MCs1KzEwZnVhOGxyZ2dGMlZqbjBaVUplWEltYUdyZ255eUF6eVF1dldWZzFObStaVEQ3QU1RS1lzcjhDVU4wWFovQ1p1QjJXbW1YZUJ3PT0K".into())); assert_eq!( updater.download_url, "https://github.com/lemarier/tauri-test/releases/download/v1.0.0/app.x64.msi.zip" ); } #[test] fn simple_http_updater_raw_json_uptodate() { let _m = mockito::mock("GET", "/") .with_status(200) .with_header("content-type", "application/json") .with_body(generate_sample_raw_json()) .create(); let check_update = block!(builder(Default::default()) .current_version("10.0.0") .url(mockito::server_url()) .build()); assert!(check_update.is_ok()); let updater = check_update.expect("Can't check update"); assert!(!updater.should_update); } #[test] fn simple_http_updater_without_version() { let _m = mockito::mock("GET", "/darwin/1.0.0") .with_status(200) .with_header("content-type", "application/json") .with_body(generate_sample_platform_json( "2.0.0", "SampleTauriKey", "https://tauri.studio", )) .create(); let check_update = block!(builder(Default::default()) .current_version("1.0.0") .url(format!( "{}/darwin/{{{{current_version}}}}", mockito::server_url() )) .build()); assert!(check_update.is_ok()); let updater = check_update.expect("Can't check update"); assert!(updater.should_update); } #[test] fn simple_http_updater_percent_decode() { let _m = mockito::mock("GET", "/darwin/1.0.0") .with_status(200) .with_header("content-type", "application/json") .with_body(generate_sample_platform_json( "2.0.0", "SampleTauriKey", "https://tauri.studio", )) .create(); let check_update = block!(builder(Default::default()) .current_version("1.0.0") .url( url::Url::parse(&format!( "{}/darwin/{{{{current_version}}}}", mockito::server_url() )) .unwrap() .to_string() ) .build()); assert!(check_update.is_ok()); let updater = check_update.expect("Can't check update"); assert!(updater.should_update); let check_update = block!(builder(Default::default()) .current_version("1.0.0") .urls(&[url::Url::parse(&format!( "{}/darwin/{{{{current_version}}}}", mockito::server_url() )) .unwrap() .to_string()]) .build()); assert!(check_update.is_ok()); let updater = check_update.expect("Can't check update"); assert!(updater.should_update); } #[test] fn simple_http_updater_with_elevated_task() { let _m = mockito::mock("GET", "/win64/1.0.0") .with_status(200) .with_header("content-type", "application/json") .with_body(generate_sample_with_elevated_task_platform_json( "2.0.0", "SampleTauriKey", "https://tauri.studio", true, )) .create(); let check_update = block!(builder(Default::default()) .current_version("1.0.0") .url(format!( "{}/win64/{{{{current_version}}}}", mockito::server_url() )) .build()); assert!(check_update.is_ok()); let updater = check_update.expect("Can't check update"); assert!(updater.should_update); } #[test] fn http_updater_uptodate() { let _m = mockito::mock("GET", "/darwin/10.0.0") .with_status(200) .with_header("content-type", "application/json") .with_body(generate_sample_platform_json( "2.0.0", "SampleTauriKey", "https://tauri.studio", )) .create(); let check_update = block!(builder(Default::default()) .current_version("10.0.0") .url(format!( "{}/darwin/{{{{current_version}}}}", mockito::server_url() )) .build()); assert!(check_update.is_ok()); let updater = check_update.expect("Can't check update"); assert!(!updater.should_update); } #[test] fn http_updater_fallback_urls() { let _m = mockito::mock("GET", "/") .with_status(200) .with_header("content-type", "application/json") .with_body(generate_sample_raw_json()) .create(); let check_update = block!(builder(Default::default()) .url("http://badurl.www.tld/1".into()) .url(mockito::server_url()) .current_version("0.0.1") .build()); assert!(check_update.is_ok()); let updater = check_update.expect("Can't check remote update"); assert!(updater.should_update); } #[test] fn http_updater_fallback_urls_withs_array() { let _m = mockito::mock("GET", "/") .with_status(200) .with_header("content-type", "application/json") .with_body(generate_sample_raw_json()) .create(); let check_update = block!(builder(Default::default()) .urls(&["http://badurl.www.tld/1".into(), mockito::server_url(),]) .current_version("0.0.1") .build()); assert!(check_update.is_ok()); let updater = check_update.expect("Can't check remote update"); assert!(updater.should_update); } #[test] fn http_updater_missing_remote_data() { let _m = mockito::mock("GET", "/") .with_status(200) .with_header("content-type", "application/json") .with_body(generate_sample_bad_json()) .create(); let check_update = block!(builder(Default::default()) .url(mockito::server_url()) .current_version("0.0.1") .build()); assert!(check_update.is_err()); } // run complete process on mac only for now as we don't have // server (api) that we can use to test #[test] #[cfg(target_os = "macos")] fn http_updater_complete_process() { #[cfg(target_os = "macos")] let archive_file = "archive.macos.tar.gz"; #[cfg(target_os = "linux")] let archive_file = "archive.linux.tar.gz"; #[cfg(target_os = "windows")] let archive_file = "archive.windows.zip"; let good_archive_url = format!("{}/{}", mockito::server_url(), archive_file); let mut signature_file = File::open(format!( "./test/updater/fixture/archives/{}.sig", archive_file )) .expect("Unable to open signature"); let mut signature = String::new(); signature_file .read_to_string(&mut signature) .expect("Unable to read signature as string"); let mut pubkey_file = File::open("./test/updater/fixture/good_signature/update.key.pub") .expect("Unable to open pubkey"); let mut pubkey = String::new(); pubkey_file .read_to_string(&mut pubkey) .expect("Unable to read signature as string"); // add sample file let _m = mockito::mock("GET", format!("/{}", archive_file).as_str()) .with_status(200) .with_header("content-type", "application/octet-stream") .with_body_from_file(format!("./test/updater/fixture/archives/{}", archive_file)) .create(); // sample mock for update file let _m = mockito::mock("GET", "/") .with_status(200) .with_header("content-type", "application/json") .with_body(generate_sample_platform_json( "2.0.1", signature.as_ref(), good_archive_url.as_ref(), )) .create(); // Build a tmpdir so we can test our extraction inside // We dont want to overwrite our current executable or the directory // Otherwise tests are failing... let executable_path = current_exe().expect("Can't extract executable path"); let parent_path = executable_path .parent() .expect("Can't find the parent path"); let tmp_dir = tempfile::Builder::new() .prefix("tauri_updater_test") .tempdir_in(parent_path); assert!(tmp_dir.is_ok()); let tmp_dir_unwrap = tmp_dir.expect("Can't find tmp_dir"); let tmp_dir_path = tmp_dir_unwrap.path(); #[cfg(target_os = "linux")] let my_executable = &tmp_dir_path.join("updater-example_0.1.0_amd64.AppImage"); #[cfg(target_os = "macos")] let my_executable = &tmp_dir_path.join("my_app"); #[cfg(target_os = "windows")] let my_executable = &tmp_dir_path.join("my_app.exe"); // configure the updater let check_update = block!(builder(Default::default()) .url(mockito::server_url()) // It should represent the executable path, that's why we add my_app.exe in our // test path -- in production you shouldn't have to provide it .executable_path(my_executable) // make sure we force an update .current_version("1.0.0") .build()); #[cfg(target_os = "linux")] { env::set_var("APPIMAGE", my_executable); } // make sure the process worked assert!(check_update.is_ok()); // unwrap our results let updater = check_update.expect("Can't check remote update"); // make sure we need to update assert!(updater.should_update); // make sure we can read announced version assert_eq!(updater.version, "2.0.1"); // download, install and validate signature let install_process = block!(updater.download_and_install(pubkey)); assert!(install_process.is_ok()); // make sure the extraction went well (it should have skipped the main app.app folder) // as we can't extract in /Applications directly #[cfg(target_os = "macos")] let bin_file = tmp_dir_path.join("Contents").join("MacOS").join("app"); #[cfg(target_os = "linux")] // linux should extract at same place as the executable path let bin_file = my_executable; #[cfg(target_os = "windows")] let bin_file = tmp_dir_path.join("with").join("long").join("path.json"); assert!(bin_file.exists()); } }