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 applications1. 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.
- 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.
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
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:
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).
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:
- Declare Rust equivalents of our Redux actions
- Sending actions from Webview to Core process
- Handling incoming actions in the Core process
- 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
}
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
}
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 }
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.
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
}
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
}
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)
}
}
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)
}
}
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
}
}
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 X @umlboard.
Title Image from Wallpaper Flare.
Source code for this project is available on Github.