Firezone logo light
ReactorScram

Senior Systems Engineer

Using Tauri to build a cross-platform security app

I would have thought after more than 20 years of GUI paradigms, the major OSes would have made GUIs as nice to program as CLIs.

-- The author

I thought so too at one point, until i got into working on (instead of "with") a gui framework. Now I cry myself to sleep every night 🤷

-- A lead Tauri developer


When I joined Firezone around the end of 2023, we needed a GUI framework to port the Firezone Client software to Windows. We chose Tauri over other frameworks because it was the fastest way to get the product working. Now Tauri is the framework for both the Linux and Windows GUI Clients.

Tauri is "a framework for building tiny, blazingly fast binaries for all major desktop platforms." It's like Electron except:

  • The backend is Rust, not node.js / C++
  • The webview is provided by the OS or package manager, not bundled with the app
  • Because it doesn't bundle Chromium, the apps are under 100 MB to download

The Firezone team already knew web programming, and our cross-platform connection library connlib is written in Rust, so Tauri didn't present any huge learning curve - It's just more Rust.

We considered several other frameworks and rejected them all for now:

FrameworkPositivesNegatives
QtI really liked it around 2013Quality of Rust bindings unknown, difficult to package
FLTKI'm familiar with it, small binariesDifficult to style, missing many features
GTK+De facto standard Linux GUIQuality of Rust bindings and Windows support unknown, the Firezone team is not experienced with it
windows-rsSmallest possible binary sizeVerbose, lots of unsafe code, confusing documentation, not portable to Linux at all
native-windows-guiLooked easyNot maintained
WPFSmall binaries, I've used it beforeBinding C# to Rust considered infeasible, assumed Rust and Linux support to be bad
ElectronSame positives as Tauri, but even more popularInfamously large download sizes
IcedSmaller downloads and less RAM usage than Tauri / ElectronHasn't caught on yet, had some trouble with compatibility when building in Ubuntu 20.04 and running in 22.04

What's so good about Tauri?

Tauri is easy to learn and use

Growth solves everything.

Software grows when it is easy to use. Being easy attracts beginners, and turns beginners into advanced users and contributors. Python became popular by appealing to new programmers who needed to write small programs. JavaScript became popular by being pre-installed in every web browser. C++ became popular by being published before both of them.

Tauri makes itself easy by shipping the create-tauri-app tool for setting up boilerplate projects, and by having bundlers for Windows .msi installers and Debian .deb packages that Just Work, at least for simple programs.

Using Tauri, we got the Windows Client to beta in about 2 months, including the time needed for me to learn about connlib, and for the team to change our auth flow from one that worked well on macOS to one that worked on all platforms. This has been my first project using Tauri, and the pace of development has been great.

Bringing the Linux GUI Client to MVP took another 4 months. This took longer than Windows because Linux's security model required us to split the Client into two processes, and because we refactored the Windows Client to share the code with Linux.

We already use Rust and TypeScript

Firezone already uses Rust for our data plane (the Gateway, the Relay, and connlib) and TypeScript for the website and admin portal, so we didn't have to add another language like C++ or C# to the mix. I made a prototype of the GUI in TypeScript, hooked it up to the Rust code, and then let another team member polish and style it to match the website branding.

The Windows Client shortly before MVP, 2024 Jan 04

The Windows Client shortly before MVP, 2024 Jan 04

The Windows Client at time of writing, 2024 June

The Windows Client, at time of writing, 2024 June

The TypeScript / JavaScript inside the web view can call into async Rust functions, and from there we have access to the usual Tokio facilities like mpsc channels, tasks, etc., to perform work before returning to JavaScript.

e.g. apply_advanced_settings writes the new settings to disk using Tokio's fs module, then sends them to the central Controller over a channel. If the Controller is backed up for any reason, the TypeScript will asynchronously wait on the Rust code.

