-- Leo's gemini proxy

-- Connecting to gemini.ctrl-c.club:1965...

-- Connected

-- Sending request

-- Meta line: 20 text/gemini

Second try to use Rust and Tauri, now with Yew


There are already some choices for developing GUI programs with Rust. I don't want to rely on a C library for doing this, so a web based solution is a natural choice. Tauri provides the infrastructure to create a webview based application in Rust (something similar to Electron).


(work in progress)


Repository


The progress can be monitored in the public repository linked below. Also the final contents of all files are visible here.


Public repository of this project on BitBucket


The goal


I want to create a simple application which demonstrates the most central concepts for a Tauri application. It has to show some GUI and has to demonstrate at least the use of Tauri commands, possibly also Tauri events.

I chose a stopwatch. The watch can include start/stop buttons which control the backend and it could use Tauri events which trigger the update of the timer display.


This is the secod try - the first one is described here:

First try using Dominator


I chose Yew because some days ago there was a new version of create-tauri-app released which is able to generate a working app using Yew.


Components used in this project:


The Rust programming language

Tauri, portable GUI programs with a Rust backend

Yew - another Rust web framework

Trunk - WASM application bundler for Rust


I will not describe here how to setup Rust. You can look this up on the Rust website. Rust is able to generate code in WASM, another way to let programs run inside the browser. To connect the Javascript world and WASM generated from Rust there is the wasm-bindgen library in Rust.

Module bundling in Yew is done by "Trunk".


wasm-bindgen documentation


Install the necessary tools


First you need to install Node.js and npm because create-tauri-app needs them.

Node.js homepage


When you have Node.js installed you can install the create-tauri-app command:

npm install -g create-tauri-app

Npm is the Node.js package manager. The -g switch to the install command makes the command globally available on your computer.


Because Yew uses trunk as its module bundler it has to be installed also - this time using cargo, the Rust package manager:

cargo install --locked trunk

The installation takes some time because cargo has to update the crates index, download all necessary components and compile them. Don't be impatient!


Important for Apple users using a Mac with an M1 processor: Until wasm-bindgen has pre-built binaries for Apple M1, M1 users will need to install wasm-bindgen manually:

cargo install --locked wasm-bindgen-cli

Create the template project


Now it's time to create the example project. Tauri's create-tauri-app command supports a template for Yew, so with the following command you can create a working Tauri app:

create-tauri-app
-> Project name: stopwatch
-> Language: Rust
-> UI template: Yew

# then do:
cd stopwatch
cargo tauri dev

This will again take some time because Cargo has to install and compile a lot of packages (crates). After some time you will get a nice looking application window where you can enter your name and click on the button named "Greet". You will then get a greeting back from Rust - this means the frontend programmed in Yew sent your name to the Tauri backend, the backend created the greeting and sent it back to the frontend. Yew got the message and displayed the greeting in the user interface. A fully working Tauri application!


Application design


The user interface will show a seconds counter and three buttons: reset, start and stop. Reset should set the seconds to zero, start will start the counting of seconds and stop will halt the counting of seconds, keeping the just reached seconds value in the display.


Tauri's implementation approach is a sharp separation between the presentation logic in the webview on the one hand and the algorithmic solution and access to local computer resources/network resources at the other hand. This means that most of the computational work and the application data model has to be located in the backend.


Tauri provides procedure calls and events for communication between the webview and the backend. The plan for the stopwatch is the following:


The buttons initiate procedure calls to the backend, they don't expect a result.

The backend provides the functions which are called in reaction to a button press (start, stop, reset).

The backend holds the model. This includes the value of the stopwatch and the state of the stopwatch (running, stopped).

The backend implements a timer which triggers an increment of the stopwatch value when the state of the stopwatch is "running".

The value of the stopwatch is implemented in a way that it emits an event on a value change. This event is sent to the webview (frontend). If it is possible to transmit the timer value with the event then the webview uses the transmitted value for displaying it. If this is not possible the webview uses an additional function call to the backend to retrieve the value in reaction to the event.

