Skip to content

feat: add configurable close behavior (hide to tray / exit) with settings UI - #421

Open
LeonardW-sl wants to merge 2 commits into
xintaofei:mainfrom
LeonardW-sl:fix/linux-tray-hide-on-close
Open

feat: add configurable close behavior (hide to tray / exit) with settings UI#421
LeonardW-sl wants to merge 2 commits into
xintaofei:mainfrom
LeonardW-sl:fix/linux-tray-hide-on-close

Conversation

@LeonardW-sl

@LeonardW-sl LeonardW-sl commented Aug 7, 2026

Copy link
Copy Markdown

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 checked can_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:

  1. Hide to tray (background) — default. When the close button is clicked and tray is available, the window hides to the system tray. The app keeps running, and the tray icon restores the window. When tray is not available (e.g., GNOME 45+ without AppIndicator), this falls back to exiting.
  2. Exit application — always exits the app on close button click, regardless of tray availability.

Changes

Backend (Rust):

  • models/system.rs: New CloseAction enum (HideToTray / Exit) and SystemCloseSettings struct, persisted via app_metadata_service.
  • commands/system_settings.rs: load_system_close_settings, get_system_close_settings, update_system_close_settings — all gated behind tauri-runtime to avoid dead_code warnings in sidecar builds.
  • lib.rs: Close button handler reads the stored setting and uses CloseAction::HideToTray && can_hide_to_tray() instead of can_hide_to_tray() alone.

Frontend (TypeScript/React):

  • lib/types.ts: CloseAction type and SystemCloseSettings interface.
  • 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

  • 3,479 existing frontend tests pass (no regression).
  • Sidecar compiles cleanly (cargo build --no-default-features --bin codeg-mcp).
  • Main binary compiles cleanly (cargo build --release --bin codeg).
  • Setting persists across app restarts.
  • "Hide to tray" → close button hides window; tray icon restores it.
  • "Exit" → close button exits the app.
  • Default is "Hide to tray" (backward-compatible with existing behavior on tray-capable platforms).
  • Linux without tray: can_hide_to_tray() returns false, so both settings exit the app (no stranded process).

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.
Copilot AI lite review requested due to automatic review settings August 7, 2026 00:52

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 whether org.kde.StatusNotifierWatcher is present.
  • Stop unconditionally disabling hide-to-tray on Linux; instead, set TRAY_AVAILABLE only when the watcher is detected.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

…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.
@LeonardW-sl LeonardW-sl changed the title fix(linux): allow hide-to-tray when StatusNotifierWatcher is available feat: add configurable close behavior (hide to tray / exit) with settings UI Aug 7, 2026
@xintaofei

Copy link
Copy Markdown
Owner

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.

Blockers

1. The settings section renders in web / server / remote mode, where the command doesn't exist

get_system_close_settings / update_system_close_settings are #[cfg(feature = "tauri-runtime")] Tauri commands, and no Axum route was added in web/router.rs. But <CloseBehaviorSettings /> is mounted unconditionally at general-settings.tsx:392, so both non-local transports break:

  • browser / Docker → WebTransport POSTs /api/get_system_close_settingsapi_not_found returns 501!res.ok → throws.
  • remote-workspace window on desktop → RemoteDesktopTransportinvoke("remote_http_call") → same 501 back through the Rust proxy.

The component catches, only console.errors, then finally { setLoading(false) } renders anyway — so the user sees a hardcoded "Hide to tray" presented as if it were the stored value. Switching to "Exit" then 501s into an error toast. It's also meaningless in a browser: no OS close button, no tray.

The precedent is 300 lines above in the same file — general-settings.tsx:69-75 documents this exact rule for the rendering section:

const closeSettingsLoadable = isDesktop() && getActiveRemoteConnectionId() === null

2. NameHasOwner("org.kde.StatusNotifierWatcher") isn't a reliable signal for "the tray is usable"

Three separate failure modes:

False positive → strands the user, which is the exact bug this PR exists to prevent. A watcher can be registered with no host displaying items. The spec puts that on a separate read-only boolean, IsStatusNotifierHostRegistered (with a matching StatusNotifierHostRegistered signal) — NameHasOwner can't see it. Probe says "available", the icon is invisible, window.hide() loses the workspace.

False negative → hide-to-tray stays off where it actually works. The chain here is Tauri 2.10 → tray-iconlibappindicator-rs, which loads libayatana-appindicator3.so.1 (what CI installs). That library watches the bus name and falls back to a legacy GtkStatusIcon when it vanishes — name_vanished_handlerstart_fallback_timerfallback_timer_expiregtk_status_icon_new(), reversed by unfallback in register_service_cb. On an XFCE/MATE/legacy-X11 tray with no watcher, the icon is visibly working and the probe still says no.

Stale snapshot. TRAY_AVAILABLE is written once at startup. The library tracks the watcher appearing and vanishing dynamically; this flag never does. Enable the AppIndicator extension, or restart the panel, and the app disagrees with reality until relaunch.

Credit where due — the motivating case is handled correctly. Plain GNOME 45 ships no StatusNotifierWatcher and doesn't instantiate its dormant legacy tray manager either, so neither path would render the icon and the probe's false is right.

On fixes: switching to IsStatusNotifierHostRegistered is a partial fix — it closes the false positive but not the false negative, since with no watcher at all you'd still report unavailable while Ayatana's GtkStatusIcon is happily visible. That case needs its own decision (detect the legacy tray too, or accept and document it). Likewise NameOwnerChanged won't catch host-registration changes while the watcher owner stays the same — probing lazily at close time is probably the simpler robust answer than any startup snapshot.

