Ver código fonte

refactor(updater): replace manual parsing with struct definitions (#4162)

Co-authored-by: Lucas Nogueira <lucas@tauri.studio>
Jonas Kruckenberg 3 anos atrás
pai
commit
50a47f8c7a
3 arquivos alterados com 158 adições e 169 exclusões
  1. 1 1
      core/tauri/Cargo.toml
  2. 157 165
      core/tauri/src/updater/core.rs
  3. 0 3
      core/tauri/src/updater/error.rs

+ 1 - 1
core/tauri/Cargo.toml

@@ -60,7 +60,7 @@ tauri-macros = { version = "1.0.0-rc.7", path = "../tauri-macros" }
 tauri-utils = { version = "1.0.0-rc.7", features = [ "resources" ], path = "../tauri-utils" }
 tauri-runtime-wry = { version = "0.5.2", path = "../tauri-runtime-wry", optional = true }
 rand = "0.8"
-semver = "1.0"
+semver = { version = "1.0", features = [ "serde" ] }
 serde_repr = "0.1"
 state = "0.5"
 tar = "0.4.36"

+ 157 - 165
core/tauri/src/updater/core.rs

@@ -18,15 +18,20 @@ use http::{
   HeaderMap, StatusCode,
 };
 use minisign_verify::{PublicKey, Signature};
+use semver::Version;
+use serde::{de::Error as DeError, Deserialize, Deserializer, Serialize};
 use tauri_utils::{platform::current_exe, Env};
+use url::Url;
 
 #[cfg(feature = "updater")]
 use std::io::Seek;
 use std::{
-  env, fmt,
+  collections::HashMap,
+  env,
+  fmt::{self},
   io::{Cursor, Read},
   path::{Path, PathBuf},
-  str::from_utf8,
+  str::{from_utf8, FromStr},
   time::Duration,
 };
 
@@ -43,161 +48,142 @@ use std::{
   process::{exit, Command},
 };
 
-#[derive(Debug)]
+#[derive(Debug, Deserialize, Serialize)]
+#[serde(untagged)]
+pub enum RemoteReleaseInner {
+  Dynamic(ReleaseManifestPlatform),
+  Static {
+    platforms: HashMap<String, ReleaseManifestPlatform>,
+  },
+}
+
+/// 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, Serialize)]
 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
+  /// Version to install.
+  pub version: Version,
+  /// Release notes.
+  pub notes: Option<String>,
+  /// Release date.
+  pub pub_date: String,
+  /// Release data.
+  #[serde(flatten)]
+  pub data: RemoteReleaseInner,
+}
+
+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: String,
+      platforms: Option<HashMap<String, ReleaseManifestPlatform>>,
+      // dynamic platform response
+      url: Option<Url>,
+      signature: Option<String>,
+      #[cfg(target_os = "windows")]
+      #[serde(default)]
+      with_elevated_task: bool,
+    }
+
+    let release = InnerRemoteRelease::deserialize(deserializer)?;
+
+    Ok(RemoteRelease {
+      version: release.version,
+      notes: release.notes,
+      pub_date: release.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")
+          })?,
+          #[cfg(target_os = "windows")]
+          with_elevated_task: release.with_elevated_task,
+        })
+      },
+    })
+  }
+}
+
+#[derive(Debug, Deserialize, Serialize)]
+pub struct ReleaseManifestPlatform {
+  /// Download URL for the platform
+  pub url: Url,
+  /// Signature for the platform
   pub signature: String,
   #[cfg(target_os = "windows")]
+  #[serde(default)]
   /// Optional: Windows only try to use elevated task
   pub with_elevated_task: bool,
 }
 
+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)
+}
+
 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::InvalidResponseType("version", "string", version.clone()))?
-        .trim_start_matches('v')
-        .to_string(),
-      None => {
-        let name = release
-          .get("name")
-          .ok_or(Error::MissingResponseField("version or name"))?;
-        name
-          .as_str()
-          .ok_or_else(|| Error::InvalidResponseType("name", "string", name.clone()))?
-          .trim_start_matches('v')
-          .to_string()
-      }
-    };
+  pub fn version(&self) -> &Version {
+    &self.version
+  }
 
-    // pub_date is required default is: `N/A` if not provided by the remote JSON
-    let date = if let Some(date) = release.get("pub_date") {
-      date
-        .as_str()
-        .map(|d| d.to_string())
-        .ok_or_else(|| Error::InvalidResponseType("pub_date", "string", date.clone()))?
-    } else {
-      "N/A".into()
-    };
+  pub fn notes(&self) -> &Option<String> {
+    &self.notes
+  }
 
