| tags: [ Software Tauri Rust Vue JavaScript ]
Tauri + Async Rust Process
The complete source code for this post is available at https://github.com/rfdonnelly/tauri-async-example.
1. Goal
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.
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)
2. Create a Tauri App
First, we need to create a Tauri application.
Follow the Tauri Getting Started instructions for installing the necessary prerequisites.
Run the create-tauri-app
utility
npm create tauri-app
And make the following entries/selections
? 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
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.
Our async process model in src-tauri/src/main.rs
use tokio::sync::mpsc;
// ...
async fn async_process_model(
mut input_rx: mpsc::Receiver<String>,
output_tx: mpsc::Sender<String>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
loop {
while let Some(input) = input_rx.recv().await {
let output = input;
output_tx.send(output).await?;
}
}
}
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()
.
<script setup>
import { ref } from 'vue'
import { listen } from '@tauri-apps/api/event'
import { invoke } from '@tauri-apps/api/tauri'
const output = ref("");
const outputs = ref([]);
const inputs = ref([]);
function sendOutput() {
console.log("js: js2rs: " + output.value)
outputs.value.push({ timestamp: Date.now(), message: output.value }) 2
invoke('js2rs', { message: output.value }) 3
}
await listen('rs2js', (event) => { 4
console.log("js: rs2js: " + event)
let input = event.payload
inputs.value.push({ timestamp: Date.now(), message: input }) 5
})
</script>
<template>
<div style="display: grid; grid-template-columns: auto auto;">
<div style="grid-column: span 2; grid-row: 1;">
<label for="input" style="display: block;">Message</label>
<input id="input" v-model="output">
<br>
<button @click="sendOutput()">Send to Rust</button> 1
</div>
<div style="grid-column: 1; grid-row: 2;">
<h3>js2rs events</h3>
<ol>
<li v-for="output in outputs">
{{output}}
</li>
</ol>
</div>
<div style="grid-column: 2; grid-row: 2;">
<h3>rs2js events</h3>
<ol>
<li v-for="input in inputs">
{{input}}
</li>
</ol>
</div>
</div>
</template>
- Clicking the button calls
sendOutput()
- 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.
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.
To fix, modify src/App.vue
as follows
- <HelloWorld/>
+ <Suspense>
+ <HelloWorld/>
+ </Suspense>
4.3.2. Result
If we run the application again, it looks like
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.
use tauri::Manager;
use tokio::sync::mpsc;
// ...
fn main() {
// ...
let (async_proc_input_tx, async_proc_input_rx) = mpsc::channel(1);
let (async_proc_output_tx, mut async_proc_output_rx) = mpsc::channel(1);
tauri::Builder::default()
// ...
.invoke_handler(tauri::generate_handler![js2rs])
.setup(|app| {
// ...
let app_handle = app.handle();
tauri::async_runtime::spawn(async move {
// A loop that takes output from the async process and sends it
// to the webview via a Tauri Event
loop {
if let Some(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 Event
fn rs2js<R: tauri::Runtime>(message: String, manager: &impl Manager<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]
async fn js2rs(
message: String,
state: tauri::State<'_, AsyncProcInputTx>,
) -> Result<(), String> { 1
info!(?message, "js2rs");
// ...
}
- Stateful async Tauri Commands must return a
Result
(see tauri-apps/tauri#2533).
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.
#[tokio::main]
async fn main() {
tauri::async_runtime::set(tokio::runtime::Handle::current());
// ...
}
5.1.1. Inside Tauri
If we make all of our async calls inside of Tauri, then Tauri can own and manage the Tokio runtime.
fn main() {
// ...
tauri::Builder::default()
.setup(|app| {
tokio::spawn(async move {
async_process(
async_process_input_rx,
async_process_output_tx,
).await
});
Ok(())
}
// ...
}
This is the method we’re going to use because it is slightly simpler.
5.1.2. Outside Tauri
If we make any async calls outside of Tauri, then we need to own and manage the Tokio runtime.
#[tokio::main]
async fn main() {
tauri::async_runtime::set(tokio::runtime::Handle::current());
// ...
tokio::spawn(async move {
async_process(
async_process_input_rx,
async_process_output_tx,
).await
});
tauri::Builder::default()
// ...
}
5.2. Creating the Channels
The tokio::mpsc
channels need to be created for both directions: inputs to the async process and outputs from the async process.
fn main() {
// ...
let (async_process_input_tx, async_process_input_rx) = mpsc::channel(1);
let (async_process_output_tx, async_process_output_rx) = mpsc::channel(1);
// ...
}
5.3. Running the Async Process
We’ll have Tauri own and manage the Tokio runtime so we’ll need to run the async process inside tauri::Builder::setup()
.
fn main() {
// ...
let (async_process_input_tx, async_process_input_rx) = mpsc::channel(1);
let (async_process_output_tx, async_process_output_rx) = mpsc::channel(1);
tauri::Builder::default()
// ...
.setup(|app| {
tokio::spawn(async move {
async_process(
async_process_input_rx,
async_process_output_tx,
).await
});
Ok(())
}
// ...
}
5.4. Main Thread to Async Process
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.
struct AsyncProcInputTx {
inner: Mutex<mpsc::Sender<String>>,
}
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
.
fn main() {
// ...
tauri::Builder::default()
.manage(AsyncProcInputTx {
inner: Mutex::new(async_proc_input_tx),
})
// ...
}
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.
#[tauri::command]
async fn js2rs(message: String, state: tauri::State<'_, AsyncProcInputTx>) -> Result<(), String> {
info!(?message, "js2rs");
let async_proc_input_tx = state.inner.lock().await;
async_proc_input_tx
.send(message)
.await
.map_err(|e| e.to_string())
}
5.5. Async Process to Main Thread
In comparison, sending messages from the async process to the main thread is trivial.
We spawn an async process that pulls messages out of the output channel and forwards them to our rs2js
function.
fn main() {
// ...
tauri::Builder::default()
// ...
.setup(|app| {
// ...
let app_handle = app.handle();
tauri::async_runtime::spawn(async move {
loop {
if let Some(output) = async_proc_output_rx.recv().await {
rs2js(output, &app_handle);
}
}
});
Ok(())
})
// ...
}
6. Result
The following demo shows three messages "a", "b", and "c" sent from the webview to the async Rust process and back.
- When the "Send to Rust" button is clicked, the frontend
- reports the message in the "js2rs events" portion of the page,
- and sends the message to the main thread.
- The main thread
- receives the message,
- reports the message in the terminal,
- and sends the message to the async process.
- The async process,
- receives the message
- and sends the message back to the main thread.
- The main thread,
- receives the message,
- reports the message in the terminal,
- and sends the message to the frontend.
- The frontend,
- receives the message,
- and reports the message in the "rs2js events" portion of th page.
Here is the complete Rust code in src-tauri/src/main.rs
#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
use tauri::Manager;
use tokio::sync::mpsc;
use tokio::sync::Mutex;
use tracing::info;
use tracing_subscriber;
struct AsyncProcInputTx {
inner: Mutex<mpsc::Sender<String>>,
}
fn main() {
tracing_subscriber::fmt::init();
let (async_proc_input_tx, async_proc_input_rx) = mpsc::channel(1);
let (async_proc_output_tx, mut async_proc_output_rx) = mpsc::channel(1);
tauri::Builder::default()
.manage(AsyncProcInputTx {
inner: Mutex::new(async_proc_input_tx),
})
.invoke_handler(tauri::generate_handler![js2rs])
.setup(|app| {
tauri::async_runtime::spawn(async move {
async_process_model(
async_proc_input_rx,
async_proc_output_tx,
).await
});
let app_handle = app.handle();
tauri::async_runtime::spawn(async move {
loop {
if let Some(output) = async_proc_output_rx.recv().await {
rs2js(output, &app_handle);
}
}
});
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
fn rs2js<R: tauri::Runtime>(message: String, manager: &impl Manager<R>) {
info!(?message, "rs2js");
manager
.emit_all("rs2js", format!("rs: {}", message))
.unwrap();
}
#[tauri::command]
async fn js2rs(
message: String,
state: tauri::State<'_, AsyncProcInputTx>,
) -> Result<(), String> {
info!(?message, "js2rs");
let async_proc_input_tx = state.inner.lock().await;
async_proc_input_tx
.send(message)
.await
.map_err(|e| e.to_string())
}
async fn async_process_model(
mut input_rx: mpsc::Receiver<String>,
output_tx: mpsc::Sender<String>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
while let Some(input) = input_rx.recv().await {
let output = input;
output_tx.send(output).await?;
}
Ok(())
}