// Copyright 2019-2023 Tauri Programme within The Commons Conservancy // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT use std::{ collections::HashMap, ffi::OsString, io::Cursor, path::{Path, PathBuf}, str::FromStr, sync::Arc, time::Duration, }; #[cfg(not(target_os = "macos"))] use std::ffi::OsStr; use base64::Engine; use futures_util::StreamExt; use http::HeaderName; use minisign_verify::{PublicKey, Signature}; use percent_encoding::{AsciiSet, CONTROLS}; 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, AppHandle, Resource, Runtime}; use time::OffsetDateTime; use url::Url; use crate::{ error::{Error, Result}, Config, }; const UPDATER_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),); #[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 type OnBeforeRequest = Arc ClientBuilder + Send + Sync + 'static>; pub type VersionComparator = Arc bool + Send + Sync>; type MainThreadClosure = Box; type RunOnMainThread = Box std::result::Result<(), tauri::Error> + Send + Sync + 'static>; pub struct UpdaterBuilder { #[allow(dead_code)] run_on_main_thread: RunOnMainThread, app_name: String, current_version: Version, config: Config, pub(crate) version_comparator: Option, executable_path: Option, target: Option, endpoints: Option>, headers: HeaderMap, timeout: Option, proxy: Option, installer_args: Vec, current_exe_args: Vec, on_before_exit: Option, configure_client: Option, } impl UpdaterBuilder { pub(crate) fn new(app: &AppHandle, config: crate::Config) -> Self { let app_ = app.clone(); let run_on_main_thread = move |f: Box| app_.run_on_main_thread(f); Self { run_on_main_thread: Box::new(run_on_main_thread), installer_args: config .windows .as_ref() .map(|w| w.installer_args.clone()) .unwrap_or_default(), current_exe_args: Vec::new(), app_name: app.package_info().name.clone(), current_version: app.package_info().version.clone(), config, version_comparator: None, executable_path: None, target: None, endpoints: None, headers: Default::default(), timeout: None, proxy: None, on_before_exit: None, configure_client: None, } } pub fn version_comparator bool + Send + Sync + 'static>( mut self, f: F, ) -> Self { self.version_comparator = Some(Arc::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) -> Result { crate::config::validate_endpoints( &endpoints, self.config.dangerous_insecure_transport_protocol, )?; self.endpoints.replace(endpoints); Ok(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 headers(mut self, headers: HeaderMap) -> Self { self.headers = headers; self } pub fn clear_headers(mut self) -> Self { self.headers.clear(); 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 } /// Allows you to modify the `reqwest` client builder before the HTTP request is sent. /// /// Note that `reqwest` crate may be updated in minor releases of tauri-plugin-updater. /// Therefore it's recommended to pin the plugin to at least a minor version when you're using `configure_client`. /// pub fn configure_client ClientBuilder + Send + Sync + 'static>( mut self, f: F, ) -> Self { self.configure_client.replace(Arc::new(f)); self } pub fn build(self) -> Result { let endpoints = self .endpoints .unwrap_or_else(|| self.config.endpoints.clone()); 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 { run_on_main_thread: Arc::new(self.run_on_main_thread), config: self.config, app_name: self.app_name, current_version: self.current_version, version_comparator: self.version_comparator, timeout: self.timeout, proxy: self.proxy, endpoints, installer_args: self.installer_args, current_exe_args: self.current_exe_args, arch, target, json_target, headers: self.headers, extract_path, on_before_exit: self.on_before_exit, configure_client: self.configure_client, }) } } impl UpdaterBuilder { pub(crate) fn current_exe_args(mut self, args: I) -> Self where I: IntoIterator, S: Into, { let args = args.into_iter().map(|a| a.into()).collect::>(); self.current_exe_args.extend_from_slice(&args); self } } pub struct Updater { #[allow(dead_code)] run_on_main_thread: Arc, config: Config, app_name: String, current_version: Version, version_comparator: Option, timeout: Option, proxy: Option, endpoints: 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, configure_client: Option, #[allow(unused)] installer_args: Vec, #[allow(unused)] current_exe_args: Vec, } 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 raw_json: 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 version = self.current_version.to_string(); let version = version.as_bytes(); const CONTROLS_ADD: &AsciiSet = &CONTROLS.add(b'+'); let encoded_version = percent_encoding::percent_encode(version, CONTROLS_ADD); let encoded_version = encoded_version.to_string(); let url: Url = url .to_string() // url::Url automatically url-encodes the path components .replace("%7B%7Bcurrent_version%7D%7D", &encoded_version) .replace("%7B%7Btarget%7D%7D", &self.target) .replace("%7B%7Barch%7D%7D", self.arch) // but not query parameters .replace("{{current_version}}", &encoded_version) .replace("{{target}}", &self.target) .replace("{{arch}}", self.arch) .parse()?; log::debug!("checking for updates {url}"); let mut request = ClientBuilder::new().user_agent(UPDATER_USER_AGENT); if let Some(timeout) = self.timeout { request = request.timeout(timeout); } if let Some(ref proxy) = self.proxy { log::debug!("using proxy {proxy}"); let proxy = reqwest::Proxy::all(proxy.as_str())?; request = request.proxy(proxy); } if let Some(ref configure_client) = self.configure_client { request = configure_client(request); } let response = request .build()? .get(url) .headers(headers.clone()) .send() .await; match response { Ok(res) => { if res.status().is_success() { // no updates found! if StatusCode::NO_CONTENT == res.status() { log::debug!("update endpoint returned 204 No Content"); return Ok(None); }; let update_response: serde_json::Value = res.json().await?; log::debug!("update response: {update_response:?}"); raw_json = Some(update_response.clone()); match serde_json::from_value::(update_response) .map_err(Into::into) { Ok(release) => { log::debug!("parsed release response {release:?}"); last_error = None; remote_release = Some(release); // we found a release, break the loop break; } Err(err) => { log::error!("failed to deserialize update response: {err}"); last_error = Some(err) } } } else { log::error!( "update endpoint did not respond with a successful status code" ); } } Err(err) => { log::error!("failed to check for updates: {err}"); last_error = Some(err.into()) } } } // 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 { run_on_main_thread: self.run_on_main_thread.clone(), config: self.config.clone(), on_before_exit: self.on_before_exit.clone(), app_name: self.app_name.clone(), current_version: self.current_version.to_string(), target: self.target.clone(), extract_path: self.extract_path.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(), raw_json: raw_json.unwrap(), timeout: self.timeout, proxy: self.proxy.clone(), headers: self.headers.clone(), installer_args: self.installer_args.clone(), current_exe_args: self.current_exe_args.clone(), configure_client: self.configure_client.clone(), }) } else { None }; Ok(update) } } #[derive(Clone)] pub struct Update { #[allow(dead_code)] run_on_main_thread: Arc, 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, /// Download URL announced pub download_url: Url, /// Signature announced pub signature: String, /// The raw version of server's JSON response. Useful if the response contains additional fields that the updater doesn't handle. pub raw_json: serde_json::Value, /// Request timeout pub timeout: Option, /// Request proxy pub proxy: Option, /// Request headers pub headers: HeaderMap, /// Extract path #[allow(unused)] extract_path: PathBuf, /// App name, used for creating named tempfiles on Windows #[allow(unused)] app_name: String, #[allow(unused)] installer_args: Vec, #[allow(unused)] current_exe_args: Vec, configure_client: Option, } 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(), ); let mut request = ClientBuilder::new().user_agent(UPDATER_USER_AGENT); 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); } if let Some(ref configure_client) = self.configure_client { request = configure_client(request); } 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 { path: PathBuf, #[allow(unused)] temp: Option, }, Msi { path: PathBuf, #[allow(unused)] temp: Option, }, } #[cfg(windows)] impl WindowsUpdaterType { fn nsis(path: PathBuf, temp: Option) -> Self { Self::Nsis { path, temp } } fn msi(path: PathBuf, temp: Option) -> Self { Self::Msi { path: path.wrap_in_quotes(), temp, } } } #[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 std::iter::once; use windows_sys::{ w, Win32::UI::{Shell::ShellExecuteW, WindowsAndMessaging::SW_SHOW}, }; let updater_type = self.extract(bytes)?; let install_mode = self.config.install_mode(); let current_args = &self.current_exe_args()[1..]; let msi_args; let installer_args: Vec<&OsStr> = match &updater_type { WindowsUpdaterType::Nsis { .. } => install_mode .nsis_args() .iter() .map(OsStr::new) .chain(once(OsStr::new("/UPDATE"))) .chain(once(OsStr::new("/ARGS"))) .chain(current_args.to_vec()) .chain(self.installer_args()) .collect(), WindowsUpdaterType::Msi { path, .. } => { let escaped_args = current_args .iter() .map(escape_msi_property_arg) .collect::>() .join(" "); msi_args = OsString::from(format!("LAUNCHAPPARGS=\"{escaped_args}\"")); [OsStr::new("/i"), path.as_os_str()] .into_iter() .chain(install_mode.msiexec_args().iter().map(OsStr::new)) .chain(once(OsStr::new("/promptrestart"))) .chain(self.installer_args()) .chain(once(OsStr::new("AUTOLAUNCHAPP=True"))) .chain(once(msi_args.as_os_str())) .collect() } }; if let Some(on_before_exit) = self.on_before_exit.as_ref() { log::debug!("running on_before_exit hook"); on_before_exit(); } let file = match &updater_type { WindowsUpdaterType::Nsis { path, .. } => path.as_os_str().to_os_string(), WindowsUpdaterType::Msi { .. } => std::env::var("SYSTEMROOT").as_ref().map_or_else( |_| OsString::from("msiexec.exe"), |p| OsString::from(format!("{p}\\System32\\msiexec.exe")), ), }; let file = encode_wide(file); let parameters = installer_args.join(OsStr::new(" ")); let parameters = encode_wide(parameters); unsafe { ShellExecuteW( std::ptr::null_mut(), 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 current_exe_args(&self) -> Vec<&OsStr> { self.current_exe_args .iter() .map(OsStr::new) .collect::>() } fn extract(&self, bytes: &[u8]) -> Result { #[cfg(feature = "zip")] if infer::archive::is_zip(bytes) { return self.extract_zip(bytes); } self.extract_exe(bytes) } fn make_temp_dir(&self) -> Result { Ok(tempfile::Builder::new() .prefix(&format!("{}-{}-updater-", self.app_name, self.version)) .tempdir()? .into_path()) } #[cfg(feature = "zip")] fn extract_zip(&self, bytes: &[u8]) -> Result { let temp_dir = self.make_temp_dir()?; let archive = Cursor::new(bytes); let mut extractor = zip::ZipArchive::new(archive)?; extractor.extract(&temp_dir)?; let paths = std::fs::read_dir(&temp_dir)?; for path in paths { let path = path?.path(); let ext = path.extension(); if ext == Some(OsStr::new("exe")) { return Ok(WindowsUpdaterType::nsis(path, None)); } else if ext == Some(OsStr::new("msi")) { return Ok(WindowsUpdaterType::msi(path, None)); } } Err(crate::Error::BinaryNotFoundInArchive) } fn extract_exe(&self, bytes: &[u8]) -> Result { if infer::app::is_exe(bytes) { let (path, temp) = self.write_to_temp(bytes, ".exe")?; Ok(WindowsUpdaterType::nsis(path, temp)) } else if infer::archive::is_msi(bytes) { let (path, temp) = self.write_to_temp(bytes, ".msi")?; Ok(WindowsUpdaterType::msi(path, temp)) } else { Err(crate::Error::InvalidUpdaterFormat) } } fn write_to_temp( &self, bytes: &[u8], ext: &str, ) -> Result<(PathBuf, Option)> { use std::io::Write; let temp_dir = self.make_temp_dir()?; let mut temp_file = tempfile::Builder::new() .prefix(&format!("{}-{}-installer", self.app_name, self.version)) .suffix(ext) .rand_bytes(0) .tempfile_in(temp_dir)?; temp_file.write_all(bytes)?; let temp = temp_file.into_temp_path(); Ok((temp.to_path_buf(), Some(temp))) } } /// Linux (AppImage and Deb) #[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 /// ├── [AppName]_[version]_amd64.deb # Debian package /// └── ... /// fn install_inner(&self, bytes: &[u8]) -> Result<()> { if self.is_deb_package() { self.install_deb(bytes) } else { // Handle AppImage or other formats self.install_appimage(bytes) } } fn install_appimage(&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::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"); let permissions = std::fs::metadata(&self.extract_path)?.permissions(); // 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) { log::debug!("extracting AppImage"); // 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); } log::debug!("rewriting AppImage"); return match std::fs::write(&self.extract_path, bytes) .and_then(|_| std::fs::set_permissions(&self.extract_path, permissions)) { 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) } fn is_deb_package(&self) -> bool { // First check if we're in a typical Debian installation path let in_system_path = self .extract_path .to_str() .map(|p| p.starts_with("/usr")) .unwrap_or(false); if !in_system_path { return false; } // Then verify it's actually a Debian-based system by checking for dpkg let dpkg_exists = std::path::Path::new("/var/lib/dpkg").exists(); let apt_exists = std::path::Path::new("/etc/apt").exists(); // Additional check for the package in dpkg database let package_in_dpkg = if let Ok(output) = std::process::Command::new("dpkg") .args(["-S", &self.extract_path.to_string_lossy()]) .output() { output.status.success() } else { false }; // Consider it a deb package only if: // 1. We're in a system path AND // 2. We have Debian package management tools AND // 3. The binary is tracked by dpkg dpkg_exists && apt_exists && package_in_dpkg } fn install_deb(&self, bytes: &[u8]) -> Result<()> { // First verify the bytes are actually a .deb package if !infer::archive::is_deb(bytes) { log::warn!("update is not a valid deb package"); return Err(Error::InvalidUpdaterFormat); } // Try different temp directories let tmp_dir_locations = vec![ Box::new(|| Some(std::env::temp_dir())) as Box Option>, Box::new(dirs::cache_dir), Box::new(|| Some(self.extract_path.parent().unwrap().to_path_buf())), ]; // Try writing to multiple temp locations until one succeeds for tmp_dir_location in tmp_dir_locations { if let Some(path) = tmp_dir_location() { if let Ok(tmp_dir) = tempfile::Builder::new() .prefix("tauri_deb_update") .tempdir_in(path) { let deb_path = tmp_dir.path().join("package.deb"); // Try writing the .deb file if std::fs::write(&deb_path, bytes).is_ok() { // If write succeeds, proceed with installation return self.try_install_with_privileges(&deb_path); } // If write fails, continue to next temp location } } } // If we get here, all temp locations failed Err(Error::TempDirNotFound) } fn try_install_with_privileges(&self, deb_path: &Path) -> Result<()> { // 1. First try using pkexec (graphical sudo prompt) if let Ok(status) = std::process::Command::new("pkexec") .arg("dpkg") .arg("-i") .arg(deb_path) .status() { if status.success() { log::debug!("installed deb with pkexec"); return Ok(()); } } // 2. Try zenity or kdialog for a graphical sudo experience if let Ok(password) = self.get_password_graphically() { if self.install_with_sudo(deb_path, &password)? { log::debug!("installed deb with GUI sudo"); return Ok(()); } } // 3. Final fallback: terminal sudo let status = std::process::Command::new("sudo") .arg("dpkg") .arg("-i") .arg(deb_path) .status()?; if status.success() { log::debug!("installed deb with sudo"); Ok(()) } else { Err(Error::DebInstallFailed) } } fn get_password_graphically(&self) -> Result { // Try zenity first let zenity_result = std::process::Command::new("zenity") .args([ "--password", "--title=Authentication Required", "--text=Enter your password to install the update:", ]) .output(); if let Ok(output) = zenity_result { if output.status.success() { return Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()); } } // Fall back to kdialog if zenity fails or isn't available let kdialog_result = std::process::Command::new("kdialog") .args(["--password", "Enter your password to install the update:"]) .output(); if let Ok(output) = kdialog_result { if output.status.success() { return Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()); } } Err(Error::AuthenticationFailed) } fn install_with_sudo(&self, deb_path: &Path, password: &str) -> Result { use std::io::Write; use std::process::{Command, Stdio}; let mut child = Command::new("sudo") .arg("-S") // read password from stdin .arg("dpkg") .arg("-i") .arg(deb_path) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn()?; if let Some(mut stdin) = child.stdin.take() { // Write password to stdin writeln!(stdin, "{}", password)?; } let status = child.wait()?; Ok(status.success()) } } /// 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(); // Create temp directories for backup and extraction let tmp_backup_dir = tempfile::Builder::new() .prefix("tauri_current_app") .tempdir()?; let tmp_extract_dir = tempfile::Builder::new() .prefix("tauri_updated_app") .tempdir()?; let decoder = GzDecoder::new(cursor); let mut archive = tar::Archive::new(decoder); // Extract files to temporary directory for entry in archive.entries()? { let mut entry = entry?; let collected_path: PathBuf = entry.path()?.iter().skip(1).collect(); let extraction_path = tmp_extract_dir.path().join(&collected_path); // Ensure parent directories exist if let Some(parent) = extraction_path.parent() { std::fs::create_dir_all(parent)?; } if let Err(err) = entry.unpack(&extraction_path) { // Cleanup on error std::fs::remove_dir_all(tmp_extract_dir.path()).ok(); return Err(err.into()); } extracted_files.push(extraction_path); } // Try to move the current app to backup let move_result = std::fs::rename( &self.extract_path, tmp_backup_dir.path().join("current_app"), ); let need_authorization = if let Err(err) = move_result { if err.kind() == std::io::ErrorKind::PermissionDenied { true } else { std::fs::remove_dir_all(tmp_extract_dir.path()).ok(); return Err(err.into()); } } else { false }; if need_authorization { log::debug!("app installation needs admin privileges"); // Use AppleScript to perform moves with admin privileges let apple_script = format!( "do shell script \"rm -rf '{src}' && mv -f '{new}' '{src}'\" with administrator privileges", src = self.extract_path.display(), new = tmp_extract_dir.path().display() ); let (tx, rx) = std::sync::mpsc::channel(); let res = (self.run_on_main_thread)(Box::new(move || { let mut script = osakit::Script::new_from_source(osakit::Language::AppleScript, &apple_script); script.compile().expect("invalid AppleScript"); let r = script.execute(); tx.send(r).unwrap(); })); let result = rx.recv().unwrap(); if res.is_err() || result.is_err() { std::fs::remove_dir_all(tmp_extract_dir.path()).ok(); return Err(Error::Io(std::io::Error::new( std::io::ErrorKind::PermissionDenied, "Failed to move the new app into place", ))); } } else { // Remove existing directory if it exists if self.extract_path.exists() { std::fs::remove_dir_all(&self.extract_path)?; } // Move the new app to the target path std::fs::rename(tmp_extract_dir.path(), &self.extract_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) -> 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() } #[cfg(windows)] trait PathExt { fn wrap_in_quotes(&self) -> Self; } #[cfg(windows)] impl PathExt for PathBuf { fn wrap_in_quotes(&self) -> Self { let mut msi_path = OsString::from("\""); msi_path.push(self.as_os_str()); msi_path.push("\""); PathBuf::from(msi_path) } } #[cfg(windows)] fn escape_msi_property_arg(arg: impl AsRef) -> String { let mut arg = arg.as_ref().to_string_lossy().to_string(); // Otherwise this argument will get lost in ShellExecute if arg.is_empty() { return "\"\"\"\"".to_string(); } else if !arg.contains(' ') && !arg.contains('"') { return arg; } if arg.contains('"') { arg = arg.replace('"', r#""""""#) } if arg.starts_with('-') { if let Some((a1, a2)) = arg.split_once('=') { format!("{a1}=\"\"{a2}\"\"") } else { format!("\"\"{arg}\"\"") } } else { format!("\"\"{arg}\"\"") } } #[cfg(test)] mod tests { #[test] #[cfg(windows)] fn it_wraps_correctly() { use super::PathExt; use std::path::PathBuf; assert_eq!( PathBuf::from("C:\\Users\\Some User\\AppData\\tauri-example.exe").wrap_in_quotes(), PathBuf::from("\"C:\\Users\\Some User\\AppData\\tauri-example.exe\"") ) } #[test] #[cfg(windows)] fn it_escapes_correctly() { use crate::updater::escape_msi_property_arg; // Explanation for quotes: // The output of escape_msi_property_args() will be used in `LAUNCHAPPARGS=\"{HERE}\"`. This is the first quote level. // To escape a quotation mark we use a second quotation mark, so "" is interpreted as " later. // This means that the escaped strings can't ever have a single quotation mark! // Now there are 3 major things to look out for to not break the msiexec call: // 1) Wrap spaces in quotation marks, otherwise it will be interpreted as the end of the msiexec argument. // 2) Escape escaping quotation marks, otherwise they will either end the msiexec argument or be ignored. // 3) Escape emtpy args in quotation marks, otherwise the argument will get lost. let cases = [ "something", "--flag", "--empty=", "--arg=value", "some space", // This simulates `./my-app "some string"`. "--arg value", // -> This simulates `./my-app "--arg value"`. Same as above but it triggers the startsWith(`-`) logic. "--arg=unwrapped space", // `./my-app --arg="unwrapped space"` "--arg=\"wrapped\"", // `./my-app --args=""wrapped""` "--arg=\"wrapped space\"", // `./my-app --args=""wrapped space""` "--arg=midword\"wrapped space\"", // `./my-app --args=midword""wrapped""` "", // `./my-app '""'` ]; let cases_escaped = [ "something", "--flag", "--empty=", "--arg=value", "\"\"some space\"\"", "\"\"--arg value\"\"", "--arg=\"\"unwrapped space\"\"", r#"--arg=""""""wrapped"""""""#, r#"--arg=""""""wrapped space"""""""#, r#"--arg=""midword""""wrapped space"""""""#, "\"\"\"\"", ]; // Just to be sure we didn't mess that up assert_eq!(cases.len(), cases_escaped.len()); for (orig, escaped) in cases.iter().zip(cases_escaped) { assert_eq!(escape_msi_property_arg(orig), escaped); } } }