The frontend (webview) has it's own model of the seconds display. It adapts the model in reaction to the events it gets from the backend.


Implementing procedure calls


Tauri names backend procedures which can be called from the frontend "commands". For now we will prepare the backend to receive the procedure calls for the button presses. For this we will introduce only one command which gets the name of the button as parameter.


First we change the backend. This is located in the file src-tauri/src/main.rs in the project. The template already contains a command called greet:

// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command
#[tauri::command]
fn greet(name: &str) -> String {
    format!("Hello, {}! You've been greeted from Rust!", name)
}

In this form the command receives a name as parameter and returns a greeting containing the name as result.


The buttons will not request to get any string as result when they get pressed - they should simply change the state of the backend process. So the command will return nothing. To see that the backend in fact gets the information about a pressed button we simply print out a string when this happens (for now). The changed command looks like this:

#[tauri::command]
fn button_press(name: &str)  {
    println!("Button pressed: {}", name);
}

The main function does register the commands. Because the name of the only command is changed now the main function has to be adapted:

fn main() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![button_press])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

That's it for the backend! Now the changes for the frontend - they happen in the file src/app.rs in the project.


First we delete the following blocks within the app function:

let greet_input_ref = ...
and
let name = use_state...
and
let greet_msg = ...
and
    {
        let greet_msg = greet_msg.clone();
        let name = name.clone();
...
up to the line before the html! macro.

These are all lines handling the name and greeting response. Within the html macro the last parts can be deleted (the form and the greeting text):

<form class="row" ...
up to
<p><b>{ &*greet_msg }</b></p>

Now the GreetArgs struct at the top of the file is unused. We change it to the name "ButtonArgs" which we will use later:

#[derive(Serialize, Deserialize)]
struct ButtonArgs<'a> {
    name: &'a str,
}

We also need a state of the seconds value in the frontend and an enum for the Button values. The enum is not strictly needed but it makes the coding of the buttons inside the callback more formal and better readable. The definitions got after the "ButtonArgs" definition an before the app function.

struct SecondsState {
    seconds: u64,
}

enum ButtonName {
    Reset,
    Start,
    Stop
}

For the button clicks we use a callback function which gets defined outside the app function:

fn button_click(name: ButtonName ) {
    let namestr: &str;
    match name {
        ButtonName::Reset => namestr = "Reset",
        ButtonName::Start => namestr = "Start",
        ButtonName::Stop => namestr = "Stop",
    }
    spawn_local(async move {
        let args = to_value(&ButtonArgs { name: namestr }).unwrap();
        invoke("button_press", args).await;
    });
}

Here the ButtonName enum is used as parameter. The match statement sets the variable namestr according to the value of the parameter. A default path for the match statement is not necessary because all values of the enum are handled.


After setting the button name as string the interesting part comes. The spawn_local and async move create an asynchronous environment for calling the Tauri command. The frontend running as WASM application calls a Javascript function which in turn calls the Tauri backend command. That's why the parameter has to be converted to a Javascript dictionary which contains the named parameter "name". Also the Tauri commands return a Future, which translates to a Javascript Promise. That's why the async environment is necessary and also the "await" for the result of the "invoke" function. The result gets ignored then because we are not interested in it.


Now the app function gets changed a bit. First we need to hold the frontend model for the seconds in a "state" to make it immune against competing access from multiple threads. So within the app function the follwing line is used for this:

let seconds_state = use_state(|| SecondsState{seconds: 2453});

We can assign a value here already. Normally 0 would be used but with another value it's easier to see if the HTML generation works.


