Tauri vs. Electron for Tray Apps
- Build a desktop tray starter app using Tauri, Rust, and JavaScript
- Size Comparison
- Design and Architecture Comparison
- A Case for Keeping it Simple
- How to Build a Tray App With Tauri?
- Tauri Has The Only Stable System Tray Implementation
- The Demo App: Overview
- Frontend
- Main process
- Setting a Menu Item Dynamically from JavaScript
- Long-running Processes
- Running async commands
- Navigation
- Preventing Exit and Hide On Blur
- Updating The Native Menu
Build a desktop tray starter app using Tauri, Rust, and JavaScript
Tauri is similar to Electron in general. The principles are the same: build apps quickly by using JavaScript for doing most of the app / UI work.
However, Tauri takes a radically different approach in how it is implemented, and therefore, achieves better results on virtually every possible criterion.
Size Comparison
We already know that there are two major pains with Electron-based apps, are:
- Bundle size (download size)
- RAM usage and growth over time
With Electron, you start out with an 85MB bundle size for almost no functionality, and most apps come up to a 150MB download easily.
In terms of memory usage, an Electron app starts out using 500MB of your RAM for doing almost nothing. This is why I donāt use any Electron app (so-called ānative appsā) for various popular Web apps such as Slack and others. I just use Chrome, hoping it can optimize RAM usage across tabs in a better way.
For Tauri: 8MB of bundle size for a whole lot of functionality, and peak memory of 150ā200MB usage, most of it due to a browser being a browser. Browsers tend to just eat up memory (it uses the native Web view of each operating system).
In terms of features, well, what took Electron years to get to (Iām talking as an Electron developer here)āāāyou have it available right now in Tauri, in its first stable version. From a quality bundler and developer tooling, to a built-in updater, to tray icon support, and more.
Design and Architecture Comparison
An additional benefit you get from Tauri apps comes directly from its engineering mindset, which Iād guess Rust helped drive.
Security as a First-class Citizen
Security as a first-class citizen. Not many app platforms (actually: none that I know of) start out with a detailed description of their built-in security features, as well as an implementation of advanced features for you to use, focusing on the JS side, the IPC, and more. This comes in addition to what Rust as a language provides in safety and stability. Even more impressive is the inclusion of threat modeling and the thinking behind how to operate defensively: security by design.
Performance as a First-class Citizen
Benchmarks as a first-class citizen. Again, not many platforms start out with this topic, providing highly detailed, automated and transparent benchmarking. When this happens in a library, tool, or platform, you know that performance is a mainline KPI. And when you build apps, you want to ensure that the performance of the platform youāre using stays great so your app which is built on top of it stays great. If the platform has performance problems it automatically means your app has performance problems, and alsoāāāthat you as a developer will probably have no way to resolve it.
Complete backend and frontend separation
While this initially may be perceived as a disadvantage vs Electron, the fact that the backend process is built in Rust and the frontend is built in JavaScript is actually extremely beneficial:
- It forces us to really reason about the IPC, which encourages safety and stability
- In the case of Rust, it gives you exactly what you need in a backend process: performance, safety, stability, and a systems approach for when you need to operate tightly with the underlying system. You canāt really do that with a Node.js-based backend process (youāll have to go native API in this case)
And with how Tauri abstracts IPC with Rust macros and developer experience, even if you donāt know Rust, youāre going to be alright.
A Case for Keeping it Simple
Building Electron apps is by no means simple. Itās virtually the peak of the paradox of choice, as with many JavaScript apps, but hereāāāeven more so. While you have the standard JavaScript fatigue in your frontend side of the Electron app, you have the same in the backend āmainā process: you have to choose your JavaScript tooling, libraries, and principles, but limited as a pure Node.js process (so some libraries wonāt work, and some would only work).
With Tauri, the backend is written in Rust, and the Rust ecosystem is simple and mostly thereās one good library to do a thing. So you steer clear of the paradox of choice and have more time building.
For the frontend part, youāre left with the same experience as with your Electron apps. Youāre going to need to choose your bundler, app framework, styling framework, state management framework, linter, and moreāāāwhether you go with JavaScript or TypeScript.
In that sense, building on Tauri is simpler.
How to Build a Tray App With Tauri?
For this exercise, letās see what a ātray appā means. Off the bat, itās actually many things. Itās a:
- Desktop app
- An operating system widget or accessory
- A long-living background process
- A windowing exercise (e.g. popping up at the correct place)
And so, it might be a hard exercise for a new app platform to go through, which is perfect to understand how Tauri handles it. We already know many tray apps are implemented in Electron today, so how does Tauri fare?### Example Case: Docker Desktop App
Letās break down the Docker desktop app. Weāre going to try and rebuild most of its parts in Tauri, and see what works.
The Docker desktop requirement or spec is along the lines of:
- Tray with an updating icon (when Docker loads)
- Updating, dynamic menu items in tray, for example to show status āDocker is runningā and current logged in userās name when opening the tray menu
- A desktop window that opens (the Dashboard), which has a specific behavior:
- When showing the window (Tray ā Open dashboard), the window should pop to your current workspace and monitor
- When clicking out of the window, or going to do something else, the window should hide
- This is a matter of taste, we can have the window hang around, and the user should explicitly hide it by clicking close (in fact, closing should quit, but weāre preventing that and hiding instead)
- The app should not have presence on your task bar / springboard. It should only live in your tray.
- This is a preference of taste, we can toggle it to be on/off.
- Has a specific size, cannot be resized
- Can be moved around, has a window shell (this can be removed, but letās have the shell)
In terms of infrastructure and mechanics, we need the following:
- Some kind of polling of status between the JavaScript app and the Rust main process. We can go:
- JavaScript polls Rust with setInterval and invoking a Tauri command, or
- Rust side pushing a periodic event by doing an
emit
on the main window for the JavaScript side to listen on - Either way, we have a constant line of communication between both sides which is great.
- Weāre turning a desktop app, into a long running powerful process.
- We also need ad-hoc calling code from JavaScript into Rust, this is magically solved by Tauriās
command
abstraction - Lastly, we need a fully-fledged desktop app framework and infrastructure. Here, weāll use React, Chakra-UI, React Router, and Zustand or Redux for state and specifically state persistence (for example when storing user settings).
You can go try the finished starter project here:
https://github.com/jondot/tauri-tray-app
Tauri Has The Only Stable System Tray Implementation
This might come as a surprise, but compared to Go or Node.js, Rust hasnāt got a great system tray library, and Tauri actually has the best implementation right now. What we have is the following:
- https://github.com/Ciantic/trayicon-rsāāāworks only on windows, and is designed to work with
winit
- https://github.com/qdot/systray-rsāāānot maintained anymore, and the author indicates itās not an easy task
- Tauri made https://github.com/tauri-apps/tao which implements a system tray functionality and is actually a fork of https://crates.io/crates/winit
The Demo App: Overview
Set up and bring the starter app (youāll need pnpm
and you probably want to run the getting started guide to get the basic prereqs in place)
$ git clone https://github.com/jondot/tauri-tray-app
$ cd tauri-tray-app
$ pnpm install
$ cargo tauri dev
Frontend
Every Tauri app divides into frontend and the main process (Tauri core). In the starter project the frontend has the following major components:
- Vite for building
- React as a UI framework
- Chakra UI as a component framework and for styling
- React Router as a general purpose routing framework, for when an app has to load and unload āscreensā
- Redux Toolkit or Zustand for state management and data persistence (both are wired, pick what you prefer)
Main process
Here are a few mechanics in our wishlist that we want in place in the core / main process part implemented in Rust and Tauriās API.
- Avoiding closing from the window because its a tray app
- Hiding from taskbar
- Making sure the window appears in front of the user
- Stateāāāto be familiar we can use either of these (weāll use state on the JavaScript side)
- JavaScript side (managing state as usual).
- Rust sideāāāthe state manager in Tauri can be used for general-purpose state storage.
- Background processes running and triggering
- JavaScript sideāāāweāll answer the questionāāāwill a naive setInterval loop live forever and ping the Rust core? (yes)
- And also whatās the best way to initiate such a forever loop?
- Controlling native features from the JavaScript side, such as setting a native menu item
Setting a Menu Item Dynamically from JavaScript
This is easy to do, hereās the recipe for it:
Create an item and give it a unique ID:
.add_item(CustomMenuItem::new("dynamic-item".to_string(), "Change me"))
Build a command, and request the app handle in the function signature:
#[tauri::command]
fn set_menu_item(app_handle: tauri::AppHandle, title: &str) {
let item_handle = app_handle.tray_handle().get_item("dynamic-item");
item_handle.set_title(title).unwrap();
}
Register it using the following piece of code:
.invoke_handler(tauri::generate_handler![greet, set_menu_item])
And call it from the JS side with invoke:
const setMenuItem = () => {
invoke('set_menu_item', { title: `count is ${count}` })
}
//...
<button onClick={setMenuItem}>Set menu item</button>
Long-running Processes
We have two ways to do this, in the sequence diagram below, both are charted out:
Long-running processes on the JavaScript side
We want to run a normal JavaScript setInterval
and hope it stays alive and able to drive the Rust process as well through IPC (a Tauri command
).
In short, it works:
// Note: setInterval is firing off a promise and does not wait for it to resolve.
// depending on what you want to get done, it may be smarter to use a different
// scheduling technique for an async call that may take longer than the interval
// from time to time.
useEffect(() => {
const interval = setInterval(() => {
invoke('interval_action', { msg: `count is ${count}` }).then((s: any) => {
setMsg(s)
})
}, 5000)
return () => clearInterval(interval)
}, [count])
But, beware of some pitfalls:
- Watch out for timers slowing down when the JavaScript part senses being in the background https://stackoverflow.com/questions/23506103/setinterval-slows-down-with-tab-window-inactive
- Like always, something thatās async in an interval can drift. Since we donāt really have a way to hold up the interval if the async operation gets delayed for some reason
Long-running processes on the Rust side
We can have a long-running process on the Rust side, kick off a thread and have it do a forever loop with a small sleep between iterations.
A few questions here:
- How and when to start it?
- Whatās the concurrency model? since a Rust thread wants to ping the JavaScript side via IPC, there must be something to reason about here
For starting out such a process, we can have a command
and invoke it when weāre ready on the JavaScript side. Thereās no right or wrong here, itās a matter of what you need.
#[tauri::command]
fn init_process(window: tauri::Window) {
std::thread::spawn(move || loop {
window
.emit(
"beep",
format!("beep: {}", chrono::Local::now().to_rfc3339()),
)
.unwrap();
thread::sleep(Duration::seconds(5).to_std().unwrap())
});
}
Or, kick off a forever loop in the Tauri setup stage, where we still have access to an app. For emitting an event, we have to do so via a Window
, since App
wonāt be thread-safe.
.setup(|app| {
let window = app.get_window("main").unwrap();
std::thread::spawn(move || loop {
window
.emit(
"beep",
format!("beep: {}", chrono::Local::now().to_rfc3339()),
)
.unwrap();
println!("beep");
thread::sleep(Duration::seconds(5).to_std().unwrap())
});
Ok(())
})
Running async commands
Letās try kicking off a network request, which the Rust side performs. Here, we use request, which uses async, and tokio
as the runtime.
We know that this requires an initialized tokio
runtime in any executable that we produce. As weāll find out, Tauri uses tokio
, so everything works out of the box. Just make the function async as you would normally, but remember: you have to return a serializable error.
To make it interesting weāre also returning a Vec
(serializable) of Content
(serializable).
#[tauri::command]
async fn api_request(msg: &str) -> Result<Vec<Content>, String> {
let res = reqwest::get("https://example.com")
.await
.map_err(|e| e.to_string())?;
let out = res.text().await.map_err(|e| e.to_string())?;
Ok(vec![Content { body: out }])
}
After this completes, weāll happily receive the serialized content as a JavaScript data object on the JS side.
Navigation
When navigating outside of a window with long-running interval, it is unloaded and cleared. And React Router works out of the box.
- Remember: weāre not really navigating in a website. So using a router like React Router becomes something of a specialized need. Instead of using a router, you can use some kind of Tabs control containing all of the screens of the app that you need.
All in all, for long-running, processes its best to drop them in the react-router root, and for UI parts that change, drop them in an <Outlet/>
in the root.
Preventing Exit and Hide On Blur
The mix of pattern matching in Rust and Tauriās API is just fantastic:
.on_window_event(|event| match event.event() {
tauri::WindowEvent::CloseRequested { api, .. } => {
// don't kill the app when the user clicks close. this is important
event.window().hide().unwrap();
api.prevent_close();
}
tauri::WindowEvent::Focused(false) => {
// hide the window automaticall when the user
// clicks out. this is for a matter of taste.
event.window().hide().unwrap();
}
_ => {}
})
Updating The Native Menu
In general, you have a high degree of control over the native side of things. You can do whatever you want with an existing menu. However, adding or removing items gets complicated, because thereās no access to the current āstateā of a menu.
To add items, you need to rebuild the complete menu with the new item included, and re-set it via the native API:
// there's no way to grab the current menu, and add to it, creating
// an evergrowing menu. so, we rebuild the initial menu and add an item.
// this means we'll only add one item, but to add an arbitrary number,
// make this command accept an array of items.
// also, you probably would want to inject the new items in a specific place,
// so you'd have to split the initial menu to [start] [your content] [end],
// where 'end' contains things like "show" and "quit".
#[tauri::command]
fn add_menu_item(app_handle: tauri::AppHandle, id: &str, title: &str) {
let mut menu = build_menu();
let item = CustomMenuItem::new(id.to_string(), title);
menu = menu.add_item(item);
app_handle.tray_handle().set_menu(menu).unwrap();
}
```### Conclusion
Thereās so much more to Tauri. Weāve just touched on a few cases, which Tauri 1.1 handled very wellāāāthe platform is mature; more mature than where Electron was at a similar point in its evolution.
While I havenāt yet pushed it to the official Apple store, you can build fully bundled and ready-to-distribute apps with Tauri today, and distribute them however you like.
To build your next tray app, you can definitely start from [https://github.com/jondot/tauri-tray-app](https://github.com/jondot/tauri-tray-app). If you find that you can improve it, feel free to submit a pull request!