lucas-test 1.0.0-rc.5

Make tiny, secure apps for all desktop platforms with Tauri
// Copyright 2019-2021 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT

use super::error::{Error, Result};
use crate::api::{
  file::{ArchiveFormat, Extract, Move},
  http::{ClientBuilder, HttpRequestBuilder},
  version,
};
use base64::decode;
use http::StatusCode;
use minisign_verify::{PublicKey, Signature};
use tauri_utils::{platform::current_exe, Env};

use std::{
  collections::HashMap,
  env,
  io::{Cursor, Read, Seek},
  path::{Path, PathBuf},
  str::from_utf8,
};

#[cfg(not(target_os = "macos"))]
use std::ffi::OsStr;

#[cfg(not(target_os = "windows"))]
use crate::api::file::Compression;

#[cfg(target_os = "windows")]
use std::{
  fs::read_dir,
  process::{exit, Command},
};

#[derive(Debug)]
pub struct RemoteRelease {
  /// Version to install
  pub version: String,
  /// Release date
  pub date: String,
  /// Download URL for current platform
  pub download_url: String,
  /// Update short description
  pub body: Option<String>,
  /// Optional signature for the current platform
  pub signature: Option<String>,
  #[cfg(target_os = "windows")]
  /// Optional: Windows only try to use elevated task
  pub with_elevated_task: bool,
}

impl RemoteRelease {
  // Read JSON and confirm this is a valid Schema
  fn from_release(release: &serde_json::Value, target: &str) -> Result<RemoteRelease> {
    // Version or name is required for static and dynamic JSON
    // if `version` is not announced, we fallback to `name` (can be the tag name example v1.0.0)
    let version = match release.get("version") {
      Some(version) => version
        .as_str()
        .ok_or_else(|| {
          Error::RemoteMetadata("Unable to extract `version` from remote server".into())
        })?
        .trim_start_matches('v')
        .to_string(),
      None => release
        .get("name")
        .ok_or_else(|| Error::RemoteMetadata("Release missing `name` and `version`".into()))?
        .as_str()
        .ok_or_else(|| {
          Error::RemoteMetadata("Unable to extract `name` from remote server`".into())
        })?
        .trim_start_matches('v')
        .to_string(),
    };

    // pub_date is required default is: `N/A` if not provided by the remote JSON
    let date = release
      .get("pub_date")
      .and_then(|v| v.as_str())
      .unwrap_or("N/A")
      .to_string();

    // body is optional to build our update
    let body = release
      .get("notes")
      .map(|notes| notes.as_str().unwrap_or("").to_string());

    // signature is optional to build our update
    let mut signature = release
      .get("signature")
      .map(|signature| signature.as_str().unwrap_or("").to_string());

    let download_url;
    #[cfg(target_os = "windows")]
    let with_elevated_task;

    match release.get("platforms") {
      //
      // Did we have a platforms field?
      // If we did, that mean it's a static JSON.
      // The main difference with STATIC and DYNAMIC is static announce ALL platforms
      // and dynamic announce only the current platform.
      //
      // This could be used if you do NOT want an update server and use
      // a GIST, S3 or any static JSON file to announce your updates.
      //
      // Notes:
      // Dynamic help to reduce bandwidth usage or to intelligently update your clients
      // based on the request you give. The server can remotely drive behaviors like
      // rolling back or phased rollouts.
      //
      Some(platforms) => {
        // make sure we have our target available
        if let Some(current_target_data) = platforms.get(target) {
          // use provided signature if available
          signature = current_target_data
            .get("signature")
            .map(|found_signature| found_signature.as_str().unwrap_or("").to_string());
          // Download URL is required
          download_url = current_target_data
            .get("url")
            .ok_or_else(|| Error::RemoteMetadata("Release missing `url`".into()))?
            .as_str()
            .ok_or_else(|| {
              Error::RemoteMetadata("Unable to extract `url` from remote server`".into())
            })?
            .to_string();
          #[cfg(target_os = "windows")]
          {
            with_elevated_task = current_target_data
              .get("with_elevated_task")
              .and_then(|v| v.as_bool())
              .unwrap_or_default();
          }
        } else {
          // make sure we have an available platform from the static
          return Err(Error::RemoteMetadata("Platform not available".into()));
        }
      }
      // We don't have the `platforms` field announced, let's assume our
      // download URL is at the root of the JSON.
      None => {
        download_url = release
          .get("url")
          .ok_or_else(|| Error::RemoteMetadata("Release missing `url`".into()))?
          .as_str()
          .ok_or_else(|| {
            Error::RemoteMetadata("Unable to extract `url` from remote server`".into())
          })?
          .to_string();
        #[cfg(target_os = "windows")]
        {
          with_elevated_task = match release.get("with_elevated_task") {
            Some(with_elevated_task) => with_elevated_task.as_bool().unwrap_or(false),
            None => false,
          };
        }
      }
    }
    // Return our formatted release
    Ok(RemoteRelease {
      version,
      date,
      download_url,
      body,
      signature,
      #[cfg(target_os = "windows")]
      with_elevated_task,
    })
  }
}

