Leptos + Tauri Tutorial
Davide Del Papa
Posted on September 1, 2024
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'
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
Now let's create the Tauri app:
cargo create-tauri-app --rc -m cargo -t leptos -y leptos-tauri && cd leptos-tauri
We will use Rust nightly, at least for this project:
rustup toolchain install nightly
rustup override set nightly
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
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"
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"]
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
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/>
}
})
}
Let's explain the code a little bit, for those who are new with Leptos:
We initialize
console_error_panic_hook
with theset_once()
function.We also use
console_log::init_with_level()
to correctly init the logging on the web browser console of any errormount_to_body
is a leptos function to mount aIntoView
type to the body of the page. In this case we pass it a function that returns aIntoView
type through theview!
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 modulemod 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>
}
}
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 casecount
andset_count
respectively.We then use the
view!
macro which returns anIntoView
, and accets a HTML-like syntax mixed with Rust. We create a<div>
containing two<button>
and a<p>
. We can pass amove
closure to theon:click
parameter of the button component, where we update the value in the signal we created earlier. We update the signal's value using theupdate()
function on the signal's setter. Theupdate()
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 itInside the
view!
macro we also use the signal getter (simply{count}
for the rendering inview!
, butcount()
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 syntaxclass:<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;
}
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
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
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");
}
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");
}
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::*;
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;
}
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,
}
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);
});
};
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 anOption<bool>
as_f64
which returns anOption<f64>
as_string
which returns anOption<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>
}
}
Conclusion
Now with a
cargo tauri dev
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.
Posted on September 1, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.