refactor: rework transformCallback (#13325)

* refactor: rework `transformCallback`

* Migrate listen and unlisten js

* handlerId -> listener.handlerId

* Update docs

* `transformCallback` change file

* typo
This commit is contained in:
Tony 2025-05-05 20:15:38 +08:00 committed by GitHub
parent 208f4bcadc
commit b5c549d189
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 134 additions and 97 deletions

View File

@ -0,0 +1,6 @@
---
"@tauri-apps/api": minor:changes
"tauri": minor:changes
---
`transformCallback` now registers the callbacks inside `window.__TAURI_INTERNALS__.callbacks` instead of directly on `window['_{id}']`

File diff suppressed because one or more lines are too long

View File

@ -19,25 +19,50 @@
}
})
Object.defineProperty(window.__TAURI_INTERNALS__, 'transformCallback', {
value: function transformCallback(callback, once) {
const identifier = uid()
const prop = `_${identifier}`
const callbacks = new Map()
Object.defineProperty(window, prop, {
value: (result) => {
if (once) {
Reflect.deleteProperty(window, prop)
}
function registerCallback(callback, once) {
const identifier = uid()
callbacks.set(identifier, (data) => {
if (once) {
unregisterCallback(identifier)
}
return callback && callback(data)
})
return identifier
}
return callback && callback(result)
},
writable: false,
configurable: true
})
function unregisterCallback(id) {
callbacks.delete(id)
}
return identifier
function runCallback(id, data) {
const callback = callbacks.get(id)
if (callback) {
callback(data)
} else {
console.warn(
`[TAURI] Couldn't find callback id ${id}. This might happen when the app is reloaded while Rust is running an asynchronous operation.`
)
}
}
// Maybe let's rename it to `registerCallback`?
Object.defineProperty(window.__TAURI_INTERNALS__, 'transformCallback', {
value: registerCallback
})
Object.defineProperty(window.__TAURI_INTERNALS__, 'unregisterCallback', {
value: unregisterCallback
})
Object.defineProperty(window.__TAURI_INTERNALS__, 'runCallback', {
value: runCallback
})
// This is just for the debugging purposes
Object.defineProperty(window.__TAURI_INTERNALS__, 'callbacks', {
value: callbacks
})
const ipcQueue = []
@ -56,13 +81,13 @@
Object.defineProperty(window.__TAURI_INTERNALS__, 'invoke', {
value: function (cmd, payload = {}, options) {
return new Promise(function (resolve, reject) {
const callback = window.__TAURI_INTERNALS__.transformCallback((r) => {
const callback = registerCallback((r) => {
resolve(r)
delete window[`_${error}`]
unregisterCallback(error)
}, true)
const error = window.__TAURI_INTERNALS__.transformCallback((e) => {
const error = registerCallback((e) => {
reject(e)
delete window[`_${callback}`]
unregisterCallback(callback)
}, true)
const action = () => {

View File

@ -40,25 +40,16 @@
headers
})
.then((response) => {
const cb =
const callbackId =
response.headers.get('Tauri-Response') === 'ok' ? callback : error
// we need to split here because on Android the content-type gets duplicated
switch ((response.headers.get('content-type') || '').split(',')[0]) {
case 'application/json':
return response.json().then((r) => [cb, r])
return response.json().then((r) => [callbackId, r])
case 'text/plain':
return response.text().then((r) => [cb, r])
return response.text().then((r) => [callbackId, r])
default:
return response.arrayBuffer().then((r) => [cb, r])
}
})
.then(([cb, data]) => {
if (window[`_${cb}`]) {
window[`_${cb}`](data)
} else {
console.warn(
`[TAURI] Couldn't find callback id {cb} in window. This might happen when the app is reloaded while Rust is running an asynchronous operation.`
)
return response.arrayBuffer().then((r) => [callbackId, r])
}
})
.catch((e) => {
@ -71,6 +62,9 @@
customProtocolIpcFailed = true
sendIpcMessage(message)
})
.then(([callbackId, data]) => {
window.__TAURI_INTERNALS__.runCallback(callbackId, data)
})
} else {
// otherwise use the postMessage interface
const { data } = processIpcMessage({

View File

@ -13,6 +13,8 @@ mod event_name;
pub(crate) use event_name::EventName;
use crate::ipc::CallbackFn;
/// Unique id of an event.
pub type EventId = u32;
@ -167,8 +169,9 @@ pub fn listen_js_script(
serialized_target: &str,
event: EventName<&str>,
event_id: EventId,
handler: &str,
handler: CallbackFn,
) -> String {
let handler_id = handler.0;
format!(
"(function () {{
if (window['{listeners_object_name}'] === void 0) {{
@ -180,7 +183,7 @@ pub fn listen_js_script(
const eventListeners = window['{listeners_object_name}']['{event}']
const listener = {{
target: {serialized_target},
handler: {handler}
handlerId: {handler_id}
}};
Object.defineProperty(eventListeners, '{event_id}', {{ value: listener, configurable: true }});
}})()
@ -211,23 +214,23 @@ pub fn unlisten_js_script(
"(function () {{
const listeners = (window['{listeners_object_name}'] || {{}})['{event_name}']
if (listeners) {{
delete window['{listeners_object_name}']['{event_name}'][{event_id}];
window.__TAURI_INTERNALS__.unregisterCallback(listeners[{event_id}].handlerId)
}}
}})()
",
)
}
pub fn event_initialization_script(function: &str, listeners: &str) -> String {
pub fn event_initialization_script(function_name: &str, listeners: &str) -> String {
format!(
"Object.defineProperty(window, '{function}', {{
"Object.defineProperty(window, '{function_name}', {{
value: function (eventData, ids) {{
const listeners = (window['{listeners}'] && window['{listeners}'][eventData.event]) || []
for (const id of ids) {{
const listener = listeners[id]
if (listener && listener.handler) {{
if (listener) {{
eventData.id = id
listener.handler(eventData)
window.__TAURI_INTERNALS__.runCallback(listener.handlerId, eventData)
}}
}}
}}

View File

@ -21,7 +21,8 @@ use crate::{
};
use super::{
format_callback, CallbackFn, InvokeError, InvokeResponseBody, IpcResponse, Request, Response,
format_callback::format_raw_js, CallbackFn, InvokeError, InvokeResponseBody, IpcResponse,
Request, Response,
};
pub const IPC_PAYLOAD_PREFIX: &str = "__CHANNEL__:";
@ -150,15 +151,17 @@ impl JavaScriptChannelId {
match body {
// Don't go through the fetch process if the payload is small
InvokeResponseBody::Json(string) if string.len() < MAX_JSON_DIRECT_EXECUTE_THRESHOLD => {
webview.eval(format_callback::format_raw_js(
InvokeResponseBody::Json(json_string)
if json_string.len() < MAX_JSON_DIRECT_EXECUTE_THRESHOLD =>
{
webview.eval(format_raw_js(
callback_id,
&format!("{{ message: {string}, index: {current_index} }}"),
format!("{{ message: {json_string}, index: {current_index} }}"),
))?;
}
InvokeResponseBody::Raw(bytes) if bytes.len() < MAX_RAW_DIRECT_EXECUTE_THRESHOLD => {
let bytes_as_json_array = serde_json::to_string(&bytes)?;
webview.eval(format_callback::format_raw_js(callback_id, &format!("{{ message: new Uint8Array({bytes_as_json_array}).buffer, index: {current_index} }}")))?;
webview.eval(format_raw_js(callback_id, format!("{{ message: new Uint8Array({bytes_as_json_array}).buffer, index: {current_index} }}")))?;
}
// use the fetch API to speed up larger response payloads
_ => {
@ -172,7 +175,7 @@ impl JavaScriptChannelId {
.insert(data_id, body);
webview.eval(format!(
"window.__TAURI_INTERNALS__.invoke('{FETCH_CHANNEL_DATA_COMMAND}', null, {{ headers: {{ '{CHANNEL_ID_HEADER_NAME}': '{data_id}' }} }}).then((response) => window['_{callback_id}']({{ message: response, index: {current_index} }})).catch(console.error)",
"window.__TAURI_INTERNALS__.invoke('{FETCH_CHANNEL_DATA_COMMAND}', null, {{ headers: {{ '{CHANNEL_ID_HEADER_NAME}': '{data_id}' }} }}).then((response) => window.__TAURI_INTERNALS__.runCallback({callback_id}, {{ message: response, index: {current_index} }})).catch(console.error)",
))?;
}
}
@ -181,9 +184,9 @@ impl JavaScriptChannelId {
}),
Some(Box::new(move || {
let current_index = counter_clone.load(Ordering::Relaxed);
let _ = webview_clone.eval(format_callback::format_raw_js(
let _ = webview_clone.eval(format_raw_js(
callback_id,
&format!("{{ end: true, index: {current_index} }}"),
format!("{{ end: true, index: {current_index} }}"),
));
})),
)
@ -244,14 +247,16 @@ impl<TSend> Channel<TSend> {
Box::new(move |body| {
match body {
// Don't go through the fetch process if the payload is small
InvokeResponseBody::Json(string) if string.len() < MAX_JSON_DIRECT_EXECUTE_THRESHOLD => {
webview.eval(format_callback::format_raw_js(callback_id, &string))?;
InvokeResponseBody::Json(json_string)
if json_string.len() < MAX_JSON_DIRECT_EXECUTE_THRESHOLD =>
{
webview.eval(format_raw_js(callback_id, json_string))?;
}
InvokeResponseBody::Raw(bytes) if bytes.len() < MAX_RAW_DIRECT_EXECUTE_THRESHOLD => {
let bytes_as_json_array = serde_json::to_string(&bytes)?;
webview.eval(format_callback::format_raw_js(
webview.eval(format_raw_js(
callback_id,
&format!("new Uint8Array({bytes_as_json_array}).buffer"),
format!("new Uint8Array({bytes_as_json_array}).buffer"),
))?;
}
// use the fetch API to speed up larger response payloads
@ -266,7 +271,7 @@ impl<TSend> Channel<TSend> {
.insert(data_id, body);
webview.eval(format!(
"window.__TAURI_INTERNALS__.invoke('{FETCH_CHANNEL_DATA_COMMAND}', null, {{ headers: {{ '{CHANNEL_ID_HEADER_NAME}': '{data_id}' }} }}).then((response) => window['_{callback_id}'](response)).catch(console.error)",
"window.__TAURI_INTERNALS__.invoke('{FETCH_CHANNEL_DATA_COMMAND}', null, {{ headers: {{ '{CHANNEL_ID_HEADER_NAME}': '{data_id}' }} }}).then((response) => window.__TAURI_INTERNALS__.runCallback({callback_id}, response)).catch(console.error)",
))?;
}
}

View File

@ -91,21 +91,18 @@ pub fn format<T: Serialize>(function_name: CallbackFn, arg: &T) -> crate::Result
/// than 10 KiB with `JSON.parse('...')`.
/// See [json-parse-benchmark](https://github.com/GoogleChromeLabs/json-parse-benchmark).
pub fn format_raw(function_name: CallbackFn, json_string: String) -> crate::Result<String> {
let callback_id = function_name.0;
serialize_js_with(json_string, Default::default(), |arg| {
format_raw_js(function_name.0, arg)
format_raw_js(callback_id, arg)
})
}
/// Formats a callback function invocation, properly accounting for error handling.
pub fn format_raw_js(id: u32, js: &str) -> String {
format!(
r#"
if (window["_{id}"]) {{
window["_{id}"]({js})
}} else {{
console.warn("[TAURI] Couldn't find callback id {id} in window. This happens when the app is reloaded while Rust is running an asynchronous operation.")
}}"#
)
/// Formats a function name and a JavaScript string argument to be evaluated as callback.
pub fn format_raw_js(callback_id: u32, js: impl AsRef<str>) -> String {
fn format_inner(callback_id: u32, js: &str) -> String {
format!("window.__TAURI_INTERNALS__.runCallback({callback_id}, {js})")
}
format_inner(callback_id, js.as_ref())
}
/// Formats a serializable Result type to its Promise response.
@ -236,11 +233,11 @@ mod test {
// call format callback
let fc = format(f, &a).unwrap();
fc.contains(&format!(
r#"window["_{}"](JSON.parse('{}'))"#,
"window.__TAURI_INTERNALS__.runCallback({}, JSON.parse('{}'))",
f.0,
serde_json::Value::String(a.clone()),
)) || fc.contains(&format!(
r#"window["_{}"]({})"#,
r#"window.__TAURI_INTERNALS__.runCallback({}, {})"#,
f.0,
serde_json::Value::String(a),
))
@ -256,7 +253,7 @@ mod test {
};
resp.contains(&format!(
r#"window["_{}"]({})"#,
r#"window.__TAURI_INTERNALS__.runCallback({}, {})"#,
function.0,
serde_json::Value::String(value),
))
@ -320,8 +317,13 @@ mod test {
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))
fc.contains(&format!(
r#"window.__TAURI_INTERNALS__.runCallback({}, JSON.parse('{}'))"#,
f.0, a
)) || fc.contains(&format!(
r#"window.__TAURI_INTERNALS__.runCallback({}, {})"#,
f.0, a
))
}
// check arbitrary strings in format_result
@ -334,6 +336,9 @@ mod test {
Err(e) => (ec, e),
};
resp.contains(&format!(r#"window["_{}"]({})"#, function.0, value))
resp.contains(&format!(
r#"window.__TAURI_INTERNALS__.runCallback({}, {})"#,
function.0, value
))
}
}

View File

@ -1668,7 +1668,7 @@ fn main() {
&serde_json::to_string(&target)?,
event,
id,
&format!("window['_{}']", handler.0),
handler,
))?;
listeners.listen_js(event, self.label(), target, id);

View File

@ -59,14 +59,15 @@
export const SERIALIZE_TO_IPC_FN = '__TAURI_TO_IPC_KEY__'
/**
* Transforms a callback function to a string identifier that can be passed to the backend.
* Stores the callback in a known location, and returns an identifier that can be passed to the backend.
* The backend uses the identifier to `eval()` the callback.
*
* @return A unique identifier associated with the callback function.
* @return An unique identifier associated with the callback function.
*
* @since 1.0.0
*/
function transformCallback<T = unknown>(
// TODO: Make this not optional in v3
callback?: (response: T) => void,
once = false
): number {
@ -131,7 +132,7 @@ class Channel<T = unknown> {
}
private cleanupCallback() {
Reflect.deleteProperty(window, `_${this.id}`)
window.__TAURI_INTERNALS__.unregisterCallback(this.id)
}
set onmessage(handler: (response: T) => void) {
@ -325,7 +326,7 @@ export class Resource {
}
function isTauri(): boolean {
// eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-member-access
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
return !!((globalThis as any) || window).isTauri
}

View File

@ -12,6 +12,7 @@ declare global {
__TAURI_INTERNALS__: {
invoke: typeof invoke
transformCallback: typeof transformCallback
unregisterCallback: (number) => void
convertFileSrc: typeof convertFileSrc
ipc: (message: {
cmd: string

View File

@ -56,7 +56,7 @@ export async function newMenu(
if (opts && typeof opts === 'object') {
if ('action' in opts && opts.action) {
handler.onmessage = opts.action as () => void
handler.onmessage = opts.action
delete opts.action
}
@ -107,7 +107,7 @@ export async function newMenu(
// @ts-expect-error the `prepareItem` return doesn't exactly match
// this is fine, because the difference is in `[number, string]` variant
opts.items = (opts.items as []).map(prepareItem)
opts.items = opts.items.map(prepareItem)
}
}

View File

@ -16,7 +16,7 @@ function mockInternals() {
* # Examples
*
* Testing setup using Vitest:
* ```js
* ```ts
* import { mockIPC, clearMocks } from "@tauri-apps/api/mocks"
* import { invoke } from "@tauri-apps/api/core"
*
@ -66,36 +66,33 @@ export function mockIPC(
): void {
mockInternals()
window.__TAURI_INTERNALS__.transformCallback = function transformCallback(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
callback?: (response: any) => void,
const callbacks = new Map()
function registerCallback<T = unknown>(
callback?: (response: T) => void,
once = false
) {
const identifier = window.crypto.getRandomValues(new Uint32Array(1))[0]
const prop = `_${identifier}`
Object.defineProperty(window, prop, {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: (result: any) => {
if (once) {
Reflect.deleteProperty(window, prop)
}
return callback && callback(result)
},
writable: false,
configurable: true
callbacks.set(identifier, (data: T) => {
if (once) {
unregisterCallback(identifier)
}
return callback && callback(data)
})
return identifier
}
function unregisterCallback(id: number) {
callbacks.delete(id)
}
window.__TAURI_INTERNALS__.transformCallback = registerCallback
// eslint-disable-next-line @typescript-eslint/require-await
window.__TAURI_INTERNALS__.invoke = async function (
cmd: string,
args?: InvokeArgs,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
options?: InvokeOptions
_options?: InvokeOptions
): Promise<unknown> {
return cb(cmd, args)
} as typeof invoke