System tray, menus, and keyboard shortcuts
Step 10 of 13. Step 9 gave rustnote its intake: imports and drag-and-drop. What the app still lacks is anything that lives outside the window. Quit the window and the whole app dies; there is no menu bar, no tray icon, no way to reach rustnote while another app is in front. This step adds the native chrome — a tray icon, a real application menu with working accelerators, close-the-window-hides-it behaviour, and a global shortcut that fires in the background. All of it is built from Rust; the front end gains one listener. It starts from tauri-ch08, the tag step 9 left behind.
Two dependencies, no capability lines
Section titled “Two dependencies, no capability lines”The tray module is gated behind a cargo feature, and global shortcuts live in a plugin. Both go into src-tauri/Cargo.toml:
[dependencies]tauri = { version = "2", features = ["tray-icon"] }tauri-plugin-opener = "2"serde = { version = "1", features = ["derive"] }serde_json = "1"rusqlite = { version = "0.37", features = ["bundled"] }tauri-plugin-fs = "2"tauri-plugin-dialog = "2"tauri-plugin-global-shortcut = "2"That is the whole dependency story. No npm package is needed — the plugin is driven from Rust — and src-tauri/capabilities/default.json does not change: the permissions file gates what the front end invokes, and every API this step calls is Rust-side. The one front-end API added below, listen(), needs core:event:default, which the scaffold’s core:default bundle already granted — the drag-and-drop listener in step 9 ran on the same permission.
Two functions own the behaviour
Section titled “Two functions own the behaviour”Three triggers — a menu item, a tray item, a global hotkey — and one reaction: show and focus the window, then tell the front end to open a blank editor. That reaction lives in two plain Rust functions, not inside any handler, because a handler is the wrong place to discover that three call sites want the same thing:
fn show_main(app: &AppHandle) { if let Some(window) = app.get_webview_window("main") { let _ = window.show(); let _ = window.set_focus(); }}
fn open_new_note(app: &AppHandle) { show_main(app); let _ = app.emit("new-note", ());}emit broadcasts a global event named new-note. What the front end does with it comes at the end of this page.
The menu bar
Section titled “The menu bar”Menus are built bottom-up: items first, then submenus, then one Menu holding them. The building happens in setup, where app implements Manager and every constructor takes it as its first argument. PredefinedMenuItem gives the OS-native entries — the ones macOS expects to find, with their standard accelerators already wired:
let app_menu = Submenu::with_items( app, "rustnote", true, &[ &PredefinedMenuItem::about(app, None, None)?, &PredefinedMenuItem::separator(app)?, &PredefinedMenuItem::hide(app, None)?, &PredefinedMenuItem::hide_others(app, None)?, &PredefinedMenuItem::separator(app)?, &PredefinedMenuItem::quit(app, None)?, ], )?; let new_note_item = MenuItem::with_id(app, "new-note", "New Note", true, Some("CmdOrCtrl+N"))?; let file_menu = Submenu::with_items( app, "File", true, &[ &new_note_item, &PredefinedMenuItem::separator(app)?, &PredefinedMenuItem::close_window(app, None)?, ], )?; let edit_menu = Submenu::with_items( app, "Edit", true, &[ &PredefinedMenuItem::undo(app, None)?, &PredefinedMenuItem::redo(app, None)?, &PredefinedMenuItem::separator(app)?, &PredefinedMenuItem::cut(app, None)?, &PredefinedMenuItem::copy(app, None)?, &PredefinedMenuItem::paste(app, None)?, &PredefinedMenuItem::select_all(app, None)?, ], )?; let menu = Menu::with_items(app, &[&app_menu, &file_menu, &edit_menu])?; app.set_menu(menu)?;MenuItem::with_id takes the manager, a string id, the label, an enabled flag, and an optional accelerator. The id is how your code recognises the item later; the label is what the user sees. On macOS the first submenu becomes the application menu — its title in the menu bar comes from the app name, not the string you passed, which is why the "rustnote" title there is not a typo for something else. The Edit submenu matters more than it looks: without it, copy and paste have no menu home and their shortcuts have nothing to attach to.
One trap worth knowing before you type a fancier accelerator than CmdOrCtrl+N: Tauri hands the string to the menu library to parse, and an unparseable string does not error — the item silently gets no accelerator at all. If a shortcut key is missing from your menu, check the spelling before you check anything else.
One handler for every menu event
Section titled “One handler for every menu event”Clicks from the application menu and from tray menus both arrive at one handler, registered once:
app.on_menu_event(|app, event| match event.id().as_ref() { "new-note" => open_new_note(app), "open" => show_main(app), "quit" => app.exit(0), _ => {} });The match is on the id string from MenuItem::with_id. _ => {} matters: every predefined item also produces events (with generated ids), and an unrecognised id should do nothing rather than crash.
The tray icon
Section titled “The tray icon”The tray gets its own menu — the same Menu machinery, reused — plus a builder:
let open_item = MenuItem::with_id(app, "open", "Open rustnote", true, None::<&str>)?; let tray_new_item = MenuItem::with_id(app, "new-note", "New Note", true, None::<&str>)?; let quit_item = MenuItem::with_id(app, "quit", "Quit rustnote", true, None::<&str>)?; let tray_menu = Menu::with_items( app, &[ &open_item, &tray_new_item, &PredefinedMenuItem::separator(app)?, &quit_item, ], )?; TrayIconBuilder::with_id("main-tray") .icon( app.default_window_icon() .expect("no default window icon") .clone(), ) .tooltip("rustnote") .menu(&tray_menu) .show_menu_on_left_click(false) .on_tray_icon_event(|tray, event| { if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = event { show_main(tray.app_handle()); } }) .build(app)?;The icon comes from the bundle the scaffold generated — default_window_icon() returns the same image the dock uses, so the tray does not need its own asset. show_menu_on_left_click(false) is deliberate: by default macOS shows the tray menu on left click, and this app wants left click to mean show the window, with the menu on right click. The tray menu’s new-note item needs no new handler — its click arrives at the same on_menu_event above, matches the same arm, and runs the same two functions. The with_id on the builder is not decoration either: app.tray_by_id("main-tray") is how other code finds the tray later.
Close hides; quit quits
Section titled “Close hides; quit quits”A tray app that dies when the window closes is a tray app for one minute. So the close button stops closing:
.plugin(tauri_plugin_global_shortcut::Builder::new().build()) .on_window_event(|window, event| { if let WindowEvent::CloseRequested { api, .. } = event { api.prevent_close(); let _ = window.hide(); } })That block is part of the builder chain, sitting next to the .plugin(...) registrations. prevent_close() tells the runtime the window may not close; hide() parks it instead. The predefined Close Window item (⌘W on macOS) routes through the same event, so it hides too — and with it, the only ways out of the app are the two Quit items you built above. In tauri dev, Ctrl-C in the terminal still kills everything, which is what you want while developing.
A shortcut that works in the background
Section titled “A shortcut that works in the background”The plugin is registered on the builder (the first line of the block above); the shortcut itself is registered in setup:
app.global_shortcut() .on_shortcut("CmdOrCtrl+Shift+N", |app, _shortcut, event| { if event.state == ShortcutState::Pressed { open_new_note(app); } })?;on_shortcut does two jobs in one call: it asks the operating system to reserve the combination, and it attaches the handler. CmdOrCtrl resolves per platform — ⌘⇧N on macOS, Ctrl+Shift+N on Windows and Linux. Registration can fail: the combination may belong to another application, or to this one twice. The second case you can reproduce by calling on_shortcut with the same string again:
Unable to register hotkey: RegisterEventHotKey failed for KeyNThat is the real error from macOS, captured on this machine — RegisterEventHotKey is the Carbon call underneath. The ? on on_shortcut means a failure like this one kills the app at startup rather than leaving a shortcut that silently does nothing. The event.state check matters because the handler fires on press and on release; without the guard, one keypress opens two empty editors.
The front end’s one listener
Section titled “The front end’s one listener”The whole front-end side of this step:
useEffect(() => { const unlisten = listen("new-note", () => newNote()); return () => { unlisten.then((stop) => stop()); }; }, []);The import is import { listen } from "@tauri-apps/api/event"; at the top of src/App.tsx. newNote is the function behind the window’s own New note button — it clears the editor for a fresh note. Same shape as the drag-and-drop subscription from step 9: the subscription resolves to an unlisten function, and the cleanup stops it when the component unmounts.
See it work
Section titled “See it work”Real menu clicks and a real hotkey need a hand on the mouse and keyboard. Everything short of that can be driven from temporary commands, the way step 9 drove its drop handler. Add two commands to src-tauri/src/lib.rs and register them in generate_handler! alongside the others:
#[tauri::command]fn probe_chrome(app: tauri::AppHandle) { eprintln!( "PROBE menu={} tray={} shortcut={}", app.menu().is_some(), app.tray_by_id("main-tray").is_some(), app.global_shortcut() .is_registered("CmdOrCtrl+Shift+N") ); open_new_note(&app); if let Some(window) = app.get_webview_window("main") { window.hide().expect("hide failed"); eprintln!( "PROBE visible after hide: {}", window.is_visible().expect("is_visible failed") ); window.show().expect("show failed"); eprintln!( "PROBE visible after show: {}", window.is_visible().expect("is_visible failed") ); }}
#[tauri::command]fn probe_receipt() { eprintln!("PROBE front end received new-note");}and one throwaway effect in src/App.tsx that listens for new-note — to invoke the second command when the event arrives — and fires the first one three seconds after mount:
useEffect(() => { const unlistenProbe = listen("new-note", () => invoke("probe_receipt")); const timer = setTimeout(() => { invoke("probe_chrome").catch((e) => console.error(String(e))); }, 3000); return () => { clearTimeout(timer); unlistenProbe.then((stop) => stop()); }; }, []);Run npm run tauri dev:
PROBE menu=true tray=true shortcut=truePROBE visible after hide: falsePROBE visible after show: truePROBE front end received new-noteLine by line: the application menu is installed, the tray icon exists under the id the builder gave it, and the operating system confirmed the ⌘⇧N registration — is_registered queries the OS, not your own bookkeeping, and it answers false for shortcuts some other application holds. The window then reported itself invisible after hide() and visible again after show() — the exact calls the close handler and the tray’s Open item make. The fourth line is the front end’s receipt: open_new_note emitted the event, the throwaway listener caught it, and its probe_receipt call printed through Rust — which means the event crossed from Rust into React and the same listener shape your real code uses fired. Delete both commands and the effect afterwards; they are scaffolding. What the probes cannot show is the part that needs eyes — the menu bar at the top of the screen, the tray icon in the corner — and that is one manual launch away.
Where the code stands
Section titled “Where the code stands”The step is tagged tauri-ch09 in the examples repository:
git clone https://github.com/hackingwithrust/examplescd examples/tauri-desktop-appgit checkout tauri-ch09npm installnpm run tauri devIf you have been following along, your tree is current: this step touched src-tauri/Cargo.toml, src-tauri/src/lib.rs, and src/App.tsx.
Step 11 renders the Markdown you have been storing as plain text — pulldown-cmark turns a note into HTML, and the webview prints it. The track overview keeps the full list current as articles land.