-    // body is optional to build our update
-    let body = if let Some(notes) = release.get("notes") {
-      Some(
-        notes
-          .as_str()
-          .map(|n| n.to_string())
-          .ok_or_else(|| Error::InvalidResponseType("notes", "string", notes.clone()))?,
-      )
-    } else {
-      None
-    };
+  pub fn pub_date(&self) -> &String {
+    &self.pub_date
+  }
 
-    let download_url;
-    #[cfg(target_os = "windows")]
-    let with_elevated_task;
-    let signature;
-
-    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")
-            .ok_or(Error::MissingResponseField("signature"))
-            .and_then(|signature| {
-              signature
-                .as_str()
-                .ok_or_else(|| Error::InvalidResponseType("signature", "string", signature.clone()))
-            })?;
-          // Download URL is required
-          let url = current_target_data
-            .get("url")
-            .ok_or(Error::MissingResponseField("url"))?;
-          download_url = url
-            .as_str()
-            .ok_or_else(|| Error::InvalidResponseType("url", "string", url.clone()))?
-            .to_string();
-          #[cfg(target_os = "windows")]
-          {
-            with_elevated_task = current_target_data
-              .get("with_elevated_task")
-              .map(|v| {
-                v.as_bool().ok_or_else(|| {
-                  Error::InvalidResponseType("with_elevated_task", "boolean", v.clone())
-                })
-              })
-              .unwrap_or(Ok(false))?;
-          }
-        } else {
-          // make sure we have an available platform from the static
-          return Err(Error::TargetNotFound(target.into()));
-        }
-      }
-      // We don't have the `platforms` field announced, let's assume our
-      // download URL is at the root of the JSON.
-      None => {
-        signature = release
-          .get("signature")
-          .ok_or(Error::MissingResponseField("signature"))
-          .and_then(|signature| {
-            signature
-              .as_str()
-              .ok_or_else(|| Error::InvalidResponseType("signature", "string", signature.clone()))
-          })?;
-        let url = release
-          .get("url")
-          .ok_or(Error::MissingResponseField("url"))?;
-        download_url = url
-          .as_str()
-          .ok_or_else(|| Error::InvalidResponseType("url", "string", url.clone()))?
-          .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,
-          };
-        }
-      }
+  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)
+        }),
+    }
+  }
+
+  #[cfg(target_os = "windows")]
+  /// Optional: Windows only try to use elevated task
+  pub fn with_elevated_task(&self, target: &str) -> Result<bool> {
+    match self.data {
+      RemoteReleaseInner::Dynamic(ref platform) => Ok(platform.with_elevated_task),
+      RemoteReleaseInner::Static { ref platforms } => platforms
+        .get(target)
+        .map_or(Err(Error::TargetNotFound(target.to_string())), |platform| {
+          Ok(platform.with_elevated_task)
+        }),
     }
-    // Return our formatted release
-    Ok(RemoteRelease {
-      version,
-      date,
-      download_url,
-      body,
-      signature: signature.to_string(),
-      #[cfg(target_os = "windows")]
-      with_elevated_task,
-    })
   }
 }
 
@@ -394,7 +380,7 @@ impl<R: Runtime> UpdateBuilder<R> {
             return Err(Error::UpToDate);
           };
           // Convert the remote result to our local struct
