feat: add configurable close behavior (hide to tray / exit) with settings UI - #421
feat: add configurable close behavior (hide to tray / exit) with settings UI#421LeonardW-sl wants to merge 6 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR updates the Tauri (desktop) tray-availability logic so Linux sessions with a real, usable system tray can safely “hide to tray” on window close, while preserving the existing fail-safe behavior on desktops where the tray icon would be invisible.
Changes:
- Add a Linux-only D-Bus check (via
gdbus call org.freedesktop.DBus.NameHasOwner) to detect whetherorg.kde.StatusNotifierWatcheris present. - Stop unconditionally disabling hide-to-tray on Linux; instead, set
TRAY_AVAILABLEonly when the watcher is detected.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Thanks for tackling this — hide-to-tray on Linux has been a real gap, and splitting it into "detect the tray properly" + "let the user choose" is the right shape. The code reads well, the comments explain the why, and CI is green on all seven cells. I did a fairly deep pass and re-ran the checks locally; a few things I'd like to see addressed before this lands. Blockers1. The settings section renders in web / server / remote mode, where the command doesn't exist
The component catches, only The precedent is 300 lines above in the same file — const closeSettingsLoadable = isDesktop() && getActiveRemoteConnectionId() === null2.
|
867365a to
87aca41
Compare
|
Update: All review fixes have been pushed to this PR (commit 87aca41). The branch now contains all 3 commits with the complete implementation. Ready for review. 🚀 |
On Linux, Tauri's tray build() succeeds even when the desktop session does not provide a StatusNotifierWatcher (notably GNOME 45+ without an AppIndicator extension). In that case the tray icon is silently invisible and hiding the main window would leave the user with no way to recover it. Previously codeg avoided this by unconditionally returning false from can_hide_to_tray() on Linux, which prevented hide-to-tray even on KDE, XFCE, Cinnamon, Budgie, and GNOME-with-AppIndicator — all of which have a working tray. Fix: detect the actual tray availability at install_tray_icon() time by querying D-Bus for org.kde.StatusNotifierWatcher. Only set TRAY_AVAILABLE when the service is present, so the close handler hides the window on fully capable desktops and exits otherwise.
…ings UI Add a new setting to let users choose what happens when the main window close button is clicked: - Hide to tray (background) — default. Window hides to system tray if available; falls back to exit if no tray is present. - Exit application — always exits on close, regardless of tray. Backend changes: - New CloseAction enum + SystemCloseSettings model - load_system_close_settings/get_system_close_settings/update_system_close_settings - Close button handler reads stored setting instead of checking can_hide_to_tray() alone Frontend changes: - CloseBehaviorSettings component with radio-button UI - Integrated into GeneralSettings page - All 10 locales updated with 4 new strings - 3 new unit tests covering load, save, and save-failure revert All new items gated behind tauri-runtime feature to keep sidecar builds clean. 3482 existing tests pass, no lint warnings.
… cache Address three blocking issues from code review: 1. **Tray probe is advisory-only**: Split tray detection into hard fact (TRAY_AVAILABLE, set after install_tray_icon succeeds) vs best-effort guess (tray_probably_visible, used only for defaults and UI hints). An explicit HideToTray choice is always honored regardless of probe result. The probe prevents a misleading default but never overrides user intent. 2. **Linux defaults to Exit**: Platform-dependent CloseAction::default() returns Exit on Linux, HideToTray on Windows/macOS. Linux tray support is the least predictable of the three — defaulting to hide-to-tray would silently turn "I closed the app" into "the app won't quit" on upgrade, so Linux users opt in instead. 3. **Atomic cache instead of preferences.json**: Added CACHED_CLOSE_ACTION static and prime_close_settings_cache() to avoid block_on DB read on GUI event-loop thread. Rejected preferences.json approach (only exists for pre-runtime WebView2 flags; this setting has no such timing constraint). Additional changes: - Made zbus optional and feature-gated (Linux + tauri-runtime only) - Linux tray probe uses zbus blocking API with 2s timeout on detached thread - Rewrote close-behavior-settings.tsx on shared settings UI grammar - Restored trailing newlines in all i18n message files - Fixed punctuation and terminology consistency across 10 locales
87aca41 to
0e067a3
Compare
|
@xintaofei The refreshed CI is 6/7 green, but I attempted to cancel the workflow, but fork contributors do not have permission to manage Actions runs in the upstream repository ( |
|
Re-reviewed at What's fixed
Also unprompted and appreciated: the models are Remaining — all in
|
|
Thanks for the PR contribution; I am currently optimizing the issue mentioned above. |
…probe `linux_status_notifier_host_registered` completed into a terminal `ProbeState::Complete`, so the first answer was reused for the rest of the process — while its own doc comment, the one on `get_system_close_settings` and the one in `close-behavior-settings.tsx` all described a per-read probe. Installing the GNOME AppIndicator extension or restarting a panel therefore left the "no system tray was detected" hint stuck until the app relaunched. A worker that never published was worse. `.spawn(…).ok()` discarded the spawn error and the timeout path left the slot `Running`, so every later call waited out the deadline and answered `false` for the rest of the session. Both waits used `wait_timeout` with no predicate, so a spurious wakeup read as a timeout, and an answer published between unlocking and waiting was missed entirely. Replace it with `SingleFlightProbe`: an answer is reused for `TRAY_PROBE_TTL` and then re-probed, concurrent callers share one worker, and every give-up path — waiter timeout, runner timeout, failed spawn — hands the slot back so the next call starts fresh. A `generation` counter tracks slot ownership: a worker publishes only while it still owns the slot, so an abandoned worker's late answer cannot overwrite the one that replaced it, and a caller whose own worker was already replaced cannot evict that replacement. The two clocks are kept apart. A caller waiting on someone else's worker is bounded by its own budget and releases nothing when that budget expires, since the worker may still be healthy; only a worker past its *own* deadline is released. A caller running the probe waits on that worker's deadline rather than its entry-relative budget, which expires a few microseconds earlier and would otherwise leave a worker already known to be dead for the next caller to discover, costing that caller a wasted `false`. The type compiles on Linux and in test builds on every target, so the state machine is covered by unit tests on all CI cells rather than only the one that compiles the D-Bus call.
`get_system_close_settings` and `update_system_close_settings` are async commands, which Tauri drives on the Tokio runtime, and both called the synchronous `tray_probably_visible` directly — parking a worker thread for as long as the session-bus probe takes. Route it through `spawn_blocking`. A probe that panics reports "no tray", the same as one that fails.
|
Pushed two commits to this branch ( What changed
Also corrected the doc lines that no longer matched: A subtlety worth flagging, since it's the least obvious part of the diff: a caller's own budget and its worker's deadline are different clocks. A caller waiting on someone else's worker must not release it when its own budget runs out — the worker may be perfectly healthy, and evicting it throws away a good probe. But a caller that runs the probe has to wait on its own worker's deadline, not its entry-relative budget: the budget starts a few microseconds earlier, so it would give up just before its worker was formally overdue, decline to release it, and leave a worker already known to be dead for the next caller to trip over. That cost the next call a wasted Tests. I checked each one is load-bearing by regressing the fix and confirming failures — removing both recovery paths fails 3, removing the generation guard fails 1, removing the worker-deadline check fails 2. Verified: full The Linux-gated code still isn't compiled on macOS, so I lifted it verbatim into a scratch crate pinned to the exact locked versions ( |
Problem
On Linux (and other platforms), clicking the main window's close button always exits the entire application. There is no way to minimize to the system tray and keep the app running in the background.
The existing
can_hide_to_tray()check was already able to detect tray availability, but the close button simply checkedcan_hide_to_tray()without consulting any user preference — if the tray was available, it always hid; if not, it always exited. There was no UI for the user to choose their preferred behavior.Solution
Add a configurable close-behavior setting with two options:
Changes
Backend (Rust):
models/system.rs: NewCloseActionenum (HideToTray/Exit) andSystemCloseSettingsstruct, persisted viaapp_metadata_service.commands/system_settings.rs:load_system_close_settings,get_system_close_settings,update_system_close_settings— all gated behindtauri-runtimeto avoid dead_code warnings in sidecar builds.lib.rs: Close button handler reads the stored setting and usesCloseAction::HideToTray && can_hide_to_tray()instead ofcan_hide_to_tray()alone.Frontend (TypeScript/React):
lib/types.ts:CloseActiontype andSystemCloseSettingsinterface.lib/api.ts:getSystemCloseSettings()/updateSystemCloseSettings()transport wrappers.components/settings/close-behavior-settings.tsx: Radio-button UI with loading/saving states and error toast.components/settings/general-settings.tsx: Integrates the new section.i18n/messages/*.json: All 10 locales updated with the 4 new strings.Testing
cargo build --no-default-features --bin codeg-mcp).cargo build --release --bin codeg).can_hide_to_tray()returns false, so both settings exit the app (no stranded process).