DEV Community

Cover image for Create a desktop app in Rust using Tauri and Yew
Steve Pryde
Steve Pryde

Posted on • Updated on

Create a desktop app in Rust using Tauri and Yew

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

(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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

You can now build your frontend app and view it in the browser! Let's test it out:

trunk build
trunk serve
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

If you prefer to add dependencies manually, just add the following lines to the dependencies section of Cargo.toml:

yew = "0.19.3"
Enter fullscreen mode Exit fullscreen mode

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>
    }
}
Enter fullscreen mode Exit fullscreen mode

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 ..
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
  },
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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))
  }
}
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

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 the tauri 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});
}
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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();
            }
        }
    });
}
Enter fullscreen mode Exit fullscreen mode

And this should compile and run.

Navigate up one directory (to the tauri-yew-demo directory), and run:

cargo tauri dev
Enter fullscreen mode Exit fullscreen mode

You should see the application window showing Hello, World!.

App screenshot

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());
Enter fullscreen mode Exit fullscreen mode

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(),
        );
    }
Enter fullscreen mode Exit fullscreen mode

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) => {
                ...
            }
        }
    });
}
Enter fullscreen mode Exit fullscreen mode

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 the wasm-logger crate for an easy way to send logs to the browser console using the log 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!

Discussion (5)

Collapse
papa_robot_mdm profile image
Marciano Marxiano

tower http was yanked from registry, had to use

cargo install trunk --locked

error: failed to compile `trunk v0.14.0`, intermediate artifacts can be found at `/tmp/cargo-installRrqVj8`

Caused by:
  failed to select a version for the requirement `tower-http = "^0.1"`
  candidate versions found which didn't match: 0.2.1, 0.0.0
Enter fullscreen mode Exit fullscreen mode
Collapse
tobiaskluth profile image
tobiaskluth

Hi!
Thanks for the great writeup! Excited to dip further into the topic.
As i followed along your tutorial i encountered a problem in src-tauri/src/main.rs. Defining the hello function public throws compilation error[E0255], removing the pub declaration fixes it. I'm not a 100% sure why though.

error[E0255]: the name `__cmd__hello` is defined multiple times
  --> src/main.rs:14:8
   |
13 | #[tauri::command]
   | ----------------- previous definition of the macro `__cmd__hello` here
14 | pub fn hello (name: &str) -> Result<String, String> {
   |        ^^^^^ `__cmd__hello` reimported here
   |
   = note: `__cmd__hello` must be defined only once in the macro namespace of this module
help: you can use `as` to change the binding name of the import
   |
14 | pub fn hello as other___cmd__hello (name: &str) -> Result<String, String> {
   |        ~~~~~~~~~~~~~~~~~~~~~~~~~~~

For more information about this error, try `rustc --explain E0255`.
error: could not compile `app` due to previous error
Enter fullscreen mode Exit fullscreen mode
Collapse
stevepryde profile image
Steve Pryde Author

I've removed the pub. Hopefully that solves the issue if anyone else is following along. I just have encountered the same thing on my travels and removed it. I guess there's no need for it to be public. It could be a bug with the proc macro or maybe it's intentional. I'm not sure.

Collapse
lupyuen profile image
Lee Lup Yuen

Thanks for the awesome post! The demo works OK on PinePhone, here's what I did: gist.github.com/lupyuen/5566af817e...

Collapse
itflyingstart profile image
ItFlyingStart

Great tutorial. Thanks.

Dummy question: if I want to consume an external web service. Should I implement it in the frontend (Yew) or in the backend (Tauri)?