Building a system tray app with Tauri

Maybe you want to create an app that exclusively lives in system-tray, or you want to add a custom menu to an existing app, fortunately, Tauri makes this easy.

Setting up

Start off with initializing a new tauri project, or you can follow along with your own project.

Start by opening your src-tauri/Cargo.toml and add the "system-tray" feature flag to tauri, then add tauri-plugin-positioner crate to our dependencies, also with a "system-tray" flag.

tauri = { version = "1.5", features = ["shell-open", "system-tray"] }
tauri-plugin-positioner = {version = "1.0", features = ["system-tray"]}

Now, open your tauri.conf.json file, and we'll remove the default window that comes with our tauri app, and have our app run exclusively in the system tray. To do that, just empty the "windows" array in the json. Next we'll add our "systemTray" block to set the icon.

"windows": [],
"systemTray": {
"iconPath": "icons/icon.png",
"iconAsTemplate": true
}

Adding the tray

Now that we have that setup, we can dive into our rust code. In main.rs in the main function, we can instantiate our system tray.

use tauri::SystemTray;
fn main() {
let tray = SystemTray::new();
tauri::Builder::default()
.system_tray(tray)
// ...
}

At this point we can go ahead and run our build using cargo tauri dev or npm run tauri dev. It should build, and we should see the tauri logo in our system tray. (maybe under the carrot in windows)

Building the window

Now that we have the tray icon in place, let's get our window built.
In the tauri::Builder chain call .on_system_tray_event() to handle when a user left-clicks our tray icon.

.on_system_tray_event(|app, event|
match event {
SystemTrayEvent::LeftClick { .. } => {
let window =
WindowBuilder::new(
app,
"tray",
tauri::WindowUrl::App("index.html".into())
)
.inner_size(450 as f64, 600 as f64)
.decorations(false)
.focused(true)
.always_on_top(true)
.build();
}
_ => {}
}
})

This will build a new window, and will render our index.html page inside, next we set the window's size, setting decorations to false will remove the window's borders and bars, then we focus and force the window to stay on top.

If we run this and click on our icon the window will be created, but it'll be in the middle of our screen, and we won't be able to close it.

Positioning the window

To get the window in the right spot, we'll use the tauri-plugin-positioner crate we added earlier. To initialize it call tauri_plugin_positioner::init().

tauri::Builder::default()
.plugin(tauri_plugin_positioner::init())
...

Next add tauri_plugin_positioner::on_tray_event(app, &event); before the match statement.

.on_system_tray_event(|app, event| {
tauri_plugin_positioner::on_tray_event(app, &event);
match event {
...

Then after we built the window we can move it to our tray.

if let Ok(window) = window {
let _ = window.move_window(Position::TrayCenter);
}

Now our window will open in the proper spot near our system tray icon.

Hiding and showing the window

Now that we have the window in the right spot, we still need to hide and show the window when we click on the icon again, or if we lose focus on the window.

Let's start by toggling when we click on the icon, by adding this code to our left click handler.

if let Some(tray) = app.get_window("tray") {
if tray.is_visible().is_ok_and(|is_visible| is_visible) {
let _ = tray.hide();
} else {
let _ = tray.set_focus();
}
} else {
// the code we had before.
}

Here we get the window if it exists, then we check if it is visible, and hide or show accordingly, then we wrap our previous code in the else block. We use set_focus() instead of show(), because the show function will only bring the window up and not force it to be focused, this will be important later.

Now we can toggle the visibility by clicking our icon again.

Finally we can hide the window when it loses focus by adding a on_window_event() handler after our move_window() inside the if statement.

let window_handle = window.clone();
window.on_window_event(move |event| match event {
WindowEvent::Focused(focused) if !focused => {
let _ = window_handle.hide();
}
_ => {}
});

We start by cloning our window handle, then we call on_window_event() and move our cloned handle into it, and create a match statement to handle the Focused event, and we use a match guard to only handle when the window loses focus to call hide() on our window.

Now we have just about everything we want, we have a system tray icon that will open a window near it when clicked, and will hide when the window loses focus, and toggle when the icon is clicked again.

Hide dock/taskbar icon

We still have one problem, our tauri icon still appears in the dock/taskbar. To fix this is different on Mac and Windows/Linux.

Hiding dock icon on Mac

To hide the dock icon on Mac we need to set the activation policy to accessory.

First, replace .run(tauri::generate_context!()) with .build(...) and assign it to a mutable variable called app. Then under the #[cfg(target_os = "macos")] macro set our activation policy tauri::ActivationPolicy::Accessory. Finally, we'll run the app with an empty event handler.

let mut app = tauri::Builder::default()
...
.build(tauri::generate_context!())
.expect("error building the tauri application");
 
// keeps the app out of the dock on mac
#[cfg(target_os = "macos")]
app.set_activation_policy(tauri::ActivationPolicy::Accessory);
app.run(|_app_handle, _event| {});

Hiding the taskbar icon on Windows/Linux

All we need to do to hide the taskbar icon on windows or Linux is to add .skip_taskbar(true) to our window builder.

Now we have all the pieces in place for a stellar system tray app, we learned how to add our app to the tray, handle the click event, build a new window, position it using a plugin, toggle its visibility, and hide the dock icon. From here, the rest is up to you.