DEV Community

Cover image for Leptos + Tauri Tutorial
Davide Del Papa
Davide Del Papa

Posted on

Leptos + Tauri Tutorial

Photo by Toa Heftiba on Unsplash, cropped

Summer for me is over, and I realized it has been a long time since I posted my last blog. Lately, I've accepted a job which involves the programming on a very specific, proprietary and rather backward piece of software (innuendo intended), it's also a long time I have no fun programming.

Bad, very bad: one should never forget that coding is fun. Let's remedy that.

This is the repo connected to the post.

Isometric Rust for Your Stack

Definitely skip this rant, and start reading from next section: Setup a basic Envronment. I mean it: it's useless stuff for the sake of the tutorial. It's just me doing me stuff.

I'm not here to present Leptos or Tauri, for that, just clik the links, ask Goole, or ClaudePilotGPTWhateverLLama... I'm not even here to make just a tutorial on how to get the two working -- if you see Tauri documentation on it is quite "terse," but still enough.

What I want to do here is to give you a walkthough, a repo you can copy from, and also sprinkle it all with some considerations of my own.

I did say to skip this stuff, now it's the moment.

First consideration: forget about isometric Rust. It's true, with Leptos + Tauri you can achieve the goal of programming in Rust full-stack. that is, if you want to do just a Hello World program, you can do it entirely in Rust; but, let's face the truth: we cannot truly escape from JavaScript. JS is everywhere in the background, JS is present as glue to various moving parts of the stack. At the actual state of things, it's impossible to create some WASM functions and not have at least some JS snippets that call said WASM functions.

Anyway, why would you want to avoid JS? It's full of little, well-written JS libraries out there, ready to be integrated into our stack, so why not take advantage of this ecosystem, instead of re-inventing the wheel over and over again?

Besides JavaScript there's HTML and CSS, let's not forget it. Leptos own view! macro syntax mimicks HTML!

That said, in this tutorial we will not pretend we live on a Rust moon and try to achieve isometric Rust; instead, we will get down to earth and do things proper.

Enugh ranting, let's begin!

Setup a Basic Environment

To follow this part of the tutorial you can git pull https://github.com/davidedelpapa/leptos-tauri.git and checkout this tag: git checkout v1.1

First the tools we will need:

cargo install trunk
cargo install leptosfmt # optional, but useful
cargo install create-tauri-app
cargo install tauri-cli --version '^2.0.0-rc'
Enter fullscreen mode Exit fullscreen mode

Note that for Tauri there are some dependencies to set, so follow this guide

Now, let's add the target wasm32 with rustup, if not already done:

rustup target add wasm32-unknown-unknown
Enter fullscreen mode Exit fullscreen mode

Now let's create the Tauri app:

cargo create-tauri-app --rc -m cargo -t leptos -y leptos-tauri && cd leptos-tauri
Enter fullscreen mode Exit fullscreen mode

We will use Rust nightly, at least for this project:

rustup toolchain install nightly
rustup override set nightly 
Enter fullscreen mode Exit fullscreen mode