#[derive(Debug)]
pub struct UpdateBuilder<'a> {
  /// Environment information.
  pub env: Env,
  /// Current version we are running to compare with announced version
  pub current_version: &'a str,
  /// The URLs to checks updates. We suggest at least one fallback on a different domain.
  pub urls: Vec<String>,
  /// The platform the updater will check and install the update. Default is from `get_updater_target`
  pub target: Option<String>,
  /// The current executable path. Default is automatically extracted.
  pub executable_path: Option<PathBuf>,
}

// Create new updater instance and return an Update
impl<'a> UpdateBuilder<'a> {
  pub fn new(env: Env) -> Self {
    UpdateBuilder {
      env,
      urls: Vec::new(),
      target: None,
      executable_path: None,
      current_version: env!("CARGO_PKG_VERSION"),
    }
  }

  #[allow(dead_code)]
  pub fn url(mut self, url: String) -> Self {
    self.urls.push(
      percent_encoding::percent_decode(url.as_bytes())
        .decode_utf8_lossy()
        .to_string(),
    );
    self
  }

  /// Add multiple URLS at once inside a Vec for future reference
  pub fn urls(mut self, urls: &[String]) -> Self {
    let mut formatted_vec: Vec<String> = Vec::new();
    for url in urls {
      formatted_vec.push(
        percent_encoding::percent_decode(url.as_bytes())
          .decode_utf8_lossy()
          .to_string(),
      );
    }
    self.urls = formatted_vec;
    self
  }

  /// Set the current app version, used to compare against the latest available version.
  /// The `cargo_crate_version!` macro can be used to pull the version from your `Cargo.toml`
  pub fn current_version(mut self, ver: &'a str) -> Self {
    self.current_version = ver;
    self
  }

  /// Set the target (os)
  /// win32, win64, darwin and linux are currently supported
  #[allow(dead_code)]
  pub fn target(mut self, target: &str) -> Self {
    self.target = Some(target.to_owned());
    self
  }

  /// Set the executable path
  #[allow(dead_code)]
  pub fn executable_path<A: AsRef<Path>>(mut self, executable_path: A) -> Self {
    self.executable_path = Some(PathBuf::from(executable_path.as_ref()));
    self
  }

  pub async fn build(self) -> Result<Update> {
    let mut remote_release: Option<RemoteRelease> = None;

    // make sure we have at least one url
    if self.urls.is_empty() {
      return Err(Error::Builder(
        "Unable to check update, `url` is required.".into(),
      ));
    };

    // set current version if not set
    let current_version = self.current_version;

    // If no executable path provided, we use current_exe from tauri_utils
    let executable_path = self.executable_path.unwrap_or(current_exe()?);

    // Did the target is provided by the config?
    // Should be: linux, darwin, win32 or win64
    let target = self
      .target
      .or_else(get_updater_target)
      .ok_or(Error::UnsupportedPlatform)?;

    // Get the extract_path from the provided executable_path
    let extract_path = extract_path_from_executable(&self.env, &executable_path);

    // Set SSL certs for linux if they aren't available.
    // We do not require to recheck in the download_and_install as we use
    // ENV variables, we can expect them to be set for the second call.
    #[cfg(target_os = "linux")]
    {
      if env::var_os("SSL_CERT_FILE").is_none() {
        env::set_var("SSL_CERT_FILE", "/etc/ssl/certs/ca-certificates.crt");
      }
      if env::var_os("SSL_CERT_DIR").is_none() {
        env::set_var("SSL_CERT_DIR", "/etc/ssl/certs");
      }
    }

    // Allow fallback if more than 1 urls is provided
    let mut last_error: Option<Error> = None;
    for url in &self.urls {
      // replace {{current_version}} and {{target}} in the provided URL
      // this is usefull if we need to query example
      // https://releases.myapp.com/update/{{target}}/{{current_version}}
      // will be transleted into ->
      // https://releases.myapp.com/update/darwin/1.0.0
      // The main objective is if the update URL is defined via the Cargo.toml
      // the URL will be generated dynamicly
      let fixed_link = str::replace(
        &str::replace(url, "{{current_version}}", current_version),
        "{{target}}",
        &target,
      );

      // we want JSON only
      let mut headers = HashMap::new();
      headers.insert("Accept".into(), "application/json".into());

      let resp = ClientBuilder::new()
        .build()?
        .send(
          HttpRequestBuilder::new("GET", &fixed_link)?
            .headers(headers)
            // wait 20sec for the firewall
            .timeout(20),
        )
        .await;

      // If we got a success, we stop the loop
      // and we set our remote_release variable
      if let Ok(res) = resp {
        let res = res.read().await?;
        // got status code 2XX
        if StatusCode::from_u16(res.status)
          .map_err(|e| Error::Builder(e.to_string()))?
          .is_success()
        {
          // if we got 204
          if StatusCode::NO_CONTENT.as_u16() == res.status {
            // return with `UpToDate` error
            // we should catch on the client
            return Err(Error::UpToDate);
          };
          // Convert the remote result to our local struct
          let built_release = RemoteRelease::from_release(&res.data, &target);
          // make sure all went well and the remote data is compatible
          // with what we need locally
          match built_release {
            Ok(release) => {
              last_error = None;
              remote_release = Some(release);
              break;
            }
            Err(err) => last_error = Some(err),
          }
        } // if status code is not 2XX we keep loopin' our urls
      }
    }

    // Last error is cleaned on success -- shouldn't be triggered if
    // we have a successful call
    if let Some(error) = last_error {
      return Err(Error::Network(error.to_string()));
    }

    // Extracted remote metadata
    let final_release = remote_release.ok_or_else(|| {
      Error::RemoteMetadata("Unable to extract update metadata from the remote server.".into())
    })?;

    // did the announced version is greated than our current one?
    let should_update =
      version::is_greater(current_version, &final_release.version).unwrap_or(false);

    // create our new updater
    Ok(Update {
      env: self.env,
      target,
      extract_path,
      should_update,
      version: final_release.version,
      date: final_release.date,
      current_version: self.current_version.to_owned(),
      download_url: final_release.download_url,
      body: final_release.body,
      signature: final_release.signature,
      #[cfg(target_os = "windows")]
      with_elevated_task: final_release.with_elevated_task,
    })
  }
}

