Integrate an async Rust process into a Tauri application.
More specifically, perform bidirectional communication between the Tauri webview and an async Rust process where either side can initiate.
The Tauri main thread manages both the webview and the async process.
The main thread sits between the two.
Figure 1. A diagram of our desired Tauri application
We can break this up into two smaller problems: bidirectional communication between,
the webview (JavaScript) and the main thread (Rust)
the main thread (Rust) and the async process (Rust)
? What is your app name? tauri-async
? What should the window title be? Tauri App
? What UI recipe would you like to add? create-vite
? Add "@tauri-apps/api" npm package? Yes
? Which vite template would you like to use? vue
Then build and run the application
cd tauri-async
npm install
npm run tauri dev
Figure 2. The Tauri application with the Vite + Vue template
3. The Async Process
Next, we need to know what our async process looks like.
We’ll keep it abstract to make this applicable to more applications.
The async process will take input via a
tokio::mpsc
(Multi-Producer, Single-Consumer) channel and give output via another
tokio::mpsc
channel.
We’ll create an async process model that acts as a sit-in for any specifc use case.
The model is an async function with a loop that takes strings from the input channel and returns them on the output channel.
Even though Tauri uses and re-exports some of the Tokio types (via the tauri::async_runtime module), it doesn’t re-export everything we need.
So we’ll need to add Tokio.
We’ll also add Tracing and Tracing Subscriber while were at it.
cd tauri-src
cargo add tokio --features full
cargo add tracing tracing-subscriber
4. Bidirectional Communication between Rust and JavaScript
Tauri provides two mechanism for communicating between Rust and JavaScript: Events and Commands.
The
Tauri docs for Commands
and
Events
do a good job of covering these.
4.1. Commands vs Events
Events can be sent in either direction while Commands can only be sent from JavaScript to Rust.
I prefer Commands for sending messages from JavaScript to Rust.
Commands automate a lot of the boiler plate like message deserialization and state management.
So while we could use Events for everything, Commands are more ergonomic.
4.2. Possible Simplification
You can get by with only async Tauri Commands (i.e. without Tauri Events) if:
JavaScript initiates all communication
Requests/responses are one-to-one or one-to-none
Otherwise, you also need Tauri Events.
In this post, the goal is to allow either side to initiate communication.
This requires the use of Events.
4.3. The JavaScript Side
On the JavaScript side we use the
invoke
and
listen
Tauri APIs to send Commands and receive Events respectively.
I rewrote the
HelloWorld
Vue component that is created by the
create-tauri-app
utility to provide an interface for sending messages to Rust and reporting messages in both directions.
Replace the content of
src/components/HelloWorld.vue
with the listing below.
The interesting parts are the
sendOutput()
function and the call to
listen()
.
Add the 'js2rs' message to the outputs array to show the user what was sent
Send the 'js2rs' message to Rust via the Tauri
invoke
API
Setup a listener for the 'rs2js' event via the Tauri
listen
API
Add the 'rs2js' message to the
inputs
array to show the user what was received
4.3.1. An Aside: The (lack of)
<Suspense>
is Killing Me
If we run the application now, the
HelloWorld
world component is no longer rendered.
If we open the JavaScript console, we find an error.
Figure 3. "A component with async setup() must be nested in a <Suspense>"
The
HelloWorld
component is now awaiting an async function in
<script setup>
.
When a Vue component includes a top-level
await
statement in
<script setup>
, the Vue component must be placed in a
<Suspense>
component.
Figure 4. The Tauri application after the modifications to the
HelloWorld
component
4.4. The Rust Side
Here is the Rust side of the bidirectional communication between the main thread and the webview.
Most of the bidirectional communication between the main thread and the async process has been commented out.
usetauri::Manager;usetokio::sync::mpsc;// ...fnmain(){// ...let(async_proc_input_tx,async_proc_input_rx)=mpsc::channel(1);let(async_proc_output_tx,mutasync_proc_output_rx)=mpsc::channel(1);tauri::Builder::default()// ....invoke_handler(tauri::generate_handler![js2rs]).setup(|app|{// ...letapp_handle=app.handle();tauri::async_runtime::spawn(asyncmove{// A loop that takes output from the async process and sends it// to the webview via a Tauri Eventloop{ifletSome(output)=async_proc_output_rx.recv().await{rs2js(output,&app_handle);Ok(()).run(tauri::generate_context!()).expect("error while running tauri application");// A function that sends a message from Rust to JavaScript via a Tauri Eventfnrs2js<R: tauri::Runtime>(message: String,manager: &implManager<R>){info!(?message,"rs2js");manager.emit_all("rs2js",message).unwrap();// The Tauri command that gets called when Tauri `invoke` JavaScript API is// called#[tauri::command]asyncfnjs2rs(message: String,state: tauri::State<'_,AsyncProcInputTx>,
)-> Result<(),String>{1info!(?message,"js2rs");// ...
}
5. Bidirectional Communication between the Main Thread and the Async Process
Passing messages between Rust and JavaScript may be straightforward but doing so between the Tauri main thread and an async process is a little more involved.
The inputs and outputs of the async process are implemented as
tokio::mpsc
(Multi-Producer, Single-Consumer) channels.
We only have a single producer but there isn’t a more specific persistent channel primitive for single-producer, single-consumer.
There is
tokio::oneshot
which is single-producer, single-consumer but as the name implies, it can only send a single value ever.
5.1. An Aside: Who Owns the Async Runtime?
By default, Tauri owns and initializes the Tokio runtime.
Because of this, you don’t need an async
main
and a
#[tokio::main]
annotation.
For additional flexibility, Tauri allows us to own and initialize the Tokio runtime ourselves.
We can do this by adding the
#[tokio::main]
annotation, adding
async
to
main
, and then telling Tauri to use our Tokio runtime.
Sending messages from the main thread to the async process requires more sophistication.
This additional sophistication is dictated by the need for our command to have mutable access to input channel for the async process.
To review, the main thread receives a message from JavaScript via a Tauri Command.
The Command then needs to forward the message to the async process via input channel for the async process.
The Command needs access to the channel.
So how do we get give the Command access to the input channel?
The answer is tauri::State<T>.
We can use Tauri’s state management system to pass the input channel to the Command.
The Tauri Command guide covers state management but it is missing a key piece.
Mutability.
We need mutable access to the input channel but Tauri managed state is immutable and what good is state if you can mutate it?
How do we get mutable access to the input channel via immutable state?
The answer is interior mutability and "the most basic type for interior mutability that supports concurrency is Mutex<T>"[1].
We can’t use std::sync::Mutex<T> because we need to .await a send() on the input channel and the guard for std::sync::Mutex<T> cannot be held across an .await.
However, the guard for tokio::sync::Mutex<T> can!
First, we create a struct that wraps a mutex on the input channel.
This wrapper struct simplifies the type signature.
Instead of having to write Mutex<mpsc::Sender<String>> everywhere, we only have to write AsyncProcInputTx.
Then, we put our input channel into a mutex, put the mutex into our wrapper struct, and hand it off to Tauri to manage via tauri::Builder::manage.
Finally, we can access this immutable state in our command, take a lock on the Mutex to get mutable access to the input channel, put the message in the channel, and implicitly unlock the Mutex when the guard goes out of scope at the end of the function.