The last command must be done inside the project root to set it project wide, and leave stable Rust for all your other projects (to set it system-wide: rustup default nightly, but I wouldn't recommend doing that).

Now we can add our Leptos dependencies:

cargo add leptos --features=csr,nightly
cargo add console_error_panic_hook
cargo add console_log
cargo add log
Enter fullscreen mode Exit fullscreen mode

The first adds leptos with csr and nighlty features, the second adds console_error_panic_hook which is useful in order to use the browser inspector and get some sensible Rust-like error messages (at least the lines that caused the error), instead of the default link to the wasm bundle and unhelpful messages.

console_log is needed in order to log to the browser console.

Now let's create all the additional files we need to make this work.

The following is for the formatters. Inside the project root, let's add a rustfmt.toml file, to set the correct edition:

# rustfmt.toml
edition = "2021"
Enter fullscreen mode Exit fullscreen mode

Let's add also a rust-analyzer.toml that will help us to override the default rustfmt and use leptosfmt instead:

# rust-analyzer.toml
[rustfmt] 
overrideCommand = ["leptosfmt", "--stdin", "--rustfmt"]
Enter fullscreen mode Exit fullscreen mode

FInally, let's configure leptosfmt with the leptosfmt.toml file:

# leptosfmt.toml
max_width = 120 # Maximum line width
tab_spaces = 4 # Number of spaces per tab
indentation_style = "Auto" # "Tabs", "Spaces" or "Auto"
newline_style = "Auto" # "Unix", "Windows" or "Auto"
attr_value_brace_style = "WhenRequired" # "Always", "AlwaysUnlessLit", "WhenRequired" or "Preserve"
macro_names = [ "leptos::view", "view" ] # Macro to be formatted by leptosfmt
closing_tag_style = "Preserve" # "Preserve", "SelfClosing" or "NonSelfClosing"

# Attribute values with custom formatters
[attr_values]
class = "Tailwind" # "Tailwind" is the only available formatter for now
Enter fullscreen mode Exit fullscreen mode

Noise. Enough with configuration files, let's do something in src/main.rs. Tauri already created main.rs and app.rs for us, so we need to add few lines to src/main.rs:

// src/min.rs
mod app;

use app::*;
use leptos::*;

fn main() {
    console_error_panic_hook::set_once();
    _ = console_log::init_with_level(log::Level::Debug);
    mount_to_body(|| {
        view! {
            <App/>
        }
    })
}
Enter fullscreen mode Exit fullscreen mode

Let's explain the code a little bit, for those who are new with Leptos:

  • We initialize console_error_panic_hook with the set_once() function.

  • We also use console_log::init_with_level() to correctly init the logging on the web browser console of any error

  • mount_to_body is a leptos function to mount a IntoView type to the body of the page. In this case we pass it a function that returns a IntoView type through the view! macro. This macro accepts HTML-like syntax, and in this case we pass to it a custom component that we need to create in the module mod app.

Now we need to take care of said App component inside src/app.rs (already created by Tauri's template for us); replace all the content of the file with the following:

// src/app.rs
use leptos::*;

#[component]
pub fn App() -> impl IntoView {
    let (count, set_count) = create_signal(0);

    view! {
        <div>
            <button on:click=move |_| {
                set_count.update(|n| *n += 1);
            }>"+1"</button>
            <button on:click=move |_| {
                set_count.update(|n| *n -= 1);
            }>"-1"</button>
            <p class:red=move || count() < 0>"Counter: "{count}</p>
        </div>
    }
}
Enter fullscreen mode Exit fullscreen mode
  • At the heart, a component can be just a function that returns an IntoView type, marked by #[component]. The name of the function will be the name of the component, that is why we wrote it with a capitalized first letter (yes, it's a React-like style).

  • At the beginning of our function, we set a signal, which is a reactive type, meaning that it can change during the WASM app lifecycle, and in turn make the component change because of it. We use create_signal() which initializes a type given to it as parameter, and returns a getter and a setter for the reactive type thus created, in this case count and set_count respectively.

  • We then use the view! macro which returns an IntoView, and accets a HTML-like syntax mixed with Rust. We create a <div> containing two <button> and a <p>. We can pass a move closure to the on:click parameter of the button component, where we update the value in the signal we created earlier. We update the signal's value using the update() function on the signal's setter. The update() function accepts a closure where the parameter is the current value of the signal. With this method we creatd a button that increases the counter and a button to decrease it

  • Inside the view! macro we also use the signal getter (simply {count} for the rendering in view!, but count() if needed inside a closure). The getter will visualize the current value of the signal. Notice also that we conditionally assign a class to the <p> only if the value of the signal is less than 0, with the syntax class:<value>=<move closure>

To render the view! inside the fn main() we decared that it will be mounted to the <body> of the page, but which page? Tauri already created for us a index.html file for it, so we dont need to worry; moreoer the template created also a styles.css imported in the index. In this way, we just need to add the following style to the already created styles.css:

/* Append this at the end of styles.css */
.red {
    color: red;
}
Enter fullscreen mode Exit fullscreen mode

This .red CSS class will be condiionally assigned to the <p> inside our <App> component as we saw earlier.

Now if all went well, running trunk should show our Leptos app inside our favorite browser:

trunk serve --open
Enter fullscreen mode Exit fullscreen mode

Notice that if the --open flag does not work, it is sufficient to point the browser to localhost:1420

Adding Back Tauri Integration

We deleted everything in src/app.rs, but in those lines there was also an example of Tauri integration. Now we can bring back our own integration code and understand things a little better, step by step.

Presently the first thing we will do is to launch the tauri app proper with:

cargo tauri dev
Enter fullscreen mode Exit fullscreen mode

With this we launched th app inside a desktop Tauri app, instead of the browser. Notice that with <ctrl>+<shift>+i we have an inspector even in the tauri window... sweet!

Now, let's take care of creating some Tauri integration, as said.

To follow this part of the tutorial you can checkout this tag: git checkout v1.2

When we used cargo create-tauri-app the leptos template we used added a src-tauri/ folder we ignored so far. Inside this foder, we have a src-tauri/main.rs and a src-tauri/lib.rs.

The main.rs is minimal and it is used to basically call the leptos_tauri_lib::run().

The src-tauri/lib.rs instead is where we can put some functions to be called by the leptos front-end.

What we have already is the following:

// src-tauri/lib.rs
#[tauri::command]
fn greet(name: &str) -> String {
    format!("Hello, {}! You've been greeted from Rust!", name)
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_shell::init())
        .invoke_handler(tauri::generate_handler![greet])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
Enter fullscreen mode Exit fullscreen mode

We have a custom fn greet() that is a function annotated as a #[tauri:command]. This function is passed to the tauri::Builder in fn run() by generating a invoke_handler. Let's change this file to have an increase and decrease command to use for the counter.

This is the new src-tauri/lib.rs:

// src-tauri/lib.rs
#[tauri::command]
fn increase(count: i32) -> i32 {
    count + 1
}

#[tauri::command]
fn decrease(count: i32) -> i32 {
    count - 1
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_shell::init())
        .invoke_handler(tauri::generate_handler![increase, decrease])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
Enter fullscreen mode Exit fullscreen mode

As you can see, we created two tauri::command increase and decrease. Then, in the tauri:Builder inside the run() function (which is the main entry-point for the tauri app), we pass them to the invoke_handler(); the rest is all boiler-plate.

Back to our src/app.rs, we need to let our App component use these two functions (tauri commands).

The first thing we need is to add the needed use statements:

// src/app.rs
use leptos::*;
use leptos::leptos_dom::ev::MouseEvent;
use serde::{Deserialize, Serialize};
use serde_wasm_bindgen::to_value;
use wasm_bindgen::prelude::*;
Enter fullscreen mode Exit fullscreen mode

We added the MouseEvent from leptos::leptos_dom::ev::MouseEvent, which will let us handle the creation of a custom closure for the on:click in our buttons.

We imported Deserialize and Serialize from serde, and to_value from the serde_wasm_bindgen, and of course the wasm_bindgen itself, because we need to pass arguments from the Rust frontend to the Tauri backend, passing through JavaScript (Remeber the rant I told you to skip? Just forget it already).

Now, with an external call to the wasm_bindgen we bind to a generic JavaScript function invoke that is meant to call the Tauri commands:

#[wasm_bindgen]
extern "C" {
    #[wasm_bindgen(js_namespace = ["window", "__TAURI__", "core"])]
    async fn invoke(cmd: &str, args: JsValue) -> JsValue;
}
Enter fullscreen mode Exit fullscreen mode

At the end of the day, it's just boiler-plate to setup at the beginning of our components; then we can use this to bridge from the front-end to the back-end.

Next, we create a struct to pass arguments to our Tauri commands:

#[derive(Serialize, Deserialize)]
struct CounterArgs {
    count: i32,
}
Enter fullscreen mode Exit fullscreen mode

The struct will be converted to a JsValue by serde and it's ready to be passed to the invoke function as argument.

Finall there's our component. After the signal we need to create two closures to communicate with the Tauri commands. We will discuss just the increase_me, but the considerations are valid for both closures:

let increase_me = move |ev: MouseEvent| {
    ev.prevent_default();
    spawn_local(async move {
        let count = count.get_untracked();
        let args = to_value(&CounterArgs { count }).unwrap();
        let new_value = invoke("increase", args).await.as_f64().unwrap();
        set_count.set(new_value as i32);
    });
};
Enter fullscreen mode Exit fullscreen mode

In the closure we need to specify that the parameter ev is a MouseEvent type, so we can bind the closure to the on:cick of our button.

With ev.prevent_default(); we prevent the usual event behaviour, since we will provide our own behaviour. This is the same as the Event: preventDefault() method - Web APIs | MDN in the Web API. It is particularly useful for closures that have to handle <input> fields.

We then spawn a thread-local Future with Leptos' spawn_local(), providing an async move closure. This is needed because when we use the invoke bridge, it will return a Future, that is a promise in JavaScript and async in Rust. This is because the Tauri commands are syncronous.

Inside the closure we get the count signal's value with count.get_untracked(). We need the get_untracked() because in this way we prevent the reactive binding that usually is produced with Leptos signals, so that even if the value in the count changes, Leptos will not try to update the closure.

We then create the argument to be passed to the invoke. We use the CounterArgs structure.

We finally invoke the increase command, and retrieve the new value for the counter, which we use to set the signal with its set() provided method. Since the wasm_bindgen::JsValue that gets returned by the invoke bridge does not convert to integers, we need to convert it to f64 first, and then coherce it to a i32 value.

FYI these are the conversions available for a wasm_bindgen::JsValue:

  • as_bool which returns an Option<bool>
  • as_f64 which returns an Option<f64>
  • as_string which returns an Option<String>

Here is the whole src/app.rs code:

// src/app.rs
use leptos::leptos_dom::ev::MouseEvent;
use leptos::*;
use serde::{Deserialize, Serialize};
use serde_wasm_bindgen::to_value;
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
extern "C" {
    #[wasm_bindgen(js_namespace = ["window", "__TAURI__", "core"])]
    async fn invoke(cmd: &str, args: JsValue) -> JsValue;
}

#[derive(Serialize, Deserialize)]
struct CounterArgs {
    count: i32,
}

#[component]
pub fn App() -> impl IntoView {
    let (count, set_count) = create_signal(0);
    let increase_me = move |ev: MouseEvent| {
        ev.prevent_default();
        spawn_local(async move {
            let count = count.get_untracked();
            let args = to_value(&CounterArgs { count }).unwrap();
            let new_value = invoke("increase", args).await.as_f64().unwrap();
            set_count.set(new_value as i32);
        });
    };
    let decrease_me = move |ev: MouseEvent| {
        ev.prevent_default();
        spawn_local(async move {
            let count = count.get_untracked();
            let args = to_value(&CounterArgs { count }).unwrap();
            let new_value = invoke("decrease", args).await.as_f64().unwrap();
            set_count.set(new_value as i32);
        });
    };
    view! {
        <div>
            <button on:click=increase_me>"+1"</button>
            <button on:click=decrease_me>"-1"</button>
            <p class:red=move || count() < 0>"Counter: "{count}</p>
        </div>
    }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Now with a

cargo tauri dev
Enter fullscreen mode Exit fullscreen mode

We can appreciate our counter being increased or decrased as it was before, but using code from the back-end.

Granted, it feels the same (there should not even be any appreciable decrease in speed), however this example can be adapted to do a whole deal more useful stuff in the back-end and present it on the front-end as needed.

If you are still here, that means you have successfully glossed over all my rants and (maybe) finished the tutorial. Hope this has been instructive, feel free to comment below.

Top comments (0)