pub fn builder<'a>(env: Env) -> UpdateBuilder<'a> {
  UpdateBuilder::new(env)
}

#[derive(Debug, Clone)]
pub struct Update {
  /// Environment information.
  pub env: Env,
  /// Update description
  pub body: Option<String>,
  /// Should we update or not
  pub should_update: bool,
  /// Version announced
  pub version: String,
  /// Running version
  pub current_version: String,
  /// Update publish date
  pub date: String,
  /// Target
  #[allow(dead_code)]
  target: String,
  /// Extract path
  extract_path: PathBuf,
  /// Download URL announced
  download_url: String,
  /// Signature announced
  signature: Option<String>,
  #[cfg(target_os = "windows")]
  /// Optional: Windows only try to use elevated task
  /// Default to false
  with_elevated_task: bool,
}

impl Update {
  // Download and install our update
  // @todo(lemarier): Split into download and install (two step) but need to be thread safe
  pub async fn download_and_install(&self, pub_key: String) -> Result {
    // download url for selected release
    let url = self.download_url.as_str();
    // extract path
    let extract_path = &self.extract_path;

    // make sure we can install the update on linux
    // We fail here because later we can add more linux support
    // actually if we use APPIMAGE, our extract path should already
    // be set with our APPIMAGE env variable, we don't need to do
    // anythin with it yet
    #[cfg(target_os = "linux")]
    if self.env.appimage.is_none() {
      return Err(Error::UnsupportedPlatform);
    }

    // set our headers
    let mut headers = HashMap::new();
    headers.insert("Accept".into(), "application/octet-stream".into());
    headers.insert("User-Agent".into(), "tauri/updater".into());

    // Create our request
    let resp = ClientBuilder::new()
      .build()?
      .send(
        HttpRequestBuilder::new("GET", url)?
          .headers(headers)
          // wait 20sec for the firewall
          .timeout(20),
      )
      .await?
      .bytes()
      .await?;

    // make sure it's success
    if !StatusCode::from_u16(resp.status)
      .map_err(|e| Error::Network(e.to_string()))?
      .is_success()
    {
      return Err(Error::Network(format!(
        "Download request failed with status: {}",
        resp.status
      )));
    }

    // create memory buffer from our archive (Seek + Read)
    let mut archive_buffer = Cursor::new(resp.data);

    // We need an announced signature by the server
    // if there is no signature, bail out.
    if let Some(signature) = &self.signature {
      // we make sure the archive is valid and signed with the private key linked with the publickey
      verify_signature(&mut archive_buffer, signature, &pub_key)?;
    } else {
      // We have a public key inside our source file, but not announced by the server,
      // we assume this update is NOT valid.
      return Err(Error::MissingUpdaterSignature);
    }

    // we copy the files depending of the operating system
    // we run the setup, appimage re-install or overwrite the
    // macos .app
    #[cfg(target_os = "windows")]
    copy_files_and_run(archive_buffer, extract_path, self.with_elevated_task)?;
    #[cfg(not(target_os = "windows"))]
    copy_files_and_run(archive_buffer, extract_path)?;
    // We are done!
    Ok(())
  }
}

// Linux (AppImage)

// ### Expected structure:
// ├── [AppName]_[version]_amd64.AppImage.tar.gz    # GZ generated by tauri-bundler
// │   └──[AppName]_[version]_amd64.AppImage        # Application AppImage
// └── ...

// We should have an AppImage already installed to be able to copy and install
// the extract_path is the current AppImage path
// tmp_dir is where our new AppImage is found
#[cfg(target_os = "linux")]
fn copy_files_and_run<R: Read + Seek>(archive_buffer: R, extract_path: &Path) -> Result {
  let tmp_dir = tempfile::Builder::new()
    .prefix("tauri_current_app")
    .tempdir()?;

  let tmp_app_image = &tmp_dir.path().join("current_app.AppImage");

  // create a backup of our current app image
  Move::from_source(extract_path).to_dest(tmp_app_image)?;

  // extract the buffer to the tmp_dir
  // we extract our signed archive into our final directory without any temp file
  let mut extractor =
    Extract::from_cursor(archive_buffer, ArchiveFormat::Tar(Some(Compression::Gz)));

  for file in extractor.files()? {
    if file.extension() == Some(OsStr::new("AppImage")) {
      // if something went wrong during the extraction, we should restore previous app
      if let Err(err) = extractor.extract_file(extract_path, &file) {
        Move::from_source(tmp_app_image).to_dest(extract_path)?;
        return Err(Error::Extract(err.to_string()));
      }
      // early finish we have everything we need here
      return Ok(());
    }
  }

  Ok(())
}

