DEV Community

Cover image for Moving From Electron to Tauri
Patric Genfer
Patric Genfer

Posted on • Edited on • Originally published at umlboard.com

Moving From Electron to Tauri

Part 1: Interprocess Communication — porting a Typescript-based message system of an Electron App to Tauri and Rust.


TL;DR: Tauri is a Rust-based Electron alternative.
However, porting an existing Electron application to Tauri requires some work. This post explains how UMLBoard's message system used for inter-process communication could be ported to Rust without much effort.


Many people would agree that native apps - if done right - provide better user experience and performance compared to hybrid or cross-platform solutions.

However, having separate apps also means maintaining different code bases, each written in its own language.
Keeping them all in sync is quite a lot of work for a single developer - usually more than what we've planned for our side projects.

The Electron framework is a compromise: It provides a convenient way to write platform-independent applications using a web-based technology stack most people are familiar with. Also, the framework is very mature and actively supported by a large community.

But nevertheless, it’s only a compromise:
Its platform-independence comes with the cost of larger binaries and higher memory consumption compared to native apps. Take, for instance, UMLBoard's macOS binaries:
The universal platform package ends up with a total size of 250 MB - that's a massive number for a lightweight drawing tool...

Still, alternatives with the same level of maturity as Electron are relatively rare. Tauri, however, is one of these alternatives that looks very promising.

A First look at Tauri

While Electron and Tauri share some similarities - like using separate processes for their core and rendering logic - they follow divergent philosophies regarding bundle size.

Instead of deplyoing your application with a complete browser frontend, Tauri relies on the built-in Webviews the underlying operating systems provide, resulting in much smaller applications. Despite that, Tauri uses Rust as the language of choice for its Core process, resulting in better performance compared to Electron's node.js backend.

While porting UMLBoard from Electron to Rust won't happen overnight, exploring how some of its core concepts could be translated from TypeScript to Rust would still be interesting.

The following list contains some crucial features of UMLBoard. Some of them are real show-stoppers in case they don't work.
A possible port would have to deal with these issues first.

  • [x] Porting the inter-process communication to Tauri (this post here!)
  • [ ] Accessing a document-based local data store with Rust
  • [ ] Validate the SVG compatibility of different Webview
  • [ ] Check if Rust has a library for automatic graph layouting

The remaining post is dedicated to the first bullet point:
We will investigate how UMLBoard’s existing inter-process communication could be ported to Tauri. The other topics may be the subjects of further articles.

Ok, but enough said, let's start!

Sending Messages between Electron Processes

UMLBoard's current implementation uses a React front end with Redux state management.
Every user interaction dispatches an action that a reducer translates into a change resulting in a new front end state.

If, for instance, a user starts editing a classifier's name, a renamingClassifier action gets dispatched. The classifier reducer reacts to this action and updates the classifier's name, triggering a rerender of the component.
So far, this is all standard Redux behavior.

A user input dispatches a redux action that leads to a state update in the front end.

A user input dispatches a redux action that leads to a state update in the front end.



But UMLBoard even goes one step further and uses the same technique for sending notifications to Electron's main process.

Taking our previous example, when the user hits the ENTER key, a renameClassifier action is dispatched, indicating that the user finished editing.

This time, however, the action gets processed by a custom middleware instead of a reducer. The middleware opens an IPC channel and sends the action directly to the main process.
There, a previously registered handler reacts to the incoming action and processes it. It updates the domain model accordingly and persists the new state to the local datastore.

If all that goes well, a response action is sent back on the same channel. The middleware receives the response and dispatches it like a regular action. This keeps the front-end state in sync again with the domain state.

See the following diagram for an overview of this process:

Inter Process Communication between renderer and main process in UMLBoard.

Inter Process Communication between renderer and main process in UMLBoard.



It might look a bit odd to extend Redux to the main process, but from the view of a lazy developer like me, it has some benefits:

Since both mechanisms, Redux and IPC, rely on plain serializable JSON objects, everything that goes through a Redux dispatcher can also go through an IPC channel.
This is very convenient as it means we can reuse our actions and their payloads without writing additional data conversions or DTO objects.
We also don't have to write any custom dispatching logic. We only need a simple middleware to connect the Redux front end with the IPC channel.

This action-based messaging system is the backbone of UMLBoard's process communication, so let's see how we can achieve this in Tauri...

Porting to Tauri

