web-view
web-view copied to clipboard
Two-way bindings between Webview and Rust Yew
How can i call the invoke_handler from a Yew application? I want to communicate between Yew and Webview. I already tried adding javascript to Yew through stdweb and then calling external.invoke("foo"). But this doesn't work.
I have not yet tried stdweb. I went with wasm_bindgen. It compiles in Rust, but errors out in JS at runtime. This happens even when just including wasm_bindgen and not invoking any functions (source code). I'm curious if there's a suggested path forward for this.
external.invoke definitely works via stdweb from Yew. I'll post an example soon.
@Ante-dev @Boscop Here is an example of successfully executing external.invoke from Rust Yew, which in turn activates the WebView layer.
I'm still trying to work out how to get messages going the other direction. One thing I tried was to inline stdweb js! macro into the view HTML. That did not compile. My next thought was to do a #[js_export] on a model function. That did not compile either, as self is apparently not allowed in JS exports. Not sure what to explore next.
@mbuscemi In your frontend (in your App::create method), you can register an event handler on document for a custom event that sends a Msg to your app.
Then from the backend you execute js that dispatches this event on document.
https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent https://javascript.info/dispatch-events#bubbling-example
Let me know how it goes :)
@Boscop If I use link.callback in App::create, does that translate to an event handler registered on the document, or is there a different syntax for that?
It looks like virtual_dom::Listener::attach is probably what I want, but I can't find any examples on how to get access to a virtual dom element. https://docs.rs/yew-stdweb/0.14.0/yew_stdweb/virtual_dom/trait.Listener.html
@Boscop Here is where I'm at this morning. I've set up a js! block in my App::create to do the document.addEventListener. Unfortunately, my callback isn't being passed in succesfully. I get this error:
error[E0277]: the trait bound `stdweb::private::Newtype<_, yew::callback::Callback<std::string::String>>: stdweb::private::JsSerializeOwned` is not satisfied
--> src/lib.rs:42:21
|
42 | let value = js! {
| _____________________^
43 | | var callback = @{set_file_callback};
44 | | return document.addEventListener("set_file", content => alert(content));
45 | | };
| |_________^ the trait `stdweb::private::JsSerializeOwned` is not implemented for `stdweb::private::Newtype<_, yew::callback::Callback<std::string::String>>`
|
= help: the following implementations were found:
<stdweb::private::Newtype<(stdweb::webcore::serialization::FunctionTag, ()), F> as stdweb::private::JsSerializeOwned>
<stdweb::private::Newtype<(stdweb::webcore::serialization::FunctionTag, ()), std::option::Option<F>> as stdweb::private::JsSerializeOwned>
<stdweb::private::Newtype<(stdweb::webcore::serialization::FunctionTag, ()), std::option::Option<stdweb::Mut<F>>> as stdweb::private::JsSerializeOwned>
<stdweb::private::Newtype<(stdweb::webcore::serialization::FunctionTag, ()), std::option::Option<stdweb::Once<F>>> as stdweb::private::JsSerializeOwned>
and 81 others
= note: required by `stdweb::private::JsSerializeOwned::into_js_owned`
= note: this error originates in a macro outside of the current crate (in Nightly builds, run with -Z external-macro-backtrace for more info)
I found this article, but applying the ReferenceType derive to my Msg enum generates:
error: proc-macro derive panicked
--> src/lib.rs:28:39
|
28 | #[derive(Clone, Debug, PartialEq, Eq, ReferenceType)]
| ^^^^^^^^^^^^^
|
= help: message: Only tuple structures are supported!
So, presuming this is how I'm supposed to set up the event listener, I'm unclear how to proceed.
You can do it with this: https://docs.rs/stdweb/0.4.20/stdweb/web/fn.document.html https://docs.rs/stdweb/0.4.20/stdweb/web/trait.IEventTarget.html#method.add_event_listener
I've arrived at a working example of bi-directional communication.
I explored using document and add_event_listener on the Rust side extensively. There doesn't seem to be an implementation of CustomEvent in stdweb, and so the type of the event to pass into add_event_listener proved to be an impassable hurdle.
However, I was able to get this working by setting up a js! block in my App::create function that registers the event on document (source). I modified my call from the Webview layer (source), and it all worked. I read on this ticket that I need to drop variables I register in js! blocks, so I added that to my App::destroy.
I'll check with the maintainer of stdweb if they have any interest in an implementation of CustomEvent. I'd be happy to contribute it, and it would make this example a lot less error prone. As it stands, a slight typo in a JS variable name leads to an obscure error.
That said, as per the topic of this ticket, two-way communication between Webview and Yew achieved.
Nice!
Btw:
-
In general you probably want to serialize your string as json here: https://github.com/mbuscemi/hedgehog/blob/d6bd3b14ff9a058593954c71d6dfd4a9b4f1e2da/src/rpc.rs#L6 It could contain quotes etc.
-
I'm pretty sure this will not work: https://github.com/mbuscemi/hedgehog/blob/d6bd3b14ff9a058593954c71d6dfd4a9b4f1e2da/frontend/src/lib.rs#L59
set_file_callbackis not in scope there. Also, if you want to clean up properly, you should callremoveEventListener. To be able to pass the listener, you'd need to store it in your model, like
self.event_listener = js! {
var set_file_callback = @{js_set_file_callback};
return event => set_file_callback(event.detail.contents);
};
And dropping would be like this: https://github.com/Boscop/yew-geolocation/blob/63dec0618393eda60becf8978a212c854f0ed5be/src/lib.rs#L133-L158
So you could just store a Value in your model, that is a js object like { listener: event => set_file_callback(event.detail.contents), callback: set_file_callback } (returned by that js block that registers the listener) so this contains everything you need to clean up.
Good to know. I'm curious, as I had assumed that all js! blocks would be contained within the same JS scope, so a var in one would be accessible to all the others. Apparently that's not the case?
My mind had already being going down a similar road as to your suggestion about the model. Mostly because (having already written large Elm apps), I'm thinking about how I want to organize the code when I have twenty or thirty or more of these communication points between Webview and Yew. I'll want a way to run through a vector or slice of them and initialize/destroy them all.
By the way, I experimented with loading up a file containing double quotes, and it loaded up just fine under the current implementation. Perhaps format! is taking care of that?
Thanks for your help!
I'm curious, as I had assumed that all js! blocks would be contained within the same JS scope, so a var in one would be accessible to all the others. Apparently that's not the case?
Calling js!{} is like calling eval right then and there, it can't reference symbols from other js!{} blocks, unless those defined global vars and were called before (or returned values into Rust that you then pass into the other js block).
I experimented with loading up a file containing double quotes, and it loaded up just fine under the current implementation. Perhaps format! is taking care of that?
It's because you're putting single quotes around the contents. But it would fail with single quotes in contents..
@Boscop How does this look for an implementation of cleaning up the callbacks?
Also, you were right about the strings. The OpenFile event was failing on files containing single quote characters. I fixed that, too.
@mbuscemi Yeah it makes sense to factor the event stuff out..
But if I'm not completely mistaken: Each time you bring a rust closure into a js!{} scope it will allocate on the js side, so to be able to .drop() the right one, you need to keep a reference to it around (via a Value like in yew-geolocation). As it is now, since you're bringing the closure from rust to js in Event::destroy a second time, you're only dropping that instance that just got allocated in destroy.
What I'd do here is:
js! {
var callback = @{js_callback};
var name = @{name};
var listener = event => callback(event.detail);
document.addEventListener(name, listener);
return {
callback: callback,
name: name,
listener: listener,
};
};
And then in Event::destroy, first call document.removeEventListener(handle.name, handle.listener); and then handle.callback.drop();
And some minor things:
- On the backend I'd use
serde_json::to_string(&contents)instead ofrustc_serializebecause that's been deprecated in favor ofserde. - I'd use
Into<Message>instead of this newDetailtrait. - I'd use
#[derive(TypeName)]on each event type and inEvent::newI'd requireD: Into<Message> + TypeNameinstead of thisname: Stringarg, to be more type-safe (no way to use an event type with the wrong name). - I'd put all command types (currently
SetFileandSetProjectPath) in anapicrate that is shared between backend and frontend, and instead of duplicating the code to raise an event in a separate function for each command, I'd just have one function likepub fn send_command(webview: &mut WebView<()>, cmd: &impl Serialize). (And then deriveSerializeandDeserializeon all command types (using serde).)
@Boscop Awesome suggestions!
- So, I'm pretty sure I was dropping the callback already. I saved it out a field on my event, which got saved to my Yew model, which I then used to invoke destroy. Not sure what to do differently in that regard. I did take your advice about also calling
removeEventListenerand saving a handle to the listener and event name. - I removed my dependency on
rustc_serializecompletely. Thanks for the heads up. - I really liked the idea of sharing code between the two layers. I got this going productively early this weekend with the
DiskEntrystruct. Very cool to make changes in one place have both layers interoperate seamlessly. I've just moved myEventandMessagestructs over to the shared library, and next I'm going to work on adjusting rpc.rs to call into a generic method onEvent, which will remove the duplication on the backend as well. - I like my custom event names, but I agree that having them live at the root of the Yew layer isn't quite right. I made a constant on the
Detailtrait to store this, which moves the information more appropriately into theEventmodule, where it can now be shared between the frontend and backend. :)
So, I'm pretty sure I was dropping the callback already. I saved it out a field on my event, which got saved to my Yew model, which I then used to invoke destroy.
Ah right, you had callback: Value in the model. For some reason I misread and thought you were storing the rust closure..
Putting this here for anyone who might find this in the future: https://github.com/mbuscemi/webview-yew-minimal
I also took a stab at it (taking a lot of inspiration from the discussed approach): https://github.com/hobofan/yew_webview_bridge (also available on crates.io)
A few notable points:
- On the yew-side, I packaged the core logic into a service, which I think is a bit more of a idiomatic approach for yew
- How the user wants to handle their "shared" message types is left up to them
- The communication logic internally uses a
Messagewrapper type which carries asubscription_idandmessage_id, which allows for association of responses to their initial messages (this is handled by the service) - When you use
.send_messagefromyew, it returns aMessageFuturewith the response fromweb-view(which can be used to trigger a component update with the includedsend_futurehelper).
I'd also like this.
Sadly I'm using seed and not yew so the premade solution won't work for me.
And while I was prepared to build a custom local storage backend (since localStorage doesn't work in data urls, which my embedded single page app uses) I don't really feel like delving into browser apis, seed's abstractions and where they intersect to build something that works :/
It'd be really great if the bind function from upstream could be implemented here, since at the moment there's no way for me to read stored values from my web app when it's embedded in a native wrapper and not running in a browser.