// Windows

// ### Expected structure:
// ├── [AppName]_[version]_x64.msi.zip          # ZIP generated by tauri-bundler
// │   └──[AppName]_[version]_x64.msi           # Application MSI
// └── ...

// ## MSI
// Update server can provide a MSI for Windows. (Generated with tauri-bundler from *Wix*)
// To replace current version of the application. In later version we'll offer
// incremental update to push specific binaries.

// ## EXE
// Update server can provide a custom EXE (installer) who can run any task.
#[cfg(target_os = "windows")]
#[allow(clippy::unnecessary_wraps)]
fn copy_files_and_run<R: Read + Seek>(
  archive_buffer: R,
  _extract_path: &Path,
  with_elevated_task: bool,
) -> Result {
  // FIXME: We need to create a memory buffer with the MSI and then run it.
  //        (instead of extracting the MSI to a temp path)
  //
  // The tricky part is the MSI need to be exposed and spawned so the memory allocation
  // shouldn't drop but we should be able to pass the reference so we can drop it once the installation
  // is done, otherwise we have a huge memory leak.

  let tmp_dir = tempfile::Builder::new().tempdir()?.into_path();

  // extract the buffer to the tmp_dir
  // we extract our signed archive into our final directory without any temp file
  let mut extractor = Extract::from_cursor(archive_buffer, ArchiveFormat::Zip);

  // extract the msi
  extractor.extract_into(&tmp_dir)?;

  let paths = read_dir(&tmp_dir)?;
  // This consumes the TempDir without deleting directory on the filesystem,
  // meaning that the directory will no longer be automatically deleted.

  for path in paths {
    let found_path = path?.path();
    // we support 2 type of files exe & msi for now
    // If it's an `exe` we expect an installer not a runtime.
    if found_path.extension() == Some(OsStr::new("exe")) {
      // Run the EXE
      Command::new(found_path)
        .spawn()
        .expect("installer failed to start");

      exit(0);
    } else if found_path.extension() == Some(OsStr::new("msi")) {
      if with_elevated_task {
        if let Some(bin_name) = current_exe()
          .ok()
          .and_then(|pb| pb.file_name().map(|s| s.to_os_string()))
          .and_then(|s| s.into_string().ok())
        {
          let product_name = bin_name.replace(".exe", "");

          // Check if there is a task that enables the updater to skip the UAC prompt
          let update_task_name = format!("Update {} - Skip UAC", product_name);
          if let Ok(status) = Command::new("schtasks")
            .arg("/QUERY")
            .arg("/TN")
            .arg(update_task_name.clone())
            .status()
          {
            if status.success() {
              // Rename the MSI to the match file name the Skip UAC task is expecting it to be
              let temp_msi = tmp_dir.with_file_name(bin_name).with_extension("msi");
              Move::from_source(&found_path)
                .to_dest(&temp_msi)
                .expect("Unable to move update MSI");
              let exit_status = Command::new("schtasks")
                .arg("/RUN")
                .arg("/TN")
                .arg(update_task_name)
                .status()
                .expect("failed to start updater task");

              if exit_status.success() {
                // Successfully launched task that skips the UAC prompt
                exit(0);
              }
            }
            // Failed to run update task. Following UAC Path
          }
        }
      }

      // restart should be handled by WIX as we exit the process
      Command::new("msiexec.exe")
        .arg("/i")
        .arg(found_path)
        // quiet basic UI with prompt at the end
        .arg("/qb+")
        .spawn()
        .expect("installer failed to start");

      exit(0);
    }
  }

  Ok(())
}

// MacOS
// ### Expected structure:
// ├── [AppName]_[version]_x64.app.tar.gz       # GZ generated by tauri-bundler
// │   └──[AppName].app                         # Main application
// │      └── Contents                          # Application contents...
// │          └── ...
// └── ...
#[cfg(target_os = "macos")]
fn copy_files_and_run<R: Read + Seek>(archive_buffer: R, extract_path: &Path) -> Result {
  let mut extracted_files: Vec<PathBuf> = Vec::new();

  // extract the buffer to the tmp_dir
  // we extract our signed archive into our final directory without any temp file
  let mut extractor =
    Extract::from_cursor(archive_buffer, ArchiveFormat::Tar(Some(Compression::Gz)));
  // the first file in the tar.gz will always be
  // <app_name>/Contents
  let all_files = extractor.files()?;
  let tmp_dir = tempfile::Builder::new()
    .prefix("tauri_current_app")
    .tempdir()?;

  // create backup of our current app
  Move::from_source(extract_path).to_dest(tmp_dir.path())?;

  // extract all the files
  for file in all_files {
    // skip the first folder (should be the app name)
    let collected_path: PathBuf = file.iter().skip(1).collect();
    let extraction_path = extract_path.join(collected_path);

    // if something went wrong during the extraction, we should restore previous app
    if let Err(err) = extractor.extract_file(&extraction_path, &file) {
      for file in extracted_files {
        // delete all the files we extracted
        if file.is_dir() {
          std::fs::remove_dir(file)?;
        } else {
          std::fs::remove_file(file)?;
        }
      }
      Move::from_source(tmp_dir.path()).to_dest(extract_path)?;
      return Err(Error::Extract(err.to_string()));
    }

    extracted_files.push(extraction_path);
  }

  Ok(())
}