For our proof-of-concept, we will create a small demo application in Tauri. The app will use a React/Redux front end with a single text field. Pressing a button will send the changes to the backend (Tauri's Core process).

The example app we're going to implement.

The example app we're going to implement.



We are only interested in inter-process communication, so we will skip all state tracking in the core process (this will be part of a future blog entry...). That's why our Cancel method behaves a bit weird as it will always restore the original class name. But for proving our concept, this should be sufficient.

We basically have to implement four tasks:

  1. Declare Rust equivalents of our Redux actions
  2. Sending actions from Webview to Core process
  3. Handling incoming actions in the Core process
  4. Sending response actions back to Webview

Let's go through the implementation step by step.

1. Declare Rust equivalents of Redux actions

Redux actions are plain JSON objects with a string identifying their type and a field holding their payload.

Rust has a similar concept we could use to mimic this behavior, the Enum type. Enums in Rust are more powerful than in other languages because they allow storing additional data for each variant.

In that way, we could define our Redux actions as a single Enum, where each variant represents an individual type of action.

#[derive(Serialize, Deserialize, Display)]
#[serde(rename_all(serialize="camelCase", deserialize="camelCase"))]
#[serde(tag = "type", content = "payload")]
enum ClassiferAction {
  // received from webview when user changed name
  RenameClassifier(EditNameDto),
  // user canceled name change operation
  CancelClassifierRename,
  // response action after successful change
  ClassifierRenamed(EditNameDto),
  // response action for cancel operation
  ClassifierRenameCanceled(EditNameDto),
  // error response
  ClassifierRenameError
}
Enter fullscreen mode Exit fullscreen mode

To convert our Redux action into a Rust Enum and vice versa, we can use Rust's serde macro: We specify that the variant's name should be serialized into a type field and its data into a field called payload. This corresponds precisely to the scheme we use to define our Redux actions.

But we can even go one step further by using the ts-rs crate. This library can generate the TypeScript interfaces for the action payloads straight from our Rust code. We don't have to write a single line of TypeScript code for this.
That's really neat!

Decorating our Rust struct with the relevant macros

#[derive(TS)]
#[ts(export, rename_all="camelCase")]
struct EditNameDto {
    new_name: String
}
Enter fullscreen mode Exit fullscreen mode

gives us the following auto-generated TypeScript interface for our action payloads:

// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface EditNameDto { newName: string }
Enter fullscreen mode Exit fullscreen mode

Ok, we have the correct data types on both edges of our communication channel, let's now see how we can send data between them.

2. Sending actions from Webview to Core process

Interprocess communication in Tauri is done through commands. These commands are implemented as Rust functions and can be called within the Webviews using the invoke API.

One problem we face is that the Redux Toolkit generates the type for identifying an action by concatenating the name of the slice where the action is defined with the action's name.
In our case, the resulting type would therefore be classifier/renameClassifier instead of just renameClassifier. This first part, classifier, is also called the domain to which this action belongs.

Unfortunately, this naming convention does not work for Rust, as it would result in invalid names for our Enum options.
We can avoid this by separating the domain from the action type and wrapping everything up in an additional object, the IpcMessage, before submitting.

See the following diagram for the complete invocation process.

Invoking a Tauri command from the Webview process.

Invoking a Tauri command from the Webview process.



3. Handling incoming actions in the Core process

On the backend side, we must also define a Rust struct for our IpcMessage. Since we don't know the concrete type of the payload yet, we keep it stored as a JSON value and parse it later when needed.

// data structure to store incoming messages
#[derive(Deserialize, Serialize)]
struct IpcMessage {
    domain: String,
    action: Value
} 
Enter fullscreen mode Exit fullscreen mode

We can now define the signature of the method for our Tauri command. Our function, ipc_message, will receive an IpcMessage, processes it somehow, and at the end, returns another IpcMessage as a response.

#[tauri::command]
fn ipc_message(message: IpcMessage) -> IpcMessage {
    // TODO: implement
}
Enter fullscreen mode Exit fullscreen mode

Ok, but what would the actual implementation look like?

The function should take the domain from the message, see if a handler is registered for this domain and if yes, call the corresponding handler with the action stored inside our IpcMessage. Since we will have many different domains and handlers later, it makes sense to minimize the implementation effort by extracting common behavior into a separate ActionHandler trait.

// trait that must be implemented by every domain service
pub trait ActionHandler {
    // specifies the domain actions this trait can handle
    type TAction: DeserializeOwned + Serialize + std::fmt::Display;

    // the domain for which this handler is responsible
    fn domain(&self) -> &str;

    // must be implemented by derived structs
    fn handle_action(&self, action: Self::TAction) -> 
    Result<Self::TAction, serde_json::Error>;    

    // boiler plate code for converting actions to and from json  
    fn receive_action(&self, json_action: Value) -> 
    Result<Value, serde_json::Error> {
        // convert json to action
        let incoming: Self::TAction = serde_json::from_value(json_action)?;
        // call action specific handler
        let response = self.handle_action(incoming)?;
        // convert response to json
        let response_json = serde_json::to_value(response)?;
        Ok(response_json)
    }
}
Enter fullscreen mode Exit fullscreen mode

The trait uses the TemplateMethod design pattern: The receive_action specifies the general workflow for converting the action. The handle_action method contains the actual logic for processing a specific action.

In our case, a ClassifierService could be responsible for processing all actions of the domain classifier:

// ClassifierService handles all classifier specific actions
struct ClassifierService {}
impl ClassifierService {
    pub fn update_classifier_name(&self, new_name: &str) -> () {
        /* TODO: implement domain logic here */
    }
}
impl ActionHandler for ClassifierService {
    type TActionType = ClassifierAction;

    fn domain(&self) -> &str { CLASSIFIER_DOMAIN}

    fn handle_action(&self, action: Self::TActionType) -> 
    Result<Self::TActionType, serde_json::Error> {
        // here happens the domain logic
        let response = match action {
            ClassifierAction::RenameClassifier(data) => {
                // update data store
                self.update_classifier_name(&data.new_name);
                ClassifierAction::ClassifierRenamed(data)
            },
            ClassifierAction::CancelClassifierRename =>
                // user has canceled, return previous name
                // here we just return an example text
                ClassifierAction::ClassifierRenameCanceled(
                    EditNameDto { new_name: "Old Classname".to_string() }
                )
            , // if front end sends different actions, something went wrong
            _ => ClassifierAction::ClassifierRenameError
        };
        Ok(response)
    }
}
Enter fullscreen mode Exit fullscreen mode

4. Sending response actions back to Webview

We're almost done. We have the signature of our Tauri command and the code we need to handle an action and generate a response. If we glue everything together, our final ipc_message function may look like the following snippet:

#[tauri::command]
fn ipc_message(message: IpcMessage) -> IpcMessage {
    // This code is just for demonstration purposes.
    // In a real scenario, this would be done during application startup.
    let service = ClassifierService{};
    let mut handlers = HashMap::new();
    handlers.insert(service.domain(), &service);

    // this is were our actual command begins
    let message_handler = handlers.get(&*message.domain).unwrap(); 
    let response = message_handler.receive_action(message.action).unwrap();
    IpcMessage {
        domain: message_handler.domain().toString(),
        action: response
    }
}
Enter fullscreen mode Exit fullscreen mode

Please note that the service creation and registration code are only for demonstration purposes. In an actual application, we would instead use a managed state to store our action handlers during application startup.

We also omitted the error handling here to keep the code simple. However, there are quite some scenarios we should check, e.g., what should happen if no handler is found, or how should we proceed if parsing an action into an enum goes wrong, etc.

Conclusion

Our proof-of-concept was successful! Sure, some parts of the implementation can be tweaked, but porting UMLBoard's IPC messaging from Electron/TypeScript to Tauri/Rust is definitely manageable.

Rust's enums are an elegant and type-safe way to implement our message system. We only have to ensure that potential serialization errors are handled when converting JSON objects into our enum variants.

In the next post in this series, we will try to use a document-based local database to store our domain model.
Hopefully, by then, I'll finally understand how the borrow checker works...



What's your opinion on that? Have you already worked with Tauri and Rust, and what were your experiences? Please share your thoughts in the comments or via Twitter @umlboard.



Title Image from Wallpaper Flare.
Source code for this project is available on Github.
Originally published at https://www.umlboard.com.

Top comments (2)

Collapse
 
lexlohr profile image
Alex Lohr

Excellent write-up. I'm looking forward to the next part!

Collapse
 
pgenfer profile image
Patric Genfer

Thanks a lot, glad you like it! I'm on it ;-)