diff --git a/examples/eval.rs b/examples/eval.rs index 3a16b774e..54959df69 100644 --- a/examples/eval.rs +++ b/examples/eval.rs @@ -5,29 +5,34 @@ fn main() { } fn app(cx: Scope) -> Element { - let eval = dioxus_desktop::use_eval(cx); - let script = use_state(cx, String::new); - let output = use_state(cx, String::new); + let eval_provider = use_eval(cx); - cx.render(rsx! { - div { - p { "Output: {output}" } - input { - placeholder: "Enter an expression", - value: "{script}", - oninput: move |e| script.set(e.value.clone()), - } - button { - onclick: move |_| { - to_owned![script, eval, output]; - cx.spawn(async move { - if let Ok(res) = eval(script.to_string()).await { - output.set(res.to_string()); - } - }); - }, - "Execute" - } + let future = use_future(cx, (), |_| { + to_owned![eval_provider]; + async move { + let eval = eval_provider( + r#" + dioxus.send("Hi from JS!"); + let msg = await dioxus.recv(); + console.log(msg); + return "hello world"; + "#, + ) + .unwrap(); + + eval.send("Hi from Rust!".into()).unwrap(); + let res = eval.recv().await.unwrap(); + println!("{:?}", eval.await); + res } - }) + }); + + match future.value() { + Some(v) => cx.render(rsx!( + p { "{v}" } + )), + _ => cx.render(rsx!( + p { "hello" } + )), + } } diff --git a/packages/desktop/Cargo.toml b/packages/desktop/Cargo.toml index ed99fbfd4..6c2ee640c 100644 --- a/packages/desktop/Cargo.toml +++ b/packages/desktop/Cargo.toml @@ -36,6 +36,7 @@ slab = { workspace = true } futures-util = { workspace = true } urlencoding = "2.1.2" +async-trait = "0.1.68" [target.'cfg(any(target_os = "windows",target_os = "macos",target_os = "linux",target_os = "dragonfly", target_os = "freebsd", target_os = "netbsd", target_os = "openbsd"))'.dependencies] diff --git a/packages/desktop/headless_tests/events.rs b/packages/desktop/headless_tests/events.rs index f0f31c4ea..ac62cd37b 100644 --- a/packages/desktop/headless_tests/events.rs +++ b/packages/desktop/headless_tests/events.rs @@ -29,21 +29,24 @@ pub fn main() { } fn mock_event(cx: &ScopeState, id: &'static str, value: &'static str) { - use_effect(cx, (), |_| { - let desktop_context: DesktopContext = cx.consume_context().unwrap(); - async move { - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - desktop_context.eval(&format!( - r#"let element = document.getElementById('{}'); + let eval_provider = use_eval(cx).clone(); + + use_effect(cx, (), move |_| async move { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + let js = format!( + r#" + //console.log("ran"); // Dispatch a synthetic event - const event = {}; + let event = {}; + let element = document.getElementById('{}'); console.log(element, event); element.dispatchEvent(event); "#, - id, value - )); - } - }); + value, id + ); + + eval_provider(&js).unwrap(); + }) } #[allow(deprecated)] @@ -56,149 +59,149 @@ fn app(cx: Scope) -> Element { cx, "button", r#"new MouseEvent("click", { - view: window, - bubbles: true, - cancelable: true, - button: 0, - })"#, + view: window, + bubbles: true, + cancelable: true, + button: 0, + })"#, ); // mouse_move_div mock_event( cx, "mouse_move_div", r#"new MouseEvent("mousemove", { - view: window, - bubbles: true, - cancelable: true, - buttons: 2, - })"#, + view: window, + bubbles: true, + cancelable: true, + buttons: 2, + })"#, ); // mouse_click_div mock_event( cx, "mouse_click_div", r#"new MouseEvent("click", { - view: window, - bubbles: true, - cancelable: true, - buttons: 2, - button: 2, - })"#, + view: window, + bubbles: true, + cancelable: true, + buttons: 2, + button: 2, + })"#, ); // mouse_dblclick_div mock_event( cx, "mouse_dblclick_div", r#"new MouseEvent("dblclick", { - view: window, - bubbles: true, - cancelable: true, - buttons: 1|2, - button: 2, - })"#, + view: window, + bubbles: true, + cancelable: true, + buttons: 1|2, + button: 2, + })"#, ); // mouse_down_div mock_event( cx, "mouse_down_div", r#"new MouseEvent("mousedown", { - view: window, - bubbles: true, - cancelable: true, - buttons: 2, - button: 2, - })"#, + view: window, + bubbles: true, + cancelable: true, + buttons: 2, + button: 2, + })"#, ); // mouse_up_div mock_event( cx, "mouse_up_div", r#"new MouseEvent("mouseup", { - view: window, - bubbles: true, - cancelable: true, - buttons: 0, - button: 0, - })"#, + view: window, + bubbles: true, + cancelable: true, + buttons: 0, + button: 0, + })"#, ); // wheel_div mock_event( cx, "wheel_div", r#"new WheelEvent("wheel", { - view: window, - deltaX: 1.0, - deltaY: 2.0, - deltaZ: 3.0, - deltaMode: 0x00, - bubbles: true, - })"#, + view: window, + deltaX: 1.0, + deltaY: 2.0, + deltaZ: 3.0, + deltaMode: 0x00, + bubbles: true, + })"#, ); // key_down_div mock_event( cx, "key_down_div", r#"new KeyboardEvent("keydown", { - key: "a", - code: "KeyA", - location: 0, - repeat: true, - keyCode: 65, - charCode: 97, - char: "a", - charCode: 0, - altKey: false, - ctrlKey: false, - metaKey: false, - shiftKey: false, - isComposing: false, - which: 65, - bubbles: true, - })"#, + key: "a", + code: "KeyA", + location: 0, + repeat: true, + keyCode: 65, + charCode: 97, + char: "a", + charCode: 0, + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + isComposing: false, + which: 65, + bubbles: true, + })"#, ); // key_up_div mock_event( cx, "key_up_div", r#"new KeyboardEvent("keyup", { - key: "a", - code: "KeyA", - location: 0, - repeat: false, - keyCode: 65, - charCode: 97, - char: "a", - charCode: 0, - altKey: false, - ctrlKey: false, - metaKey: false, - shiftKey: false, - isComposing: false, - which: 65, - bubbles: true, - })"#, + key: "a", + code: "KeyA", + location: 0, + repeat: false, + keyCode: 65, + charCode: 97, + char: "a", + charCode: 0, + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + isComposing: false, + which: 65, + bubbles: true, + })"#, ); // key_press_div mock_event( cx, "key_press_div", r#"new KeyboardEvent("keypress", { - key: "a", - code: "KeyA", - location: 0, - repeat: false, - keyCode: 65, - charCode: 97, - char: "a", - charCode: 0, - altKey: false, - ctrlKey: false, - metaKey: false, - shiftKey: false, - isComposing: false, - which: 65, - bubbles: true, - })"#, + key: "a", + code: "KeyA", + location: 0, + repeat: false, + keyCode: 65, + charCode: 97, + char: "a", + charCode: 0, + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + isComposing: false, + which: 65, + bubbles: true, + })"#, ); // focus_in_div mock_event( diff --git a/packages/desktop/headless_tests/rendering.rs b/packages/desktop/headless_tests/rendering.rs index 635da9c23..0907121d4 100644 --- a/packages/desktop/headless_tests/rendering.rs +++ b/packages/desktop/headless_tests/rendering.rs @@ -27,20 +27,20 @@ fn main() { } fn use_inner_html(cx: &ScopeState, id: &'static str) -> Option { + let eval_provider = use_eval(cx); + let value: &UseRef> = use_ref(cx, || None); use_effect(cx, (), |_| { - to_owned![value]; - let desktop_context: DesktopContext = cx.consume_context().unwrap(); + to_owned![value, eval_provider]; async move { tokio::time::sleep(std::time::Duration::from_millis(100)).await; - let html = desktop_context - .eval(&format!( - r#"let element = document.getElementById('{}'); - return element.innerHTML;"#, - id - )) - .await; - if let Ok(serde_json::Value::String(html)) = html { + let html = eval_provider(&format!( + r#"let element = document.getElementById('{}'); + return element.innerHTML"#, + id + )) + .unwrap(); + if let Ok(serde_json::Value::String(html)) = html.await { println!("html: {}", html); value.set(Some(html)); } @@ -53,13 +53,15 @@ const EXPECTED_HTML: &str = r#"
EvalResult { - // the query id lets us keep track of the eval result and send it back to the main thread - let query = self.query.new_query(code, &self.webview); - - EvalResult::new(query) - } - /// Create a wry event handler that listens for wry events. /// This event handler is scoped to the currently active window and will only recieve events that are either global or related to the current window. /// diff --git a/packages/desktop/src/element.rs b/packages/desktop/src/element.rs index 0de5304ba..20ca951bf 100644 --- a/packages/desktop/src/element.rs +++ b/packages/desktop/src/element.rs @@ -34,7 +34,7 @@ impl RenderedElementBacking for DesktopElement { let fut = self .query - .new_query::>>(&script, &self.webview.webview) + .new_query::>>(&script, self.webview.clone()) .resolve(); Box::pin(async move { match fut.await { @@ -61,7 +61,7 @@ impl RenderedElementBacking for DesktopElement { let fut = self .query - .new_query::(&script, &self.webview.webview) + .new_query::(&script, self.webview.clone()) .resolve(); Box::pin(async move { match fut.await { @@ -87,7 +87,7 @@ impl RenderedElementBacking for DesktopElement { let fut = self .query - .new_query::(&script, &self.webview.webview) + .new_query::(&script, self.webview.clone()) .resolve(); Box::pin(async move { diff --git a/packages/desktop/src/eval.rs b/packages/desktop/src/eval.rs index 1e11cad18..e187be450 100644 --- a/packages/desktop/src/eval.rs +++ b/packages/desktop/src/eval.rs @@ -1,41 +1,70 @@ -use std::rc::Rc; - -use crate::query::Query; -use crate::query::QueryError; -use crate::use_window; +#![allow(clippy::await_holding_refcell_ref)] +use async_trait::async_trait; use dioxus_core::ScopeState; -use std::future::Future; -use std::future::IntoFuture; -use std::pin::Pin; +use dioxus_html::prelude::{EvalError, EvalProvider, Evaluator}; +use std::{cell::RefCell, rc::Rc}; -/// A future that resolves to the result of a JavaScript evaluation. -pub struct EvalResult { - pub(crate) query: Query, +use crate::{query::Query, DesktopContext}; + +/// Provides the DesktopEvalProvider through [`cx.provide_context`]. +pub fn init_eval(cx: &ScopeState) { + let desktop_ctx = cx.consume_context::().unwrap(); + let provider: Rc = Rc::new(DesktopEvalProvider { desktop_ctx }); + cx.provide_context(provider); } -impl EvalResult { - pub(crate) fn new(query: Query) -> Self { - Self { query } +/// Reprents the desktop-target's provider of evaluators. +pub struct DesktopEvalProvider { + desktop_ctx: DesktopContext, +} + +impl EvalProvider for DesktopEvalProvider { + fn new_evaluator(&self, js: String) -> Result, EvalError> { + Ok(Rc::new(DesktopEvaluator::new(self.desktop_ctx.clone(), js))) } } -impl IntoFuture for EvalResult { - type Output = Result; +/// Reprents a desktop-target's JavaScript evaluator. +pub struct DesktopEvaluator { + query: Rc>>, +} - type IntoFuture = Pin>>>; +impl DesktopEvaluator { + /// Creates a new evaluator for desktop-based targets. + pub fn new(desktop_ctx: DesktopContext, js: String) -> Self { + let ctx = desktop_ctx.clone(); + let query = desktop_ctx.query.new_query(&js, ctx); - fn into_future(self) -> Self::IntoFuture { - Box::pin(self.query.resolve()) - as Pin>>> + Self { + query: Rc::new(RefCell::new(query)), + } } } -/// Get a closure that executes any JavaScript in the WebView context. -pub fn use_eval(cx: &ScopeState) -> &Rc EvalResult> { - let desktop = use_window(cx); - &*cx.use_hook(|| { - let desktop = desktop.clone(); +#[async_trait(?Send)] +impl Evaluator for DesktopEvaluator { + async fn join(&self) -> Result { + self.query + .borrow_mut() + .result() + .await + .map_err(|e| EvalError::Communication(e.to_string())) + } - Rc::new(move |script: String| desktop.eval(&script)) as Rc EvalResult> - }) + /// Sends a message to the evaluated JavaScript. + fn send(&self, data: serde_json::Value) -> Result<(), EvalError> { + if let Err(e) = self.query.borrow_mut().send(data) { + return Err(EvalError::Communication(e.to_string())); + } + Ok(()) + } + + /// Gets an UnboundedReceiver to receive messages from the evaluated JavaScript. + async fn recv(&self) -> Result { + self.query + .borrow_mut() + .recv() + .await + .map_err(|e| EvalError::Communication(e.to_string())) + } } diff --git a/packages/desktop/src/lib.rs b/packages/desktop/src/lib.rs index cb295a853..070bfa7d1 100644 --- a/packages/desktop/src/lib.rs +++ b/packages/desktop/src/lib.rs @@ -30,7 +30,7 @@ use dioxus_core::*; use dioxus_html::MountedData; use dioxus_html::{native_bind::NativeFileEngine, FormData, HtmlEvent}; use element::DesktopElement; -pub use eval::{use_eval, EvalResult}; +use eval::init_eval; use futures_util::{pin_mut, FutureExt}; use shortcut::ShortcutRegistry; pub use shortcut::{use_global_shortcut, ShortcutHandle, ShortcutId, ShortcutRegistryError}; @@ -204,15 +204,17 @@ pub fn launch_with_props(root: Component

, props: P, cfg: Config) }, Event::NewEvents(StartCause::Init) => { - // let props = props.take().unwrap(); let cfg = cfg.take().unwrap(); + // Create a dom + let dom = VirtualDom::new_with_props(root, props); + let handler = create_new_window( cfg, event_loop, &proxy, - VirtualDom::new_with_props(root, props), + dom, &queue, &event_handlers, shortcut_manager.clone(), @@ -389,7 +391,11 @@ fn create_new_window( shortcut_manager, )); - dom.base_scope().provide_context(desktop_context.clone()); + let cx = dom.base_scope(); + cx.provide_context(desktop_context.clone()); + + // Init eval + init_eval(cx); WebviewHandler { // We want to poll the virtualdom and the event loop at the same time, so the waker will be connected to both diff --git a/packages/desktop/src/query.rs b/packages/desktop/src/query.rs index fb23dc487..ca034293a 100644 --- a/packages/desktop/src/query.rs +++ b/packages/desktop/src/query.rs @@ -1,52 +1,127 @@ use std::{cell::RefCell, rc::Rc}; +use crate::DesktopContext; use serde::{de::DeserializeOwned, Deserialize}; use serde_json::Value; use slab::Slab; use thiserror::Error; use tokio::sync::broadcast::error::RecvError; -use wry::webview::WebView; + +const DIOXUS_CODE: &str = r#" +let dioxus = { + recv: function () { + return new Promise((resolve, _reject) => { + // Ever 50 ms check for new data + let timeout = setTimeout(() => { + let __msg = null; + while (true) { + let __data = _message_queue.shift(); + if (__data) { + __msg = __data; + break; + } + } + clearTimeout(timeout); + resolve(__msg); + }, 50); + }); + }, + + send: function (value) { + window.ipc.postMessage( + JSON.stringify({ + "method":"query", + "params": { + "id": _request_id, + "data": value, + "returned_value": false + } + }) + ); + } +}"#; /// Tracks what query ids are currently active -#[derive(Default, Clone)] -struct SharedSlab { - slab: Rc>>, + +struct SharedSlab { + slab: Rc>>, } -/// Handles sending and receiving arbitrary queries from the webview. Queries can be resolved non-sequentially, so we use ids to track them. -#[derive(Clone)] -pub(crate) struct QueryEngine { - sender: Rc>, - active_requests: SharedSlab, -} - -impl Default for QueryEngine { - fn default() -> Self { - let (sender, _) = tokio::sync::broadcast::channel(1000); +impl Clone for SharedSlab { + fn clone(&self) -> Self { Self { - sender: Rc::new(sender), - active_requests: SharedSlab::default(), + slab: self.slab.clone(), } } } +impl Default for SharedSlab { + fn default() -> Self { + SharedSlab { + slab: Rc::new(RefCell::new(Slab::new())), + } + } +} + +struct QueryEntry { + channel_sender: tokio::sync::mpsc::UnboundedSender, + return_sender: Option>, +} + +const QUEUE_NAME: &str = "__msg_queues"; + +/// Handles sending and receiving arbitrary queries from the webview. Queries can be resolved non-sequentially, so we use ids to track them. +#[derive(Clone, Default)] +pub(crate) struct QueryEngine { + active_requests: SharedSlab, +} + impl QueryEngine { /// Creates a new query and returns a handle to it. The query will be resolved when the webview returns a result with the same id. - pub fn new_query(&self, script: &str, webview: &WebView) -> Query { - let request_id = self.active_requests.slab.borrow_mut().insert(()); + pub fn new_query( + &self, + script: &str, + context: DesktopContext, + ) -> Query { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let (return_tx, return_rx) = tokio::sync::oneshot::channel(); + let request_id = self.active_requests.slab.borrow_mut().insert(QueryEntry { + channel_sender: tx, + return_sender: Some(return_tx), + }); // start the query // We embed the return of the eval in a function so we can send it back to the main thread - if let Err(err) = webview.evaluate_script(&format!( - r#"window.ipc.postMessage( - JSON.stringify({{ - "method":"query", - "params": {{ - "id": {request_id}, - "data": (function(){{{script}}})() + if let Err(err) = context.webview.evaluate_script(&format!( + r#"(function(){{ + (async (resolve, _reject) => {{ + {DIOXUS_CODE} + if (!window.{QUEUE_NAME}) {{ + window.{QUEUE_NAME} = []; }} + + let _request_id = {request_id}; + + if (!window.{QUEUE_NAME}[{request_id}]) {{ + window.{QUEUE_NAME}[{request_id}] = []; + }} + let _message_queue = window.{QUEUE_NAME}[{request_id}]; + + {script} + }})().then((result)=>{{ + let returned_value = {{ + "method":"query", + "params": {{ + "id": {request_id}, + "data": result, + "returned_value": true + }} + }}; + window.ipc.postMessage( + JSON.stringify(returned_value) + ); }}) - );"# + }})();"# )) { log::warn!("Query error: {err}"); } @@ -54,57 +129,131 @@ impl QueryEngine { Query { slab: self.active_requests.clone(), id: request_id, - reciever: self.sender.subscribe(), + receiver: rx, + return_receiver: Some(return_rx), + desktop: context, phantom: std::marker::PhantomData, } } - /// Send a query result + /// Send a query channel message to the correct query pub fn send(&self, data: QueryResult) { - let _ = self.sender.send(data); + let QueryResult { + id, + data, + returned_value, + } = data; + let mut slab = self.active_requests.slab.borrow_mut(); + if let Some(entry) = slab.get_mut(id) { + if returned_value { + if let Some(sender) = entry.return_sender.take() { + let _ = sender.send(data); + } + } else { + let _ = entry.channel_sender.send(data); + } + } } } pub(crate) struct Query { - slab: SharedSlab, + desktop: DesktopContext, + slab: SharedSlab, + receiver: tokio::sync::mpsc::UnboundedReceiver, + return_receiver: Option>, id: usize, - reciever: tokio::sync::broadcast::Receiver, phantom: std::marker::PhantomData, } impl Query { /// Resolve the query pub async fn resolve(mut self) -> Result { - let result = loop { - match self.reciever.recv().await { - Ok(result) => { - if result.id == self.id { - break V::deserialize(result.data).map_err(QueryError::DeserializeError); - } - } - Err(err) => { - break Err(QueryError::RecvError(err)); - } - } - }; + match self.receiver.recv().await { + Some(result) => V::deserialize(result).map_err(QueryError::Deserialize), + None => Err(QueryError::Recv(RecvError::Closed)), + } + } - // Remove the query from the slab + /// Send a message to the query + pub fn send(&self, message: S) -> Result<(), QueryError> { + let queue_id = self.id; + + let data = message.to_string(); + let script = format!( + r#" + if (!window.{QUEUE_NAME}) {{ + window.{QUEUE_NAME} = []; + }} + + if (!window.{QUEUE_NAME}[{queue_id}]) {{ + window.{QUEUE_NAME}[{queue_id}] = []; + }} + window.{QUEUE_NAME}[{queue_id}].push({data}); + "# + ); + + self.desktop + .webview + .evaluate_script(&script) + .map_err(|e| QueryError::Send(e.to_string()))?; + + Ok(()) + } + + /// Receive a message from the query + pub async fn recv(&mut self) -> Result { + self.receiver + .recv() + .await + .ok_or(QueryError::Recv(RecvError::Closed)) + } + + /// Receive the result of the query + pub async fn result(&mut self) -> Result { + match self.return_receiver.take() { + Some(receiver) => receiver + .await + .map_err(|_| QueryError::Recv(RecvError::Closed)), + None => Err(QueryError::Finished), + } + } +} + +impl Drop for Query { + fn drop(&mut self) { self.slab.slab.borrow_mut().remove(self.id); + let queue_id = self.id; - result + _ = self.desktop.webview.evaluate_script(&format!( + r#" + if (!window.{QUEUE_NAME}) {{ + window.{QUEUE_NAME} = []; + }} + + if (window.{QUEUE_NAME}[{queue_id}]) {{ + window.{QUEUE_NAME}[{queue_id}] = []; + }} + "# + )); } } #[derive(Error, Debug)] pub enum QueryError { #[error("Error receiving query result: {0}")] - RecvError(RecvError), + Recv(RecvError), + #[error("Error sending message to query: {0}")] + Send(String), #[error("Error deserializing query result: {0}")] - DeserializeError(serde_json::Error), + Deserialize(serde_json::Error), + #[error("Query has already been resolved")] + Finished, } #[derive(Clone, Debug, Deserialize)] pub(crate) struct QueryResult { id: usize, data: Value, + #[serde(default)] + returned_value: bool, } diff --git a/packages/fullstack/src/router.rs b/packages/fullstack/src/router.rs index a8372e1ba..97bcdf5d1 100644 --- a/packages/fullstack/src/router.rs +++ b/packages/fullstack/src/router.rs @@ -78,11 +78,7 @@ where ::Err: std::fmt::Display, { fn clone(&self) -> Self { - Self { - failure_external_navigation: self.failure_external_navigation, - scroll_restoration: self.scroll_restoration, - phantom: std::marker::PhantomData, - } + *self } } diff --git a/packages/hooks/src/usestate.rs b/packages/hooks/src/usestate.rs index d2b6db6fc..9e3fb5eed 100644 --- a/packages/hooks/src/usestate.rs +++ b/packages/hooks/src/usestate.rs @@ -274,6 +274,7 @@ impl UseState { /// *val.make_mut() += 1; /// ``` #[must_use] + #[allow(clippy::missing_panics_doc)] pub fn make_mut(&self) -> RefMut { let mut slot = self.slot.borrow_mut(); diff --git a/packages/html/Cargo.toml b/packages/html/Cargo.toml index e72c20c97..23d215154 100644 --- a/packages/html/Cargo.toml +++ b/packages/html/Cargo.toml @@ -21,6 +21,9 @@ keyboard-types = "0.6.2" async-trait = "0.1.58" serde-value = "0.7.0" tokio = { workspace = true, features = ["fs", "io-util"], optional = true } +rfd = { version = "0.11.3", optional = true } +async-channel = "1.8.0" +serde_json = { version = "1", optional = true } [dependencies.web-sys] optional = true @@ -54,6 +57,7 @@ default = ["serialize"] serialize = [ "serde", "serde_repr", + "serde_json", "euclid/serde", "keyboard-types/serde", "dioxus-core/serialize", diff --git a/packages/html/src/eval.rs b/packages/html/src/eval.rs new file mode 100644 index 000000000..b39eef805 --- /dev/null +++ b/packages/html/src/eval.rs @@ -0,0 +1,96 @@ +#![allow(clippy::await_holding_refcell_ref)] + +use async_trait::async_trait; +use dioxus_core::ScopeState; +use std::future::{Future, IntoFuture}; +use std::pin::Pin; +use std::rc::Rc; + +/// A struct that implements EvalProvider is sent through [`ScopeState`]'s provide_context function +/// so that [`use_eval`] can provide a platform agnostic interface for evaluating JavaScript code. +pub trait EvalProvider { + fn new_evaluator(&self, js: String) -> Result, EvalError>; +} + +/// The platform's evaluator. +#[async_trait(?Send)] +pub trait Evaluator { + /// Sends a message to the evaluated JavaScript. + fn send(&self, data: serde_json::Value) -> Result<(), EvalError>; + /// Receive any queued messages from the evaluated JavaScript. + async fn recv(&self) -> Result; + /// Gets the return value of the JavaScript + async fn join(&self) -> Result; +} + +type EvalCreator = Rc Result>; + +/// Get a struct that can execute any JavaScript. +/// +/// # Safety +/// +/// Please be very careful with this function. A script with too many dynamic +/// parts is practically asking for a hacker to find an XSS vulnerability in +/// it. **This applies especially to web targets, where the JavaScript context +/// has access to most, if not all of your application data.** +pub fn use_eval(cx: &ScopeState) -> &EvalCreator { + &*cx.use_hook(|| { + let eval_provider = cx + .consume_context::>() + .expect("evaluator not provided"); + + Rc::new(move |script: &str| { + eval_provider + .new_evaluator(script.to_string()) + .map(|evaluator| UseEval::new(evaluator)) + }) as Rc Result> + }) +} + +/// A wrapper around the target platform's evaluator. +#[derive(Clone)] +pub struct UseEval { + evaluator: Rc, +} + +impl UseEval { + /// Creates a new UseEval + pub fn new(evaluator: Rc) -> Self { + Self { evaluator } + } + + /// Sends a [`serde_json::Value`] to the evaluated JavaScript. + pub fn send(&self, data: serde_json::Value) -> Result<(), EvalError> { + self.evaluator.send(data) + } + + /// Gets an UnboundedReceiver to receive messages from the evaluated JavaScript. + pub async fn recv(&self) -> Result { + self.evaluator.recv().await + } + + /// Gets the return value of the evaluated JavaScript. + pub async fn join(self) -> Result { + self.evaluator.join().await + } +} + +impl IntoFuture for UseEval { + type Output = Result; + type IntoFuture = Pin>>; + + fn into_future(self) -> Self::IntoFuture { + Box::pin(self.join()) + } +} + +/// Represents an error when evaluating JavaScript +#[derive(Debug)] +pub enum EvalError { + /// The provided JavaScript has already been ran. + Finished, + /// The provided JavaScript is not valid and can't be ran. + InvalidJs(String), + /// Represents an error communicating between JavaScript and Rust. + Communication(String), +} diff --git a/packages/html/src/lib.rs b/packages/html/src/lib.rs index e02ba2d5a..2fc1baeac 100644 --- a/packages/html/src/lib.rs +++ b/packages/html/src/lib.rs @@ -37,6 +37,9 @@ pub use events::*; pub use global_attributes::*; pub use render_template::*; +mod eval; + pub mod prelude { + pub use crate::eval::*; pub use crate::events::*; } diff --git a/packages/liveview/Cargo.toml b/packages/liveview/Cargo.toml index b2c701877..268a23a4d 100644 --- a/packages/liveview/Cargo.toml +++ b/packages/liveview/Cargo.toml @@ -35,6 +35,7 @@ axum = { version = "0.6.1", optional = true, features = ["ws"] } # salvo salvo = { version = "0.44.1", optional = true, features = ["ws"] } once_cell = "1.17.1" +async-trait = "0.1.71" # actix is ... complicated? # actix-files = { version = "0.6.2", optional = true } diff --git a/packages/liveview/src/element.rs b/packages/liveview/src/element.rs index 21be23bee..ae0eb4efe 100644 --- a/packages/liveview/src/element.rs +++ b/packages/liveview/src/element.rs @@ -1,23 +1,17 @@ use dioxus_core::ElementId; use dioxus_html::{geometry::euclid::Rect, MountedResult, RenderedElementBacking}; -use tokio::sync::mpsc::UnboundedSender; use crate::query::QueryEngine; /// A mounted element passed to onmounted events pub struct LiveviewElement { id: ElementId, - query_tx: UnboundedSender, query: QueryEngine, } impl LiveviewElement { - pub(crate) fn new(id: ElementId, tx: UnboundedSender, query: QueryEngine) -> Self { - Self { - id, - query_tx: tx, - query, - } + pub(crate) fn new(id: ElementId, query: QueryEngine) -> Self { + Self { id, query } } } @@ -39,7 +33,7 @@ impl RenderedElementBacking for LiveviewElement { let fut = self .query - .new_query::>>(&script, &self.query_tx) + .new_query::>>(&script) .resolve(); Box::pin(async move { match fut.await { @@ -64,10 +58,7 @@ impl RenderedElementBacking for LiveviewElement { serde_json::to_string(&behavior).expect("Failed to serialize ScrollBehavior") ); - let fut = self - .query - .new_query::(&script, &self.query_tx) - .resolve(); + let fut = self.query.new_query::(&script).resolve(); Box::pin(async move { match fut.await { Ok(true) => Ok(()), @@ -90,10 +81,7 @@ impl RenderedElementBacking for LiveviewElement { self.id.0, focus ); - let fut = self - .query - .new_query::(&script, &self.query_tx) - .resolve(); + let fut = self.query.new_query::(&script).resolve(); Box::pin(async move { match fut.await { diff --git a/packages/liveview/src/eval.rs b/packages/liveview/src/eval.rs new file mode 100644 index 000000000..37ba0c9db --- /dev/null +++ b/packages/liveview/src/eval.rs @@ -0,0 +1,75 @@ +#![allow(clippy::await_holding_refcell_ref)] + +use async_trait::async_trait; +use dioxus_core::ScopeState; +use dioxus_html::prelude::{EvalError, EvalProvider, Evaluator}; +use std::{cell::RefCell, rc::Rc}; + +use crate::query::{Query, QueryEngine}; + +/// Provides the DesktopEvalProvider through [`cx.provide_context`]. +pub fn init_eval(cx: &ScopeState) { + let query = cx.consume_context::().unwrap(); + let provider: Rc = Rc::new(DesktopEvalProvider { query }); + cx.provide_context(provider); +} + +/// Reprents the desktop-target's provider of evaluators. +pub struct DesktopEvalProvider { + query: QueryEngine, +} + +impl EvalProvider for DesktopEvalProvider { + fn new_evaluator(&self, js: String) -> Result, EvalError> { + Ok(Rc::new(DesktopEvaluator::new(self.query.clone(), js))) + } +} + +/// Reprents a desktop-target's JavaScript evaluator. +pub(crate) struct DesktopEvaluator { + query: Rc>>, +} + +impl DesktopEvaluator { + /// Creates a new evaluator for desktop-based targets. + pub fn new(query: QueryEngine, js: String) -> Self { + let query = query.new_query(&js); + + Self { + query: Rc::new(RefCell::new(query)), + } + } +} + +#[async_trait(?Send)] +impl Evaluator for DesktopEvaluator { + /// # Panics + /// This will panic if the query is currently being awaited. + async fn join(&self) -> Result { + self.query + .borrow_mut() + .result() + .await + .map_err(|e| EvalError::Communication(e.to_string())) + } + + /// Sends a message to the evaluated JavaScript. + fn send(&self, data: serde_json::Value) -> Result<(), EvalError> { + if let Err(e) = self.query.borrow_mut().send(data) { + return Err(EvalError::Communication(e.to_string())); + } + Ok(()) + } + + /// Gets an UnboundedReceiver to receive messages from the evaluated JavaScript. + /// + /// # Panics + /// This will panic if the query is currently being awaited. + async fn recv(&self) -> Result { + self.query + .borrow_mut() + .recv() + .await + .map_err(|e| EvalError::Communication(e.to_string())) + } +} diff --git a/packages/liveview/src/lib.rs b/packages/liveview/src/lib.rs index ff02febac..92cce63e6 100644 --- a/packages/liveview/src/lib.rs +++ b/packages/liveview/src/lib.rs @@ -23,6 +23,7 @@ pub mod pool; mod query; use futures_util::{SinkExt, StreamExt}; pub use pool::*; +mod eval; pub trait WebsocketTx: SinkExt {} impl WebsocketTx for T where T: SinkExt {} diff --git a/packages/liveview/src/pool.rs b/packages/liveview/src/pool.rs index dc7e4ea9e..2fd06cec6 100644 --- a/packages/liveview/src/pool.rs +++ b/packages/liveview/src/pool.rs @@ -1,5 +1,6 @@ use crate::{ element::LiveviewElement, + eval::init_eval, query::{QueryEngine, QueryResult}, LiveViewError, }; @@ -119,6 +120,12 @@ pub async fn run(mut vdom: VirtualDom, ws: impl LiveViewSocket) -> Result<(), Li rx }; + // Create the a proxy for query engine + let (query_tx, mut query_rx) = tokio::sync::mpsc::unbounded_channel(); + let query_engine = QueryEngine::new(query_tx); + vdom.base_scope().provide_context(query_engine.clone()); + init_eval(vdom.base_scope()); + // todo: use an efficient binary packed format for this let edits = serde_json::to_string(&ClientUpdate::Edits(vdom.rebuild())).unwrap(); @@ -128,10 +135,6 @@ pub async fn run(mut vdom: VirtualDom, ws: impl LiveViewSocket) -> Result<(), Li // send the initial render to the client ws.send(edits.into_bytes()).await?; - // Create the a proxy for query engine - let (query_tx, mut query_rx) = tokio::sync::mpsc::unbounded_channel(); - let query_engine = QueryEngine::default(); - // desktop uses this wrapper struct thing around the actual event itself // this is sorta driven by tao/wry #[derive(serde::Deserialize, Debug)] @@ -165,7 +168,7 @@ pub async fn run(mut vdom: VirtualDom, ws: impl LiveViewSocket) -> Result<(), Li IpcMessage::Event(evt) => { // Intercept the mounted event and insert a custom element type if let EventData::Mounted = &evt.data { - let element = LiveviewElement::new(evt.element, query_tx.clone(), query_engine.clone()); + let element = LiveviewElement::new(evt.element, query_engine.clone()); vdom.handle_event( &evt.name, Rc::new(MountedData::new(element)), diff --git a/packages/liveview/src/query.rs b/packages/liveview/src/query.rs index 04d531a63..8052c2afa 100644 --- a/packages/liveview/src/query.rs +++ b/packages/liveview/src/query.rs @@ -4,110 +4,261 @@ use serde::{de::DeserializeOwned, Deserialize}; use serde_json::Value; use slab::Slab; use thiserror::Error; -use tokio::sync::{broadcast::error::RecvError, mpsc::UnboundedSender}; +use tokio::sync::broadcast::error::RecvError; + +const DIOXUS_CODE: &str = r#" +let dioxus = { + recv: function () { + return new Promise((resolve, _reject) => { + // Ever 50 ms check for new data + let timeout = setTimeout(() => { + let __msg = null; + while (true) { + let __data = _message_queue.shift(); + if (__data) { + __msg = __data; + break; + } + } + clearTimeout(timeout); + resolve(__msg); + }, 50); + }); + }, + + send: function (value) { + window.ipc.postMessage( + JSON.stringify({ + "method":"query", + "params": { + "id": _request_id, + "data": value, + "returned_value": false + } + }) + ); + } +}"#; /// Tracks what query ids are currently active -#[derive(Default, Clone)] -struct SharedSlab { - slab: Rc>>, + +struct SharedSlab { + slab: Rc>>, } -/// Handles sending and receiving arbitrary queries from the webview. Queries can be resolved non-sequentially, so we use ids to track them. -#[derive(Clone)] -pub(crate) struct QueryEngine { - sender: Rc>, - active_requests: SharedSlab, -} - -impl Default for QueryEngine { - fn default() -> Self { - let (sender, _) = tokio::sync::broadcast::channel(8); +impl Clone for SharedSlab { + fn clone(&self) -> Self { Self { - sender: Rc::new(sender), - active_requests: SharedSlab::default(), + slab: self.slab.clone(), } } } +impl Default for SharedSlab { + fn default() -> Self { + SharedSlab { + slab: Rc::new(RefCell::new(Slab::new())), + } + } +} + +struct QueryEntry { + channel_sender: tokio::sync::mpsc::UnboundedSender, + return_sender: Option>, +} + +const QUEUE_NAME: &str = "__msg_queues"; + +/// Handles sending and receiving arbitrary queries from the webview. Queries can be resolved non-sequentially, so we use ids to track them. +#[derive(Clone)] +pub(crate) struct QueryEngine { + active_requests: SharedSlab, + query_tx: tokio::sync::mpsc::UnboundedSender, +} + impl QueryEngine { + pub(crate) fn new(query_tx: tokio::sync::mpsc::UnboundedSender) -> Self { + Self { + active_requests: Default::default(), + query_tx, + } + } + /// Creates a new query and returns a handle to it. The query will be resolved when the webview returns a result with the same id. - pub fn new_query( - &self, - script: &str, - tx: &UnboundedSender, - ) -> Query { - let request_id = self.active_requests.slab.borrow_mut().insert(()); + pub fn new_query(&self, script: &str) -> Query { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let (return_tx, return_rx) = tokio::sync::oneshot::channel(); + let request_id = self.active_requests.slab.borrow_mut().insert(QueryEntry { + channel_sender: tx, + return_sender: Some(return_tx), + }); // start the query // We embed the return of the eval in a function so we can send it back to the main thread - if let Err(err) = tx.send(format!( - r#"window.ipc.postMessage( - JSON.stringify({{ - "method":"query", - "params": {{ - "id": {request_id}, - "data": (function(){{{script}}})() + if let Err(err) = self.query_tx.send(format!( + r#"(function(){{ + (async (resolve, _reject) => {{ + {DIOXUS_CODE} + if (!window.{QUEUE_NAME}) {{ + window.{QUEUE_NAME} = []; }} + + let _request_id = {request_id}; + + if (!window.{QUEUE_NAME}[{request_id}]) {{ + window.{QUEUE_NAME}[{request_id}] = []; + }} + let _message_queue = window.{QUEUE_NAME}[{request_id}]; + + {script} + }})().then((result)=>{{ + let returned_value = {{ + "method":"query", + "params": {{ + "id": {request_id}, + "data": result, + "returned_value": true + }} + }}; + window.ipc.postMessage( + JSON.stringify(returned_value) + ); }}) - );"# + }})();"# )) { log::warn!("Query error: {err}"); } Query { - slab: self.active_requests.clone(), + query_engine: self.clone(), id: request_id, - reciever: self.sender.subscribe(), + receiver: rx, + return_receiver: Some(return_rx), phantom: std::marker::PhantomData, } } - /// Send a query result + /// Send a query channel message to the correct query pub fn send(&self, data: QueryResult) { - let _ = self.sender.send(data); + let QueryResult { + id, + data, + returned_value, + } = data; + let mut slab = self.active_requests.slab.borrow_mut(); + if let Some(entry) = slab.get_mut(id) { + if returned_value { + if let Some(sender) = entry.return_sender.take() { + let _ = sender.send(data); + } + } else { + let _ = entry.channel_sender.send(data); + } + } } } pub(crate) struct Query { - slab: SharedSlab, + query_engine: QueryEngine, + pub receiver: tokio::sync::mpsc::UnboundedReceiver, + pub return_receiver: Option>, id: usize, - reciever: tokio::sync::broadcast::Receiver, phantom: std::marker::PhantomData, } impl Query { /// Resolve the query pub async fn resolve(mut self) -> Result { - let result = loop { - match self.reciever.recv().await { - Ok(result) => { - if result.id == self.id { - break V::deserialize(result.data).map_err(QueryError::DeserializeError); - } - } - Err(err) => { - break Err(QueryError::RecvError(err)); - } - } - }; + match self.receiver.recv().await { + Some(result) => V::deserialize(result).map_err(QueryError::Deserialize), + None => Err(QueryError::Recv(RecvError::Closed)), + } + } - // Remove the query from the slab - self.slab.slab.borrow_mut().remove(self.id); + /// Send a message to the query + pub fn send(&self, message: S) -> Result<(), QueryError> { + let queue_id = self.id; - result + let data = message.to_string(); + let script = format!( + r#" + if (!window.{QUEUE_NAME}) {{ + window.{QUEUE_NAME} = []; + }} + + if (!window.{QUEUE_NAME}[{queue_id}]) {{ + window.{QUEUE_NAME}[{queue_id}] = []; + }} + window.{QUEUE_NAME}[{queue_id}].push({data}); + "# + ); + + self.query_engine + .query_tx + .send(script) + .map_err(|e| QueryError::Send(e.to_string()))?; + + Ok(()) + } + + /// Receive a message from the query + pub async fn recv(&mut self) -> Result { + self.receiver + .recv() + .await + .ok_or(QueryError::Recv(RecvError::Closed)) + } + + /// Receive the result of the query + pub async fn result(&mut self) -> Result { + match self.return_receiver.take() { + Some(receiver) => receiver + .await + .map_err(|_| QueryError::Recv(RecvError::Closed)), + None => Err(QueryError::Finished), + } + } +} + +impl Drop for Query { + fn drop(&mut self) { + self.query_engine + .active_requests + .slab + .borrow_mut() + .remove(self.id); + let queue_id = self.id; + + _ = self.query_engine.query_tx.send(format!( + r#" + if (!window.{QUEUE_NAME}) {{ + window.{QUEUE_NAME} = []; + }} + + if (window.{QUEUE_NAME}[{queue_id}]) {{ + window.{QUEUE_NAME}[{queue_id}] = []; + }} + "# + )); } } #[derive(Error, Debug)] pub enum QueryError { #[error("Error receiving query result: {0}")] - RecvError(RecvError), + Recv(RecvError), + #[error("Error sending message to query: {0}")] + Send(String), #[error("Error deserializing query result: {0}")] - DeserializeError(serde_json::Error), + Deserialize(serde_json::Error), + #[error("Query has already been resolved")] + Finished, } #[derive(Clone, Debug, Deserialize)] pub(crate) struct QueryResult { id: usize, data: Value, + #[serde(default)] + returned_value: bool, } diff --git a/packages/native-core/src/real_dom.rs b/packages/native-core/src/real_dom.rs index a0eff4b2c..44c378ddf 100644 --- a/packages/native-core/src/real_dom.rs +++ b/packages/native-core/src/real_dom.rs @@ -691,10 +691,7 @@ pub struct NodeRef<'a, V: FromAnyValue + Send + Sync = ()> { impl<'a, V: FromAnyValue + Send + Sync> Clone for NodeRef<'a, V> { fn clone(&self) -> Self { - Self { - id: self.id, - dom: self.dom, - } + *self } } diff --git a/packages/rink/src/focus.rs b/packages/rink/src/focus.rs index e26f79b3d..704d13b46 100644 --- a/packages/rink/src/focus.rs +++ b/packages/rink/src/focus.rs @@ -39,23 +39,23 @@ impl FocusLevel { impl PartialOrd for FocusLevel { fn partial_cmp(&self, other: &Self) -> Option { - match (self, other) { - (FocusLevel::Unfocusable, FocusLevel::Unfocusable) => Some(std::cmp::Ordering::Equal), - (FocusLevel::Unfocusable, FocusLevel::Focusable) => Some(std::cmp::Ordering::Less), - (FocusLevel::Unfocusable, FocusLevel::Ordered(_)) => Some(std::cmp::Ordering::Less), - (FocusLevel::Focusable, FocusLevel::Unfocusable) => Some(std::cmp::Ordering::Greater), - (FocusLevel::Focusable, FocusLevel::Focusable) => Some(std::cmp::Ordering::Equal), - (FocusLevel::Focusable, FocusLevel::Ordered(_)) => Some(std::cmp::Ordering::Greater), - (FocusLevel::Ordered(_), FocusLevel::Unfocusable) => Some(std::cmp::Ordering::Greater), - (FocusLevel::Ordered(_), FocusLevel::Focusable) => Some(std::cmp::Ordering::Less), - (FocusLevel::Ordered(a), FocusLevel::Ordered(b)) => a.partial_cmp(b), - } + Some(self.cmp(other)) } } impl Ord for FocusLevel { fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.partial_cmp(other).unwrap() + match (self, other) { + (FocusLevel::Unfocusable, FocusLevel::Unfocusable) => std::cmp::Ordering::Equal, + (FocusLevel::Unfocusable, FocusLevel::Focusable) => std::cmp::Ordering::Less, + (FocusLevel::Unfocusable, FocusLevel::Ordered(_)) => std::cmp::Ordering::Less, + (FocusLevel::Focusable, FocusLevel::Unfocusable) => std::cmp::Ordering::Greater, + (FocusLevel::Focusable, FocusLevel::Focusable) => std::cmp::Ordering::Equal, + (FocusLevel::Focusable, FocusLevel::Ordered(_)) => std::cmp::Ordering::Greater, + (FocusLevel::Ordered(_), FocusLevel::Unfocusable) => std::cmp::Ordering::Greater, + (FocusLevel::Ordered(_), FocusLevel::Focusable) => std::cmp::Ordering::Less, + (FocusLevel::Ordered(a), FocusLevel::Ordered(b)) => a.cmp(b), + } } } diff --git a/packages/rink/src/widgets/button.rs b/packages/rink/src/widgets/button.rs index 9018a3e9a..523b2392a 100644 --- a/packages/rink/src/widgets/button.rs +++ b/packages/rink/src/widgets/button.rs @@ -89,7 +89,7 @@ impl Button { } } - fn switch(&mut self, ctx: &mut WidgetContext, node: NodeMut) { + fn switch(&mut self, ctx: &WidgetContext, node: NodeMut) { let data = FormData { value: self.value.to_string(), values: HashMap::new(), @@ -185,7 +185,7 @@ impl RinkWidget for Button { event: &crate::Event, mut node: dioxus_native_core::real_dom::NodeMut, ) { - let mut ctx: WidgetContext = { + let ctx: WidgetContext = { node.real_dom_mut() .raw_world_mut() .borrow::>() @@ -194,7 +194,7 @@ impl RinkWidget for Button { }; match event.name { - "click" => self.switch(&mut ctx, node), + "click" => self.switch(&ctx, node), "keydown" => { if let crate::EventData::Keyboard(data) = &event.data { if !data.is_auto_repeating() @@ -204,7 +204,7 @@ impl RinkWidget for Button { _ => false, } { - self.switch(&mut ctx, node); + self.switch(&ctx, node); } } } diff --git a/packages/router-macro/src/route_tree.rs b/packages/router-macro/src/route_tree.rs index 171b6a042..d855d435a 100644 --- a/packages/router-macro/src/route_tree.rs +++ b/packages/router-macro/src/route_tree.rs @@ -219,7 +219,7 @@ impl<'a> RouteTree<'a> { Some(id) => { // If it exists, add the route to the children of the segment let new_children = self.construct(vec![route]); - self.children_mut(id).extend(new_children.into_iter()); + self.children_mut(id).extend(new_children); } None => { // If it doesn't exist, add the route as a new segment diff --git a/packages/web/Cargo.toml b/packages/web/Cargo.toml index 74143537f..3b9b6c89d 100644 --- a/packages/web/Cargo.toml +++ b/packages/web/Cargo.toml @@ -31,8 +31,9 @@ smallstr = "0.2.0" futures-channel = { workspace = true } serde_json = { version = "1.0" } serde = { version = "1.0" } -serde-wasm-bindgen = "0.4.5" +serde-wasm-bindgen = "0.5.0" async-trait = "0.1.58" +async-channel = "1.8.0" [dependencies.web-sys] version = "0.3.56" diff --git a/packages/web/src/eval.js b/packages/web/src/eval.js new file mode 100644 index 000000000..959d422eb --- /dev/null +++ b/packages/web/src/eval.js @@ -0,0 +1,41 @@ +export class Dioxus { + constructor(sendCallback, returnCallback) { + this.sendCallback = sendCallback; + this.returnCallback = returnCallback; + this.promiseResolve = null; + this.received = []; + } + + // Receive message from Rust + recv() { + return new Promise((resolve, _reject) => { + // If data already exists, resolve immediately + let data = this.received.shift(); + if (data) { + resolve(data); + return; + } + + // Otherwise set a resolve callback + this.promiseResolve = resolve; + }); + } + + // Send message to rust. + send(data) { + this.sendCallback(data); + } + + // Internal rust send + rustSend(data) { + // If a promise is waiting for data, resolve it, and clear the resolve callback + if (this.promiseResolve) { + this.promiseResolve(data); + this.promiseResolve = null; + return; + } + + // Otherwise add the data to a queue + this.received.push(data); + } +} \ No newline at end of file diff --git a/packages/web/src/eval.rs b/packages/web/src/eval.rs new file mode 100644 index 000000000..954bb1b54 --- /dev/null +++ b/packages/web/src/eval.rs @@ -0,0 +1,130 @@ +use async_trait::async_trait; +use dioxus_core::ScopeState; +use dioxus_html::prelude::{EvalError, EvalProvider, Evaluator}; +use js_sys::Function; +use serde_json::Value; +use std::{cell::RefCell, rc::Rc, str::FromStr}; +use wasm_bindgen::prelude::*; + +/// Provides the WebEvalProvider through [`cx.provide_context`]. +pub fn init_eval(cx: &ScopeState) { + let provider: Rc = Rc::new(WebEvalProvider {}); + cx.provide_context(provider); +} + +/// Reprents the web-target's provider of evaluators. +pub struct WebEvalProvider; +impl EvalProvider for WebEvalProvider { + fn new_evaluator(&self, js: String) -> Result, EvalError> { + WebEvaluator::new(js).map(|eval| Rc::new(eval) as Rc) + } +} + +/// Required to avoid blocking the Rust WASM thread. +const PROMISE_WRAPPER: &str = r#" + return new Promise(async (resolve, _reject) => { + {JS_CODE} + resolve(null); + }); + "#; + +/// Reprents a web-target's JavaScript evaluator. +pub struct WebEvaluator { + dioxus: Dioxus, + channel_receiver: async_channel::Receiver, + result: RefCell>, +} + +impl WebEvaluator { + /// Creates a new evaluator for web-based targets. + pub fn new(js: String) -> Result { + let (channel_sender, channel_receiver) = async_channel::unbounded(); + + // This Rc cloning mess hurts but it seems to work.. + let recv_value = Closure::::new(move |data| { + match serde_wasm_bindgen::from_value::(data) { + Ok(data) => _ = channel_sender.send_blocking(data), + Err(e) => { + // Can't really do much here. + log::error!("failed to serialize JsValue to serde_json::Value (eval communication) - {}", e); + } + } + }); + + let dioxus = Dioxus::new(recv_value.as_ref().unchecked_ref()); + recv_value.forget(); + + // Wrap the evaluated JS in a promise so that wasm can continue running (send/receive data from js) + let code = PROMISE_WRAPPER.replace("{JS_CODE}", &js); + + let result = match Function::new_with_args("dioxus", &code).call1(&JsValue::NULL, &dioxus) { + Ok(result) => { + if let Ok(stringified) = js_sys::JSON::stringify(&result) { + if !stringified.is_undefined() && stringified.is_valid_utf16() { + let string: String = stringified.into(); + Value::from_str(&string).map_err(|e| { + EvalError::Communication(format!("Failed to parse result - {}", e)) + })? + } else { + return Err(EvalError::Communication( + "Failed to stringify result".into(), + )); + } + } else { + return Err(EvalError::Communication( + "Failed to stringify result".into(), + )); + } + } + Err(err) => { + return Err(EvalError::InvalidJs( + err.as_string().unwrap_or("unknown".to_string()), + )); + } + }; + + Ok(Self { + dioxus, + channel_receiver, + result: RefCell::new(Some(result)), + }) + } +} + +#[async_trait(?Send)] +impl Evaluator for WebEvaluator { + /// Runs the evaluated JavaScript. + async fn join(&self) -> Result { + self.result.take().ok_or(EvalError::Finished) + } + + /// Sends a message to the evaluated JavaScript. + fn send(&self, data: serde_json::Value) -> Result<(), EvalError> { + let data = match serde_wasm_bindgen::to_value::(&data) { + Ok(d) => d, + Err(e) => return Err(EvalError::Communication(e.to_string())), + }; + + self.dioxus.rustSend(data); + Ok(()) + } + + /// Gets an UnboundedReceiver to receive messages from the evaluated JavaScript. + async fn recv(&self) -> Result { + self.channel_receiver + .recv() + .await + .map_err(|_| EvalError::Communication("failed to receive data from js".to_string())) + } +} + +#[wasm_bindgen(module = "/src/eval.js")] +extern "C" { + pub type Dioxus; + + #[wasm_bindgen(constructor)] + pub fn new(recv_callback: &Function) -> Dioxus; + + #[wasm_bindgen(method)] + pub fn rustSend(this: &Dioxus, data: JsValue); +} diff --git a/packages/web/src/lib.rs b/packages/web/src/lib.rs index 9fec0dcd6..b8a9c57b3 100644 --- a/packages/web/src/lib.rs +++ b/packages/web/src/lib.rs @@ -54,18 +54,17 @@ // - Do DOM work in the next requestAnimationFrame callback pub use crate::cfg::Config; -pub use crate::util::{use_eval, EvalResult}; use dioxus_core::{Element, Scope, VirtualDom}; use futures_util::{pin_mut, FutureExt, StreamExt}; mod cache; mod cfg; mod dom; +mod eval; mod file_engine; mod hot_reload; #[cfg(feature = "hydrate")] mod rehydrate; -mod util; // Currently disabled since it actually slows down immediate rendering // todo: only schedule non-immediate renders through ric/raf @@ -168,6 +167,10 @@ pub async fn run_with_props(root: fn(Scope) -> Element, root_prop let mut dom = VirtualDom::new_with_props(root, root_props); + // Eval + let cx = dom.base_scope(); + eval::init_eval(cx); + #[cfg(feature = "panic_hook")] if cfg.default_panic_hook { console_error_panic_hook::set_once(); diff --git a/packages/web/src/util.rs b/packages/web/src/util.rs deleted file mode 100644 index c87d8186b..000000000 --- a/packages/web/src/util.rs +++ /dev/null @@ -1,70 +0,0 @@ -//! Utilities specific to websys - -use std::{ - future::{IntoFuture, Ready}, - rc::Rc, - str::FromStr, -}; - -use dioxus_core::*; -use serde::de::Error; -use serde_json::Value; - -/// Get a closure that executes any JavaScript in the webpage. -/// -/// # Safety -/// -/// Please be very careful with this function. A script with too many dynamic -/// parts is practically asking for a hacker to find an XSS vulnerability in -/// it. **This applies especially to web targets, where the JavaScript context -/// has access to most, if not all of your application data.** -/// -/// # Panics -/// -/// The closure will panic if the provided script is not valid JavaScript code -/// or if it returns an uncaught error. -pub fn use_eval(cx: &ScopeState) -> &Rc EvalResult> { - cx.use_hook(|| { - Rc::new(|script: String| EvalResult { - value: if let Ok(value) = - js_sys::Function::new_no_args(&script).call0(&wasm_bindgen::JsValue::NULL) - { - if let Ok(stringified) = js_sys::JSON::stringify(&value) { - if !stringified.is_undefined() && stringified.is_valid_utf16() { - let string: String = stringified.into(); - Value::from_str(&string) - } else { - Err(serde_json::Error::custom("Failed to stringify result")) - } - } else { - Err(serde_json::Error::custom("Failed to stringify result")) - } - } else { - Err(serde_json::Error::custom("Failed to execute script")) - }, - }) as Rc EvalResult> - }) -} - -/// A wrapper around the result of a JavaScript evaluation. -/// This implements IntoFuture to be compatible with the desktop renderer's EvalResult. -pub struct EvalResult { - value: Result, -} - -impl EvalResult { - /// Get the result of the Javascript execution. - pub fn get(self) -> Result { - self.value - } -} - -impl IntoFuture for EvalResult { - type Output = Result; - - type IntoFuture = Ready>; - - fn into_future(self) -> Self::IntoFuture { - std::future::ready(self.value) - } -} diff --git a/playwright-tests/web/Cargo.toml b/playwright-tests/web/Cargo.toml index 0cad7b207..8bf931ecc 100644 --- a/playwright-tests/web/Cargo.toml +++ b/playwright-tests/web/Cargo.toml @@ -9,4 +9,5 @@ publish = false [dependencies] dioxus = { path = "../../packages/dioxus" } dioxus-web = { path = "../../packages/web" } +dioxus-html = { path = "../../packages/html" } serde_json = "1.0.96" diff --git a/playwright-tests/web/src/main.rs b/playwright-tests/web/src/main.rs index c4850e943..c116d9365 100644 --- a/playwright-tests/web/src/main.rs +++ b/playwright-tests/web/src/main.rs @@ -1,13 +1,13 @@ // This test is used by playwright configured in the root of the repo use dioxus::prelude::*; -use dioxus_web::use_eval; fn app(cx: Scope) -> Element { let mut num = use_state(cx, || 0); - let eval = use_eval(cx); let eval_result = use_state(cx, String::new); + let eval_provider = dioxus_html::prelude::use_eval(cx); + cx.render(rsx! { div { "hello axum! {num}" @@ -42,13 +42,20 @@ fn app(cx: Scope) -> Element { button { class: "eval-button", onclick: move |_| { - // Set the window title - let result = eval(r#"window.document.title = 'Hello from Dioxus Eval!'; - return "returned eval value";"#.to_string()); - if let Ok(serde_json::Value::String(string)) = result.get() { - eval_result.set(string); - } - }, + let eval = eval_provider( + r#" + window.document.title = 'Hello from Dioxus Eval!'; + dioxus.send("returned eval value"); + "#).unwrap(); + let setter = eval_result.setter(); + async move { + // Set the window title + let result = eval.recv().await; + if let Ok(serde_json::Value::String(string)) = result { + setter(string); + } + + }}, "Eval" } div {