Building a Tauri app

For the next project, I want to create a Tauri app to manage initiatives for one of my all-time favourite roleplaying games "Shadowrun". I created a similar app many years ago using Jquery mobile and PhoneGap, and since these technologies have been deprecated a long time ago I thought it was a good chance to both test my skills with Tauri and to update the App.

To begin the project we need to create a new Tauri project using the command from their documentation.

cargo create-tauri-app

For the project name, we will select runners-initiative-manager, for the frontend language I will select pnpm and svelte, which is an awesome JS framework that I have been working with recently.

This will generate a new Tauri project for use. The inside of the new folder should look something like this:

$ ls -l
total 264
-rw-r--r--  1 jesperbisgaard  staff  111506 Apr 27 09:46 Cargo.lock
-rw-r--r--  1 jesperbisgaard  staff     482 Apr 27 09:45 Cargo.toml
-rw-r--r--  1 jesperbisgaard  staff     346 Apr 27 09:45 README.md
-rw-r--r--  1 jesperbisgaard  staff     122 Apr 27 09:45 Trunk.toml
drwxr-xr-x  8 jesperbisgaard  staff     256 Apr 27 09:49 dist
-rw-r--r--  1 jesperbisgaard  staff     275 Apr 27 09:45 index.html
drwxr-xr-x  4 jesperbisgaard  staff     128 Apr 27 09:45 public
drwxr-xr-x  4 jesperbisgaard  staff     128 Apr 27 09:45 src
drwxr-xr-x  8 jesperbisgaard  staff     256 Apr 27 09:45 src-tauri
-rw-r--r--  1 jesperbisgaard  staff    1678 Apr 27 09:45 styles.css
drwxr-xr-x@ 7 jesperbisgaard  staff     224 Apr 27 09:49 target

To launch the project we can use the command: pnpm tauri dev

This will install the dependencies and launch the client.

Now let's start by installing Surrealdb, which will be our storage for the project. Surrealdb is a new modern database built in Rust which can run locally as well as on the edge. In the src-tauri directory, we add the surrealdb to our cargo.toml, with the kv-rocksdb feature to be able to use a local database:

surrealdb = { version = "1.0.0-beta.9+20230402", features = ["kv-rocksdb"] }

After saving the file we then run cargo build to install the dependency.

For this project, I will be following the approach that Patric Genfer outlined in his dev article here.

First, there are a number of other dependencies that we will need for this first part:

tokio = { version = "1.28.1", features = ["full"] }
async-trait = "0.1.68"
strum = "0.24.1"
strum_macros = "0.24"

With these dependencies installed, we can start implementing.

Let's create a folder called repository, and in it create three files: mod.rs, repository.rs and surreal_repository.rs.

In mod.rs we import the other files:

pub mod repository;
pub mod surreal_repository;

In our repository, we will create our abstraction of the repository. As outlined in Patric's article we want to isolate the storage from the logic layer so we can easily switch between storage solutions. Defining our repository's behaviour in Rust is best achieved with a trait. To begin with, we will implement some default CRUD operations. In the repository.rs file we will add this:

use async_trait::async_trait;

#[async_trait]
pub trait Repository<TEntity> {
    async fn query_all(&self) -> Vec<TEntity>;
    async fn query_by_id(&self, id: &str) -> Option<TEntity>;
    async fn insert(&self, data: TEntity) -> TEntity;
    async fn edit(&self, id: &str, data: TEntity) -> TEntity;
}

This repository is generic over its entity type, allowing us to reuse the implementation for different entities. We need one implementation of this trait for every database or external storage source we want to support.

We are using the async_trait crate to allow async functions in our trait

Now let's create the implementation for Surrealdb. In the file surreal_repository.rs we will add this:

use std::{marker::PhantomData};
use async_trait::async_trait;
use serde::{de::DeserializeOwned, Serialize};
use surrealdb::{Surreal, engine::local::Db};

use crate::repository::repository::Repository;

pub struct SurrealRepository<TData> {
    db: Box<Surreal<Db>>,
    phantom: PhantomData<TData>,
    table_name: &'static str
}

