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:
Framework | Positives | Negatives |
---|---|---|
Qt | I really liked it around 2013 | Quality of Rust bindings unknown, difficult to package |
FLTK | I'm familiar with it, small binaries | Difficult to style, missing many features |
GTK+ | De facto standard Linux GUI | Quality of Rust bindings and Windows support unknown, the Firezone team is not experienced with it |
windows-rs | Smallest possible binary size | Verbose, lots of unsafe code, confusing documentation, not portable to Linux at all |
native-windows-gui | Looked easy | Not maintained |
WPF | Small binaries, I've used it before | Binding C# to Rust considered infeasible, assumed Rust and Linux support to be bad |
Electron | Same positives as Tauri, but even more popular | Infamously large download sizes |
Iced | Smaller downloads and less RAM usage than Tauri / Electron | Hasn'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, 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
:
#[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
(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:
- A GUI running as the normal desktop user
- 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
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:
- Try closing the windows instead of just hiding them
- 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
- 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.0 | webkit2gtk 4.1 | |
---|---|---|
Ubuntu 20.04 | X | |
Ubuntu 22.04 | X | X |
Ubuntu 24.04 | X |
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.
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:
On Windows, it's fine. It's been fine since Windows 95, and it's still fine.
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.
What Tauri-related issues did we have?
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:
- The
.desktop
file to display our name as "Firezone Client", so it's human-friendly, - The package to be
firezone-client-gui
, so that it's distinct from our CLI Client - The GUI binary to be
/usr/bin/firezone-client-gui
, - 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.