--- name: tauri-app description: "Use when building or fixing a Tauri 2 desktop app: the main window, close-to-tray and full screen on macOS, the tray or menu bar icon and its popover, popovers over full-screen apps, Dock and activation policy, global shortcuts and hotkey recording, window focus in the webview, single instance, autostart, window state, updater, dev versus release builds, icons, a bundled CLI, and per-platform differences between macOS, Windows and Linux." --- # Tauri 2 desktop apps Tauri gives you windows and a tray, but the native details that make a desktop app feel right are easy to get wrong, and they fail in ways no unit test catches. The rules below come from bugs that reached users more than once. Read the matching section before touching window, tray or popover code, and use [references/macos-native.md](references/macos-native.md) for the exact code. Read the installed crate source (`~/.cargo/registry/src/*/tauri-2.*`, `tao`, `tray-icon`, the plugin crates) before relying on a remembered API; see `source-driven-development`. ## Hard rules - Tauri stays on 2.x. Rust crates are exact-pinned (`=x.y.z`). Each `@tauri-apps/plugin-*` npm package matches its crate's major.minor. - Agents never run `tauri dev`, `tauri build`, the app, or a browser. The user runs them. - A desktop fix is "fixed in source, not yet tried in the app" until the user confirms it. Every report ends with the exact manual steps that reproduce the original symptom (see [Manual check](#manual-check)). - Native code (`objc2`, `block2`, `objc2-app-kit`, `windows-sys`) only for what Tauri and its plugins do not expose. Add a crate only with the user's approval for that exact pin; prefer a version already in `Cargo.lock` through Tauri so the lockfile gains only the direct entry. - Guard platform code with `#[cfg(target_os = "...")]` and keep a no-op twin for the other platforms, so every target still compiles. - Never solve a native timing problem with a sleep, a timer or a "quiet period" after resize events. Wait for the platform's own event. ## macOS: closing a full-screen window (black screen) Symptom: the main window hides on close (the app lives on in the tray), and closing it while it is full screen leaves an empty black Space, or the window drops back to windowed and stays open. Cause: hiding a window during, or instead of, the full-screen exit leaves its Space behind; hiding mid-transition makes AppKit show the window again when the transition ends. Fix, the only one that held up: 1. In `WindowEvent::CloseRequested`, call `api.prevent_close()`, then a `hide_main_window(window)` helper. 2. If `window.is_fullscreen()` is true on macOS, register a one-shot observer for `NSWindowDidExitFullScreenNotification` on that `NSWindow`, then call `window.set_fullscreen(false)`. 3. In the observer, remove it, then hide the window through `run_on_main_thread` so AppKit finishes the transition first. 4. `show_main_window` (tray item, Dock click, single-instance relaunch, shortcut) cancels a pending observer before showing, so a window reopened mid-transition stays shown. This needs `objc2` and `block2` as direct macOS-only dependencies, pinned to the versions already in `Cargo.lock`. What does not work: `is_fullscreen()` after `set_fullscreen(false)` (the flag flips before the animation ends), `Resized` events (they fire mid-animation), and any fixed delay. Code: [references/macos-native.md](references/macos-native.md#full-screen-close). ## macOS: the popover does not show over a full-screen app Symptom: clicking the tray icon while another app is full screen does nothing, or switches back to the desktop first. Cause: the popover is an ordinary `NSWindow`. Showing and focusing it activates the app, and macOS leaves the full-screen Space to show it. `visibleOnAllWorkspaces` only sets `CanJoinAllSpaces`, not `FullScreenAuxiliary`, and a higher window level alone does not stop the activation. Fix: make the popover a non-activating panel with `tauri-nspanel` (macOS-only dependency): - Convert the window once at startup (`to_panel`), with `can_become_key_window` and `is_floating_panel`. - Level `PanelLevel::Status`; collection behavior `can_join_all_spaces`, `full_screen_auxiliary`, `stationary`, `ignores_cycle`; style mask `NonactivatingPanel`. - Show with `show_and_make_key()`, never `window.show()` plus `set_focus()`. Hide with `panel.hide()`. Read visibility from the panel. - Register `tauri_nspanel::init()` behind `#[cfg(target_os = "macos")]`. Setting the collection behavior and level by hand with `objc2-app-kit` while still calling `set_focus()` on a normal window is the half fix that keeps failing for apps with a Dock icon. Code: [references/macos-native.md](references/macos-native.md#popover-panel). ## Tray popover placement Symptom: the popover opens on top of the menu bar icon and covers it. Cause: on macOS the tray rect's position is the icon's top edge, and `tauri-plugin-positioner`'s `TrayBottomCenter` treats it as the bottom. Fix per platform: | Platform | Placement | | -------- | ----------------------------------------------------------------------------------------------------------------------- | | macOS | Your own `below_tray`: center on the icon, `icon.y + icon.height + gap`, clamp to the monitor's `work_area()` | | Windows | `move_window_constrained(Position::TrayCenter)`: above the taskbar icon | | Linux | `Position::TopRight`: Linux trays report no clicks or rect | - Store the latest tray rect from `Click`, `Enter`, `Move` and `Leave` events, and still call `tauri_plugin_positioner::on_tray_event` for the plugin. - A global shortcut can open the popover before any tray event: fall back to `TopRight`. - Do the math in physical pixels with the window's `scale_factor()`. ## Popover behavior - Hide on `WindowEvent::Focused(false)` for the popover label, so it acts like a menu. - Clicking the tray icon while the popover is open blurs it first (hide), then delivers the click (show again). Record the hide time and ignore a toggle within about 250 ms of a blur-hide. - Tray icons use `.show_menu_on_left_click(false)`: left click toggles the popover, right click opens the menu. - The popover window: `decorations(false)`, `skip_taskbar(true)`, `visible(false)`, `resizable(false)`, and a `title`, because screen readers name an undecorated window by its title. - Transparent or frosted windows on macOS need the `macos-private-api` feature and `"macOSPrivateApi": true`. Windows and Linux get a solid background instead. - Keep popovers out of `tauri-plugin-window-state` (`with_denylist`), and drop `StateFlags::VISIBLE` so a restored state never shows a hidden window. ## Tray and menu bar icons - macOS: black-only template PNGs, `icon_as_template(true)`. Re-assert it after every `set_icon`. Show state by shape (a dot, a badge, a slash), never by color. - Size: an 18 pt canvas (18 px and 36 px @2x) with the glyph filling the height edge to edge. A glyph with padding looks smaller than its neighbors; users notice. - Windows and Linux: color PNGs that read on light and dark taskbars, at 16, 20, 24 and 32 px. - `set_title` (text next to the icon) works on macOS and Linux only. On Windows, put the same text in the tooltip. - Linux trays report no clicks, and some desktops have no tray at all: the menu starts with "Open ", and a second launch (single instance) shows the window. - Icons embedded with `include_bytes!` or `include_image!` need a rebuild to change. Generate the app icon set with `tauri icon` from one master; never hand-edit generated icons. ## Dock, close and relaunch - Menu bar only app: `app.set_activation_policy(ActivationPolicy::Accessory)` in `setup` (no Dock icon, no Cmd+Tab entry). - App with a main window: keep the Dock icon. Close hides (`prevent_close`, then the full-screen aware hide above); `RunEvent::Reopen` (Dock click) calls `show_main_window`. - `show_main_window`: cancel any pending full-screen hide, `unminimize`, `show`, `set_focus`. - `tauri-plugin-single-instance` is registered first, and its callback calls `show_main_window`. - Quit from the tray stops your background work, then `app.exit(0)`. Install a downloaded update on `RunEvent::Exit`. ## Global shortcuts and hotkey fields - Default to `Alt+Space` on macOS and `Ctrl+Alt+Space` elsewhere: on Windows `Alt+Space` opens the window menu. - Registration can fail (another app owns the combo; Wayland may not support global shortcuts). Log it and keep the tray path working; never fail `setup` over it. - A hotkey setting is a recorder, not a text field: click, show "Press keys...", save on the first non-modifier key, cancel on Esc or blur. Use `react-hotkeys-hook`'s `useRecordHotkeys` and map to a Tauri accelerator (`Cmd` on macOS, `Super` elsewhere, fixed modifier order). - While recording, the currently registered global shortcut still fires; pause it during recording or say so. ## Webview and pages - App origins: `tauri://localhost` on macOS and Linux, `http://tauri.localhost` on Windows, and the dev server only in debug builds. Allow exactly these in `on_navigation`; send `http`, `https` and `mailto` to the browser with `tauri-plugin-opener`, and answer `on_new_window` with `Deny`. Anything loaded in a window gets that window's IPC permissions. - Focus: WKWebView does not reliably fire `focus` or `visibilitychange` when the window comes back. Listen to `getCurrentWindow().onFocusChanged` too, and feed it to TanStack Query's `focusManager`. - "Load failed" in the webview is WebKit's message for a network or CORS failure: check that the API is running and that CORS allows the app origin. - CSP: include `ipc: http://ipc.localhost` in `connect-src`. - Open devtools only under `#[cfg(debug_assertions)]`. ## Dev versus release - Register `tauri-plugin-updater` and any OTA web-asset updater only under `#[cfg(not(debug_assertions))]`: `tauri dev` runs from `target/debug`, and an update installed there overwrites the build. - A separate dev identity (`tauri.development.conf.json` with its own `productName` and `identifier`) keeps dev and installed apps apart (keychain items, autostart, single instance). - Release secrets (Apple ID app-specific password, signing certificate, updater key) belong to the user and CI. A notarization 401 means the app-specific password, not the Apple ID password. ## A CLI in the same binary - Windows: a console-subsystem binary opens a console when started from the desktop; detach with `FreeConsole` when the process is the console's only user. - Put the CLI on `PATH` from the installed app, once: macOS symlink in `/usr/local/bin` or `/opt/homebrew/bin` (one administrator prompt if neither is writable), Linux AppImage link in `~/.local/bin`, Windows the install folder in the user `Path` (registry, keeping `%VAR%` entries). Skip this in dev builds, and never overwrite a file that is not your own link. ## Validation 1. `cargo fmt --check` and `cargo clippy --all-targets -- -D warnings` in `src-tauri` (or the repository's desktop check script), and `cargo test`. 2. Type-check the other platforms' `cfg` code with `cargo check --target ` when that target is installed; say so when it is not. 3. The repository's frontend gate (`bun run check`). 4. Hand the user the manual check below for every symptom the change touches. ## Manual check Give the user numbered steps for each touched behavior, on each platform that changed: - Full screen close: full screen the main window, close it (red button and Cmd+W), expect the desktop with no black Space; reopen from the tray and the Dock, expect a normal window. - Reopen during exit: close in full screen and immediately reopen from the tray; it stays shown. - Popover over full screen: put another app in full screen, click the tray icon, expect the popover over it without a Space switch; click away, expect it to hide. - Placement: the popover sits below the icon with a small gap, never over it; near the screen edge it stays on screen; on Windows it sits above the taskbar. - Toggle: click the icon twice; it opens, then closes, without flicker. - Icon: light and dark menu bar, every state, size next to the neighbors. - Shortcut: works from another app; recording a new one captures keys.