/// Returns a target os
/// We do not use a helper function like the target_triple
/// from tauri-utils because this function return `None` if
/// the updater do not support the platform.
///
/// Available target: `linux, darwin, win32, win64`
pub fn get_updater_target() -> Option<String> {
  if cfg!(target_os = "linux") {
    Some("linux".into())
  } else if cfg!(target_os = "macos") {
    Some("darwin".into())
  } else if cfg!(target_os = "windows") {
    if cfg!(target_pointer_width = "32") {
      Some("win32".into())
    } else {
      Some("win64".into())
    }
  } else {
    None
  }
}

/// Get the extract_path from the provided executable_path
#[allow(unused_variables)]
pub fn extract_path_from_executable(env: &Env, executable_path: &Path) -> PathBuf {
  // Return the path of the current executable by default
  // Example C:\Program Files\My App\
  let extract_path = executable_path
    .parent()
    .map(PathBuf::from)
    .expect("Can't determine extract path");

  // MacOS example binary is in /Applications/TestApp.app/Contents/MacOS/myApp
  // We need to get /Applications/<app>.app
  // todo(lemarier): Need a better way here
  // Maybe we could search for <*.app> to get the right path
  #[cfg(target_os = "macos")]
  if extract_path
    .display()
    .to_string()
    .contains("Contents/MacOS")
  {
    return extract_path
      .parent()
      .map(PathBuf::from)
      .expect("Unable to find the extract path")
      .parent()
      .map(PathBuf::from)
      .expect("Unable to find the extract path");
  }

  // We should use APPIMAGE exposed env variable
  // This is where our APPIMAGE should sit and should be replaced
  #[cfg(target_os = "linux")]
  if let Some(app_image_path) = &env.appimage {
    return PathBuf::from(app_image_path);
  }

  extract_path
}

// Convert base64 to string and prevent failing
fn base64_to_string(base64_string: &str) -> Result<String> {
  let decoded_string = &decode(base64_string)?;
  let result = from_utf8(decoded_string)?.to_string();
  Ok(result)
}

// Validate signature
// need to be public because its been used
// by our tests in the bundler
//
// NOTE: The buffer position is not reset.
pub fn verify_signature<R>(
  archive_reader: &mut R,
  release_signature: &str,
  pub_key: &str,
) -> Result<bool>
where
  R: Read,
{
  // we need to convert the pub key
  let pub_key_decoded = base64_to_string(pub_key)?;
  let public_key = PublicKey::decode(&pub_key_decoded)?;
  let signature_base64_decoded = base64_to_string(release_signature)?;
  let signature = Signature::decode(&signature_base64_decoded)?;

  // read all bytes until EOF in the buffer
  let mut data = Vec::new();
  archive_reader.read_to_end(&mut data)?;

  // Validate signature or bail out
  public_key.verify(&data, &signature, true)?;
  Ok(true)
}

#[cfg(test)]
mod test {
  use super::*;
  #[cfg(target_os = "macos")]
  use std::fs::File;

  macro_rules! block {
    ($e:expr) => {
      tokio_test::block_on($e)
    };
  }

