Electron vs Tauri for Tray Apps

·

13 min read

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 criteria.

Untitled.png

Size first comparison: Tauri vs Electron

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 a 85mb bundle size for almost no functionality, and most apps come up to a 150mb download easy.

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 available right now in Tauri, in its first stable version. From a quality bundler and developer tooling, to built-in updater, to tray icon support and more.

Design & Architecture Comparison: Tauri vs Electron

An additional benefit you get from Tauri apps come directly from its engineering mindset, which I’d guess Rust helped drive.

Security as a first-class citizen

Untitled 1.png

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

Untitled 2.png

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 a 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

Untitled 3.png

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 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 whether you go Javascript or Typescript, and more.

In that sense, building on Tauri is simpler.

How to build a tray app with Tauri?

Untitled 4.png

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 fares?

Example case: Docker Desktop App

Let’s breakdown 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:

The demo app: overview

Untitled 5.png

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 close from the window because we’re a tray app
  • Hiding from task bar
  • Making sure 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 use for general purpose state storage.
  • Background processes running and triggering
    • Javscript 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

.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:

Untitled 6.png

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:

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 set up 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 reqwest, 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, and 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 what ever you want with an existing menu. However, to add or remove items becomes 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 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 push to the official Apple store, you can build fully bundled and ready to distribute apps with Tauri today, and distribute them how ever you like.

To build your next tray app, you can definitely start from github.com/jondot/tauri-tray-app. If you find that you can improve it, feel free to submit a pull request!