Inside the html! macro (directly before the closing </main> the following is inserted:

<p><b>{"Seconds:  "}{(&seconds_state).seconds}</b></p>

<p>
    <button onclick={Callback::from(|_| button_click(ButtonName::Reset))}>{"Reset"}</button>
    <button onclick={Callback::from(|_| button_click(ButtonName::Start))}>{"Start"}</button>
    <button onclick={Callback::from(|_| button_click(ButtonName::Stop))}>{"Stop"}</button>
</p>


When you enter the command "cargo tauri dev" the application should compile without error. The application window should open showing the seconds counter and the three buttons. When you press one of the buttons it's name should be printed at the console where you entered the cargo commmand.


All changes described in this paragraph can be seen in the repository in commit cf530b4. It is linked below.


Commit with all changes of this paragraph


Application state in the backend


I already mentioned what has to be implemented in the backend to represent the model of a stopwatch. Now let's define this in Rust:

#[derive(Debug)]
enum WatchState {
    Stopped,
    Running,
}

#[derive(Debug)]
struct AppState {
    seconds: u32,
    watch_state: WatchState,
}

This code snippet defines an enum for "naming" the run-state of the stopwatch - that's why it gets called WatchState. Additionally there is a struct which "bundles" the seconds counter and the run-state. This struct defines the overall state of the stopwatch app and is called AppState. Rememmber - this is all backend code. The derive statement for the Debug trait is only there for the debug output which will show that the backend works like expected. In the final application version this is not required.


Of course a "dead" data state is not enough - it also needs functions to change it. This is done in Rust in an "impl" block for the struct:

impl AppState {
    fn seconds_tick(&mut self) {
        match self.watch_state {
            WatchState::Running => self.seconds += 1,
            WatchState::Stopped => (),
        }
        println!("Tick: {}", self.seconds);
    }
    fn reset(&mut self) {
        self.seconds = 0;
    }
    fn start(&mut self) {
        self.watch_state = WatchState::Running;
    }
    fn stop(&mut self) {
        self.watch_state = WatchState::Stopped;
    }
}

We got four functions here - the reaction to the start/stop buttons which simply set the run-state, then the reaction to the reset button which sets the seconds counter to zero and the function which reacts to a timer tick.


This implementation expects that the timer runs always. The seconds_tick function checks itself if counting the seconds is necessary by matching the run-state. Only when the run-state (watch_state) is set to "Running" the seconds counter gets incremmented. The current value of the seconds counter is printed to the terminal on every tick.


Because Tauri is threaded it's not possible to use the state directly - access has to be managed somehow. I chose a mutex for this - in the end it does not matter. Whatever does the job is o.k.. The mutex is defined outside any function as a static variable:

use std::sync::Mutex;

static GUARDED_STATE: Mutex<AppState> = Mutex::new(AppState {
    seconds: 0,
    watch_state: WatchState::Stopped,
});

When the Mutex is created it also creates the AppState instance it protects. This instance is initialised with 0 and "Stopped".


Now let's see how the mutex is used. Here is the new command implementation for the button clicks (it replaces the old version):

#[tauri::command]
fn button_press(name: &str) {
    println!("Button pressed: {}", name);
    let mut state = GUARDED_STATE.lock().unwrap();
    match name {
        "Reset" => state.reset(),
        "Start" => state.start(),
        _ => state.stop(),
    }
    println!("State: {:#?}", state);
}

After printing the name of the pressed button the function locks the mutex and gets a handle to the protected data. Depending on the button name the matching function is called for the protected data. At the end the changed application state is printed (for this the derived Debug trait is necessary). The mutex is automatically unlocked when the handle variable (state) goes out of scope.


Now only the timer is missing. It is a combination of establishing the timer and a callback function. The callback function could be implemented as a closure also. This would possibly be more Rust like but for people coming new to Rust a callback function may be easier to understand:

use chrono;
use timer;

// Callback of the timer, using mutexed state
fn timer_callback() {
    GUARDED_STATE.lock().unwrap().seconds_tick();
}

fn main() {
    let timer = timer::Timer::new();
    let _guard = timer.schedule_repeating(chrono::Duration::seconds(1), timer_callback);
    tauri::Builder::default()
...

In the first line of the main function the timer is established and activated. The _guard variable is required - the schedule_repeating function stops emitting timer tick as soon as the _guard variable gets out of scope.


That's it for the backend model. You can see all the changes of this paragraph in the commit of my repository linked below.


Commit with all changes for the backend model


Sending events from the backend to the frontend


The backend now counts the seconds when it is told so by a Tauri command. Now it's necessary to transmit the seconds counter to the frontend somehow. In the "Application design" paragraph I already mentioned that I want to use Tauri events for this.


First add a data structure for the timer event. I added this above the timer callback:

#[derive(Serialize, Clone)]
struct TimerEventData {
    seconds: u32,
}

The Serialize and Clone traits are requirements to the data by the emit function for the events.


Now there is a problem: the emit functions for Tauri events are part of the app struct of Taur which can be obtained within the setup function of Tauri or within Tauri commands. Outside of these the app handle is not availabe. Because the emit function will be used within the timer callback a static variable is needed which can hold the app handle. THis cannot be a normal variable because Tauri uses threads. Luckily there is the once-cell crate which provides a data type which can be written once and read by multiple threads after that. So above the TimerEventData definition I defined the static variable for the app handle:

static APP_HANDLE: OnceCell<tauri::AppHandle> = OnceCell::new();

Some use statements are also required:

use once_cell::sync::OnceCell;
use tauri::Manager;
use serde::Serialize;

and the once_cell crate has to be added to the Tauri Cargo.toml file. Call the following command within the src-tauri directory of the project:

cargo add once_cell

To initialize the static app handle variable the main function has to be changed to include also the setup function call:

fn main() {
    let timer = timer::Timer::new();
    let _guard = timer.schedule_repeating(chrono::Duration::seconds(1), timer_callback);
    tauri::Builder::default()
        .setup(|app| {
            APP_HANDLE.set(app.handle().clone()).unwrap();
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![button_press])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");

One thing I like to do is to avoid unnecessary computations or communication. So it would be good to send events only when the clock is running. The AppState knows if the clock is running but the timer callback will send the event. So which possibilities are there?


1. Add a function to AppState which hands out the watch_state.

2. Let the seconds_tick report if it counted the tick and if an event has to be sent.


I decided to use the second approach to not double the logic from the seconds_tick function. Here is the new version of the seconds_tick function in AppState:

    fn seconds_tick(&mut self) -> bool {
        let mut send_event = false;
        match self.watch_state {
            WatchState::Running => {
                self.seconds += 1;
                send_event = true;
            },
            WatchState::Stopped => (),
        }
        println!("Tick: {}", self.seconds);
        return send_event;
    }

Now the timer_callback has all what it needs - the app handle is available and the seconds_tick reports back if an event has to be sent:

fn timer_callback() {
    if GUARDED_STATE.lock().unwrap().seconds_tick() {
        let app = APP_HANDLE.get().unwrap();
        app.emit_all("timer_tick", TimerEventData { seconds: 5 }).unwrap();
    }
}

For now a fixed value for the seconds count is sent until the receiving side works. The real value would be in in the guarded state which would be available from GUARDED_STATE.lock().unwrap().seconds. To avoid a double get of the mutex it would be better to get the state into a variable first. This will be changed later.


That's it! Now the backend sends "timer_tick" events to all open windows when the timer is running. All changes of this paragraph can be found in this commit in my repository:


Commmit with all changes of this paragraph


Receive Tauri events in the frontend


First a quick further change in the backend: I renamed the "timer_tick" event to TimerTick and changed it to transfer an u_i32 value directly (without using a struct). I also removed all the println! statements because logic works now. This makes it possible to remove the "use serde::Serialize" and the "derive Debug" annotation for AppState.


To ensure that a zero value is transmitted to the frontend the reset function also has to send a signal. That's why reset now looks this way:

    fn reset(&mut self) {
        self.seconds = 0;
        let app = APP_HANDLE.get().unwrap();
        app.emit_all::<u32>("TimerTick", self.seconds).unwrap();
    }

The TimerEventData struct gets removed completely. I already mentioned that the timer_callback function has to be changed a bit to transfer the current seconds value of the timer:

// Callback of the timer, using mutexed state
fn timer_callback() {
    let mut state = GUARDED_STATE.lock().unwrap();
    if state.seconds_tick() {
        let app = APP_HANDLE.get().unwrap();
        app.emit_all::<u32>("TimerTick", state.seconds).unwrap();
    }
}

That's it for the backend.


The receiving of Tauri events at the frontend took me some days and without massive help by "sak96" on Discord it would have taken even longer. Many thanks again!


Because the frontend now has to deal with Javascript types it needs an additional import with use. Remember - Tauri implements it's API for the frontend in JavaScript - with a frontend using Rust and WASM this has to be translated back to Rust using the wasm-bindgen crate and some helper crates, f.e. js-sys:

use js_sys::{Function, Promise};

Through wasm-bindgen not only the invoke function has to be imported but also the listen function. So the whole wasm-bindgen block now looks like this:

#[wasm_bindgen]
extern "C" {
    #[wasm_bindgen(js_namespace = ["window.__TAURI__.tauri"])]
    async fn invoke(cmd: &str, args: JsValue) -> JsValue;
    #[wasm_bindgen(js_namespace = ["window.__TAURI__.event"], js_name = "listen")]
    fn listen_(event: &str, handler: &Closure<dyn FnMut(JsValue)>) -> Promise;
}

The Tauri event comes in a JSON structure which contains some fields. One of them is the "payload". So a struct is needed which matches this JSON structure at least in parts (required for the serde_wasm_bindgen::from_value function):

#[derive(Deserialize, Debug)]
struct TimerEvent {
    payload: u32,
}

The Deserialize annotation is required to make "unpacking" possible, the Debug annotation is only for debugging purposes - I left it in.


The frontend state was changed a bit - now it also uses an u_i32 value directly for the state instead a struct. Also a "setter" handle was required to be able to set the state value:

let seconds_state: UseStateHandle<u32> = use_state_eq(|| 0);
let seconds_setter = seconds_state.setter();

I don't know exactly if the following is required but I think so. To decouple the handling of the Tauri event and setting the frontend state a Yew Callback is used. It gets triggered by the closure which handles the Tauri event and sets the state value though the setter handle:

// Yew-Timer-Callback - setting the state variable
let ontimer = Callback::from(move |seconds: u32| {
    seconds_setter.set(seconds);
});

Now to the receiving of the Tauri event:

    use_effect( move || {
        let timer_closure = Closure::<dyn FnMut(JsValue)>::new(move |raw| {
            let payload: TimerEvent = serde_wasm_bindgen::from_value(raw).unwrap();
            ontimer.emit(payload.payload);
        });

        let unlisten = listen_("TimerTick", &timer_closure);
        let listener = (unlisten, timer_closure);
        || {
               let promise = listener.0.clone();
               spawn_local(async move {
                   let unlisten: Function = wasm_bindgen_futures::JsFuture::from(promise)
                       .await
                       .unwrap()
                       .into();
                   unlisten.call0(&JsValue::undefined()).unwrap();
               });
               drop(listener);
        }
    });

First the timer_closure is defined. It takes the JSON structure of the Tauri event and creates a TimerEvent structure using the from_value function. Then it calls the Yew Callback through it's emit function, giving the seconds value from the Tauri event. This definition doesn't do anything at the moment.


Now comes the part I still don't fully understand - I got this code from sak96. Unlisten is assigned the Promise given back from the listen JavaScript function which is named "listen_" in our Rust frontend. This is then used in the listener assignment. Part of the parameters is the previously defined closure - so I think the event handling happens here. I will have to consult the wasm-bindgen and wasm-bindgen-futures documentation I think.


The closure after that is the cleanup code for the use_effect hook. It somehow extracts an unlisten function from the listener variable and calls it asynchronously. The spawn_local block lets the async task run in the current thread. The drop at the end somehow destroys the whole construct. If someone could explain this to me in detail I would be thankful!


Now the frontend listens and reacts to Tauri events with the name "TimerTick". As in the previous paragraphs all changes to the code which were done here can be seen in the following git commit.


Commmit with all changes of this paragraph


I hope this helps someone.


Here is again the git repository for the whole project:


git repository of the project

-- Response ended

-- Page fetched on Mon May 27 16:20:11 2024