Making desktop apps with revved-up potential: Rust + Tauri + sidecar

The Tauri framework provides a productive development flow as it can catch many errors at compile time, allowing us to think less about possible errors and more of what we want to implement. But what if you want to go beyond the basic setup which consists of the web app and the Tauri backend? For example, non-trivial desktop features, like background file syncing. Perhaps use the filesystem, the network… and write it in Go? Then, read on.
Going beyond the browser, first steps
There are already plenty of articles about wrapping your web app into a desktop app. These cover basic topics, like how to wrap an existing Svelte app with Tauri and make a desktop app. But that’s not enough if you want to go beyond the browser’s limits and get closer to the real desktop experience.
A web app might have a file picker for uploading files, modals that get user input, or in-browser notifications, meanwhile a desktop app might mean accessing files on a computer, sending native notifications, creating new windows, or communicating with other programs.
I need to go beyond the web application and into the desktop world for a recent project. I’m not a desktop developer, nor an experienced frontend developer, so I wanted a tool that would be easy to integrate, set up and develop with without diving into it too much.
Electron is probably the most famous framework when it comes to distributing a web app as a desktop app, but I found that it required more effort from me to integrate into an existing web app and distribute it. The resulting executable file was more than 100 megabytes in size, so I decided to try something more lightweight and simple.
For me, that solution was Tauri, and I gave it a shot and found that it better fit my needs.
For instance, it produced a 10Mb executable instead of a 100Mb one. I also found that Rust catching errors at compile-time was saving my time otherwise spent on debugging (and I spent it learning Rust basics and the Tauri API instead).
Why Tauri? A deeper analysis of the pros and cons
Before we continue with this guide, I want to share a few more detailed notes about why I ultimately went with Tauri:
- Compile time errors. A Tauri app won’t compile if you have issues in the code. This prevents you from making stupid mistakes.
- Tauri is pluggable. You don’t have to rewrite an entire web app, and you don’t even need to add the Tauri API as a dependency. This way you don’t build the whole app with desktop framework in mind, but you can add special logic to some parts of it. This is an especially good when you want to distribute both web and desktop apps.
- Convenience. As mentioned, I’m not a desktop app developer, and I am new to desktop frameworks. So, I can make a lot of mistakes with Electron and discover them only in the runtime. With Tauri you get much stronger type system and compile-time checks. This is a huge advantage a backend engineer like me who doesn’t want to dive into the frontend world.
- Performance. Tauri is a lot faster than Electron. It uses a webview instead of an embedded Chromium engine and does not embed the runtime (like Node for Electron). This makes apps lightweight and fast.
With Electron, I would have to adjust the codebase so that it could be used with the Electron framework. In Tauri, you simply make a folder with the static HTML files of your app and tell Tauri where you’ve put it. This way feels more attractive to me as a backend engineer that didn’t want to break anything in the existing Next.js app.
Now, here are some of the drawbacks of Tauri that I have to acknowledge:
- Webview. Tauri uses WebKit on macOS and Linux, and WebView2 on Windows. This requires us to check the frontend’s behavior outside of Chromium. I assumed this wasn’t a big problem, however, you can’t rely on the modern browser JS API and you should always keep this in mind.
- Rust. People used to think of Rust as a hard-to-learn language. Happily, there are a lot of code examples and some perfect guides, so you learn by example.
After considering all pros and cons I decided that using Tauri should prove itself in the future and I wasn’t wrong.
Choosing an approach
Once you’ve made the choice to go with Tauri, you must also think about implementation. For our study here today (based on my real project experience), our task here will be to bring all the features of a preexisting web app and add one more that included working with filesystem (e.g. monitoring file changes). There are at least two approaches to do this:
- You could write special handles in Rust for every specific action you trigger in your app. This makes things as simple as possible, but this limits you to the Rust language.
- You can embed a sidecar, which is a separate program running besides the desktop app. This allows you to use almost any programming language and opens up some new possibilities, like running a background process. The only limit: the program must not have external runtime dependencies so it can execute on any computer.
I decided to go with the second approach, and my experience below is based on that decision; you’ll find it useful if you want to see some Tauri usage examples that go beyond those basic setups mentioned before.
Configuring Next.js
First up, I was adding Tauri to a Next.js app, so I needed to adjust the next.config.js
when building Tauri:
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = { /* ... */ };
+const tauriNextConfig = {
+ ...nextConfig,
+ output: 'export',
+};
-module.exports = nextConfig;
+module.exports = process.env.TAURI === 'true' ? tauriNextConfig : nextConfig;
That’s it, with that, when I want to build the app for a desktop I just set TAURI=true
.
Configuring tauri.conf.json
Before moving to the code part I’d like to share some notes about the configuration. There are a lot of options (and they all are well covered in the Tauri documentation) but some of them have nuances.
The configuration lives in src-tauri/tauri.conf.json
file, let’s take a deeper look in the next sections.
version
This is optional and if not provided the desktop app version will be taken from Cargo.toml
. Still, it makes sense to maintain this, especially if you have automated CI for releases. When you push a tag you can simply replace the version in tauri.conf.json
and build the app with the new version without you needing to manually commit it.
{
"version": "1.0.0"
}
app.windows
You can configure application windows in the tauri.conf.json
or create them manually in the code. I decided to do it in the code because there you have more control. This an especially good choice when you have multiple windows or if you do something with windows (like subscribing to the events). Thus, I preferred to leave this setting empty.
{
"app": {
"windows": []
}
}
bundle.icon
You can see a lot of icons here in the default configuration, but most likely, you’ll need just three of them. Keep only the icons for the platforms you’re going to support.
icons/icon.icns
- a bundled macOS-specific format for icons.icons/icon.ico
- This file applies to Windows build.icons/32x32.png
- These NxN.png files apply to Linux build.
You can also configure the icon for the Windows installer if you distribute it via nsis (as an .exe file)
{
"bundle": {
"icon": [
"icons/icon-macos.icns",
"icons/icon-windows.ico",
"icons/32x32.png"
],
"windows": {
"nsis": {
"installerIcon": "icons/icon-windows.ico"
}
}
}
}
Capabilities and plugins
Beginning with the 2nd version of the Tauri framework, pretty much every app interaction with the desktop environment is managed by “capabilities”; this prevents your app from using features it doesn’t need.
For example, you may want to allow the app to render files in the user home directory. For that, you’ll have to add tauri-plugin-fs and explicitly declare that we want full access to user’s home directory:
// src-tauri/capabilities/filesystem.json
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "filesystem",
"description": "enables filesystem access",
"windows": [
"main"
],
"permissions": [
"fs:default",
{
"identifier": "fs:read-all",
"allow": [{ "path": "$HOME/**" }]
}
]
}
This setting in tauri.conf.json
allows us to use the asset protocol for rendering files:
// src-tauri/tauri.conf.json
{
"app": {
"security": {
"assetProtocol": {
"scope": [
"$HOME/**"
],
"enable": true
}
}
}
}
Then, we need to setup the plugin in the Rust code:
// src-tauri/src/lib.rs
pub fn run() {
tauri::Builder::default()
+ .plugin(tauri_plugin_fs::init())
After that you can use the window.__TAURI__.core.convertFileSrc
function, or use @tauri/api/core
.
// lib/tauri.ts
// Option one
import { convertFileSrc } from '@tauri-apps/api/core'
// Option two (don't forget to set `app.withGlobalTauri: true` in `tauri.conf.json`)
export function localAssetUrl(path: string): string | null {
return window.__TAURI__.core.convertFileSrc(path)
}
Adding a sidecar
Tauri’s sidecar feature allows you to include executables in your app distribution which you can call later using the Tauri API. These are supposed to run for a short time and just do some action. This is useful when you have some tool and you don’t want to rewrite it in Rust. So, sidecars provide us with a convenient way to distribute an isolated feature without too much work.
But for my app I wanted a sidecar that would run for a long time: one that starts with the desktop app and exits when it closes. Why? For example, say I want to monitor the filesystem and handle any changes. So, how can we adapt the sidecar for this use case?
Let’s imagine we already have an executable: we need to tell Tauri to include this executable and properly name the file. For example, for a sidecar with the name bin/sidecar
we’ll have to create a file called src-tauri/bin/sidecar-aarch64-apple-darwin
or src-tauri/bin/sidecar-x86_64-pc-windows-msvc.exe
depending on the target OS and architecture:
{
"bundle": {
"externalBin": [
"bin/sidecar"
]
}
Next, we spawn the sidecar in Rust code, like this:
let command = app.handle()
.shell()
.sidecar("sidecar")
.expect("couldn't get sidecar executable");
// rx - events receiver, like stdout or stderr data
// child - process manager, we can send messages to stdin or kill the process
let (mut rx, mut child) = command.spawn().expect("failed to spawn sidecar");
The only native way to communicate with a sidecar is via stdin/stdout; this is a pretty basic and inconvenient approach, so let’s use something more advanced, like TCP sockets.
Communication over TCP
Using stdin/stdout for communication is too basic and unreliable but might be good for a start. However, to show that we’re not limited to this approach, let’s use TCP sockets instead. We will let the sidecar open a TCP socket on a random port and pass it back to the Tauri via stdout. Then we’ll connect to it and use it for App -> Sidecar communication.
// src-tauri/src/lib.rs
mod sidecar;
mod commands;
use tauri::Manager;
// We share the client via state to proxy messages from web.
pub(crate) struct AppState {
sidecar_client: sidecar::Client,
}
pub fn run() {
let mut builder = tauri::Builder::default()
.plugin(tauri_plugin_shell::init());
tauri::Builder::default()
.setup(|app| {
let app_handle_copy = app.handle().clone();
tauri::async_runtime::spawn(async move {
let app_handle_copy = app.handle().clone();
// Spawning the sidecar and receiving the port
let port = sidecar::spawn(&app_handle_copy).await;
// Connecting to a TCP socket on a port
let sidecar_client = sidecar::connect(port).await;
// sidecar_client will be used in the sidecar_send command to send messages
app_handle_copy.manage(AppState { sidecar_client });
});
Ok(())
});
builder = builder.invoke_handler(generate_handler![
// sidecar_send is a command we can invoke in the frontend code
commands::sidecar_send,
]);
builder
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
This is a basic implementation of a TCP client, including a “protocol” of messages divided by newline; we receive the TCP port via stdout from the sidecar.
// src-tauri/src/sidecar.rs
use log::{debug, info};
use tauri::AppHandle;
use tauri_plugin_shell::process::CommandEvent;
use tauri_plugin_shell::ShellExt;
use std::sync::Arc;
use tokio::io::{AsyncWriteExt, WriteHalf};
use tokio::net::TcpStream;
use tokio::sync::mpsc::unbounded_channel;
use tokio::sync::Mutex;
pub(crate) struct Client {
writer: Arc<Mutex<WriteHalf<TcpStream>>>,
}
impl Client {
pub(crate) async fn write(&self, s: String) {
self.writer
.lock()
.await
.write_all((s + "\n").as_bytes()) // using a simple "\n" as the messages divider
.await
.expect("can't write data to TCP socket");
}
}
pub(crate) async fn spawn(app_handle: &AppHandle) -> u32 {
info!("Spawning sidecar process");
let command = app_handle
.shell()
.sidecar("sidecar")
.expect("couldn't get sidecar executable");
let (port_tx, mut port_rx) = unbounded_channel::<u32>();
let (mut rx, mut _child) = command.spawn().expect("failed to spawn sidecar");
tauri::async_runtime::spawn(async move {
let mut port_parsed = false;
while let Some(event) = rx.recv().await {
match event {
// Receive only port number from stdout
CommandEvent::Stdout(bytes) => {
if !port_tx.is_closed() && !port_parsed {
if let Ok(port) = String::from_utf8_lossy(&bytes).trim().parse() {
port_tx.send(port).expect("unable to send port");
port_parsed = true;
continue;
}
}
// Just log the other messages
debug!("{}", String::from_utf8_lossy(&bytes).trim());
}
_ => {}
}
}
});
let port = port_rx
.recv()
.await
.expect("couldn't receive daemon port number");
port_rx.close();
port
}
pub(crate) async fn connect(port: u32) -> Client {
info!("Connecting to a sidecar at port {}", port);
let stream = TcpStream::connect(format!("127.0.0.1:{}", port))
.await
.expect("couldn't connect to socket");
let (_read_stream, write_stream) = tokio::io::split(stream);
Client {
writer: Arc::new(Mutex::new(write_stream)),
}
}
The internal API for the frontend part will have a sidecar_send
command which accepts a message as a string:
// src-tauri/src/commands.rs
use log::debug;
#[tauri::command]
pub(crate) async fn sidecar_send(
state: tauri::State<'_, crate::AppState>,
message: String,
) -> Result<(), String> {
debug!("sending message: {}", &message);
state.sidecar_client.write(message).await;
Ok(())
}
Sending messages
If I was only distributing this as a desktop app in Rust, I would use the @tauri-apps/api
package to communicate with Tauri. But since the app also works as a web version, I want to use the Tauri API directly to avoid extra dependencies. This isn’t a big deal, but you need to be careful with the types.
The Tauri API is available in the window.__TAURI__
object; you can use it to call commands, subscribe to events, create notifications, and so on:
// lib/tauri.ts
interface Window {
__TAURI__: {
core: {
invoke: (_e: string, _data?: any) => Promise<any>
}
}
}
declare const window: Window
export const isTauri = typeof window !== 'undefined' && !!window.__TAURI__
export async function sidecarSend(event: string, data: any): Promise<void> {
// We could raise an error here to make sure we do not use desktop app specific code in web
if (!isTauri) return
const { invoke } = window.__TAURI__.core
// An example message structure:
// {
// event: 'event_name',
// data: <any JSON data for event handlers defined in the sidecar>
// }
const message = JSON.stringify({ event, data })
await invoke('sidecar_send', { message })
}
Bonus: a sidecar
Let’s implement the sidecar in Go:
- Start listening on a random TCP port
- Print the port to stdout
- Handle incoming messages
- Exit when the connection closes
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var lc net.ListenConfig
listener, err := lc.Listen(ctx, "tcp", "")
if err != nil {
slog.Error("failed to start the listener", "err", err)
return
}
defer listener.Close()
addr := listener.Addr().String()
// Print the port we started the sidecar on
fmt.Println(addr[strings.LastIndex(addr, ":")+1:])
slog.Info("start server at " + addr)
done := make(chan struct{})
go func() {
conn, err := listener.Accept()
if err != nil {
slog.Error("failed to accept the connection", "err", err)
done <- struct{}{}
return
}
slog.Info("new connection", "remote_addr", conn.RemoteAddr())
// Accept commands from the desktop app
handleConnection(ctx, conn)
slog.Info("connection closed")
// We handle only one connection and should exit when the connection is closed
done <- struct{}{}
}()
select {
case <-ctx.Done():
case <-done:
}
}
type Message struct {
Event string
Data json.RawMessage
}
func handleConnection(ctx context.Context, conn net.Conn) {
defer conn.Close()
eof := make(chan struct{})
go func() {
// Let's assume we don't need to handle more than 1 message at once
sem := make(chan struct{}, 1)
// NOTE: Scan() uses "\n" as a divider. If there are problems with reading messages
// consider changing the message divider on reading: provide a different split func.
//
// scan.Split(splitFn)
//
scan := bufio.NewScanner(conn)
for scan.Scan() {
bytes := scan.Bytes()
var message Message
if err := json.Unmarshal(bytes, &message); err != nil {
slog.Error("failed to unmarshal the message from desktop", "message", bytes)
continue
}
sem <- struct{}{}
go func() {
// Define your own handler
// handle(ctx, message)
slog.Info("new message", "event", message.Event, "data", string(message.Data))
<-sem
}()
}
eof <- struct{}{}
}()
select {
case <-ctx.Done():
case <-eof:
}
}
Coming from a mostly Rails background, starting development with Tauri wasn’t especially easy. Although the project structure was well shaped (with a Next.js application, Tauri framework, and a sidecar) I had to try different approaches.
I first developed a sidecar in TypeScript and built it with pkg. Then, after finding the Node.js system not as advanced as Go’s when it comes to working with the OS, I decided to switch to the latter. Happily, using TCP sockets was a good decision and I didn’t need to rewrite everything, only the sidecar.
There are certainly things to improve, for example, with gRPC or JSON-RPC protocol on top of the TCP connection. I also have plans to run the sidecar as a fully separate process via forking, but this is a story for another day.
Ultimately, the thing I liked most about the experience was the clarity and robustness you’re rewarded with in the process of building with Tauri and learning Rust. Thanks for reading, and wishing you the same rewards ahead!
One more thing: for the full code see the example repo here!