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::{header::ACCEPT, 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::{
config::BundleType,
platform::{bundle_type, 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(Copy, Clone)]
pub enum Installer {
AppImage,
Deb,
Rpm,
App,
Msi,
Nsis,
}
impl Installer {
fn name(self) -> &'static str {
match self {
Self::AppImage => "appimage",
Self::Deb => "deb",
Self::Rpm => "rpm",
Self::App => "app",
Self::Msi => "msi",
Self::Nsis => "nsis",
}
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ReleaseManifestPlatform {
pub url: Url,
pub signature: String,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(untagged)]
pub enum RemoteReleaseInner {
Dynamic(ReleaseManifestPlatform),
Static {
platforms: HashMap<String, ReleaseManifestPlatform>,
},
}
#[derive(Debug, Clone)]
pub struct RemoteRelease {
pub version: Version,
pub notes: Option<String>,
pub pub_date: Option<OffsetDateTime>,
pub data: RemoteReleaseInner,
}
impl RemoteRelease {
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)
}),
}
}
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<dyn Fn() + Send + Sync + 'static>;
pub type OnBeforeRequest = Arc<dyn Fn(ClientBuilder) -> ClientBuilder + Send + Sync + 'static>;
pub type VersionComparator = Arc<dyn Fn(Version, RemoteRelease) -> bool + Send + Sync>;
#[cfg(target_os = "macos")]
type MainThreadClosure = Box<dyn FnOnce() + Send + Sync + 'static>;
#[cfg(target_os = "macos")]
type RunOnMainThread = Arc<dyn Fn(MainThreadClosure) -> tauri::Result<()> + Send + Sync + 'static>;
#[derive(Clone)]
struct UpdaterContext {
config: Config,
configure_client: Option<OnBeforeRequest>,
#[cfg(target_os = "macos")]
run_on_main_thread: RunOnMainThread,
#[cfg(windows)]
app_name: String,
#[cfg(windows)]
installer_args: Vec<OsString>,
#[cfg(windows)]
current_exe_args: Vec<OsString>,
#[cfg(windows)]
on_before_exit: Option<OnBeforeExit>,
#[cfg(windows)]
restart_after_install: bool,
}
pub struct UpdaterBuilder {
current_version: Version,
pub(crate) version_comparator: Option<VersionComparator>,
executable_path: Option<PathBuf>,
target: Option<String>,
endpoints: Option<Vec<Url>>,
headers: HeaderMap,
timeout: Option<Duration>,
proxy: Option<Url>,
no_proxy: bool,
context: UpdaterContext,
}
impl UpdaterBuilder {
pub(crate) fn new<R: Runtime>(app: &AppHandle<R>, config: crate::Config) -> Self {
#[cfg(target_os = "macos")]
let run_on_main_thread = {
let app_ = app.clone();
Arc::new(move |f| app_.run_on_main_thread(f))
};
Self {
context: UpdaterContext {
#[cfg(windows)]
installer_args: config
.windows
.as_ref()
.map(|w| w.installer_args.clone())
.unwrap_or_default(),
config,
configure_client: None,
#[cfg(target_os = "macos")]
run_on_main_thread,
#[cfg(windows)]
app_name: app.package_info().name.clone(),
#[cfg(windows)]
current_exe_args: Vec::new(),
#[cfg(windows)]
on_before_exit: None,
#[cfg(windows)]
restart_after_install: true,
},
current_version: app.package_info().version.clone(),
version_comparator: None,
executable_path: None,
target: None,
endpoints: None,
headers: Default::default(),
timeout: None,
proxy: None,
no_proxy: false,
}
}
pub fn version_comparator<F: Fn(Version, RemoteRelease) -> 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<String>) -> Self {
self.target.replace(target.into());
self
}
pub fn endpoints(mut self, endpoints: Vec<Url>) -> Result<Self> {
crate::config::validate_endpoints(
&endpoints,
self.context.config.dangerous_insecure_transport_protocol,
)?;
self.endpoints.replace(endpoints);
Ok(self)
}
pub fn executable_path<P: AsRef<Path>>(mut self, p: P) -> Self {
self.executable_path.replace(p.as_ref().into());
self
}
pub fn header<K, V>(mut self, key: K, value: V) -> Result<Self>
where
HeaderName: TryFrom<K>,
<HeaderName as TryFrom<K>>::Error: Into<http::Error>,
HeaderValue: TryFrom<V>,
<HeaderValue as TryFrom<V>>::Error: Into<http::Error>,
{
let key: std::result::Result<HeaderName, http::Error> = key.try_into().map_err(Into::into);
let value: std::result::Result<HeaderValue, http::Error> =
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 no_proxy(mut self) -> Self {
self.no_proxy = true;
self
}
pub fn pubkey<S: Into<String>>(mut self, pubkey: S) -> Self {
self.context.config.pubkey = pubkey.into();
self
}
#[cfg_attr(not(windows), allow(unused))]
pub fn installer_arg<S>(mut self, arg: S) -> Self
where
S: Into<OsString>,
{
#[cfg(windows)]
{
self.context.installer_args.push(arg.into());
}
self
}
#[cfg_attr(not(windows), allow(unused))]
pub fn installer_args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
#[cfg(windows)]
{
self.context
.installer_args
.extend(args.into_iter().map(Into::into));
}
self
}
#[cfg_attr(not(windows), allow(unused))]
pub fn clear_installer_args(mut self) -> Self {
#[cfg(windows)]
{
self.context.installer_args.clear();
}
self
}
#[cfg_attr(not(windows), allow(unused))]
pub fn on_before_exit<F: Fn() + Send + Sync + 'static>(mut self, f: F) -> Self {
#[cfg(windows)]
{
self.context.on_before_exit.replace(Arc::new(f));
}
self
}
#[cfg_attr(not(windows), allow(unused))]
pub fn restart_after_install(mut self, restart_after_install: bool) -> Self {
#[cfg(windows)]
{
self.context.restart_after_install = restart_after_install;
}
self
}
pub fn configure_client<F: Fn(ClientBuilder) -> ClientBuilder + Send + Sync + 'static>(
mut self,
f: F,
) -> Self {
self.context.configure_client.replace(Arc::new(f));
self
}
pub fn build(self) -> Result<Updater> {
let endpoints = self
.endpoints
.unwrap_or_else(|| self.context.config.endpoints.clone());
if endpoints.is_empty() {
return Err(Error::EmptyEndpoints);
};
let arch = updater_arch().ok_or(Error::UnsupportedArch)?;
let executable_path = self.executable_path.clone().unwrap_or(current_exe()?);
let extract_path = if cfg!(target_os = "linux") {
executable_path
} else {
extract_path_from_executable(&executable_path)?
};
Ok(Updater {
current_version: self.current_version,
version_comparator: self.version_comparator,
timeout: self.timeout,
proxy: self.proxy,
no_proxy: self.no_proxy,
endpoints,
arch,
target: self.target,
headers: self.headers,
extract_path,
context: self.context.clone(),
})
}
}
#[cfg(windows)]
impl UpdaterBuilder {
pub(crate) fn current_exe_args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
self.context
.current_exe_args
.extend(args.into_iter().map(Into::into));
self
}
}
pub struct Updater {
current_version: Version,
version_comparator: Option<VersionComparator>,
timeout: Option<Duration>,
proxy: Option<Url>,
no_proxy: bool,
endpoints: Vec<Url>,
arch: &'static str,
target: Option<String>,
headers: HeaderMap,
extract_path: PathBuf,
context: UpdaterContext,
}
impl Updater {
pub async fn check(&self) -> Result<Option<Update>> {
let mut headers = self.headers.clone();
if !headers.contains_key(ACCEPT) {
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
}
#[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 target = if let Some(target) = &self.target {
target
} else {
updater_os().ok_or(Error::UnsupportedOs)?
};
let mut remote_release: Option<RemoteRelease> = None;
let mut raw_json: Option<serde_json::Value> = None;
let mut last_error: Option<Error> = None;
for url in &self.endpoints {
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 installer = installer_for_bundle_type(bundle_type())
.map(|i| i.name())
.unwrap_or("unknown");
let url: Url = url
.to_string()
.replace("%7B%7Bcurrent_version%7D%7D", &encoded_version)
.replace("%7B%7Btarget%7D%7D", target)
.replace("%7B%7Barch%7D%7D", self.arch)
.replace("%7B%7Bbundle_type%7D%7D", installer)
.replace("{{current_version}}", &encoded_version)
.replace("{{target}}", target)
.replace("{{arch}}", self.arch)
.replace("{{bundle_type}}", installer)
.parse()?;
log::debug!("checking for updates {url}");
#[cfg(feature = "rustls-tls")]
if rustls::crypto::CryptoProvider::get_default().is_none() {
let _ = rustls::crypto::ring::default_provider().install_default();
}
let mut request = ClientBuilder::new().user_agent(UPDATER_USER_AGENT);
if self.context.config.dangerous_accept_invalid_certs {
request = request.danger_accept_invalid_certs(true);
}
if self.context.config.dangerous_accept_invalid_hostnames {
request = request.danger_accept_invalid_hostnames(true);
}
if let Some(timeout) = self.timeout {
request = request.timeout(timeout);
}
if self.no_proxy {
log::debug!("disabling proxy");
request = request.no_proxy();
} else 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.context.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() {
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::<RemoteRelease>(update_response)
.map_err(Into::into)
{
Ok(release) => {
log::debug!("parsed release response {release:?}");
last_error = None;
remote_release = Some(release);
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())
}
}
}
if let Some(error) = last_error {
return Err(error);
}
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 installer = installer_for_bundle_type(bundle_type());
let (download_url, signature) = self.get_urls(&release, &installer)?;
let update = if should_update {
Some(Update {
current_version: self.current_version.to_string(),
target: target.to_owned(),
extract_path: self.extract_path.clone(),
version: release.version.to_string(),
date: release.pub_date,
download_url: download_url.clone(),
signature: signature.to_owned(),
body: release.notes,
raw_json: raw_json.unwrap(),
timeout: None,
proxy: self.proxy.clone(),
no_proxy: self.no_proxy,
headers: self.headers.clone(),
context: self.context.clone(),
})
} else {
None
};
Ok(update)
}
fn get_urls<'a>(
&self,
release: &'a RemoteRelease,
installer: &Option<Installer>,
) -> Result<(&'a Url, &'a String)> {
if let Some(target) = &self.target {
return Ok((release.download_url(target)?, release.signature(target)?));
}
let os = updater_os().ok_or(Error::UnsupportedOs)?;
let arch = self.arch;
let mut targets = Vec::new();
if let Some(installer) = installer {
let installer = installer.name();
targets.push(format!("{os}-{arch}-{installer}"));
}
targets.push(format!("{os}-{arch}"));
for target in &targets {
log::debug!("Searching for updater target '{target}' in release data");
if let (Ok(download_url), Ok(signature)) =
(release.download_url(target), release.signature(target))
{
return Ok((download_url, signature));
};
}
Err(Error::TargetsNotFound(targets))
}
}
#[derive(Clone)]
pub struct Update {
pub body: Option<String>,
pub current_version: String,
pub version: String,
pub date: Option<OffsetDateTime>,
pub target: String,
pub download_url: Url,
pub signature: String,
pub raw_json: serde_json::Value,
pub timeout: Option<Duration>,
pub proxy: Option<Url>,
pub no_proxy: bool,
pub headers: HeaderMap,
#[allow(unused)]
extract_path: PathBuf,
context: UpdaterContext,
}
impl Resource for Update {}
impl Update {
pub async fn download<C: FnMut(usize, Option<u64>), D: FnOnce()>(
&self,
mut on_chunk: C,
on_download_finish: D,
) -> Result<Vec<u8>> {
let mut headers = self.headers.clone();
if !headers.contains_key(ACCEPT) {
headers.insert(ACCEPT, HeaderValue::from_static("application/octet-stream"));
}
let mut request = ClientBuilder::new().user_agent(UPDATER_USER_AGENT);
if self.context.config.dangerous_accept_invalid_certs {
request = request.danger_accept_invalid_certs(true);
}
if self.context.config.dangerous_accept_invalid_hostnames {
request = request.danger_accept_invalid_hostnames(true);
}
if let Some(timeout) = self.timeout {
request = request.timeout(timeout);
}
if self.no_proxy {
request = request.no_proxy();
} else 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.context.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<u64> = 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.context.config.pubkey,
&self.version,
self.context.config.require_signed_version,
)?;
Ok(buffer)
}
pub fn install(&self, bytes: impl AsRef<[u8]>) -> Result<()> {
self.install_inner(bytes.as_ref())
}
pub async fn download_and_install<C: FnMut(usize, Option<u64>), 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_attr(not(windows), allow(unused))]
pub fn restart_after_install(mut self, restart_after_install: bool) -> Self {
#[cfg(windows)]
{
self.context.restart_after_install = restart_after_install;
}
self
}
}
#[cfg(windows)]
enum WindowsUpdaterType {
Nsis {
path: PathBuf,
#[allow(unused)]
temp: Option<tempfile::TempPath>,
},
Msi {
path: PathBuf,
#[allow(unused)]
temp: Option<tempfile::TempPath>,
},
}
#[cfg(windows)]
impl WindowsUpdaterType {
fn nsis(path: PathBuf, temp: Option<tempfile::TempPath>) -> Self {
Self::Nsis { path, temp }
}
fn msi(path: PathBuf, temp: Option<tempfile::TempPath>) -> 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()
}
}
#[cfg(windows)]
impl Update {
fn install_inner(&self, bytes: &[u8]) -> Result<()> {
use windows_sys::{
w,
Win32::UI::{Shell::ShellExecuteW, WindowsAndMessaging::SW_SHOW},
};
let updater_type = self.extract(bytes)?;
if let Some(on_before_exit) = self.context.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 parameters = self.updater_parameters(&updater_type);
log::debug!("Executing updater {file:?} with parameters: {parameters:?}");
let file = encode_wide(file);
let parameters = encode_wide(parameters);
let result = unsafe {
ShellExecuteW(
std::ptr::null_mut(),
w!("open"),
file.as_ptr(),
parameters.as_ptr(),
std::ptr::null(),
SW_SHOW,
)
};
if result as isize <= 32 {
return Err(crate::Error::Io(std::io::Error::last_os_error()));
}
std::process::exit(0);
}
fn updater_parameters(&self, updater_type: &WindowsUpdaterType) -> OsString {
let install_mode = self.context.config.install_mode();
let current_args = &self.context.current_exe_args[1..];
match updater_type {
WindowsUpdaterType::Nsis { .. } => {
let mut installer_args: Vec<&OsStr> = Vec::new();
installer_args.extend(install_mode.nsis_args().iter().map(OsStr::new));
installer_args.push(OsStr::new("/UPDATE"));
let nsis_current_exe_arg;
if self.context.restart_after_install {
nsis_current_exe_arg = current_args
.iter()
.map(escape_nsis_current_exe_arg)
.collect::<Vec<_>>();
installer_args.extend(
install_mode
.nsis_restart_after_install_args()
.iter()
.map(OsStr::new),
);
installer_args.push(OsStr::new("/ARGS"));
installer_args.extend(nsis_current_exe_arg.iter().map(OsStr::new));
}
installer_args.extend(self.installer_args());
installer_args.join(OsStr::new(" "))
}
WindowsUpdaterType::Msi { path, .. } => {
let mut installer_args: Vec<&OsStr> = vec![OsStr::new("/i"), path.as_os_str()];
installer_args.extend(install_mode.msiexec_args().iter().map(OsStr::new));
installer_args.push(OsStr::new("/promptrestart"));
installer_args.extend(self.installer_args());
let msi_current_exe_arg;
if self.context.restart_after_install {
msi_current_exe_arg = format!(
"LAUNCHAPPARGS=\"{}\"",
current_args
.iter()
.map(escape_msi_property_arg)
.collect::<Vec<_>>()
.join(" ")
);
installer_args.extend(
install_mode
.msi_restart_after_install_args()
.iter()
.map(OsStr::new),
);
installer_args.push(OsStr::new(&msi_current_exe_arg));
}
installer_args.join(OsStr::new(" "))
}
}
}
fn installer_args(
&self,
) -> std::iter::Map<std::slice::Iter<'_, OsString>, fn(&OsString) -> &OsStr> {
self.context.installer_args.iter().map(OsStr::new)
}
fn extract(&self, bytes: &[u8]) -> Result<WindowsUpdaterType> {
#[cfg(feature = "zip")]
if infer::archive::is_zip(bytes) {
return self.extract_zip(bytes);
}
self.extract_exe(bytes)
}
fn make_temp_dir(&self) -> Result<PathBuf> {
Ok(tempfile::Builder::new()
.prefix(&format!(
"{}-{}-updater-",
self.context.app_name, self.version
))
.tempdir()?
.keep())
}
#[cfg(feature = "zip")]
fn extract_zip(&self, bytes: &[u8]) -> Result<WindowsUpdaterType> {
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<WindowsUpdaterType> {
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<tempfile::TempPath>)> {
use std::io::Write;
let temp_dir = self.make_temp_dir()?;
let mut temp_file = tempfile::Builder::new()
.prefix(&format!(
"{}-{}-installer",
self.context.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)))
}
}
#[cfg(any(
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd"
))]
impl Update {
fn install_inner(&self, bytes: &[u8]) -> Result<()> {
match installer_for_bundle_type(bundle_type()) {
Some(Installer::Deb) => self.install_deb(bytes),
Some(Installer::Rpm) => self.install_rpm(bytes),
_ => 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<dyn FnOnce() -> Option<PathBuf>>,
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();
std::fs::rename(&self.extract_path, tmp_app_image)?;
#[cfg(feature = "zip")]
if infer::archive::is_gz(bytes) {
log::debug!("extracting AppImage");
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 let Err(err) = entry.unpack(&self.extract_path) {
std::fs::rename(tmp_app_image, &self.extract_path)?;
return Err(err.into());
}
return Ok(());
}
}
}
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) => {
std::fs::rename(tmp_app_image, &self.extract_path)?;
Err(err.into())
}
Ok(_) => Ok(()),
};
}
}
}
Err(Error::TempDirNotOnSameMountPoint)
}
fn install_deb(&self, bytes: &[u8]) -> Result<()> {
if !infer::archive::is_deb(bytes) {
log::warn!("update is not a valid deb package");
return Err(Error::InvalidUpdaterFormat);
}
self.try_tmp_locations(bytes, "dpkg", "-i", "deb")
}
fn install_rpm(&self, bytes: &[u8]) -> Result<()> {
if !infer::archive::is_rpm(bytes) {
return Err(Error::InvalidUpdaterFormat);
}
self.try_tmp_locations(bytes, "rpm", "-U", "rpm")
}
fn try_tmp_locations(
&self,
bytes: &[u8],
install_cmd: &str,
install_arg: &str,
package_extension: &str,
) -> Result<()> {
let tmp_dir_locations = vec![
Box::new(|| Some(std::env::temp_dir())) as Box<dyn FnOnce() -> Option<PathBuf>>,
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(path) = tmp_dir_location() {
let prefix = format!("tauri_{package_extension}_update");
if let Ok(tmp_dir) = tempfile::Builder::new().prefix(&prefix).tempdir_in(path) {
let pkg_path = tmp_dir.path().join(format!("package.{package_extension}"));
if std::fs::write(&pkg_path, bytes).is_ok() {
return self.try_install_with_privileges(
&pkg_path,
install_cmd,
install_arg,
);
}
}
}
}
Err(Error::TempDirNotFound)
}
fn try_install_with_privileges(
&self,
pkg_path: &Path,
install_cmd: &str,
install_arg: &str,
) -> Result<()> {
if let Ok(status) = std::process::Command::new("pkexec")
.arg(install_cmd)
.arg(install_arg)
.arg(pkg_path)
.status()
{
if status.success() {
log::debug!("installed {pkg_path:?} with pkexec");
return Ok(());
}
}
if let Ok(password) = self.get_password_graphically() {
if self.install_with_sudo(pkg_path, &password, install_cmd, install_arg)? {
log::debug!("installed {pkg_path:?} with GUI sudo");
return Ok(());
}
}
let status = std::process::Command::new("sudo")
.arg(install_cmd)
.arg(install_arg)
.arg(pkg_path)
.status()?;
if status.success() {
log::debug!("installed {pkg_path:?} with sudo");
Ok(())
} else {
Err(Error::PackageInstallFailed)
}
}
fn get_password_graphically(&self) -> Result<String> {
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());
}
}
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,
pkg_path: &Path,
password: &str,
install_cmd: &str,
install_arg: &str,
) -> Result<bool> {
use std::io::Write;
use std::process::{Command, Stdio};
let mut child = Command::new("sudo")
.arg("-S") .arg(install_cmd)
.arg(install_arg)
.arg(pkg_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
writeln!(stdin, "{password}")?;
}
let status = child.wait()?;
Ok(status.success())
}
}
#[cfg(target_os = "macos")]
impl Update {
fn install_inner(&self, bytes: &[u8]) -> Result<()> {
use flate2::read::GzDecoder;
let cursor = Cursor::new(bytes);
let mut extracted_files: Vec<PathBuf> = Vec::new();
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);
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);
if let Some(parent) = extraction_path.parent() {
std::fs::create_dir_all(parent)?;
}
if let Err(err) = entry.unpack(&extraction_path) {
std::fs::remove_dir_all(tmp_extract_dir.path()).ok();
return Err(err.into());
}
extracted_files.push(extraction_path);
}
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");
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.context.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 {
if self.extract_path.exists() {
std::fs::remove_dir_all(&self.extract_path)?;
}
std::fs::rename(tmp_extract_dir.path(), &self.extract_path)?;
}
let _ = std::process::Command::new("touch")
.arg(&self.extract_path)
.status();
Ok(())
}
}
pub fn target() -> Option<String> {
if let (Some(target), Some(arch)) = (updater_os(), updater_arch()) {
Some(format!("{target}-{arch}"))
} else {
None
}
}
fn updater_os() -> Option<&'static str> {
if cfg!(target_os = "linux") {
Some("linux")
} else if cfg!(target_os = "macos") {
Some("darwin")
} else if cfg!(target_os = "windows") {
Some("windows")
} else {
None
}
}
fn 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 if cfg!(target_arch = "riscv64") {
Some("riscv64")
} else {
None
}
}
pub fn extract_path_from_executable(executable_path: &Path) -> Result<PathBuf> {
let extract_path = executable_path
.parent()
.map(PathBuf::from)
.ok_or(Error::FailedToDetermineExtractPath)?;
#[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<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
struct InnerRemoteRelease {
#[serde(alias = "name", deserialize_with = "parse_version")]
version: Version,
notes: Option<String>,
pub_date: Option<String>,
platforms: Option<HashMap<String, ReleaseManifestPlatform>>,
url: Option<Url>,
signature: Option<String>,
}
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 installer_for_bundle_type(bundle: Option<BundleType>) -> Option<Installer> {
match bundle? {
BundleType::Deb => Some(Installer::Deb),
BundleType::Rpm => Some(Installer::Rpm),
BundleType::AppImage => Some(Installer::AppImage),
BundleType::Msi => Some(Installer::Msi),
BundleType::Nsis => Some(Installer::Nsis),
BundleType::App => Some(Installer::App), _ => None,
}
}
fn parse_version<'de, D>(deserializer: D) -> std::result::Result<Version, D::Error>
where
D: serde::Deserializer<'de>,
{
let str = String::deserialize(deserializer)?;
Version::from_str(str.trim_start_matches('v')).map_err(serde::de::Error::custom)
}
fn verify_signature(
data: &[u8],
release_signature: &str,
pub_key: &str,
announced_version: &str,
require_signed_version: bool,
) -> Result<()> {
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)?;
public_key.verify(data, &signature, true)?;
verify_signed_version(
signature.trusted_comment(),
announced_version,
require_signed_version,
)
}
fn verify_signed_version(
trusted_comment: &str,
announced_version: &str,
require_signed_version: bool,
) -> Result<()> {
let Some(signed_version) = signed_version(trusted_comment) else {
return if require_signed_version {
Err(Error::MissingSignedVersion)
} else {
Ok(())
};
};
let matches = match (
Version::from_str(signed_version.trim_start_matches('v')),
Version::from_str(announced_version.trim_start_matches('v')),
) {
(Ok(signed), Ok(announced)) => signed == announced,
_ => signed_version == announced_version,
};
if matches {
Ok(())
} else {
Err(Error::SignedVersionMismatch {
signed: signed_version.to_string(),
announced: announced_version.to_string(),
})
}
}
fn signed_version(trusted_comment: &str) -> Option<&str> {
trusted_comment
.split('\t')
.find_map(|field| field.strip_prefix("version:"))
}
fn base64_to_string(base64_string: &str) -> Result<String> {
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<OsStr>) -> Vec<u16> {
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_nsis_current_exe_arg(arg: impl AsRef<OsStr>) -> OsString {
use std::os::windows::ffi::{OsStrExt, OsStringExt};
let arg = arg.as_ref();
let mut cmd: Vec<u16> = Vec::new();
let quote = arg
.as_encoded_bytes()
.iter()
.any(|c| *c == b' ' || *c == b'\t' || *c == b'/')
|| arg.is_empty();
let escape = true;
if quote {
cmd.push('"' as u16);
}
let mut backslashes: usize = 0;
for x in arg.encode_wide() {
if escape {
if x == '\\' as u16 {
backslashes += 1;
} else {
if x == '"' as u16 {
cmd.extend((0..=backslashes).map(|_| '\\' as u16));
}
backslashes = 0;
}
}
cmd.push(x);
}
if quote {
cmd.extend((0..backslashes).map(|_| '\\' as u16));
cmd.push('"' as u16);
}
OsString::from_wide(&cmd)
}
#[cfg(windows)]
fn escape_msi_property_arg(arg: impl AsRef<OsStr>) -> String {
let mut arg = arg.as_ref().to_string_lossy().to_string();
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 {
use super::{signed_version, verify_signed_version};
use crate::error::Error;
const CURRENT: &str = "timestamp:1700000000\tfile:app_1.2.3_x64.msi.zip\tversion:1.2.3";
const LEGACY: &str = "timestamp:1600000000\tfile:app_1.0.0_x64.msi.zip";
#[test]
fn reads_the_signed_version() {
assert_eq!(signed_version(CURRENT), Some("1.2.3"));
assert_eq!(signed_version(LEGACY), None);
assert_eq!(signed_version("timestamp:1\tfile:app-version:2.zip"), None);
}
#[test]
fn accepts_a_matching_version() {
assert!(verify_signed_version(CURRENT, "1.2.3", true).is_ok());
assert!(verify_signed_version(CURRENT, "1.2.3", false).is_ok());
assert!(verify_signed_version(CURRENT, "v1.2.3", true).is_ok());
}
#[test]
fn rejects_a_version_the_artifact_was_not_signed_for() {
let err = verify_signed_version(CURRENT, "9.9.9", false).unwrap_err();
assert!(
matches!(err, Error::SignedVersionMismatch { ref signed, ref announced }
if signed == "1.2.3" && announced == "9.9.9"),
"unexpected error: {err}"
);
assert!(verify_signed_version(CURRENT, "9.9.9", true).is_err());
assert!(verify_signed_version(CURRENT, "1.2.4", true).is_err());
}
#[test]
fn only_requires_a_signed_version_when_configured() {
assert!(verify_signed_version(LEGACY, "9.9.9", false).is_ok());
assert!(matches!(
verify_signed_version(LEGACY, "9.9.9", true).unwrap_err(),
Error::MissingSignedVersion
));
}
#[test]
fn compares_non_semver_versions_literally() {
let comment = "timestamp:1700000000\tfile:app.zip\tversion:2024-01-01";
assert!(verify_signed_version(comment, "2024-01-01", true).is_ok());
assert!(verify_signed_version(comment, "2024-01-02", true).is_err());
}
#[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_for_msi() {
use crate::updater::escape_msi_property_arg;
let cases = [
"something",
"--flag",
"--empty=",
"--arg=value",
"some space", "--arg value", "--arg=unwrapped space", "--arg=\"wrapped\"", "--arg=\"wrapped space\"", "--arg=midword\"wrapped space\"", "", ];
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"""""""#,
"\"\"\"\"",
];
assert_eq!(cases.len(), cases_escaped.len());
for (orig, escaped) in cases.iter().zip(cases_escaped) {
assert_eq!(escape_msi_property_arg(orig), escaped);
}
}
#[test]
#[cfg(windows)]
fn it_escapes_correctly_for_nsis() {
use crate::updater::escape_nsis_current_exe_arg;
use std::ffi::OsStr;
let cases = [
"something",
"--flag",
"--empty=",
"--arg=value",
"some space", "--arg value", "--arg=unwrapped space", "--arg=\"wrapped\"", "--arg=\"wrapped space\"", "--arg=midword\"wrapped space\"", "", ];
let cases_escaped = [
"something",
"--flag",
"--empty=",
"--arg=value",
"\"some space\"",
"\"--arg value\"",
"\"--arg=unwrapped space\"",
"--arg=\\\"wrapped\\\"",
"\"--arg=\\\"wrapped space\\\"\"",
"\"--arg=midword\\\"wrapped space\\\"\"",
"\"\"",
];
assert_eq!(cases.len(), cases_escaped.len());
for (orig, escaped) in cases.iter().zip(cases_escaped) {
assert_eq!(escape_nsis_current_exe_arg(&OsStr::new(orig)), escaped);
}
}
}