impl <'a, TData> SurrealRepository<TData> {
    pub fn new(db: Box<Surreal<Db>>, table_name: &'static str) -> Self { Self { 
        db, 
        phantom: PhantomData, 
        table_name 
    }}
}

#[async_trait]
impl <TData> Repository<TData> for SurrealRepository<TData> 
where TData: std::marker::Sync + std::marker::Send + DeserializeOwned + Serialize {

    async fn query_all(&self) -> Vec<TData> {
        let entities: Vec<TData> = self.db.select(self.table_name).await.unwrap();
        entities
    }

    async fn insert(&self, data: TData) -> TData {
        let created = self.db.create(self.table_name).content(data).await.unwrap();
        created
    }

    async fn edit(&self, id: &str, data: TData) -> TData {
        let updated = self.db.update((self.table_name, id)).content(data).await.unwrap();
        updated
    }

    async fn query_by_id(&self, id: &str) -> Option<TData> {
        let entity = self.db.select((self.table_name, id)).await.unwrap();
        entity
    }
}

The struct has a generic argument, so we can already specify the entity type upon instance creation. However, since the compiler complains that the type is not used in the struct, we must define a phantom field to suppress this error.

Let's also create our first model. For the Initiative manager, we need some combattens to take part in an encounter. We create a model folder in src and here we create mod.rs and combatten.rs. We add combatten to mod and then we add the following code to combatten.rs.

use serde::{Serialize, Deserialize};

#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Combatten {
  pub id: Option<String>,
  pub name: String,
  pub initiative: u32,
  pub health: u32,
  pub damage: u32
}

Now we will create our Service class to connect the repository to the application logic. In the repository folder, we will create a combatten_service.rs file.

Here we use Arc to allow multiple connections to the same memory resource. You can read about it here.

Let's import the files we need and create the struct.

use async_trait::async_trait;
use serde_json::Value;
use crate::{
    repository::repository::Repository, 
    model::combatten::Combatten, 
    action_handler::{ActionHandler, ActionDispatcher}, 
    actions::{combatten_action::{COMBATTEN_DOMAIN, CombattenAction, EditNameDto}, 
    application_action::{ApplicationAction, CombattenDto}}
};

pub struct CombattenService {
    repository : Box<dyn Repository<Combatten> + Send + Sync>
}

Some of these have not been created yet but we will get to that.

Next, we will implement the CombattenService. The service needs to implement the functions we will need to CRUD combattens.

impl CombattenService {

    pub fn new(repository: Box<dyn Repository<Combatten> + Send + Sync>) -> Self { 
        Self { repository }
    }

    pub async fn load_combattens(&self) -> Vec<Combatten> {
        let combattens = self.repository.query_all().await;
        combattens
    }

    pub async fn get_by_id(&self, id: &str) -> Combatten {
        self.repository.query_by_id(id).await.unwrap()
    }

    pub async fn create_new_combatten(&self, new_name: &str) -> Combatten {
        let new_combatten = self.repository.insert(Combatten{
            name: new_name.to_string(),
            ..Default::default()
        }).await;
        new_combatten
    }

    pub async fn update_combatten_name(&self, id: &str, new_name: &str) -> Combatten {
        let mut combatten = self.repository.query_by_id(id).await.unwrap();
        combatten.name = new_name.to_string();
        // a bit ugly, but we need to copy the id because "edit" owns the containing struct
        let id = combatten.id.clone();
        let updated = self.repository.edit(&id.as_ref().unwrap(), combatten).await;
        updated
    }
}

Now we create the action handler class, we add a new file in src-tauri/src called action_handler.rs and in it we add the following code:

use async_trait::async_trait;
use serde::{de::DeserializeOwned, Serialize};
use serde_json::Value;

/// Trait used to send incoming actions
/// to the correct action handler
#[async_trait]
pub trait ActionDispatcher {
    async fn dispatch_action(&self, domain: String, action: Value) -> Value;
}

/// trait must be implemented to handle actions of a specific domain
#[async_trait]
pub trait ActionHandler<T: DeserializeOwned + Serialize + std::fmt::Display + Send> {
    async fn convert_and_handle(&self, action: Value) -> Value {
        let incoming: T = serde_json::from_value(action).unwrap();
        let response = self.handle_action(incoming).await;
        let response_json = serde_json::to_value(response).unwrap();
        response_json
    }
    async fn handle_action(&self, action: T) -> T;
}