-          let built_release = RemoteRelease::from_release(&res.data, &json_target);
+          let built_release = serde_json::from_value(res.data).map_err(Into::into);
           // make sure all went well and the remote data is compatible
           // with what we need locally
           match built_release {
@@ -420,9 +406,10 @@ impl<R: Runtime> UpdateBuilder<R> {
 
     // did the announced version is greated than our current one?
     let should_update = if let Some(comparator) = self.should_install.take() {
-      comparator(&self.current_version, &final_release.version)
+      comparator(&self.current_version, &final_release.version().to_string())
     } else {
-      version::is_greater(&self.current_version, &final_release.version).unwrap_or(false)
+      version::is_greater(&self.current_version, &final_release.version().to_string())
+        .unwrap_or(false)
     };
 
     headers.remove("Accept");
@@ -433,14 +420,14 @@ impl<R: Runtime> UpdateBuilder<R> {
       target,
       extract_path,
       should_update,
-      version: final_release.version,
-      date: final_release.date,
+      version: final_release.version().to_string(),
+      date: final_release.pub_date().to_string(),
       current_version: self.current_version.to_owned(),
-      download_url: final_release.download_url,
-      body: final_release.body,
-      signature: final_release.signature,
+      download_url: final_release.download_url(&json_target)?.to_owned(),
+      body: final_release.notes().to_owned(),
+      signature: final_release.signature(&json_target)?.to_owned(),
       #[cfg(target_os = "windows")]
-      with_elevated_task: final_release.with_elevated_task,
+      with_elevated_task: final_release.with_elevated_task(&json_target)?,
       timeout: self.timeout,
       headers,
     })
@@ -471,7 +458,7 @@ pub struct Update<R: Runtime> {
   /// Extract path
   extract_path: PathBuf,
   /// Download URL announced
-  download_url: String,
+  download_url: Url,
   /// Signature announced
   signature: String,
   #[cfg(target_os = "windows")]
@@ -1008,7 +995,7 @@ mod test {
           "pub_date": "2020-06-25T14:14:19Z",
           "signature": "{}",
           "url": "{}",
-          "with_elevated_task": "{}"
+          "with_elevated_task": {}
         }}
       "#,
       version, public_signature, download_url, with_elevated_task
@@ -1074,7 +1061,7 @@ mod test {
     assert_eq!(updater.version, "2.0.0");
     assert_eq!(updater.signature, "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUldUTE5QWWxkQnlZOVJHMWlvTzRUSlQzTHJOMm5waWpic0p0VVI2R0hUNGxhQVMxdzBPRndlbGpXQXJJakpTN0toRURtVzBkcm15R0VaNTJuS1lZRWdzMzZsWlNKUVAzZGdJPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNTkyOTE1NTIzCWZpbGU6RDpcYVx0YXVyaVx0YXVyaVx0YXVyaVxleGFtcGxlc1xjb21tdW5pY2F0aW9uXHNyYy10YXVyaVx0YXJnZXRcZGVidWdcYXBwLng2NC5tc2kuemlwCitXa1lQc3A2MCs1KzEwZnVhOGxyZ2dGMlZqbjBaVUplWEltYUdyZ255eUF6eVF1dldWZzFObStaVEQ3QU1RS1lzcjhDVU4wWFovQ1p1QjJXbW1YZUJ3PT0K");
     assert_eq!(
-      updater.download_url,
+      updater.download_url.to_string(),
       "https://github.com/tauri-apps/updater-test/releases/download/v1.0.0/app.x64.msi.zip"
     );
   }
@@ -1448,19 +1435,23 @@ mod test {
       }
     }"#;
 
+    fn missing_field_error(field: &str) -> String {
+      format!("the `{}` field was not set on the updater response", field)
+    }
+
     let test_cases = [
-      (missing_signature, Error::MissingResponseField("signature")),
+      (missing_signature, missing_field_error("signature")),
+      (missing_version, "missing field `version`".to_string()),
+      (missing_url, missing_field_error("url")),
       (
-        missing_version,
-        Error::MissingResponseField("version or name"),
+        missing_target,
+        Error::TargetNotFound("test-target".into()).to_string(),
       ),
-      (missing_url, Error::MissingResponseField("url")),
-      (missing_target, Error::TargetNotFound("test-target".into())),
       (
         missing_platform_signature,
-        Error::MissingResponseField("signature"),
+        "missing field `signature`".to_string(),
       ),
-      (missing_platform_url, Error::MissingResponseField("url")),
+      (missing_platform_url, "missing field `url`".to_string()),
     ];
 
     for (response, error) in test_cases {
@@ -1477,7 +1468,8 @@ mod test {
         .target("test-target")
         .build());
       if let Err(e) = check_update {
-        assert_eq!(e.to_string(), error.to_string());
+        println!("ERROR: {}, expected: {}", e, error);
+        assert!(e.to_string().contains(&error));
       } else {
         panic!("unexpected Ok response");
       }

+ 0 - 3
core/tauri/src/updater/error.rs

@@ -59,9 +59,6 @@ pub enum Error {
   /// On client side, it's important to catch this error.
   #[error("No updates available")]
   UpToDate,
-  /// The server did not include the signature field.
-  #[error("the `{0}` field was not set on the updater response")]
-  MissingResponseField(&'static str),
   /// The updater responded with an invalid signature type.
   #[error("the updater response field `{0}` type is invalid, expected {1} but found {2}")]
   InvalidResponseType(&'static str, &'static str, serde_json::Value),