  fn generate_sample_raw_json() -> String {
    r#"{
      "version": "v2.0.0",
      "notes": "Test version !",
      "pub_date": "2020-06-22T19:25:57Z",
      "platforms": {
        "darwin": {
          "signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUldUTE5QWWxkQnlZOVJZVGdpKzJmRWZ0SkRvWS9TdFpqTU9xcm1mUmJSSG5OWVlwSklrWkN1SFpWbmh4SDlBcTU3SXpjbm0xMmRjRkphbkpVeGhGcTdrdzlrWGpGVWZQSWdzPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNTkyOTE1MDU3CWZpbGU6L1VzZXJzL3J1bm5lci9ydW5uZXJzLzIuMjYzLjAvd29yay90YXVyaS90YXVyaS90YXVyaS9leGFtcGxlcy9jb21tdW5pY2F0aW9uL3NyYy10YXVyaS90YXJnZXQvZGVidWcvYnVuZGxlL29zeC9hcHAuYXBwLnRhci5negp4ZHFlUkJTVnpGUXdDdEhydTE5TGgvRlVPeVhjTnM5RHdmaGx3c0ZPWjZXWnFwVDRNWEFSbUJTZ1ZkU1IwckJGdmlwSzJPd00zZEZFN2hJOFUvL1FDZz09Cg==",
          "url": "https://github.com/lemarier/tauri-test/releases/download/v1.0.0/app.app.tar.gz"
        },
        "linux": {
          "signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUldUTE5QWWxkQnlZOWZSM29hTFNmUEdXMHRoOC81WDFFVVFRaXdWOUdXUUdwT0NlMldqdXkyaWVieXpoUmdZeXBJaXRqSm1YVmczNXdRL1Brc0tHb1NOTzhrL1hadFcxdmdnPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNTkyOTE3MzQzCWZpbGU6L2hvbWUvcnVubmVyL3dvcmsvdGF1cmkvdGF1cmkvdGF1cmkvZXhhbXBsZXMvY29tbXVuaWNhdGlvbi9zcmMtdGF1cmkvdGFyZ2V0L2RlYnVnL2J1bmRsZS9hcHBpbWFnZS9hcHAuQXBwSW1hZ2UudGFyLmd6CmRUTUM2bWxnbEtTbUhOZGtERUtaZnpUMG5qbVo5TGhtZWE1SFNWMk5OOENaVEZHcnAvVW0zc1A2ajJEbWZUbU0yalRHT0FYYjJNVTVHOHdTQlYwQkF3PT0K",
          "url": "https://github.com/lemarier/tauri-test/releases/download/v1.0.0/app.AppImage.tar.gz"
        },
        "win64": {
          "signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUldUTE5QWWxkQnlZOVJHMWlvTzRUSlQzTHJOMm5waWpic0p0VVI2R0hUNGxhQVMxdzBPRndlbGpXQXJJakpTN0toRURtVzBkcm15R0VaNTJuS1lZRWdzMzZsWlNKUVAzZGdJPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNTkyOTE1NTIzCWZpbGU6RDpcYVx0YXVyaVx0YXVyaVx0YXVyaVxleGFtcGxlc1xjb21tdW5pY2F0aW9uXHNyYy10YXVyaVx0YXJnZXRcZGVidWdcYXBwLng2NC5tc2kuemlwCitXa1lQc3A2MCs1KzEwZnVhOGxyZ2dGMlZqbjBaVUplWEltYUdyZ255eUF6eVF1dldWZzFObStaVEQ3QU1RS1lzcjhDVU4wWFovQ1p1QjJXbW1YZUJ3PT0K",
          "url": "https://github.com/lemarier/tauri-test/releases/download/v1.0.0/app.x64.msi.zip"
        }
      }
    }"#.into()
  }

  fn generate_sample_platform_json(
    version: &str,
    public_signature: &str,
    download_url: &str,
  ) -> String {
    format!(
      r#"
        {{
          "name": "v{}",
          "notes": "This is the latest version! Once updated you shouldn't see this prompt.",
          "pub_date": "2020-06-25T14:14:19Z",
          "signature": "{}",
          "url": "{}"
        }}
      "#,
      version, public_signature, download_url
    )
  }

  fn generate_sample_with_elevated_task_platform_json(
    version: &str,
    public_signature: &str,
    download_url: &str,
    with_elevated_task: bool,
  ) -> String {
    format!(
      r#"
        {{
          "name": "v{}",
          "notes": "This is the latest version! Once updated you shouldn't see this prompt.",
          "pub_date": "2020-06-25T14:14:19Z",
          "signature": "{}",
          "url": "{}",
          "with_elevated_task": "{}"
        }}
      "#,
      version, public_signature, download_url, with_elevated_task
    )
  }

  fn generate_sample_bad_json() -> String {
    r#"{
      "version": "v0.0.3",
      "notes": "Blablaa",
      "date": "2020-02-20T15:41:00Z",
      "download_link": "https://github.com/lemarier/tauri-test/releases/download/v0.0.1/update3.tar.gz"
    }"#.into()
  }

  #[test]
  fn simple_http_updater() {
    let _m = mockito::mock("GET", "/")
      .with_status(200)
      .with_header("content-type", "application/json")
      .with_body(generate_sample_raw_json())
      .create();

    let check_update = block!(builder(Default::default())
      .current_version("0.0.0")
      .url(mockito::server_url())
      .build());

    assert!(check_update.is_ok());
    let updater = check_update.expect("Can't check update");

    assert!(updater.should_update);
  }

  #[test]
  fn simple_http_updater_raw_json() {
    let _m = mockito::mock("GET", "/")
      .with_status(200)
      .with_header("content-type", "application/json")
      .with_body(generate_sample_raw_json())
      .create();

    let check_update = block!(builder(Default::default())
      .current_version("0.0.0")
      .url(mockito::server_url())
      .build());

    assert!(check_update.is_ok());
    let updater = check_update.expect("Can't check update");

    assert!(updater.should_update);
  }

  #[test]
  fn simple_http_updater_raw_json_win64() {
    let _m = mockito::mock("GET", "/")
      .with_status(200)
      .with_header("content-type", "application/json")
      .with_body(generate_sample_raw_json())
      .create();

    let check_update = block!(builder(Default::default())
      .current_version("0.0.0")
      .target("win64")
      .url(mockito::server_url())
      .build());

    assert!(check_update.is_ok());
    let updater = check_update.expect("Can't check update");

    assert!(updater.should_update);
    assert_eq!(updater.version, "2.0.0");
    assert_eq!(updater.signature, Some("dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUldUTE5QWWxkQnlZOVJHMWlvTzRUSlQzTHJOMm5waWpic0p0VVI2R0hUNGxhQVMxdzBPRndlbGpXQXJJakpTN0toRURtVzBkcm15R0VaNTJuS1lZRWdzMzZsWlNKUVAzZGdJPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNTkyOTE1NTIzCWZpbGU6RDpcYVx0YXVyaVx0YXVyaVx0YXVyaVxleGFtcGxlc1xjb21tdW5pY2F0aW9uXHNyYy10YXVyaVx0YXJnZXRcZGVidWdcYXBwLng2NC5tc2kuemlwCitXa1lQc3A2MCs1KzEwZnVhOGxyZ2dGMlZqbjBaVUplWEltYUdyZ255eUF6eVF1dldWZzFObStaVEQ3QU1RS1lzcjhDVU4wWFovQ1p1QjJXbW1YZUJ3PT0K".into()));
    assert_eq!(
      updater.download_url,
      "https://github.com/lemarier/tauri-test/releases/download/v1.0.0/app.x64.msi.zip"
    );
  }

  #[test]
  fn simple_http_updater_raw_json_uptodate() {
    let _m = mockito::mock("GET", "/")
      .with_status(200)
      .with_header("content-type", "application/json")
      .with_body(generate_sample_raw_json())
      .create();

    let check_update = block!(builder(Default::default())
      .current_version("10.0.0")
      .url(mockito::server_url())
      .build());

    assert!(check_update.is_ok());
    let updater = check_update.expect("Can't check update");

    assert!(!updater.should_update);
  }

  #[test]
  fn simple_http_updater_without_version() {
    let _m = mockito::mock("GET", "/darwin/1.0.0")
      .with_status(200)
      .with_header("content-type", "application/json")
      .with_body(generate_sample_platform_json(
        "2.0.0",
        "SampleTauriKey",
        "https://tauri.studio",
      ))
      .create();

    let check_update = block!(builder(Default::default())
      .current_version("1.0.0")
      .url(format!(
        "{}/darwin/{{{{current_version}}}}",
        mockito::server_url()
      ))
      .build());

    assert!(check_update.is_ok());
    let updater = check_update.expect("Can't check update");

    assert!(updater.should_update);
  }

  #[test]
  fn simple_http_updater_percent_decode() {
    let _m = mockito::mock("GET", "/darwin/1.0.0")
      .with_status(200)
      .with_header("content-type", "application/json")
      .with_body(generate_sample_platform_json(
        "2.0.0",
        "SampleTauriKey",
        "https://tauri.studio",
      ))
      .create();

    let check_update = block!(builder(Default::default())
      .current_version("1.0.0")
      .url(
        url::Url::parse(&format!(
          "{}/darwin/{{{{current_version}}}}",
          mockito::server_url()
        ))
        .unwrap()
        .to_string()
      )
      .build());

    assert!(check_update.is_ok());
    let updater = check_update.expect("Can't check update");

    assert!(updater.should_update);

    let check_update = block!(builder(Default::default())
      .current_version("1.0.0")
      .urls(&[url::Url::parse(&format!(
        "{}/darwin/{{{{current_version}}}}",
        mockito::server_url()
      ))
      .unwrap()
      .to_string()])
      .build());

    assert!(check_update.is_ok());
    let updater = check_update.expect("Can't check update");

    assert!(updater.should_update);
  }

  #[test]
  fn simple_http_updater_with_elevated_task() {
    let _m = mockito::mock("GET", "/win64/1.0.0")
      .with_status(200)
      .with_header("content-type", "application/json")
      .with_body(generate_sample_with_elevated_task_platform_json(
        "2.0.0",
        "SampleTauriKey",
        "https://tauri.studio",
        true,
      ))
      .create();

    let check_update = block!(builder(Default::default())
      .current_version("1.0.0")
      .url(format!(
        "{}/win64/{{{{current_version}}}}",
        mockito::server_url()
      ))
      .build());

    assert!(check_update.is_ok());
    let updater = check_update.expect("Can't check update");

    assert!(updater.should_update);
  }

  #[test]
  fn http_updater_uptodate() {
    let _m = mockito::mock("GET", "/darwin/10.0.0")
      .with_status(200)
      .with_header("content-type", "application/json")
      .with_body(generate_sample_platform_json(
        "2.0.0",
        "SampleTauriKey",
        "https://tauri.studio",
      ))
      .create();

    let check_update = block!(builder(Default::default())
      .current_version("10.0.0")
      .url(format!(
        "{}/darwin/{{{{current_version}}}}",
        mockito::server_url()
      ))
      .build());

    assert!(check_update.is_ok());
    let updater = check_update.expect("Can't check update");

    assert!(!updater.should_update);
  }

  #[test]
  fn http_updater_fallback_urls() {
    let _m = mockito::mock("GET", "/")
      .with_status(200)
      .with_header("content-type", "application/json")
      .with_body(generate_sample_raw_json())
      .create();

    let check_update = block!(builder(Default::default())
      .url("http://badurl.www.tld/1".into())
      .url(mockito::server_url())
      .current_version("0.0.1")
      .build());

    assert!(check_update.is_ok());
    let updater = check_update.expect("Can't check remote update");

    assert!(updater.should_update);
  }

  #[test]
  fn http_updater_fallback_urls_withs_array() {
    let _m = mockito::mock("GET", "/")
      .with_status(200)
      .with_header("content-type", "application/json")
      .with_body(generate_sample_raw_json())
      .create();

    let check_update = block!(builder(Default::default())
      .urls(&["http://badurl.www.tld/1".into(), mockito::server_url(),])
      .current_version("0.0.1")
      .build());

    assert!(check_update.is_ok());
    let updater = check_update.expect("Can't check remote update");

    assert!(updater.should_update);
  }

  #[test]
  fn http_updater_missing_remote_data() {
    let _m = mockito::mock("GET", "/")
      .with_status(200)
      .with_header("content-type", "application/json")
      .with_body(generate_sample_bad_json())
      .create();

    let check_update = block!(builder(Default::default())
      .url(mockito::server_url())
      .current_version("0.0.1")
      .build());

    assert!(check_update.is_err());
  }

  // run complete process on mac only for now as we don't have
  // server (api) that we can use to test
  #[test]
  #[cfg(target_os = "macos")]
  fn http_updater_complete_process() {
    #[cfg(target_os = "macos")]
    let archive_file = "archive.macos.tar.gz";
    #[cfg(target_os = "linux")]
    let archive_file = "archive.linux.tar.gz";
    #[cfg(target_os = "windows")]
    let archive_file = "archive.windows.zip";

    let good_archive_url = format!("{}/{}", mockito::server_url(), archive_file);

    let mut signature_file = File::open(format!(
      "./test/updater/fixture/archives/{}.sig",
      archive_file
    ))
    .expect("Unable to open signature");
    let mut signature = String::new();
    signature_file
      .read_to_string(&mut signature)
      .expect("Unable to read signature as string");

    let mut pubkey_file = File::open("./test/updater/fixture/good_signature/update.key.pub")
      .expect("Unable to open pubkey");
    let mut pubkey = String::new();
    pubkey_file
      .read_to_string(&mut pubkey)
      .expect("Unable to read signature as string");

    // add sample file
    let _m = mockito::mock("GET", format!("/{}", archive_file).as_str())
      .with_status(200)
      .with_header("content-type", "application/octet-stream")
      .with_body_from_file(format!("./test/updater/fixture/archives/{}", archive_file))
      .create();

    // sample mock for update file
    let _m = mockito::mock("GET", "/")
      .with_status(200)
      .with_header("content-type", "application/json")
      .with_body(generate_sample_platform_json(
        "2.0.1",
        signature.as_ref(),
        good_archive_url.as_ref(),
      ))
      .create();

    // Build a tmpdir so we can test our extraction inside
    // We dont want to overwrite our current executable or the directory
    // Otherwise tests are failing...
    let executable_path = current_exe().expect("Can't extract executable path");
    let parent_path = executable_path
      .parent()
      .expect("Can't find the parent path");

    let tmp_dir = tempfile::Builder::new()
      .prefix("tauri_updater_test")
      .tempdir_in(parent_path);

    assert!(tmp_dir.is_ok());
    let tmp_dir_unwrap = tmp_dir.expect("Can't find tmp_dir");
    let tmp_dir_path = tmp_dir_unwrap.path();

    #[cfg(target_os = "linux")]
    let my_executable = &tmp_dir_path.join("updater-example_0.1.0_amd64.AppImage");
    #[cfg(target_os = "macos")]
    let my_executable = &tmp_dir_path.join("my_app");
    #[cfg(target_os = "windows")]
    let my_executable = &tmp_dir_path.join("my_app.exe");

    // configure the updater
    let check_update = block!(builder(Default::default())
      .url(mockito::server_url())
      // It should represent the executable path, that's why we add my_app.exe in our
      // test path -- in production you shouldn't have to provide it
      .executable_path(my_executable)
      // make sure we force an update
      .current_version("1.0.0")
      .build());

    #[cfg(target_os = "linux")]
    {
      env::set_var("APPIMAGE", my_executable);
    }

    // make sure the process worked
    assert!(check_update.is_ok());

    // unwrap our results
    let updater = check_update.expect("Can't check remote update");

    // make sure we need to update
    assert!(updater.should_update);
    // make sure we can read announced version
    assert_eq!(updater.version, "2.0.1");

    // download, install and validate signature
    let install_process = block!(updater.download_and_install(pubkey));
    assert!(install_process.is_ok());

    // make sure the extraction went well (it should have skipped the main app.app folder)
    // as we can't extract in /Applications directly
    #[cfg(target_os = "macos")]
    let bin_file = tmp_dir_path.join("Contents").join("MacOS").join("app");
    #[cfg(target_os = "linux")]
    // linux should extract at same place as the executable path
    let bin_file = my_executable;
    #[cfg(target_os = "windows")]
    let bin_file = tmp_dir_path.join("with").join("long").join("path.json");

    assert!(bin_file.exists());
  }
}