// Copyright 2019-2023 Tauri Programme within The Commons Conservancy // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT use std::{ collections::HashMap, ffi::{OsStr, OsString}, io::Cursor, path::{Path, PathBuf}, str::FromStr, sync::Arc, time::Duration, }; use base64::Engine; use futures_util::StreamExt; use http::HeaderName; use minisign_verify::{PublicKey, Signature}; use reqwest::{ header::{HeaderMap, HeaderValue}, ClientBuilder, StatusCode, }; use semver::Version; use serde::{de::Error as DeError, Deserialize, Deserializer, Serialize}; use tauri::{utils::platform::current_exe, Resource}; use time::OffsetDateTime; use url::Url; use crate::{ error::{Error, Result}, Config, }; #[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 type OnBeforeExit = Arc; pub struct UpdaterBuilder { current_version: Version, config: Config, version_comparator: Option bool + Send + Sync>>, executable_path: Option, target: Option, endpoints: Option>, headers: HeaderMap, timeout: Option, proxy: Option, installer_args: Vec, on_before_exit: Option, } impl UpdaterBuilder { pub fn new(current_version: Version, config: crate::Config) -> Self { Self { installer_args: config .windows .as_ref() .map(|w| w.installer_args.clone()) .unwrap_or_default(), current_version, config, version_comparator: None, executable_path: None, target: None, endpoints: None, headers: Default::default(), timeout: None, proxy: None, on_before_exit: None, } } pub fn version_comparator bool + Send + Sync + 'static>( mut self, f: F, ) -> Self { self.version_comparator = Some(Box::new(f)); self } pub fn target(mut self, target: impl Into) -> Self { self.target.replace(target.into()); self } pub fn endpoints(mut self, endpoints: Vec) -> Self { self.endpoints.replace(endpoints); self } pub fn executable_path>(mut self, p: P) -> Self { self.executable_path.replace(p.as_ref().into()); 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.headers.insert(key?, value?); Ok(self) } pub fn timeout(mut self, timeout: Duration) -> Self { self.timeout = Some(timeout); self } pub fn proxy(mut self, proxy: Url) -> Self { self.proxy.replace(proxy); self } pub fn pubkey>(mut self, pubkey: S) -> Self { self.config.pubkey = pubkey.into(); self } pub fn installer_arg(mut self, arg: S) -> Self where S: Into, { self.installer_args.push(arg.into()); self } pub fn installer_args(mut self, args: I) -> Self where I: IntoIterator, S: Into, { let args = args.into_iter().map(|a| a.into()).collect::>(); self.installer_args.extend_from_slice(&args); self } pub fn clear_installer_args(mut self) -> Self { self.installer_args.clear(); self } pub fn on_before_exit(mut self, f: F) -> Self { self.on_before_exit.replace(Arc::new(f)); self } pub fn build(self) -> Result { let endpoints = self .endpoints .unwrap_or_else(|| self.config.endpoints.iter().map(|e| e.0.clone()).collect()); if endpoints.is_empty() { return Err(Error::EmptyEndpoints); }; let arch = get_updater_arch().ok_or(Error::UnsupportedArch)?; let (target, json_target) = if let Some(target) = self.target { (target.clone(), target) } else { let target = get_updater_target().ok_or(Error::UnsupportedOs)?; (target.to_string(), format!("{target}-{arch}")) }; let executable_path = self.executable_path.clone().unwrap_or(current_exe()?); // Get the extract_path from the provided executable_path let extract_path = if cfg!(target_os = "linux") { executable_path } else { extract_path_from_executable(&executable_path)? }; Ok(Updater { config: self.config, current_version: self.current_version, version_comparator: self.version_comparator, timeout: self.timeout, proxy: self.proxy, endpoints, installer_args: self.installer_args, arch, target, json_target, headers: self.headers, extract_path, on_before_exit: self.on_before_exit, }) } } pub struct Updater { config: Config, current_version: Version, version_comparator: Option bool + Send + Sync>>, timeout: Option, proxy: Option, endpoints: Vec, #[allow(dead_code)] installer_args: Vec, arch: &'static str, // The `{{target}}` variable we replace in the endpoint target: String, // The value we search if the updater server returns a JSON with the `platforms` object json_target: String, headers: HeaderMap, extract_path: PathBuf, on_before_exit: Option, } impl Updater { pub async fn check(&self) -> Result> { // we want JSON only let mut headers = self.headers.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() // url::Url automatically url-encodes the path components .replace( "%7B%7Bcurrent_version%7D%7D", &self.current_version.to_string(), ) .replace("%7B%7Btarget%7D%7D", &self.target) .replace("%7B%7Barch%7D%7D", self.arch) // but not query parameters .replace("{{current_version}}", &self.current_version.to_string()) .replace("{{target}}", &self.target) .replace("{{arch}}", self.arch) .parse()?; let mut request = ClientBuilder::new(); if let Some(timeout) = self.timeout { request = request.timeout(timeout); } if let Some(ref proxy) = self.proxy { let proxy = reqwest::Proxy::all(proxy.as_str())?; request = request.proxy(proxy); } let response = request .build()? .get(url) .headers(headers.clone()) .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() { 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(), on_before_exit: self.on_before_exit.clone(), current_version: self.current_version.to_string(), target: self.target.clone(), extract_path: self.extract_path.clone(), installer_args: self.installer_args.clone(), version: release.version.to_string(), date: release.pub_date, 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, proxy: self.proxy.clone(), headers: self.headers.clone(), }) } else { None }; Ok(update) } } #[derive(Clone)] pub struct Update { config: Config, #[allow(unused)] on_before_exit: Option, /// 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, #[allow(unused)] installer_args: Vec, /// Download URL announced pub download_url: Url, /// Signature announced pub signature: String, /// Request timeout pub timeout: Option, /// Request proxy pub proxy: Option, /// Request headers pub headers: HeaderMap, } impl Resource for Update {} 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, mut 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 = ClientBuilder::new(); if let Some(timeout) = self.timeout { request = request.timeout(timeout); } if let Some(ref proxy) = self.proxy { let proxy = reqwest::Proxy::all(proxy.as_str())?; request = request.proxy(proxy); } let response = request .build()? .get(self.download_url.clone()) .headers(headers) .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?; on_chunk(chunk.len(), content_length); buffer.extend(chunk); } on_download_finish(); verify_signature(&buffer, &self.signature, &self.config.pubkey)?; Ok(buffer) } /// Installs the updater package downloaded by [`Update::download`] pub fn install(&self, bytes: impl AsRef<[u8]>) -> Result<()> { self.install_inner(bytes.as_ref()) } /// 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) } #[cfg(mobile)] fn install_inner(&self, _bytes: &[u8]) -> Result<()> { Ok(()) } } #[cfg(windows)] enum WindowsUpdaterType { Nsis, Msi, } #[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: &[u8]) -> Result<()> { use windows_sys::{ w, Win32::UI::{Shell::ShellExecuteW, WindowsAndMessaging::SW_SHOW}, }; let (updater_type, path, _temp) = Self::extract(bytes)?; 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)); installer_args.push(OsStr::new("/NS")); installer_args.push(OsStr::new("/UPDATE")); } WindowsUpdaterType::Msi => { installer_args.extend(install_mode.msiexec_args().iter().map(OsStr::new)); installer_args.push(OsStr::new("/promptrestart")); } }; if let Some(on_before_exit) = self.on_before_exit.as_ref() { on_before_exit(); } 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::>() } 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(); 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)); } } Err(crate::Error::BinaryNotFoundInArchive) } fn extract_exe( bytes: &[u8], ) -> Result<(WindowsUpdaterType, PathBuf, Option)> { use std::io::Write; 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); }; 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) #[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: &[u8]) -> Result<()> { use std::os::unix::fs::{MetadataExt, PermissionsExt}; 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)?; #[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(()); } } } // 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 #[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: &[u8]) -> Result<()> { use flate2::read::GzDecoder; let cursor = 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 decoder = GzDecoder::new(cursor); let mut archive = tar::Archive::new(decoder); std::fs::create_dir(&self.extract_path)?; for entry in archive.entries()? { let mut entry = entry?; // skip the first folder (should be the app name) let collected_path: PathBuf = entry.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.iter().rev() { // 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.to_path_buf()); } 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) -> 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); } 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 fn verify_signature(data: &[u8], release_signature: &str, pub_key: &str) -> Result { // 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)?; // 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) } #[cfg(windows)] fn encode_wide(string: impl AsRef) -> Vec { use std::os::windows::ffi::OsStrExt; string .as_ref() .encode_wide() .chain(std::iter::once(0)) .collect() }