Create a desktop app in Rust using Tauri and Yew
Steve Pryde
Posted on January 16, 2022
I recently created a complete desktop app in Rust using Tauri and Yew. A few people expressed interest in how I did this, so here is a tutorial to show how to get started.
If you're into Rust and want to build desktop apps, Tauri is a great choice. It's still very early days for web front-ends in Rust but Yew is already quite usable as you will see.
So let's get started.
First let's create a directory for our new project. We'll create a monorepo containing separate directories for the front-end and the back-end.
mkdir tauri-yew-demo
cd tauri-yew-demo
git init
If you like, you can create a new github repository for it and add the git origin following the instructions here.
All of the code in this tutorial is available on my github if you want to just download it and follow along:
https://github.com/stevepryde/tauri-yew-demo
Front-end
Next we'll create the front-end directory. We do this first because later the tauri
setup will ask where this directory is.
cargo new --bin frontend
cd frontend
mkdir public
(The public
directory is where our CSS will go)
Since this will be the Yew
part of the project, let's follow the setup instructions here: https://yew.rs/docs/getting-started/introduction
rustup target add wasm32-unknown-unknown
cargo install trunk
cargo install wasm-bindgen-cli
Next create index.html
in the base of the frontend
directory:
frontend/index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Tauri Yew Demo App</title>
<link data-trunk rel="css" href="/public/main.css"/>
</head>
<body></body>
</html>
We'll also create main.css
in the public/
directory we created earlier:
frontend/public/main.css
body {
margin: 20px;
background: #2d2d2d;
color: white;
}
.heading {
text-align: center;
}
You can now build your frontend app and view it in the browser! Let's test it out:
trunk build
trunk serve
Now open your browser and go to http::/localhost:8080
. You should see a blank page with a grey background.
You may be wondering why we are viewing this in a browser when we are supposed to be creating a desktop app. We will get to that soon. Tauri essentially bundles your web app inside a desktop app using the OS-provided browser rendering engine to display your front-end app. So the process of creating your front-end part is very similar to creating a regular web front-end, except that instead of making HTTP requests to an external web server, we will make function calls to the tauri back-end via some Javascript glue code (there's very little Javascript required, I promise!).
Adding Yew
We haven't yet added yew
to the project, so let's do that now.
To install rust packages to your project, I highly recommend the cargo-edit
tool, which you can install via cargo install cargo-edit
.
With cargo-edit
installed you can do:
cargo add yew
If you prefer to add dependencies manually, just add the following lines to the dependencies
section of Cargo.toml
:
yew = "0.19.3"
Now open src/main.rs
and replace its contents with the following:
frontend/src/main.rs
use yew::prelude::*;
fn main() {
yew::start_app::<App>();
}
#[function_component(App)]
pub fn app() -> Html {
html! {
<div>
<h2 class={"heading"}>{"Hello, World!"}</h2>
</div>
}
}
Now you should be able to re-run trunk serve
and see the updated page in your browser. This is a real Rust front-end, compiled to WASM, running in your browser. Yes, it is that easy!
By the way, if you ever wish to make a regular
Yew
web front-end, you can follow the exact same process and just continue on with this front-end as a standalone project.
Adding Tauri
For this step you'll need to be back in the base tauri-yew-demo
directory. Hit Ctrl+C
if you're still running trunk serve
.
If you're still in the frontend
directory, go up one directory:
cd ..
Tauri has its own CLI that we'll use to manage the app.
There are various installation options but since we're using Rust, we'll install it via cargo
.
The Tauri CLI installation instructions can be found here: https://tauri.studio/docs/getting-started/beginning-tutorial#alternatively-install-tauri-cli-as-a-cargo-subcommand
cargo install tauri-cli --locked --version ^1.0.0-rc
EDIT: Many readers have had trouble installing the Tauri CLI due to various bugs in previous versions or compatibility issues with newer Rust versions. I recommend checking the official docs and following the instructions there, especially as the Tauri CLI version gets updated in the future.
Then run cargo tauri init
which will ask you some questions about the app:
$ cargo tauri init
What is your app name?: tauri-yew-demo
What should the window title be?: Tauri Yew Demo
Where are your web assets (HTML/CSS/JS) located, relative to the "<current dir>/src-tauri/tauri.conf.json" file that will be created?: ../frontend/dist
What is the url of your dev server?: http://localhost:8080
This will create a src-tauri
directory, containing all of the back-end code for your app.
You can run cargo tauri info
to check all of the installation details.
Before we can run the app, we need to tell tauri
how to actually start the front-end server.
Edit the tauri.conf.json
file that is in the src-tauri
directory. This file contains all of the tauri config that tells tauri how to build your app. You will want to bookmark https://tauri.studio/en/docs/api/config/ because it contains a lot of very useful info about all of the options in this file. For now we will just update the build settings.
Replace the build
section with the following:
src-tauri/tauri.conf.json
"build": {
"distDir": "../frontend/dist",
"devPath": "http://localhost:8080",
"beforeDevCommand": "cd frontend && trunk serve",
"beforeBuildCommand": "cd frontend && trunk build",
"withGlobalTauri": true
},
This means we won't need to manually run trunk serve
in another tab while developing. We can develop in the front-end and back-end and both will hot-reload automatically when changes are made.
Ok, we're now ready to start the tauri dev server that you'll use while developing your app:
cargo tauri dev
And there's your desktop app, running with tauri
!
Adding tauri commands
Tauri itself is written in Rust and your back-end application will be a Rust application that provides "commands" through tauri
in much the same way that a Rust web-server provides routes. You can even have managed state, for things like sqlite
databases!
Open src/main.rs
in the src-tauri
project and add the following below the main()
function:
src-tauri/src/main.rs
#[tauri::command]
fn hello(name: &str) -> Result<String, String> {
// This is a very simplistic example but it shows how to return a Result
// and use it in the front-end.
if name.contains(' ') {
Err("Name should not contain spaces".to_string())
} else {
Ok(format!("Hello, {}", name))
}
}
You will also need to modify the main()
function slightly to tell tauri
about your new command:
src-tauri/src/main.rs
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![hello])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
That's all for the back-end.
For more details on what you can do with
tauri
commands, including async commands, error handling and accessing managed state, I highly recommend reading through all of the sections in thetauri
docs here: https://tauri.studio/en/docs/guides/command
Accessing tauri commands from Rust in the front-end
In order to access tauri commands on the front-end in Rust/WASM we need to add some glue code in Javascript.
There is a tauri
npm package but since we want to keep any Javascript usage to a minimum and prefer to use cargo
and trunk
to manage our front-end, we will use a different approach.
Tauri also exports its functions via the window
object in Javascript.
In our front-end
project, create a new file called glue.js
inside the public/
directory.
frontend/public/glue.js
const invoke = window.__TAURI__.invoke
export async function invokeHello(name) {
return await invoke("hello", {name: name});
}
That is all the Javascript we will add. I told you it would be minimal!
Notice that the Javascript function is async. That is because the tauri invoke()
command returns a Javascript promise. And through WASM, Javascript promises can be converted into Rust futures. Just let that awesomeness sink in for a while :)
How do we call that Javascript function from Rust? We add more glue code, this time in Rust. Add the following in your frontend's main.rs
:
frontend/src/main.rs
#[wasm_bindgen(module = "/public/glue.js")]
extern "C" {
#[wasm_bindgen(js_name = invokeHello, catch)]
pub async fn hello(name: String) -> Result<JsValue, JsValue>;
}
Here we tell wasm_bindgen about our javascript code in /public/glue.js
. We give it the javascript function name, and the catch
parameter tells wasm_bindgen that we want to add a catch()
handler to the javascript promise and turn it into a Result
in Rust.
This new code won't work yet, because we haven't added wasm-bindgen
to our front-end dependencies. Let's do that now. We'll also add wasm-bindgen-futures
which we'll use to call this later, web-sys
which provides access to the browser window
object, and js-sys
which gives us the JsValue
types we referenced above.
cargo add wasm-bindgen
cargo add wasm-bindgen-futures
cargo add web-sys
cargo add js-sys
This will add the following dependencies to Cargo.toml
in the front-end:
wasm-bindgen = "0.2.78"
wasm-bindgen-futures = "0.4.28"
web-sys = "0.3.55"
js-sys = "0.3.55"
We also need to add some imports to the top of main.rs.
frontend/src/main.rs
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::spawn_local;
use web_sys::window;
So now we've added the glue code in Javascript and the Rust code that calls that method. What we need to do is wire that up to the front-end code and actually use it.
Calling Tauri commands in Yew code
In version 0.19 Yew added function components, which operate much like React hooks if you're familiar with the React framework in Javascript. If you're not familiar with that, I will attempt to give a brief explanation of the concepts used in this tutorial, but a fuller explanation of hooks is out of scope. The yew docs themselves go into some more detail.
Replace the function component in your front-end main.rs
with the following:
frontend/src/main.rs
#[function_component(App)]
pub fn app() -> Html {
let welcome = use_state_eq(|| "".to_string());
let name = use_state_eq(|| "World".to_string());
// Execute tauri command via effects.
// The effect will run every time `name` changes.
{
let welcome = welcome.clone();
use_effect_with_deps(
move |name| {
update_welcome_message(welcome, name.clone());
|| ()
},
(*name).clone(),
);
}
let message = (*welcome).clone();
html! {
<div>
<h2 class={"heading"}>{message}</h2>
</div>
}
}
fn update_welcome_message(welcome: UseStateHandle<String>, name: String) {
spawn_local(async move {
// This will call our glue code all the way through to the tauri
// back-end command and return the `Result<String, String>` as
// `Result<JsValue, JsValue>`.
match hello(name).await {
Ok(message) => {
welcome.set(message.as_string().unwrap());
}
Err(e) => {
let window = window().unwrap();
window
.alert_with_message(&format!("Error: {:?}", e))
.unwrap();
}
}
});
}
And this should compile and run.
Navigate up one directory (to the tauri-yew-demo
directory), and run:
cargo tauri dev
You should see the application window showing Hello, World!
.
Ok, that was a lot of code. Let's unpack it.
First we add some state to the component through the use_state_eq
hook.
let welcome = use_state_eq(|| "".to_string());
let name = use_state_eq(|| "World".to_string());
Component state has an initial value, and a method for updating that value. Normally, updating a state value will cause the component to be re-rendered with the new value.
The use_state_eq
hook gives us a state handle that will only cause the component to be re-rendered if the value has changed. It uses Rust's PartialEq
trait for this.
You can dereference a UseStateHandle
to get the underlying state value.
{
let welcome = welcome.clone();
use_effect_with_deps(
move |name| {
update_welcome_message(welcome, name.clone());
|| ()
},
(*name).clone(),
);
}
We need to clone the welcome
handle so that we can move it into the closure.
The use_effect_with_deps()
hook is a variant of the use_effect
hook where this one only runs the hook when some dependency changes.
It takes two arguments. The first is the closure we want to run when the dependency value changes, and the second is the dependency value itself.
In this example the dependency is specified as (*name).clone()
, which means we are dereferencing the handle to get the underlying String
and then cloning the String
because we cannot move it.
That value is then passed as the argument to the closure.
The use_effect*()
hook is generally used to do some longer operation or some async operation where the component will update with the result of that operation. This is why we pass in the welcome
handle, because this allows the closure to set the value of that handle, which will cause the component to re-render, but only if the value has changed. Calls to tauri commands will generally go inside a use_effect
or use_effect_with_deps
hook.
The return value of the closure is always another closure that will run when the component is unloaded from the virtual DOM. You can use this closure to perform any cleanup relating to this effect hook. In this case there is nothing to clean up so we return an empty closure.
Let's look at the update_welcome_message()
function.
fn update_welcome_message(welcome: UseStateHandle<String>, name: String) {
spawn_local(async move {
match hello(name).await {
Ok(message) => {
welcome.set(message.as_string().unwrap())
}
Err(e) => {
...
}
}
});
}
The spawn_local()
function is from wasm_bindgen_futures
and it allows us to run async functions in the current thread. In this case we need it in order to call our hello()
function, because it is async.
Next we call the hello()
function, which returns Result<JsValue, JsValue>
. We know the JsValue
will be a string since the original tauri command we implemented returned Result<String, String>
, so we can just unwrap() here.
Finally if the result was Ok(..)
then we set the new value of the welcome
state variable. Otherwise we display an alert.
Alerts are not a great way to handle errors like this, but as a simple example it shows how the
Result
type is passed through to the front-end. In a proper application you would probably want to show a toast popup and optionally log to the browser console (in debug builds).
See thewasm-logger
crate for an easy way to send logs to the browser console using thelog
crate macros.
Wrapping up
Hopefully this tutorial has given you a taste for how to create a desktop app almost entirely in Rust, using Tauri
for the back-end and Yew
for the front-end.
You can find all of the code for this tutorial on my github:
https://github.com/stevepryde/tauri-yew-demo
Feel free to use it and modify it as a base for your own projects.
I highly recommend reading through the documentation for both Tauri and Yew.
Tauri: https://tauri.studio/en/docs/get-started/intro
Yew: https://yew.rs/docs/getting-started/introduction
If there's anything I missed or anything that is still unclear from this tutorial, please let me know in the comments and I'll aim to update it.
Thanks for reading!
Posted on January 16, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.