Skip to content

Commit 5a94200

Browse files
authored
feat(core): expose functions to serialize serde::Serialize values to JS (#3354)
1 parent 9aed299 commit 5a94200

2 files changed

Lines changed: 150 additions & 49 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"tauri": patch
3+
---
4+
5+
Expose `tauri::api::ipc::{serialize_js_with, serialize_js}` functions.

core/tauri/src/api/ipc.rs

Lines changed: 145 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ pub struct CallbackFn(pub usize);
2222
/// 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).
2323
const MAX_JSON_STR_LEN: usize = usize::pow(2, 30) - 2;
2424

25-
/// Minimum size JSON needs to be in order to convert it to JSON.parse with [`escape_json_parse`].
25+
/// Minimum size JSON needs to be in order to convert it to JSON.parse with [`format_json`].
2626
// TODO: this number should be benchmarked and checked for optimal range, I set 10 KiB arbitrarily
2727
// we don't want to lose the gained object parsing time to extra allocations preparing it
2828
const MIN_JSON_PARSE_LEN: usize = 10_240;
@@ -40,7 +40,7 @@ const MIN_JSON_PARSE_LEN: usize = 10_240;
4040
/// 1. `serde_json`'s ability to correctly escape and format json into a string.
4141
/// 2. JavaScript engines not accepting anything except another unescaped, literal single quote
4242
/// character to end a string that was opened with it.
43-
fn escape_json_parse(json: &RawValue) -> String {
43+
fn escape(json: &RawValue) -> String {
4444
let json = json.get();
4545

4646
// 14 chars in JSON.parse('')
@@ -62,6 +62,109 @@ fn escape_json_parse(json: &RawValue) -> String {
6262
s
6363
}
6464

65+
/// Transforms & escapes a JSON value.
66+
///
67+
/// If it's an object or array, JSON.parse('{json}') is used, with the '{json}' string properly escaped.
68+
/// The return value of this function can be safely used on [`eval`](crate::Window#method.eval) calls.
69+
///
70+
/// Single quotes chosen because double quotes are already used in JSON. With single quotes, we only
71+
/// need to escape strings that include backslashes or single quotes. If we used double quotes, then
72+
/// there would be no cases that a string doesn't need escaping.
73+
///
74+
/// The function takes a closure to handle the escaped string in order to avoid unnecessary allocations.
75+
///
76+
/// # Safety
77+
///
78+
/// The ability to safely escape JSON into a JSON.parse('{json}') relies entirely on 2 things.
79+
///
80+
/// 1. `serde_json`'s ability to correctly escape and format json into a string.
81+
/// 2. JavaScript engines not accepting anything except another unescaped, literal single quote
82+
/// character to end a string that was opened with it.
83+
///
84+
/// # Example
85+
///
86+
/// ```
87+
/// use tauri::api::ipc::serialize_js_with;
88+
/// #[derive(serde::Serialize)]
89+
/// struct Foo {
90+
/// bar: String,
91+
/// }
92+
/// let foo = Foo { bar: "x".repeat(20_000).into() };
93+
/// let value = serialize_js_with(&foo, |v| format!("console.log({})", v)).unwrap();
94+
/// assert_eq!(value, format!("console.log(JSON.parse('{{\"bar\":\"{}\"}}'))", foo.bar));
95+
/// ```
96+
pub fn serialize_js_with<T: Serialize, F: FnOnce(&str) -> String>(
97+
value: &T,
98+
cb: F,
99+
) -> crate::api::Result<String> {
100+
// get a raw &str representation of a serialized json value.
101+
let string = serde_json::to_string(value)?;
102+
let raw = RawValue::from_string(string)?;
103+
104+
// from here we know json.len() > 1 because an empty string is not a valid json value.
105+
let json = raw.get();
106+
let first = json.as_bytes()[0];
107+
108+
#[cfg(debug_assertions)]
109+
if first == b'"' {
110+
assert!(
111+
json.len() < MAX_JSON_STR_LEN,
112+
"passing a string larger than the max JavaScript literal string size"
113+
)
114+
}
115+
116+
let return_val = if json.len() > MIN_JSON_PARSE_LEN && (first == b'{' || first == b'[') {
117+
let escaped = escape(&raw);
118+
// only use JSON.parse('{arg}') for arrays and objects less than the limit
119+
// smaller literals do not benefit from being parsed from json
120+
if escaped.len() < MAX_JSON_STR_LEN {
121+
cb(&escaped)
122+
} else {
123+
cb(json)
124+
}
125+
} else {
126+
cb(json)
127+
};
128+
129+
Ok(return_val)
130+
}
131+
132+
/// Transforms & escapes a JSON value.
133+
///
134+
/// This is a convenience function for [`serialize_js_with`], simply allocating the result to a String.
135+
///
136+
/// For usage in functions where performance is more important than code readability, see [`serialize_js_with`].
137+
///
138+
/// # Example
139+
/// ```rust,no_run
140+
/// use tauri::{Manager, api::ipc::serialize_js};
141+
/// use serde::Serialize;
142+
///
143+
/// #[derive(Serialize)]
144+
/// struct Foo {
145+
/// bar: String,
146+
/// }
147+
///
148+
/// #[derive(Serialize)]
149+
/// struct Bar {
150+
/// baz: u32,
151+
/// }
152+
///
153+
/// tauri::Builder::default()
154+
/// .setup(|app| {
155+
/// let window = app.get_window("main").unwrap();
156+
/// window.eval(&format!(
157+
/// "console.log({}, {})",
158+
/// serialize_js(&Foo { bar: "bar".to_string() }).unwrap(),
159+
/// serialize_js(&Bar { baz: 0 }).unwrap()),
160+
/// ).unwrap();
161+
/// Ok(())
162+
/// });
163+
/// ```
164+
pub fn serialize_js<T: Serialize>(value: &T) -> crate::api::Result<String> {
165+
serialize_js_with(value, |v| v.into())
166+
}
167+
65168
/// Formats a function name and argument to be evaluated as callback.
66169
///
67170
/// 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>(
100203
function_name: CallbackFn,
101204
arg: &T,
102205
) -> crate::api::Result<String> {
103-
macro_rules! format_callback {
104-
( $arg:expr ) => {
105-
format!(
106-
r#"
107-
if (window["_{fn}"]) {{
108-
window["_{fn}"]({arg})
109-
}} else {{
110-
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.")
111-
}}
112-
"#,
113-
fn = function_name.0,
114-
arg = $arg
115-
)
116-
}
117-
}
118-
119-
// get a raw &str representation of a serialized json value.
120-
let string = serde_json::to_string(arg)?;
121-
let raw = RawValue::from_string(string)?;
122-
123-
// from here we know json.len() > 1 because an empty string is not a valid json value.
124-
let json = raw.get();
125-
let first = json.as_bytes()[0];
126-
127-
#[cfg(debug_assertions)]
128-
if first == b'"' {
129-
debug_assert!(
130-
json.len() < MAX_JSON_STR_LEN,
131-
"passing a callback string larger than the max JavaScript literal string size"
206+
serialize_js_with(arg, |arg| {
207+
format!(
208+
r#"
209+
if (window["_{fn}"]) {{
210+
window["_{fn}"]({arg})
211+
}} else {{
212+
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.")
213+
}}"#,
214+
fn = function_name.0,
215+
arg = arg
132216
)
133-
}
134-
135-
// only use JSON.parse('{arg}') for arrays and objects less than the limit
136-
// smaller literals do not benefit from being parsed from json
137-
Ok(
138-
if json.len() > MIN_JSON_PARSE_LEN && (first == b'{' || first == b'[') {
139-
let escaped = escape_json_parse(&raw);
140-
if escaped.len() < MAX_JSON_STR_LEN {
141-
format_callback!(escaped)
142-
} else {
143-
format_callback!(json)
144-
}
145-
} else {
146-
format_callback!(json)
147-
},
148-
)
217+
})
149218
}
150219

151220
/// Formats a Result type to its Promise response.
@@ -195,7 +264,34 @@ mod test {
195264
}
196265

197266
#[test]
198-
fn test_escape_json_parse() {
267+
fn test_serialize_js() {
268+
assert_eq!(serialize_js(&()).unwrap(), "null");
269+
assert_eq!(serialize_js(&5i32).unwrap(), "5");
270+
271+
#[derive(serde::Serialize)]
272+
struct JsonObj {
273+
value: String,
274+
}
275+
276+
let raw_str = "T".repeat(MIN_JSON_PARSE_LEN);
277+
assert_eq!(serialize_js(&raw_str).unwrap(), format!("\"{}\"", raw_str));
278+
279+
assert_eq!(
280+
serialize_js(&JsonObj {
281+
value: raw_str.clone()
282+
})
283+
.unwrap(),
284+
format!("JSON.parse('{{\"value\":\"{}\"}}')", raw_str)
285+
);
286+
287+
assert_eq!(
288+
serialize_js(&JsonObj {
289+
value: format!("\"{}\"", raw_str)
290+
})
291+
.unwrap(),
292+
format!("JSON.parse('{{\"value\":\"\\\\\"{}\\\\\"\"}}')", raw_str)
293+
);
294+
199295
let dangerous_json = RawValue::from_string(
200296
r#"{"test":"don\\🚀🐱‍👤\\'t forget to escape me!🚀🐱‍👤","te🚀🐱‍👤st2":"don't forget to escape me!","test3":"\\🚀🐱‍👤\\\\'''\\\\🚀🐱‍👤\\\\🚀🐱‍👤\\'''''"}"#.into()
201297
).unwrap();
@@ -207,7 +303,7 @@ mod test {
207303
.replace('\\', "\\\\")
208304
.replace('\'', "\\'")
209305
);
210-
let escape_single_quoted_json_test = escape_json_parse(&dangerous_json);
306+
let escape_single_quoted_json_test = escape(&dangerous_json);
211307

212308
let result = r#"JSON.parse('{"test":"don\\\\🚀🐱‍👤\\\\\'t forget to escape me!🚀🐱‍👤","te🚀🐱‍👤st2":"don\'t forget to escape me!","test3":"\\\\🚀🐱‍👤\\\\\\\\\'\'\'\\\\\\\\🚀🐱‍👤\\\\\\\\🚀🐱‍👤\\\\\'\'\'\'\'"}')"#;
213309
assert_eq!(definitely_escaped_dangerous_json, result);

0 commit comments

Comments
 (0)