Two smaller things in the same function:

  • It shells out to gdbus, which isn't guaranteed installed — on Debian/Ubuntu it lives in libglib2.0-bin, a separate package from the glib runtime GTK pulls in. Missing binary → .unwrap_or(false) → hide-to-tray permanently off on a machine where the tray works fine.
  • .output() has no timeout. gdbus call carries GLib's ~25s default D-Bus timeout with no process-level bound on top, and install_tray_icon is on the startup path (lib.rs:349), so an unresponsive session bus delays launch.

zbus is already in the lockfile at 5.13.2 (transitively, via the Tauri plugins), so a native call would reuse a version already vetted here — it'd still need an explicit Linux-only Cargo.toml entry.

3. Choosing "Hide to tray" silently falls back to Exit when the tray isn't usable

should_hide = action == HideToTray && can_hide_to_tray(). When the probe says no, the radio still shows "Hide to tray" selected and the value still persists — but closing exits the app, and the only signal is a tracing::warn! in the log. For the feature's one control, that's precisely the confusion it was added to remove. Worth exposing can_hide_to_tray() to the frontend and either disabling the option or showing an inline hint ("System tray unavailable in this session — the app will exit on close").

Should fix

Blocking DB read on the main event-loop thread, on every close. tauri::async_runtime::block_on(load_system_close_settings(&db.conn)) inside CloseRequested. No nested-runtime panic risk — that callback runs on the GUI event-loop thread, not a tokio worker, and block_on is already used elsewhere in this file. And with WAL + max_connections(5), readers don't block on writers, so this is not a common stall. But a persistent SQLite busy condition can hold the main event loop for roughly five seconds (busy_timeout=5000 is a retry budget, not a fixed delay, and can overshoot slightly), and pool acquisition adds its own connect_timeout(10s) on top — for a value that never changes between reads. Two cheaper options already exist in-repo: cache it in an atomic like TRAY_AVAILABLE, loaded at setup right next to the existing block_on(load_system_language_settings(...)) at lib.rs:344 and refreshed by the update command; or store it in preferences.json the way SystemRenderingSettings does.

The default flips Linux behavior without opt-in. Before this PR, close on Linux always exited. Now, for Linux users whose tray builds and whose probe succeeds, the default becomes hide-to-tray. That's the PR's intent, but it's a silent change and "the app won't quit" is a classic bug report. Worth considering Exit as the Linux default — the setting makes it a one-click change now — or at least a release note.

No Rust tests. load_system_close_settings (missing key → default, malformed JSON → error) and the update round-trip aren't covered; only the React component is.

Please also drop PR_BODY.md — the PR description got committed as a tracked file at the repo root. Pure hygiene, not a correctness issue, but it shouldn't land.

Nits

  • All 10 src/i18n/messages/*.json lost their trailing newline (they have one on main) — looks like a json.load / json.dump round-trip.
  • The new closeActionSaveFailed drops the space / full-width colon before {message} in all 9 non-English locales, unlike every existing *SaveFailed sibling: zh-CN 保存关闭行为设置失败:{message} vs 保存终端设置失败:{message}; ja …に失敗:{message} vs …に失敗しました: {message}; fr also uses a straight ' where the file uses .
  • The tDynamic cast in close-behavior-settings.tsx isn't needed and defeats i18n type-checking — global.d.ts declares Messages: typeof enMessages, so t("closeActionSaveFailed", { message }) compiles directly (I checked: tsc --noEmit is clean without the cast). The cast in general-settings.tsx exists only because the backend-driven shell label keys are genuinely dynamic.
  • The close handler drops the load error entirely (.transpose().ok().flatten()…unwrap_or_default()), so someone who chose "Exit" would silently get hide-on-close for that attempt. The persisted value is untouched and the setup-time locale load does the same thing, so it's minor — but a tracing::warn! costs nothing.
  • models/mod.rs:54 is 105 chars, over rustfmt's 100 default. Cosmetic; CI doesn't run cargo fmt --check and the file already has unrelated drift.
  • Native <input type="radio"> rather than the repo's components/ui/radio-group.tsx — native radios ignore the theme tokens. Mitigated by the same file already using a raw checkbox for the rendering toggle.

What I verified

CI is green on all 7 cells for 867365a9. I also re-ran locally at the PR head: eslint, tsc --noEmit (worth noting pnpm build alone doesn't typecheck tests), the new close-behavior-settings tests 3/3, next build static export, cargo clippy --all-targets --features test-utils -- -D warnings (macOS desktop), and cargo clippy --no-default-features --bin codeg-server --lib -- -D warnings — all pass.

Checked and clean: the #[cfg]-on-if/else form is valid and the ubuntu desktop cell compiles it; #[serde(rename_all = "snake_case")] matches the TS "hide_to_tray" | "exit" mirror exactly; the ungated CloseAction / SystemCloseSettings re-exports from models/mod.rs are harmless (pub in a lib target, and server clippy passes); macOS and Windows keep their previous TRAY_AVAILABLE.store(true) behavior.


Overall: good direction and a genuinely useful feature — the main things standing between this and merge are (1) gating the section off the local-desktop transport and (2) making the tray-availability signal trustworthy, since everything else in the feature hangs off it. Happy to look again once those are in.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants