// Copyright 2019-2023 Tauri Programme within The Commons Conservancy // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT use std::{ collections::HashMap, io::{Cursor, Read}, path::{Path, PathBuf}, str::FromStr, time::Duration, }; use base64::Engine; use futures_util::StreamExt; use http::HeaderName; use minisign_verify::{PublicKey, Signature}; use reqwest::{ header::{HeaderMap, HeaderValue}, Client, StatusCode, }; use semver::Version; use serde::{de::Error as DeError, Deserialize, Deserializer, Serialize}; use tauri::utils::{config::UpdaterConfig, platform::current_exe}; use time::OffsetDateTime; use url::Url; use crate::error::{Error, Result}; #[derive(Debug, Deserialize, Serialize, Clone)] pub struct ReleaseManifestPlatform { /// Download URL for the platform pub url: Url, /// Signature for the platform pub signature: String, } #[derive(Debug, Deserialize, Serialize, Clone)] #[serde(untagged)] pub enum RemoteReleaseInner { Dynamic(ReleaseManifestPlatform), Static { platforms: HashMap, }, } /// Information about a release returned by the remote update server. /// /// This type can have one of two shapes: Server Format (Dynamic Format) and Static Format. #[derive(Debug, Clone)] pub struct RemoteRelease { /// Version to install. pub version: Version, /// Release notes. pub notes: Option, /// Release date. pub pub_date: Option, /// Release data. pub data: RemoteReleaseInner, } impl RemoteRelease { /// The release's download URL for the given target. pub fn download_url(&self, target: &str) -> Result<&Url> { match self.data { RemoteReleaseInner::Dynamic(ref platform) => Ok(&platform.url), RemoteReleaseInner::Static { ref platforms } => platforms .get(target) .map_or(Err(Error::TargetNotFound(target.to_string())), |p| { Ok(&p.url) }), } } /// The release's signature for the given target. pub fn signature(&self, target: &str) -> Result<&String> { match self.data { RemoteReleaseInner::Dynamic(ref platform) => Ok(&platform.signature), RemoteReleaseInner::Static { ref platforms } => platforms .get(target) .map_or(Err(Error::TargetNotFound(target.to_string())), |platform| { Ok(&platform.signature) }), } } } pub struct UpdaterBuilder { updater: Updater, target: Option, executable_path: Option, app_image_path: Option, } impl UpdaterBuilder { pub fn new( current_version: Version, config: crate::Config, updater_config: UpdaterConfig, ) -> Self { Self { updater: Updater::new(current_version, config, updater_config), target: Default::default(), executable_path: Default::default(), app_image_path: Default::default(), } } pub fn version_comparator bool + Send + Sync + 'static>( mut self, f: F, ) -> Self { self.updater.version_comparator = Some(Box::new(f)); self } pub fn target(mut self, target: impl Into) -> Self { self.target = Some(target.into()); self } pub fn endpoints(mut self, endpoints: Vec) -> Self { self.updater.endpoints = endpoints; self } pub fn executable_path>(mut self, p: P) -> Self { self.executable_path = Some(PathBuf::from(p.as_ref())); self } pub fn header(mut self, key: K, value: V) -> Result where HeaderName: TryFrom, >::Error: Into, HeaderValue: TryFrom, >::Error: Into, { let key: std::result::Result = key.try_into().map_err(Into::into); let value: std::result::Result = value.try_into().map_err(Into::into); self.updater.headers_map.insert(key?, value?); Ok(self) } pub fn timeout(mut self, timeout: Duration) -> Self { self.updater.timeout = Some(timeout); self } pub fn app_image_path>(mut self, p: P) -> Self { self.app_image_path = Some(PathBuf::from(p.as_ref())); self } pub fn installer_args(mut self, args: I) -> Self where I: IntoIterator, S: Into, { self.updater.config.installer_args = args.into_iter().map(Into::into).collect(); self } pub fn build(mut self) -> Result { if self.updater.endpoints.is_empty() { return Err(Error::EmptyEndpoints); }; let executable_path = self.executable_path.clone().unwrap_or(current_exe()?); let arch = get_updater_arch().ok_or(Error::UnsupportedArch)?; // `target` is the `{{target}}` variable we replace in the endpoint // `json_target` is the value we search if the updater server returns a JSON with the `platforms` object (self.updater.target, self.updater.json_target) = match self.target { Some(target) => (target.clone(), target.clone()), None => { let target = get_updater_target().ok_or(Error::UnsupportedOs)?; (target.to_string(), format!("{target}-{arch}")) } }; // Get the extract_path from the provided executable_path self.updater.extract_path = extract_path_from_executable(&executable_path, self.app_image_path)?; Ok(self.updater) } } pub struct Updater { current_version: Version, version_comparator: Option bool + Send + Sync>>, timeout: Option, endpoints: Vec, arch: String, target: String, json_target: String, headers_map: HeaderMap, extract_path: PathBuf, config: crate::Config, updater_config: UpdaterConfig, } impl Updater { fn new(current_version: Version, config: crate::Config, updater_config: UpdaterConfig) -> Self { Self { current_version, version_comparator: Default::default(), timeout: Default::default(), endpoints: Default::default(), arch: Default::default(), target: Default::default(), headers_map: Default::default(), extract_path: Default::default(), json_target: Default::default(), config, updater_config, } } pub async fn check(&self) -> Result> { // we want JSON only let mut headers = self.headers_map.clone(); headers.insert("Accept", HeaderValue::from_str("application/json").unwrap()); // Set SSL certs for linux if they aren't available. #[cfg(target_os = "linux")] { if std::env::var_os("SSL_CERT_FILE").is_none() { std::env::set_var("SSL_CERT_FILE", "/etc/ssl/certs/ca-certificates.crt"); } if std::env::var_os("SSL_CERT_DIR").is_none() { std::env::set_var("SSL_CERT_DIR", "/etc/ssl/certs"); } } let mut remote_release: Option = None; let mut last_error: Option = None; for url in &self.endpoints { // replace {{current_version}}, {{target}} and {{arch}} in the provided URL // this is useful if we need to query example // https://releases.myapp.com/update/{{target}}/{{arch}}/{{current_version}} // will be translated into -> // https://releases.myapp.com/update/darwin/aarch64/1.0.0 // The main objective is if the update URL is defined via the Cargo.toml // the URL will be generated dynamically let url: Url = url .to_string() .replace("{{current_version}}", &self.current_version.to_string()) .replace("{{target}}", &self.target) .replace("{{arch}}", &self.arch) .parse()?; let mut request = Client::new().get(url).headers(headers.clone()); if let Some(timeout) = self.timeout { request = request.timeout(timeout); } let response = request.send().await; if let Ok(res) = response { if res.status().is_success() { // no updates found! if StatusCode::NO_CONTENT == res.status() { return Ok(None); }; match serde_json::from_value::(res.json().await?) .map_err(Into::into) { Ok(release) => { last_error = None; remote_release = Some(release); // we found a relase, break the loop break; } Err(err) => last_error = Some(err), } } } } // Last error is cleaned on success. // Shouldn't be triggered if we had a successfull call if let Some(error) = last_error { return Err(error); } // Extracted remote metadata let release = remote_release.ok_or(Error::ReleaseNotFound)?; let should_update = match self.version_comparator.as_ref().take() { Some(comparator) => comparator(self.current_version.clone(), release.clone()), None => release.version > self.current_version, }; let update = if should_update { Some(Update { config: self.config.clone(), current_version: self.current_version.to_string(), updater_config: self.updater_config.clone(), target: self.target.clone(), extract_path: self.extract_path.clone(), version: release.version.to_string(), date: release.pub_date.clone(), download_url: release.download_url(&self.json_target)?.to_owned(), body: release.notes.clone(), signature: release.signature(&self.json_target)?.to_owned(), timeout: self.timeout, headers: self.headers_map.clone(), }) } else { None }; Ok(update) } } #[derive(Debug, Clone)] pub struct Update { #[allow(unused)] config: crate::Config, updater_config: UpdaterConfig, /// Update description pub body: Option, /// Version used to check for update pub current_version: String, /// Version announced pub version: String, /// Update publish date pub date: Option, /// Target pub target: String, /// Extract path #[allow(unused)] extract_path: PathBuf, /// Download URL announced pub download_url: Url, /// Signature announced pub signature: String, /// Request timeout pub timeout: Option, /// Request headers pub headers: HeaderMap, } impl Update { /// Downloads the updater package, verifies it then return it as bytes. /// /// Use [`Update::install`] to install it pub async fn download), D: FnOnce()>( &self, on_chunk: C, on_download_finish: D, ) -> Result> { // set our headers let mut headers = self.headers.clone(); headers.insert( "Accept", HeaderValue::from_str("application/octet-stream").unwrap(), ); headers.insert( "User-Agent", HeaderValue::from_str("tauri-updater").unwrap(), ); let mut request = Client::new() .get(self.download_url.clone()) .headers(headers); if let Some(timeout) = self.timeout { request = request.timeout(timeout); } let response = request.send().await?; if !response.status().is_success() { return Err(Error::Network(format!( "Download request failed with status: {}", response.status() ))); } let content_length: Option = response .headers() .get("Content-Length") .and_then(|value| value.to_str().ok()) .and_then(|value| value.parse().ok()); let mut buffer = Vec::new(); 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_download_finish(); let mut update_buffer = Cursor::new(&buffer); verify_signature( &mut update_buffer, &self.signature, &self.updater_config.pubkey, )?; Ok(buffer) } /// Installs the updater package downloaded by [`Update::download`] pub fn install(&self, bytes: Vec) -> Result<()> { self.install_inner(bytes) } /// Downloads and installs the updater package pub async fn download_and_install), D: FnOnce()>( &self, on_chunk: C, on_download_finish: D, ) -> Result<()> { let bytes = self.download(on_chunk, on_download_finish).await?; self.install(bytes) } // 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)] fn install_inner(&self, bytes: Vec) -> Result<()> { use std::{ffi::OsStr, fs, process::Command}; // 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 archive = Cursor::new(bytes); 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 = zip::ZipArchive::new(archive)?; // extract the msi extractor.extract(&tmp_dir)?; let paths = 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 installer not a runtime. if found_path.extension() == Some(OsStr::new("exe")) { // Run the EXE Command::new(found_path) .args(self.updater_config.windows.install_mode.nsis_args()) .args(&self.config.installer_args) .spawn() .expect("installer failed to start"); std::process::exit(0); } else if found_path.extension() == Some(OsStr::new("msi")) { // we need to wrap the current exe path in quotes for Start-Process let mut current_exe_arg = std::ffi::OsString::new(); current_exe_arg.push("\""); current_exe_arg.push(current_exe()?); current_exe_arg.push("\""); let mut msi_path_arg = std::ffi::OsString::new(); msi_path_arg.push("\"\"\""); msi_path_arg.push(&found_path); msi_path_arg.push("\"\"\""); let msiexec_args = self .updater_config .windows .install_mode .msiexec_args() .iter() .map(|p| p.to_string()) .collect::>(); // run the installer and relaunch the application let system_root = std::env::var("SYSTEMROOT"); let powershell_path = system_root.as_ref().map_or_else( |_| "powershell.exe".to_string(), |p| format!("{p}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"), ); let powershell_install_res = Command::new(powershell_path) .args(["-NoProfile", "-windowstyle", "hidden"]) .args([ "Start-Process", "-Wait", "-FilePath", "$env:SYSTEMROOT\\System32\\msiexec.exe", "-ArgumentList", ]) .arg("/i,") .arg(msi_path_arg) .arg(format!(", {}, /promptrestart;", msiexec_args.join(", "))) .arg("Start-Process") .arg(current_exe_arg) .spawn(); if powershell_install_res.is_err() { // fallback to running msiexec directly - relaunch won't be available // we use this here in case powershell fails in an older machine somehow let msiexec_path = system_root.as_ref().map_or_else( |_| "msiexec.exe".to_string(), |p| format!("{p}\\System32\\msiexec.exe"), ); let _ = Command::new(msiexec_path) .arg("/i") .arg(found_path) .args(msiexec_args) .arg("/promptrestart") .spawn(); } std::process::exit(0); } } 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(any( target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "netbsd", target_os = "openbsd" ))] fn install_inner(&self, bytes: Vec) -> Result<()> { use std::{ ffi::OsStr, os::unix::fs::{MetadataExt, PermissionsExt}, }; let archive = Cursor::new(bytes); let extract_path_metadata = self.extract_path.metadata()?; let tmp_dir_locations = vec![ Box::new(|| Some(std::env::temp_dir())) as Box Option>, Box::new(dirs_next::cache_dir), Box::new(|| Some(self.extract_path.parent().unwrap().to_path_buf())), ]; for tmp_dir_location in tmp_dir_locations { if let Some(tmp_dir_location) = tmp_dir_location() { let tmp_dir = tempfile::Builder::new() .prefix("tauri_current_app") .tempdir_in(tmp_dir_location)?; let tmp_dir_metadata = tmp_dir.path().metadata()?; if extract_path_metadata.dev() == tmp_dir_metadata.dev() { let mut perms = tmp_dir_metadata.permissions(); perms.set_mode(0o700); std::fs::set_permissions(tmp_dir.path(), perms)?; let tmp_app_image = &tmp_dir.path().join("current_app.AppImage"); // 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 mut archive = tar::Archive::new(archive.clone()); 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(()); } } } } } } 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")] fn install_inner(&self, bytes: Vec) -> Result<()> { let archive = Cursor::new(bytes); let mut extracted_files: Vec = Vec::new(); // the first file in the tar.gz will always be // /Contents let tmp_dir = tempfile::Builder::new() .prefix("tauri_current_app") .tempdir()?; // create backup of our current app std::fs::rename(&self.extract_path, tmp_dir.path())?; let mut archive = tar::Archive::new(archive); for mut entry in archive.entries()?.flatten() { if let Ok(path) = entry.path() { // skip the first folder (should be the app name) let collected_path: PathBuf = path.iter().skip(1).collect(); let extraction_path = &self.extract_path.join(collected_path); // if something went wrong during the extraction, we should restore previous app if let Err(err) = entry.unpack(&extraction_path) { 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)?; } } std::fs::rename(tmp_dir.path(), &self.extract_path)?; return Err(err.into()); } extracted_files.push(extraction_path); } } let _ = std::process::Command::new("touch") .arg(&self.extract_path) .status(); Ok(()) } } /// Gets the target string used on the updater. pub fn target() -> Option { if let (Some(target), Some(arch)) = (get_updater_target(), get_updater_arch()) { Some(format!("{target}-{arch}")) } else { None } } pub(crate) fn get_updater_target() -> Option<&'static str> { if cfg!(target_os = "linux") { Some("linux") } else if cfg!(target_os = "macos") { // TODO shouldn't this be macos instead? Some("darwin") } else if cfg!(target_os = "windows") { Some("windows") } else { None } } pub(crate) fn get_updater_arch() -> Option<&'static str> { if cfg!(target_arch = "x86") { Some("i686") } else if cfg!(target_arch = "x86_64") { Some("x86_64") } else if cfg!(target_arch = "arm") { Some("armv7") } else if cfg!(target_arch = "aarch64") { Some("aarch64") } else { None } } pub fn extract_path_from_executable( executable_path: &Path, _app_image_path: Option, ) -> Result { // Return the path of the current executable by default // Example C:\Program Files\My App\ let extract_path = executable_path .parent() .map(PathBuf::from) .ok_or(Error::FailedToDetermineExtractPath)?; // 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) .ok_or(Error::FailedToDetermineExtractPath)? .parent() .map(PathBuf::from) .ok_or(Error::FailedToDetermineExtractPath); } // 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) = _app_image_path { return Ok(PathBuf::from(app_image_path)); } Ok(extract_path) } impl<'de> Deserialize<'de> for RemoteRelease { fn deserialize(deserializer: D) -> std::result::Result where D: Deserializer<'de>, { #[derive(Deserialize)] struct InnerRemoteRelease { #[serde(alias = "name", deserialize_with = "parse_version")] version: Version, notes: Option, pub_date: Option, platforms: Option>, // dynamic platform response url: Option, signature: Option, } let release = InnerRemoteRelease::deserialize(deserializer)?; let pub_date = if let Some(date) = release.pub_date { Some( OffsetDateTime::parse(&date, &time::format_description::well_known::Rfc3339) .map_err(|e| DeError::custom(format!("invalid value for `pub_date`: {e}")))?, ) } else { None }; Ok(RemoteRelease { version: release.version, notes: release.notes, pub_date, data: if let Some(platforms) = release.platforms { RemoteReleaseInner::Static { platforms } } else { RemoteReleaseInner::Dynamic(ReleaseManifestPlatform { url: release.url.ok_or_else(|| { DeError::custom("the `url` field was not set on the updater response") })?, signature: release.signature.ok_or_else(|| { DeError::custom("the `signature` field was not set on the updater response") })?, }) }, }) } } fn parse_version<'de, D>(deserializer: D) -> std::result::Result where D: serde::Deserializer<'de>, { let str = String::deserialize(deserializer)?; Version::from_str(str.trim_start_matches('v')).map_err(serde::de::Error::custom) } // 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) } fn base64_to_string(base64_string: &str) -> Result { let decoded_string = &base64::engine::general_purpose::STANDARD.decode(base64_string)?; let result = std::str::from_utf8(decoded_string) .map_err(|_| Error::SignatureUtf8(base64_string.into()))? .to_string(); Ok(result) }