We add a new trait that allows us to dispatch actions from the front end to backend services, and we add an ActionHandler trait to manage these actions. With these we can return to our combatten_service file and implement the ActionDispatcher and ActionHandler for the Combatten. Our service must implement both traits.


#[async_trait]
impl ActionDispatcher for CombattenService {
    async fn dispatch_action(&self, domain: String, action: Value) ->  Value {
        if domain == COMBATTEN_DOMAIN {
            ActionHandler::<CombattenAction>::convert_and_handle(self, action).await
        } else {
            ActionHandler::<ApplicationAction>::convert_and_handle(self, action).await
        }
    }
}

#[async_trait]
impl ActionHandler<CombattenAction> for CombattenService {
    async fn handle_action(&self, action: CombattenAction) -> CombattenAction {
        let response = match action {
          CombattenAction::RenameCombatten(data) => {
                let classifier = self.update_combatten_name(&data.id, &data.new_name).await;
                CombattenAction::CombattenRenamed(
                    EditNameDto{ id: classifier.id.unwrap(), new_name: classifier.name}
                )
            },
            CombattenAction::CancelCombattenRename{id} => {
                let classifier = self.get_by_id(&id).await;
                CombattenAction::CombattenRenameCanceled(
                    EditNameDto { id, new_name: classifier.name }
                )
            },
            _ => CombattenAction::CombattenRenameError
        };
        return response;
    }
}

#[async_trait]
impl ActionHandler<ApplicationAction> for CombattenService {
    async fn handle_action(&self, action: ApplicationAction) -> ApplicationAction {
        let response = match action {
            ApplicationAction::ApplicationReady => {
                // check if there is already a classifier, if not, create one
                let mut combattens = self.load_combattens().await;
                // convert entities to DTOs and return them
                ApplicationAction::CombattenLoaded(
                  combattens
                        .into_iter()
                        .map(|c| CombattenDto{id: c.id.unwrap(), name: c.name})
                        .collect()
                )
            },
            _ => ApplicationAction::ApplicationLoadError
        };
        return response;
    }
}

The dispatcher's ActionHandler calling syntax ia odd but it is needed to specify the correct trait implementation.

With the ActionDispatcher trait in place, we can now define our Tauri app state. It contains only a dictionary where we store one dispatcher per action domain.

struct ApplicationContext {
    action_dispatchers: HashMap<String, Arc<dyn ActionDispatcher + Sync + Send>>
}

impl ApplicationContext {
    async fn new() -> Self { 
        let surreal_db = Surreal::new::<File>("testdata/surreal/initiative.db").await.unwrap();
        surreal_db.use_ns("runners").use_db("manager").await.unwrap();
        let repository = Box::new(SurrealRepository::new(Box::new(surreal_db), "classifiers"));
        let service = Arc::new(CombattenService::new(repository));
        let mut action_dispatchers: HashMap<String, Arc<dyn ActionDispatcher + Sync + Send>> = HashMap::new();
        action_dispatchers.insert(actions::combatten_action::COMBATTEN_DOMAIN.to_string(), service.clone());
        action_dispatchers.insert(actions::application_action::APPLICATION_DOMAIN.to_string(), service.clone());
        Self { action_dispatchers }
    }
}

In the application context, we set up the database and the repository, and then we add our service and set up the action dispatchers. We use Arc to provide shared ownership to the Combatten service since it provides its own values.

Now let's add a Tauri command that can be called from the front end.

#[tauri::command]
async fn ipc_message(message: IpcMessage, context: State<'_, ApplicationContext>) -> Result<IpcMessage, ()> {
    let dispatcher = context.action_dispatchers.get(&message.domain).unwrap();
    let response = dispatcher.dispatch_action(message.domain.to_string(),message.action).await;
    Ok(IpcMessage {
        domain: message.domain,
        action: response
    })
}

Having followed the approach in Patric's article we now have the first building blocks for the application. Now we can move on to the first parts of the UI.

You can find the repo here