TypeScript calling the function (settings.ts#L84-L90)

invoke("apply_advanced_settings", {
  settings: {
    auth_base_url: authBaseUrlInput.value,
    api_url: apiUrlInput.value,
    log_filter: logFilterInput.value,
  },
});

The Rust implementation (settings.rs#L47-L61)

#[tauri::command]
pub(crate) async fn apply_advanced_settings(
    managed: tauri::State<'_, Managed>,
    settings: AdvancedSettings,
) -> Result<(), String> {
    if managed.inner().inject_faults {
        tokio::time::sleep(Duration::from_secs(2)).await;
    }
    apply_inner(&managed.ctlr_tx, settings)
        .await
        .map_err(|e| e.to_string())?;

    Ok(())
}

Tauri automatically serializes and deserializes both ways as long as our types implement the common serde::{Deserialize, Serialize} traits, which we derive:

settings.rs#L12

#[derive(Clone, Deserialize, Serialize)]
pub(crate) struct AdvancedSettings {
    pub auth_base_url: Url,
    pub api_url: Url,
    pub log_filter: String,
}

Tauri comes with batteries included

The requirements for the beta Windows Client included:

  • A system tray icon and menu
  • Pop-up "toast" notifications such as "Firezone is connected"
  • A plan for automatic updates
  • A plan for a Linux port
  • The ability to handle deep links from web browsers, like how Steam or Zoom can be triggered by links

Tauri has built-in modules for a system tray menu, notifications, and self-updates, and it has a .deb bundler for Linux built in. For deep links we used tauri-plugin-deep-link.

This made it easy to commit to Tauri - We knew that worst-case, we had something that would work for a beta release.

Some of those batteries stayed in and some got replaced. We replaced the default Tauri notifications on Windows with tauri-winrt-notification, we rewrote the deep link code, we haven't deployed self-updates yet, and the system tray menu will be replaced eventually.

Over the last 6 months Tauri's built-in features have saved us:

  • From using system tray and notification APIs directly
  • From starting from scratch for deep linking
  • From writing an updater at all

How does Tauri work for us?

Firezone Client
    |
    +------> Tokio
    |
    +------> connlib
    |
    +------> Tauri

The Firezone Client for Linux and Windows is a wrapper around our cross-platform connection library connlib. Tauri provides the GUI and bundling, and then we use Tokio, the popular async runtime for Rust, to glue everything together.

Tauri = TAO + WRY

Tauri
  |
  +----> Tokio
  |
  +----> WRY
  |       |
  |       +----> WebView2 (Windows)
  |       |
  |       +----> webkit2gtk (Linux)
  |
  +----> TAO
          |
          +----> Win32 (Windows)
          |
          +----> GTK+ (Linux)

Tauri internally uses Tokio too. Below that, it's built on WRY and TAO, two sibling projects also maintained by the Tauri Programme.

TAO is a "window creation library". It opens windows and runs a minimal event loop, just enough for WRY, the "webview rendering library", to spawn a webview inside each window and run the GUI.

WRY uses platform-specific web views like webkit2gtk on Linux and WebView2 on Windows.

WebView2 and WebKit2 are similar from a bird's-eye view. There's a main UI process, such as the Firezone Client or a web browser, and then multiple worker processes to render web views. In a web browser these processes might map to individual tabs. In Tauri they map to individual windows.

UI process
    |
    +--------> WebContent process
    |
    +--------> WebContent process
    |
    +--------> WebContent process

(Paraphrased from webkit.org)

The two engines are essentially cousins as well:

  • In 2001, Apple forked KHTML to make WebKit, the browser engine for Safari.
  • In 2008, Chromium 1.0 released, based on a multi-process branch of WebKit
  • In 2013, Chromium forked WebKit and named it Blink
  • In 2015, Microsoft released Edge using an engine that no longer exists
  • In 2020, Microsoft released the new Edge, built atop Chromium, and they released WebView2

So WebView2 is an API for Microsoft Edge, which is Google Chromium, based on Google Blink, which was WebKit, meant for Apple Safari, originally KDE KHTML.

Firezone is best viewed with Konqueror.

The primordial soup from which the Web evolved

The primordial soup from which the Web evolved

(Source: https://en.wikipedia.org/wiki/K_Desktop_Environment_2#/media/File:KDE_2.2.2.png)

Bundling 📦

Tauri's bundlers were a huge selling point for us. It turns out .deb packages are easy to make, but making a good .msi installer requires some boilerplate. We originally wanted to ship a portable single-file executable, but two requirements made that impractical on both platforms:

  • Controlling DNS and running connlib requires admin / sudo privileges. We don't want admin privilege to be a requirement for day-to-day use of Firezone, so we install a service that has the right privileges, and that service takes commands from the GUI. Installing a service from a portable executable makes it not portable.
  • The webview libraries are shared on both platforms, so if the system doesn't have them installed, we'd have to take admin privilege to install them on the first run of the GUI, which is not convenient, and doesn't match how Debian / Ubuntu want to install packages.

For Linux (Ubuntu), Tauri creates a .deb package. This package references webkit2gtk, so apt will install it for us when Firezone is installed, share it with other software on the system, and upgrade it when security patches are released.

deb package
    |
    +----> firezone-client-gui
    |
    +----> systemd service
    |
    +----> HTML+JS+CSS assets
    |
    +----> dependency on webkit2gtk

For Windows, Tauri creates an .msi installer, which automatically downloads and installs WebView2 if the system doesn't have it already. Windows 11 systems have WebView2 pre-installed, and just like apt on Ubuntu, we let Windows handle the security updates.

MSI installer
    |
    +----> firezone-client-gui.exe
    |
    +----> Windows service
    |
    +----> HTML+JS+CSS assets
    |
    +----> WebView2 downloader

Firezone Client 1.0.5 is 12 MB on Linux and 8 MB on Windows, much smaller than the default ~100 MB for Electron, and still a bit smaller than the ~35 MB achievable with electron-builder. Another API could have made it smaller, but considering how little effort it takes to use Tauri, the download size is certainly near the Pareto front.

If Tauri is so great, why did the Linux port take twice as long as Windows?

The Linux port took twice as long because running a GUI with sudo in Linux doesn't seem to work, and we need root permissions to control DNS. On Windows, the early releases were just one process running with admin privilege. (Not counting the WebView renderer processes)

+---------------------------+
|    Tauri GUI process      |
|                           |
|   +-------------------+   |
|   |      connlib      |   |
|   +-------------------+   |
+---------------------------+

When we ported that to Linux, everything broke.

The tray icon didn't appear on some Ubuntu versions, so we couldn't click "Sign in", and when it did appear, we couldn't open a browser anyway, because the desktop is expecting us to be a normal user and the GUI was running as root.

So we gave up on that model and split the program into 2 top-level processes:

  1. A GUI running as the normal desktop user
  2. A systemd service running as root, with lots of sandboxing.
+-----------------------+                                             +-------------------+
|   Tauri GUI process   | <------ Inter-process communication ------> |      connlib      |
+-----------------------+                                             +-------------------+

We had originally planned to do this refactor after the Linux GUI beta, but the permissions problem forced us to give it higher priority.

This is a security win for two reasons. First, it puts a boundary between a million lines of web engine code and our cryptography code. Second, systemd's sandboxing in firezone-client-ipc.service means that the code running as root can't accidentally read your email, delete the entire filesystem, or even change the time on the system clock.

We copied this architecture to Windows, where it coincidentally fixed a bug where our DNS control didn't deactivate if the system powered off suddenly or Firezone was force-stopped.

The Windows sandboxing is not as strict as systemd's, but on both platforms this change was also:

  • A convenience win, because you can run Firezone without admin privilege, and
  • A bug-fixing win because the service can clean up Firezone's DNS control if the GUI crashes or the computer suddenly reboots.

So we got everything we wanted, just not in the order we wanted it.

What non-Tauri issues did we have?

Web views use a whole bunch of RAM

Windows Task Manager showing that the webviews need about 100 MB of RAM to do nothing

The GUI uses about 100 MB of RAM even with all windows hidden. This seems to be the minimum for keeping the web view process alive. It's okay for an MVP, but it's still 5% of the 2 GB needed for a minimum Ubuntu desktop, just to run a GUI that will be closed most of the time.

We can't fix this right now, but we have at least 3 long-term options:

  1. Try closing the windows instead of just hiding them
  2. Only use the main GUI process to run the tray menu, and keep the Tauri windows in a subprocess that we can stop and restart when needed
  3. Switch away from Tauri entirely. Iced uses about half as much RAM, and GTK+ also has some Windows support and some Rust support.

Hopefully some of that 100 MB is code pages we can share with Edge and other WebView2 apps, but I suspect most of it is HTML and JS overhead.

Ubuntu forwards and backwards compatibility is hard

Ubuntu 20.04 and 24.04 don't have any webkit2gtk versions in common.

webkit2gtk 4.0webkit2gtk 4.1
Ubuntu 20.04X
Ubuntu 22.04XX
Ubuntu 24.04X

It's not possible to link with both 4.0 and 4.1, so it's impractical to make one exe that runs on all 3 of the Ubuntu versions we'd like to support. This is a common problem in Linux GUI programs.

In fact, at time of writing, Tauri 1.x just doesn't work on Ubuntu 24.04 unless you cheat and add another repo to install the 4.0 package from. Tauri 2.x, still in beta, won't work on 20.04 since it requires 4.1.

Tauri does offer an AppImage option to bundle the webview, but this increases the download size from ~10 MB to ~100 MB, we can't install our connlib service if we're running from an AppImage, and AppImages built on 20.04 have rendering bugs on 24.04 anyway.

Switching to another GUI framework might work, but since GTK+ is already involved in this incompatibility issue, it's likely that GTK+ itself has some compatibility problems we'd hit. We'd have to spend time playing with it before committing to a port. We also tried building an example Iced app on Ubuntu 20.04, and it didn't render on 24.04 either. Maybe it's something related to the recent Wayland cut-over.

There is a plan that should work, and it doesn't have any big leaps of faith, but it's still painful:

  • Refactor the code so that we can build for both Tauri 1.x and Tauri 2.x. (Needed sooner or later anyway)
  • Use Docker or something to build one .deb inside 20.04 and another inside 22.04. (Or worst case, two entire VMs)
  • Unpack the two packages, add a Bash script shim that checks which WebKit is installed and launches the correct binary, then re-pack them into a single .deb. (We already re-pack Tauri's .deb, so this should be possible)

Or maybe we can install 4.1 on 20.04 somehow. Whatever we do, it won't be simple. Worst case, we can give up and use Electron.

WebView2 just doesn't install sometimes

This is an upstream issue with WebView2 for Windows. Sometimes the installer times out in our GitHub CI runners and we don't know why.

The WebView2 installer just hanging in CI for some reason

https://github.com/firezone/firezone/pull/4981

GNOME

On GNOME, if the menu is too long and the screen is too short, you can't open submenus. If the menu is barely too long, the submenus open but they're squashed into a tiny scroll view. And the submenus are shown in-line, so at no point does it look like a submenu at all:

A very long menu displayed in GNOME, with the submenu being squashed

On Windows, it's fine. It's been fine since Windows 95, and it's still fine.

A normal-looking tray menu in Windows, same as it's been since Windows 95

Also in GNOME, clicking on a notification runs another instance of the app. Maybe this is intended to activate the app, but it doesn't seem to happen on Windows. The second instance doesn't get any obvious environment variable or argument to hint that it was launched from a notification, so we show a generic "Firezone is already running" error instead of doing anything useful. If we change that error to do nothing, then users will be confused if they try to run Firezone and don't notice it's already running.

The Tauri directory layout is odd

The root of a Tauri project, such as /rust/gui-client in the Firezone repo, has two subdirectories: src and src-tauri. The entire Rust project is contained in src-tauri, and the src directory contains files for the web view. Cargo.toml and tauri.conf.json are in src-tauri. I find this confusing. Maybe Tauri's intended use case is to wrap up web apps into native apps, so the web content gets to be in src and the Rust code is isolated over in src-tauri.

But we think of the Firezone Client as a Rust program that incidentally uses HTML+JS+CSS for the GUI, so this layout is backwards for us. We were expecting a top-level /Cargo.toml, with the Rust code under /src, and the web files under /src-web or something. There's probably a good reason why Tauri does this, but we aren't sure what that reason is.

Tauri's deb bundler is not very customizable

To install the systemd service on Linux, we need the deb package to have post-install and pre-remove scripts. dpkg doesn't install these in the filesystem, it runs them from a temp directory when adding or removing a package, and then it deletes them. Tauri doesn't have any hook for these scripts, since a typical self-contained GUI app won't need them.

So in Firezone's build process, we delete Tauri's .deb, add our scripts to the intermediate files Tauri left, and then finish the bundling ourselves.

firezone-client-gui-linux_1.0.6_x86_64.deb
   |
   +----> debian-binary
   |
   +----> data.tar.gz        (Tauri gives us all this, which is great)
   |          |
   |          +----> /usr/bin/firezone-client-gui
   |          |
   |          +----> /usr/bin/firezone-client-ipc
   |          |
   |          +----> /usr/lib/systemd/system/firezone-client-ipc.service
   |          |
   |          +----> /usr/lib/sysusers.d/firezone-client-ipc.conf
   |          |
   |          +----> /usr/share/applications/firezone-client-gui.desktop
   |          |
   |          +----> /usr/share/icons/...
   |
   +----> control.tar.gz
              |
              +----> control
              |
              +----> md5sums
              |
              +----> postinst     (... but we have to tear up the build process)
              |                   (to add postinst and prerm here              )
              +----> prerm

cargo-deb does support these scripts, but Tauri's bundler is based on cargo-bundle instead.

I'm surprised these scripts are not in Tauri's use cases. On Windows, we override Tauri's Wix files to generate an MSI that installs and configures our connlib service. So in both cases we had to hijack the build process a little. Linux required more code, Windows required more "ChatGPT, tell me how Wix works, please."

You can't left-click on the tray menu in Windows

In Windows, you can't left-click on the tray menu. Tauri doesn't fire any event for it, even though other Windows apps handle left clicks just fine. You have to right-click on Tauri system trays in Windows.

Tauri apps can't exit gracefully

They can't. From main you decide when to start Tauri, but the only way to exit Tauri is to call std::process::exit, or let Tauri call it for you.

This is implemented down in tao, Tauri's windowing library, for both Linux and Windows.

This might be due to some platform-specific limitation, such as Windows' WinMain function or the fact that some OSes require all GUI-related function calls to happen on "the main thread", or at least on a single thread.

Interestingly, tao is a fork of winit, and winit is able to return from its run function on desktop.

The run_iteration function should be able to handle this, but instead it busy-loops.

Commenting on these issues, lead Tauri developer Fabian said he also expects better from the underlying OSes and this causes him to "cry (himself) to sleep at night 🤷" Me too, Fabian. Me too.

Setting binary names is tricky

On Linux our GUI is named firezone-client-gui, and so is our package.

We want:

  1. The .desktop file to display our name as "Firezone Client", so it's human-friendly,
  2. The package to be firezone-client-gui, so that it's distinct from our CLI Client
  3. The GUI binary to be /usr/bin/firezone-client-gui,
  4. And the service binary to be /usr/lib/$ARCH/dev.firezone.client/firezone-client-ipc, to keep it out of $PATH.

But Tauri's default is to name the first 3 the same and to put all binaries in /usr/bin. We could fix this when we tear apart the .deb for the post-install script, but I'd rather have a way to cut around the defaults and take control of the bundler directly, like with the Wix files on Windows.

This is a case where Tauri doesn't reach "Easy things are simple, hard things are possible."

Initializing Tauri is flaky in CI... maybe?

https://github.com/firezone/firezone/issues/3972#issuecomment-2010728424

I think it's getting stuck on creating the GTK event loop for the window

At first we tried to initialize as much of the program as possible before starting Tauri, and then move objects into the Tauri context. But we hit this issue, which appears to be a race in initializing the GTK+ event loop or something. It's arcane stuff 3 layers below us (Tauri, then tao, then GTK+) in C code, so the workaround for now is to initialize Tauri immediately and do all our own setup within the Tauri context.

It may not be Tauri's fault, but it's disappointing because it's nice to keep control of main longer before handing control to Tauri and doing everything inside callbacks.

Conclusion

Tauri often feels like the training wheels on a bicycle. It gets you started, but after a while you can't go any further without replacing it.

The positives are:

  • It's Rust, so we get to keep all our code even if we completely ditch Tauri next year.
  • Those training wheels are very nice to have on a new cross-platform GUI project.
  • It's gratis and libre, so you can't beat it on price.

The final word is, Tauri is good, try it out.

Firezone Newsletter

Sign up with your email to receive roadmap updates, how-tos, and product announcements from the Firezone team.

Sign up for our newsletter