Skip to content

Commit

Permalink
refactor(core): serialize response once, closes #5641 (#10641)
Browse files Browse the repository at this point in the history
* refactor(core): serialize response once closes #5641

This change impacts both the custom protocol and the postMessage based IPC implementations. Basically it changes the whole IPC mechanism to work on raw JSON strings so we do not need to serialize a serde_json::Value after serializing to it from a user-provided type.

i benchmarked this with a 150MB file response (returning Vec<u8> instead of tauri::ipc::Response since the latter does not serialize at all) and it went from 29s to 23s (custom protocol) and from 54s to 48s (post message) on macOS.

* fix mobile & lint

* clippy
  • Loading branch information
lucasfernog authored Aug 15, 2024
1 parent d1ee3f4 commit d0510f5
Show file tree
Hide file tree
Showing 17 changed files with 343 additions and 136 deletions.
6 changes: 6 additions & 0 deletions .changes/refactor-ipc-response.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"tauri": patch:breaking
---

Added a dedicated type for IPC response body `InvokeResponseBody` for performance reasons.
This is only a breaking change if you are directly using types from `tauri::ipc`.
2 changes: 1 addition & 1 deletion core/tauri-config-schema/schema.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Config",
"description": "The Tauri configuration object.\n It is read from a file where you can define your frontend assets,\n configure the bundler and define a tray icon.\n\n The configuration file is generated by the\n [`tauri init`](https://tauri.app/v1/api/cli#init) command that lives in\n your Tauri application source directory (src-tauri).\n\n Once generated, you may modify it at will to customize your Tauri application.\n\n ## File Formats\n\n By default, the configuration is defined as a JSON file named `tauri.conf.json`.\n\n Tauri also supports JSON5 and TOML files via the `config-json5` and `config-toml` Cargo features, respectively.\n The JSON5 file name must be either `tauri.conf.json` or `tauri.conf.json5`.\n The TOML file name is `Tauri.toml`.\n\n ## Platform-Specific Configuration\n\n In addition to the default configuration file, Tauri can\n read a platform-specific configuration from `tauri.linux.conf.json`,\n `tauri.windows.conf.json`, `tauri.macos.conf.json`, `tauri.android.conf.json` and `tauri.ios.conf.json`\n (or `Tauri.linux.toml`, `Tauri.windows.toml`, `Tauri.macos.toml`, `Tauri.android.toml` and `Tauri.ios.toml` if the `Tauri.toml` format is used),\n which gets merged with the main configuration object.\n\n ## Configuration Structure\n\n The configuration is composed of the following objects:\n\n - [`app`](#appconfig): The Tauri configuration\n - [`build`](#buildconfig): The build configuration\n - [`bundle`](#bundleconfig): The bundle configurations\n - [`plugins`](#pluginconfig): The plugins configuration\n\n ```json title=\"Example tauri.config.json file\"\n {\n \"productName\": \"tauri-app\",\n \"version\": \"0.1.0\",\n \"build\": {\n \"beforeBuildCommand\": \"\",\n \"beforeDevCommand\": \"\",\n \"devUrl\": \"../dist\",\n \"frontendDist\": \"../dist\"\n },\n \"app\": {\n \"security\": {\n \"csp\": null\n },\n \"windows\": [\n {\n \"fullscreen\": false,\n \"height\": 600,\n \"resizable\": true,\n \"title\": \"Tauri App\",\n \"width\": 800\n }\n ]\n },\n \"bundle\": {},\n \"plugins\": {}\n }\n ```",
"description": "The Tauri configuration object.\n It is read from a file where you can define your frontend assets,\n configure the bundler and define a tray icon.\n\n The configuration file is generated by the\n [`tauri init`](https://tauri.app/v1/api/cli#init) command that lives in\n your Tauri application source directory (src-tauri).\n\n Once generated, you may modify it at will to customize your Tauri application.\n\n ## File Formats\n\n By default, the configuration is defined as a JSON file named `tauri.conf.json`.\n\n Tauri also supports JSON5 and TOML files via the `config-json5` and `config-toml` Cargo features, respectively.\n The JSON5 file name must be either `tauri.conf.json` or `tauri.conf.json5`.\n The TOML file name is `Tauri.toml`.\n\n ## Platform-Specific Configuration\n\n In addition to the default configuration file, Tauri can\n read a platform-specific configuration from `tauri.linux.conf.json`,\n `tauri.windows.conf.json`, `tauri.macos.conf.json`, `tauri.android.conf.json` and `tauri.ios.conf.json`\n (or `Tauri.linux.toml`, `Tauri.windows.toml`, `Tauri.macos.toml`, `Tauri.android.toml` and `Tauri.ios.toml` if the `Tauri.toml` format is used),\n which gets merged with the main configuration object.\n\n ## Configuration Structure\n\n The configuration is composed of the following objects:\n\n - [`app`](#appconfig): The Tauri configuration\n - [`build`](#buildconfig): The build configuration\n - [`bundle`](#bundleconfig): The bundle configurations\n - [`plugins`](#pluginconfig): The plugins configuration\n\n Example tauri.config.json file:\n\n ```json\n {\n \"productName\": \"tauri-app\",\n \"version\": \"0.1.0\",\n \"build\": {\n \"beforeBuildCommand\": \"\",\n \"beforeDevCommand\": \"\",\n \"devUrl\": \"../dist\",\n \"frontendDist\": \"../dist\"\n },\n \"app\": {\n \"security\": {\n \"csp\": null\n },\n \"windows\": [\n {\n \"fullscreen\": false,\n \"height\": 600,\n \"resizable\": true,\n \"title\": \"Tauri App\",\n \"width\": 800\n }\n ]\n },\n \"bundle\": {},\n \"plugins\": {}\n }\n ```",
"type": "object",
"properties": {
"$schema": {
Expand Down
12 changes: 6 additions & 6 deletions core/tauri-runtime-wry/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ use wry::WebViewBuilderExtWindows;
use tao::{
dpi::{
LogicalPosition as TaoLogicalPosition, LogicalSize as TaoLogicalSize,
LogicalUnit as ToaLogicalUnit, PhysicalPosition as TaoPhysicalPosition,
PhysicalSize as TaoPhysicalSize, Position as TaoPosition, Size as TaoSize,
PhysicalPosition as TaoPhysicalPosition, PhysicalSize as TaoPhysicalSize,
Position as TaoPosition, Size as TaoSize,
},
event::{Event, StartCause, WindowEvent as TaoWindowEvent},
event_loop::{
Expand Down Expand Up @@ -793,16 +793,16 @@ impl WindowBuilder for WindowBuilderWrapper {
let mut constraints = WindowSizeConstraints::default();

if let Some(min_width) = config.min_width {
constraints.min_width = Some(ToaLogicalUnit::new(min_width).into());
constraints.min_width = Some(tao::dpi::LogicalUnit::new(min_width).into());
}
if let Some(min_height) = config.min_height {
constraints.min_height = Some(ToaLogicalUnit::new(min_height).into());
constraints.min_height = Some(tao::dpi::LogicalUnit::new(min_height).into());
}
if let Some(max_width) = config.max_width {
constraints.max_width = Some(ToaLogicalUnit::new(max_width).into());
constraints.max_width = Some(tao::dpi::LogicalUnit::new(max_width).into());
}
if let Some(max_height) = config.max_height {
constraints.max_height = Some(ToaLogicalUnit::new(max_height).into());
constraints.max_height = Some(tao::dpi::LogicalUnit::new(max_height).into());
}
window = window.inner_size_constraints(constraints);

Expand Down
4 changes: 3 additions & 1 deletion core/tauri-utils/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2170,7 +2170,9 @@ where
/// - [`bundle`](#bundleconfig): The bundle configurations
/// - [`plugins`](#pluginconfig): The plugins configuration
///
/// ```json title="Example tauri.config.json file"
/// Example tauri.config.json file:
///
/// ```json
/// {
/// "productName": "tauri-app",
/// "version": "0.1.0",
Expand Down
7 changes: 7 additions & 0 deletions core/tauri/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1741,6 +1741,13 @@ tauri::Builder::default()
self.invoke_key,
));

#[cfg(any(
target_os = "linux",
target_os = "dragonfly",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd"
))]
let app_id = if manager.config.app.enable_gtk_app_id {
Some(manager.config.identifier.clone())
} else {
Expand Down
15 changes: 7 additions & 8 deletions core/tauri/src/ipc/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ use crate::{
Manager, Runtime, State, Webview,
};

use super::{CallbackFn, InvokeBody, InvokeError, IpcResponse, Request, Response};
use super::{CallbackFn, InvokeError, InvokeResponseBody, IpcResponse, Request, Response};

pub const IPC_PAYLOAD_PREFIX: &str = "__CHANNEL__:";
pub const CHANNEL_PLUGIN_NAME: &str = "__TAURI_CHANNEL__";
Expand All @@ -33,13 +33,13 @@ static CHANNEL_DATA_COUNTER: AtomicU32 = AtomicU32::new(0);

/// Maps a channel id to a pending data that must be send to the JavaScript side via the IPC.
#[derive(Default, Clone)]
pub struct ChannelDataIpcQueue(pub(crate) Arc<Mutex<HashMap<u32, InvokeBody>>>);
pub struct ChannelDataIpcQueue(pub(crate) Arc<Mutex<HashMap<u32, InvokeResponseBody>>>);

/// An IPC channel.
#[derive(Clone)]
pub struct Channel<TSend = InvokeBody> {
pub struct Channel<TSend = InvokeResponseBody> {
id: u32,
on_message: Arc<dyn Fn(InvokeBody) -> crate::Result<()> + Send + Sync>,
on_message: Arc<dyn Fn(InvokeResponseBody) -> crate::Result<()> + Send + Sync>,
phantom: std::marker::PhantomData<TSend>,
}

Expand Down Expand Up @@ -138,13 +138,13 @@ impl<'de> Deserialize<'de> for JavaScriptChannelId {

impl<TSend> Channel<TSend> {
/// Creates a new channel with the given message handler.
pub fn new<F: Fn(InvokeBody) -> crate::Result<()> + Send + Sync + 'static>(
pub fn new<F: Fn(InvokeResponseBody) -> crate::Result<()> + Send + Sync + 'static>(
on_message: F,
) -> Self {
Self::new_with_id(CHANNEL_COUNTER.fetch_add(1, Ordering::Relaxed), on_message)
}

fn new_with_id<F: Fn(InvokeBody) -> crate::Result<()> + Send + Sync + 'static>(
fn new_with_id<F: Fn(InvokeResponseBody) -> crate::Result<()> + Send + Sync + 'static>(
id: u32,
on_message: F,
) -> Self {
Expand Down Expand Up @@ -195,8 +195,7 @@ impl<TSend> Channel<TSend> {
where
TSend: IpcResponse,
{
let body = data.body()?;
(self.on_message)(body)
(self.on_message)(data.body()?)
}
}

Expand Down
19 changes: 14 additions & 5 deletions core/tauri/src/ipc/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ impl<'de, R: Runtime> Deserializer<'de> for CommandItem<'de, R> {
#[doc(hidden)]
pub mod private {
use crate::{
ipc::{InvokeBody, InvokeError, InvokeResolver, IpcResponse},
ipc::{InvokeError, InvokeResolver, InvokeResponseBody, IpcResponse},
Runtime,
};
use futures_util::{FutureExt, TryFutureExt};
Expand Down Expand Up @@ -220,7 +220,10 @@ pub mod private {
}

#[inline(always)]
pub fn future<T>(self, value: T) -> impl Future<Output = Result<InvokeBody, InvokeError>>
pub fn future<T>(
self,
value: T,
) -> impl Future<Output = Result<InvokeResponseBody, InvokeError>>
where
T: IpcResponse,
{
Expand Down Expand Up @@ -261,7 +264,7 @@ pub mod private {
pub fn future<T, E>(
self,
value: Result<T, E>,
) -> impl Future<Output = Result<InvokeBody, InvokeError>>
) -> impl Future<Output = Result<InvokeResponseBody, InvokeError>>
where
T: IpcResponse,
E: Into<InvokeError>,
Expand All @@ -288,7 +291,10 @@ pub mod private {

impl FutureTag {
#[inline(always)]
pub fn future<T, F>(self, value: F) -> impl Future<Output = Result<InvokeBody, InvokeError>>
pub fn future<T, F>(
self,
value: F,
) -> impl Future<Output = Result<InvokeResponseBody, InvokeError>>
where
T: IpcResponse,
F: Future<Output = T> + Send + 'static,
Expand All @@ -315,7 +321,10 @@ pub mod private {

impl ResultFutureTag {
#[inline(always)]
pub fn future<T, E, F>(self, value: F) -> impl Future<Output = Result<InvokeBody, InvokeError>>
pub fn future<T, E, F>(
self,
value: F,
) -> impl Future<Output = Result<InvokeResponseBody, InvokeError>>
where
T: IpcResponse,
E: Into<InvokeError>,
Expand Down
147 changes: 133 additions & 14 deletions core/tauri/src/ipc/format_callback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,14 @@ 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 serialize_js_with<T: Serialize, F: FnOnce(&str) -> String>(
value: &T,
fn serialize_js_with<F: FnOnce(&str) -> String>(
json_string: String,
options: serialize_to_javascript::Options,
cb: F,
) -> crate::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)?;

let raw = RawValue::from_string(json_string)?;

// from here we know json.len() > 1 because an empty string is not a valid json value.
let json = raw.get();
Expand Down Expand Up @@ -77,14 +77,21 @@ fn serialize_js_with<T: Serialize, F: FnOnce(&str) -> String>(
Ok(return_val)
}

/// Formats a function name and argument to be evaluated as callback.
/// Formats a function name and a serializable argument to be evaluated as callback.
///
/// See [`format_raw`] for more information.
pub fn format<T: Serialize>(function_name: CallbackFn, arg: &T) -> crate::Result<String> {
format_raw(function_name, serde_json::to_string(arg)?)
}

/// Formats a function name and a raw JSON string argument to be evaluated as callback.
///
/// This will serialize primitive JSON types (e.g. booleans, strings, numbers, etc.) as JavaScript literals,
/// but will serialize arrays and objects whose serialized JSON string is smaller than 1 GB and larger
/// than 10 KiB with `JSON.parse('...')`.
/// See [json-parse-benchmark](https://github.com/GoogleChromeLabs/json-parse-benchmark).
pub fn format<T: Serialize>(function_name: CallbackFn, arg: &T) -> crate::Result<String> {
serialize_js_with(arg, Default::default(), |arg| {
pub fn format_raw(function_name: CallbackFn, json_string: String) -> crate::Result<String> {
serialize_js_with(json_string, Default::default(), |arg| {
format!(
r#"
if (window["_{fn}"]) {{
Expand All @@ -97,7 +104,21 @@ pub fn format<T: Serialize>(function_name: CallbackFn, arg: &T) -> crate::Result
})
}

/// Formats a Result type to its Promise response.
/// Formats a serializable Result type to its Promise response.
///
/// See [`format_result_raw`] for more information.
pub fn format_result<T: Serialize, E: Serialize>(
result: Result<T, E>,
success_callback: CallbackFn,
error_callback: CallbackFn,
) -> crate::Result<String> {
match result {
Ok(res) => format(success_callback, &res),
Err(err) => format(error_callback, &err),
}
}

/// Formats a Result type of raw JSON strings to its Promise response.
/// Useful for Promises handling.
/// If the Result `is_ok()`, the callback will be the `success_callback` function name and the argument will be the Ok value.
/// If the Result `is_err()`, the callback will be the `error_callback` function name and the argument will be the Err value.
Expand All @@ -107,14 +128,14 @@ pub fn format<T: Serialize>(function_name: CallbackFn, arg: &T) -> crate::Result
/// * `error_callback` the function name of the Err callback. Usually the `reject` of the JS Promise.
///
/// Note that the callback strings are automatically generated by the `invoke` helper.
pub fn format_result<T: Serialize, E: Serialize>(
result: Result<T, E>,
pub fn format_result_raw(
raw_result: Result<String, String>,
success_callback: CallbackFn,
error_callback: CallbackFn,
) -> crate::Result<String> {
match result {
Ok(res) => format(success_callback, &res),
Err(err) => format(error_callback, &err),
match raw_result {
Ok(res) => format_raw(success_callback, res),
Err(err) => format_raw(error_callback, err),
}
}

Expand All @@ -130,8 +151,31 @@ mod test {
}
}

#[derive(Debug, Clone)]
struct JsonStr(String);

impl Arbitrary for JsonStr {
fn arbitrary(g: &mut Gen) -> Self {
if bool::arbitrary(g) {
Self(format!(
"{{ {}: {} }}",
serde_json::to_string(&String::arbitrary(g)).unwrap(),
serde_json::to_string(&String::arbitrary(g)).unwrap()
))
} else {
Self(serde_json::to_string(&String::arbitrary(g)).unwrap())
}
}
}

fn serialize_js<T: Serialize>(value: &T) -> crate::Result<String> {
serialize_js_with(value, Default::default(), |v| v.into())
serialize_js_with(serde_json::to_string(value)?, Default::default(), |v| {
v.into()
})
}

fn serialize_js_raw(value: impl Into<String>) -> crate::Result<String> {
serialize_js_with(value.into(), Default::default(), |v| v.into())
}

#[test]
Expand Down Expand Up @@ -213,4 +257,79 @@ mod test {
serde_json::Value::String(value),
))
}

#[test]
fn test_serialize_js_raw() {
assert_eq!(serialize_js_raw("null").unwrap(), "null");
assert_eq!(serialize_js_raw("5").unwrap(), "5");
assert_eq!(
serialize_js_raw("{ \"x\": [1, 2, 3] }").unwrap(),
"{ \"x\": [1, 2, 3] }"
);

#[derive(serde::Serialize)]
struct JsonObj {
value: String,
}

let raw_str = "T".repeat(MIN_JSON_PARSE_LEN);
assert_eq!(
serialize_js_raw(format!("\"{raw_str}\"")).unwrap(),
format!("\"{raw_str}\"")
);

assert_eq!(
serialize_js_raw(format!("{{\"value\":\"{raw_str}\"}}")).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();

let definitely_escaped_dangerous_json = format!(
"JSON.parse('{}')",
dangerous_json
.get()
.replace('\\', "\\\\")
.replace('\'', "\\'")
);
let escape_single_quoted_json_test =
serialize_to_javascript::Serialized::new(&dangerous_json, &Default::default()).into_string();

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);
assert_eq!(escape_single_quoted_json_test, result);
}

// check arbitrary strings in the format callback function
#[quickcheck]
fn qc_formatting_raw(f: CallbackFn, a: JsonStr) -> bool {
let a = a.0;
// call format callback
let fc = format_raw(f, a.clone()).unwrap();
fc.contains(&format!(r#"window["_{}"](JSON.parse('{}'))"#, f.0, a))
|| fc.contains(&format!(r#"window["_{}"]({})"#, f.0, a))
}

// check arbitrary strings in format_result
#[quickcheck]
fn qc_format_raw_res(result: Result<JsonStr, JsonStr>, c: CallbackFn, ec: CallbackFn) -> bool {
let result = result.map(|v| v.0).map_err(|e| e.0);
let resp = format_result_raw(result.clone(), c, ec).expect("failed to format callback result");
let (function, value) = match result {
Ok(v) => (c, v),
Err(e) => (ec, e),
};

resp.contains(&format!(r#"window["_{}"]({})"#, function.0, value))
}
}
Loading

0 comments on commit d0510f5

Please sign in to comment.