瀏覽代碼

feat(core): expose functions to serialize `serde::Serialize` values to JS (#3354)

Lucas Fernandes Nogueira 3 年之前
父節點
當前提交
5a94200f65
共有 2 個文件被更改,包括 150 次插入49 次删除
  1. 5 0
      .changes/expose-escape-json-string.md
  2. 145 49
      core/tauri/src/api/ipc.rs

+ 5 - 0
.changes/expose-escape-json-string.md

@@ -0,0 +1,5 @@
+---
+"tauri": patch
+---
+
+Expose `tauri::api::ipc::{serialize_js_with, serialize_js}` functions.

+ 145 - 49
core/tauri/src/api/ipc.rs

@@ -22,7 +22,7 @@ pub struct CallbackFn(pub usize);
 /// In Firefox, strings have a maximum length of 2\*\*30 - 2 (~1GB). In versions prior to Firefox 65, the maximum length was 2\*\*28 - 1 (~256MB).
 const MAX_JSON_STR_LEN: usize = usize::pow(2, 30) - 2;
 
-/// Minimum size JSON needs to be in order to convert it to JSON.parse with [`escape_json_parse`].
+/// Minimum size JSON needs to be in order to convert it to JSON.parse with [`format_json`].
 // TODO: this number should be benchmarked and checked for optimal range, I set 10 KiB arbitrarily
 // we don't want to lose the gained object parsing time to extra allocations preparing it
 const MIN_JSON_PARSE_LEN: usize = 10_240;
@@ -40,7 +40,7 @@ const MIN_JSON_PARSE_LEN: usize = 10_240;
 /// 1. `serde_json`'s ability to correctly escape and format json into a string.
 /// 2. JavaScript engines not accepting anything except another unescaped, literal single quote
 ///     character to end a string that was opened with it.
-fn escape_json_parse(json: &RawValue) -> String {
+fn escape(json: &RawValue) -> String {
   let json = json.get();
 
   // 14 chars in JSON.parse('')
@@ -62,6 +62,109 @@ fn escape_json_parse(json: &RawValue) -> String {
   s
 }
 
+/// Transforms & escapes a JSON value.
+///
+/// If it's an object or array, JSON.parse('{json}') is used, with the '{json}' string properly escaped.
+/// The return value of this function can be safely used on [`eval`](crate::Window#method.eval) calls.
+///
+/// Single quotes chosen because double quotes are already used in JSON. With single quotes, we only
+/// need to escape strings that include backslashes or single quotes. If we used double quotes, then
+/// there would be no cases that a string doesn't need escaping.
+///
+/// The function takes a closure to handle the escaped string in order to avoid unnecessary allocations.
+///
+/// # Safety
+///
+/// The ability to safely escape JSON into a JSON.parse('{json}') relies entirely on 2 things.
+///
+/// 1. `serde_json`'s ability to correctly escape and format json into a string.
+/// 2. JavaScript engines not accepting anything except another unescaped, literal single quote
+///     character to end a string that was opened with it.
+///
+/// # Example
+///
+/// ```
+/// use tauri::api::ipc::serialize_js_with;
+/// #[derive(serde::Serialize)]
+/// struct Foo {
+///   bar: String,
+/// }
+/// let foo = Foo { bar: "x".repeat(20_000).into() };
+/// let value = serialize_js_with(&foo, |v| format!("console.log({})", v)).unwrap();
+/// assert_eq!(value, format!("console.log(JSON.parse('{{\"bar\":\"{}\"}}'))", foo.bar));
+/// ```
+pub fn serialize_js_with<T: Serialize, F: FnOnce(&str) -> String>(
+  value: &T,
+  cb: F,
+) -> crate::api::Result<String> {
+  // get a raw &str representation of a serialized json value.
+  let string = serde_json::to_string(value)?;
+  let raw = RawValue::from_string(string)?;
+
+  // from here we know json.len() > 1 because an empty string is not a valid json value.
+  let json = raw.get();
+  let first = json.as_bytes()[0];
+
+  #[cfg(debug_assertions)]
+  if first == b'"' {
+    assert!(
+      json.len() < MAX_JSON_STR_LEN,
+      "passing a string larger than the max JavaScript literal string size"
+    )
+  }
+
+  let return_val = if json.len() > MIN_JSON_PARSE_LEN && (first == b'{' || first == b'[') {
+    let escaped = escape(&raw);
+    // only use JSON.parse('{arg}') for arrays and objects less than the limit
+    // smaller literals do not benefit from being parsed from json
+    if escaped.len() < MAX_JSON_STR_LEN {
+      cb(&escaped)
+    } else {
+      cb(json)
+    }
+  } else {
+    cb(json)
+  };
+
+  Ok(return_val)
+}
+
+/// Transforms & escapes a JSON value.
+///
+/// This is a convenience function for [`serialize_js_with`], simply allocating the result to a String.
+///
+/// For usage in functions where performance is more important than code readability, see [`serialize_js_with`].
+///
+/// # Example
+/// ```rust,no_run
+/// use tauri::{Manager, api::ipc::serialize_js};
+/// use serde::Serialize;
+///
+/// #[derive(Serialize)]
+/// struct Foo {
+///   bar: String,
+/// }
+///
+/// #[derive(Serialize)]
+/// struct Bar {
+///   baz: u32,
+/// }
+///
+/// tauri::Builder::default()
+///   .setup(|app| {
+///     let window = app.get_window("main").unwrap();
+///     window.eval(&format!(
+///       "console.log({}, {})",
+///       serialize_js(&Foo { bar: "bar".to_string() }).unwrap(),
+///       serialize_js(&Bar { baz: 0 }).unwrap()),
+///     ).unwrap();
+///     Ok(())
+///   });
+/// ```
+pub fn serialize_js<T: Serialize>(value: &T) -> crate::api::Result<String> {
+  serialize_js_with(value, |v| v.into())
+}
+
 /// Formats a function name and argument to be evaluated as callback.
 ///
 /// This will serialize primitive JSON types (e.g. booleans, strings, numbers, etc.) as JavaScript literals,
@@ -100,52 +203,18 @@ pub fn format_callback<T: Serialize>(
   function_name: CallbackFn,
   arg: &T,
 ) -> crate::api::Result<String> {
-  macro_rules! format_callback {
-    ( $arg:expr ) => {
-      format!(
-        r#"
-          if (window["_{fn}"]) {{
-            window["_{fn}"]({arg})
-          }} else {{
-            console.warn("[TAURI] Couldn't find callback id {fn} in window. This happens when the app is reloaded while Rust is running an asynchronous operation.")
-          }}
-        "#,
-        fn = function_name.0,
-        arg = $arg
-      )
-    }
-  }
-
-  // get a raw &str representation of a serialized json value.
-  let string = serde_json::to_string(arg)?;
-  let raw = RawValue::from_string(string)?;
-
-  // from here we know json.len() > 1 because an empty string is not a valid json value.
-  let json = raw.get();
-  let first = json.as_bytes()[0];
-
-  #[cfg(debug_assertions)]
-  if first == b'"' {
-    debug_assert!(
-      json.len() < MAX_JSON_STR_LEN,
-      "passing a callback string larger than the max JavaScript literal string size"
+  serialize_js_with(arg, |arg| {
+    format!(
+      r#"
+    if (window["_{fn}"]) {{
+      window["_{fn}"]({arg})
+    }} else {{
+      console.warn("[TAURI] Couldn't find callback id {fn} in window. This happens when the app is reloaded while Rust is running an asynchronous operation.")
+    }}"#,
+      fn = function_name.0,
+      arg = arg
     )
-  }
-
-  // only use JSON.parse('{arg}') for arrays and objects less than the limit
-  // smaller literals do not benefit from being parsed from json
-  Ok(
-    if json.len() > MIN_JSON_PARSE_LEN && (first == b'{' || first == b'[') {
-      let escaped = escape_json_parse(&raw);
-      if escaped.len() < MAX_JSON_STR_LEN {
-        format_callback!(escaped)
-      } else {
-        format_callback!(json)
-      }
-    } else {
-      format_callback!(json)
-    },
-  )
+  })
 }
 
 /// Formats a Result type to its Promise response.
@@ -195,7 +264,34 @@ mod test {
   }
 
   #[test]
-  fn test_escape_json_parse() {
+  fn test_serialize_js() {
+    assert_eq!(serialize_js(&()).unwrap(), "null");
+    assert_eq!(serialize_js(&5i32).unwrap(), "5");
+
+    #[derive(serde::Serialize)]
+    struct JsonObj {
+      value: String,
+    }
+
+    let raw_str = "T".repeat(MIN_JSON_PARSE_LEN);
+    assert_eq!(serialize_js(&raw_str).unwrap(), format!("\"{}\"", raw_str));
+
+    assert_eq!(
+      serialize_js(&JsonObj {
+        value: raw_str.clone()
+      })
+      .unwrap(),
+      format!("JSON.parse('{{\"value\":\"{}\"}}')", raw_str)
+    );
+
+    assert_eq!(
+      serialize_js(&JsonObj {
+        value: format!("\"{}\"", raw_str)
+      })
+      .unwrap(),
+      format!("JSON.parse('{{\"value\":\"\\\\\"{}\\\\\"\"}}')", raw_str)
+    );
+
     let dangerous_json = RawValue::from_string(
       r#"{"test":"don\\🚀🐱‍👤\\'t forget to escape me!🚀🐱‍👤","te🚀🐱‍👤st2":"don't forget to escape me!","test3":"\\🚀🐱‍👤\\\\'''\\\\🚀🐱‍👤\\\\🚀🐱‍👤\\'''''"}"#.into()
     ).unwrap();
@@ -207,7 +303,7 @@ mod test {
         .replace('\\', "\\\\")
         .replace('\'', "\\'")
     );
-    let escape_single_quoted_json_test = escape_json_parse(&dangerous_json);
+    let escape_single_quoted_json_test = escape(&dangerous_json);
 
     let result = r#"JSON.parse('{"test":"don\\\\🚀🐱‍👤\\\\\'t forget to escape me!🚀🐱‍👤","te🚀🐱‍👤st2":"don\'t forget to escape me!","test3":"\\\\🚀🐱‍👤\\\\\\\\\'\'\'\\\\\\\\🚀🐱‍👤\\\\\\\\🚀🐱‍👤\\\\\'\'\'\'\'"}')"#;
     assert_eq!(definitely_escaped_dangerous_json, result);