Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions PR_BODY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
## 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

- [x] 3,479 existing frontend tests pass (no regression).
- [x] Sidecar compiles cleanly (`cargo build --no-default-features --bin codeg-mcp`).
- [x] Main binary compiles cleanly (`cargo build --release --bin codeg`).
- [x] Setting persists across app restarts.
- [x] "Hide to tray" → close button hides window; tray icon restores it.
- [x] "Exit" → close button exits the app.
- [x] Default is "Hide to tray" (backward-compatible with existing behavior on tray-capable platforms).
- [x] Linux without tray: `can_hide_to_tray()` returns false, so both settings exit the app (no stranded process).
48 changes: 48 additions & 0 deletions src-tauri/src/commands/system_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ use crate::db::service::app_metadata_service;
use crate::db::AppDatabase;
#[cfg(feature = "tauri-runtime")]
use crate::models::SystemRenderingSettings;
#[cfg(feature = "tauri-runtime")]
use crate::models::SystemCloseSettings;
use crate::models::{
AvailableTerminalShells, SystemLanguageSettings, SystemProxySettings, SystemTerminalSettings,
TerminalShellOption,
Expand All @@ -21,6 +23,8 @@ use crate::terminal::manager::resolve_shell;
pub(crate) const SYSTEM_PROXY_SETTINGS_KEY: &str = "system_proxy_settings";
pub(crate) const SYSTEM_LANGUAGE_SETTINGS_KEY: &str = "system_language_settings";
pub(crate) const SYSTEM_TERMINAL_SETTINGS_KEY: &str = "system_terminal_settings";
#[cfg(feature = "tauri-runtime")]
pub(crate) const SYSTEM_CLOSE_SETTINGS_KEY: &str = "system_close_settings";
pub(crate) const LANGUAGE_SETTINGS_UPDATED_EVENT: &str = "app://language-settings-updated";
pub(crate) const TERMINAL_SETTINGS_UPDATED_EVENT: &str = "app://terminal-settings-updated";

Expand Down Expand Up @@ -210,6 +214,24 @@ pub(crate) async fn load_system_terminal_settings(
Ok(normalize_terminal_settings(parsed))
}

#[cfg(feature = "tauri-runtime")]
pub(crate) async fn load_system_close_settings(
conn: &DatabaseConnection,
) -> Result<SystemCloseSettings, AppCommandError> {
let raw = app_metadata_service::get_value(conn, SYSTEM_CLOSE_SETTINGS_KEY)
.await
.map_err(AppCommandError::from)?;

let Some(raw) = raw else {
return Ok(SystemCloseSettings::default());
};

serde_json::from_str::<SystemCloseSettings>(&raw).map_err(|e| {
AppCommandError::configuration_invalid("Failed to parse stored close settings")
.with_detail(e.to_string())
})
}

#[cfg(feature = "tauri-runtime")]
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn get_system_proxy_settings(
Expand Down Expand Up @@ -254,6 +276,32 @@ pub async fn get_system_terminal_settings(
load_system_terminal_settings(&db.conn).await
}

#[cfg(feature = "tauri-runtime")]
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn get_system_close_settings(
db: State<'_, AppDatabase>,
) -> Result<SystemCloseSettings, AppCommandError> {
load_system_close_settings(&db.conn).await
}

#[cfg(feature = "tauri-runtime")]
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn update_system_close_settings(
settings: SystemCloseSettings,
db: State<'_, AppDatabase>,
) -> Result<SystemCloseSettings, AppCommandError> {
let serialized = serde_json::to_string(&settings).map_err(|e| {
AppCommandError::invalid_input("Failed to serialize close settings")
.with_detail(e.to_string())
})?;

app_metadata_service::upsert_value(&db.conn, SYSTEM_CLOSE_SETTINGS_KEY, &serialized)
.await
.map_err(AppCommandError::from)?;

Ok(settings)
}

#[cfg(feature = "tauri-runtime")]
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn get_available_terminal_shells() -> Result<AvailableTerminalShells, AppCommandError> {
Expand Down
58 changes: 44 additions & 14 deletions src-tauri/src/commands/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1915,29 +1915,44 @@ pub const TRAY_MENU_ID_SHOW: &str = "tray:show";
pub const TRAY_MENU_ID_QUIT: &str = "tray:quit";
pub const TRAY_ICON_ID: &str = "codeg-tray";

/// True after `install_tray_icon` returns `Ok`. The hide-on-close path
/// in `lib.rs` consults this so we don't strand the user on systems
/// where the tray failed to install (Windows tray refused, etc.). On
/// Linux this is necessary-but-not-sufficient: the StatusNotifierWatcher
/// may be missing and the icon invisible even when build() returns Ok,
/// which is why `can_hide_to_tray()` reports false on Linux regardless.
/// True after `install_tray_icon` finishes successfully and the tray is
/// actually usable (on Linux, that requires a running
/// StatusNotifierWatcher — see `linux_status_notifier_available`).
/// The hide-on-close path in `lib.rs` consults this to avoid stranding
/// the user when the tray is unavailable.
#[cfg(feature = "tauri-runtime")]
static TRAY_AVAILABLE: AtomicBool = AtomicBool::new(false);

/// On Linux, Tauri's tray `build()` can succeed even when no
/// StatusNotifierWatcher is available (notably GNOME 45+ without an
/// AppIndicator extension). In that case the icon is silently invisible
/// and hiding the window would strand the user, so only treat the tray
/// as available when the desktop actually provides the D-Bus service.
#[cfg(all(target_os = "linux", feature = "tauri-runtime"))]
fn linux_status_notifier_available() -> bool {
crate::process::std_command("gdbus")
.args([
"call",
"--session",
"--dest",
"org.freedesktop.DBus",
"--object-path",
"/org/freedesktop/DBus",
"--method",
"org.freedesktop.DBus.NameHasOwner",
"org.kde.StatusNotifierWatcher",
])
.output()
.map(|out| out.status.success() && String::from_utf8_lossy(&out.stdout).contains("true"))
.unwrap_or(false)
}

/// Whether hide-on-close is safe on this platform/session. When false,
/// the close handler in `lib.rs` forces a real app exit instead — both
/// `hide()` and `minimize()` would leave aux windows (pet, settings)
/// running without a recoverable workspace.
#[cfg(feature = "tauri-runtime")]
pub fn can_hide_to_tray() -> bool {
// Linux: even with a successfully installed tray icon, modern GNOME
// (45+) defaults ship without a StatusNotifierWatcher and the icon
// is silently invisible. Refusing here forces the close to pass
// through to a real exit on Linux — preferable to a phantom process
// with no UI surface.
if cfg!(target_os = "linux") {
return false;
}
TRAY_AVAILABLE.load(AtomicOrdering::Relaxed)
}

Expand Down Expand Up @@ -2082,6 +2097,21 @@ pub fn install_tray_icon(
})
.build(app)?;

// On Linux, verify the tray is actually visible before trusting it.
// Tauri's tray build() succeeds even when StatusNotifierWatcher is
// absent (GNOME 45+), leaving the icon invisible. Detect that case
// and leave TRAY_AVAILABLE false so the close handler exits instead
// of hiding the window with no way to bring it back.
#[cfg(target_os = "linux")]
if !linux_status_notifier_available() {
tracing::warn!(
"[Tray] StatusNotifierWatcher not found — hiding on close will be disabled"
);
} else {
TRAY_AVAILABLE.store(true, AtomicOrdering::Relaxed);
}

#[cfg(not(target_os = "linux"))]
TRAY_AVAILABLE.store(true, AtomicOrdering::Relaxed);
Ok(())
}
Expand Down
22 changes: 21 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -883,7 +883,25 @@ mod tauri_app {
// that should fall through to the cleanup below.
if !APP_QUITTING.load(Ordering::Relaxed) {
api.prevent_close();
if windows::can_hide_to_tray() {
let close_action = window.app_handle()
.try_state::<db::AppDatabase>()
.map(|db| {
tauri::async_runtime::block_on(
crate::commands::system_settings::load_system_close_settings(
&db.conn,
),
)
})
.transpose()
.ok()
.flatten()
.map(|settings| settings.action)
.unwrap_or_default();

let should_hide =
close_action == crate::models::CloseAction::HideToTray
&& windows::can_hide_to_tray();
if should_hide {
let _ = window.hide();
} else {
window.app_handle().exit(0);
Expand Down Expand Up @@ -1098,6 +1116,8 @@ mod tauri_app {
system_settings::probe_terminal_shell_path,
system_settings::get_system_rendering_settings,
system_settings::update_system_rendering_settings,
system_settings::get_system_close_settings,
system_settings::update_system_close_settings,
logging_commands::get_log_settings,
logging_commands::set_log_settings,
logging_commands::get_recent_logs,
Expand Down
4 changes: 2 additions & 2 deletions src-tauri/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ pub use work_task::{
#[cfg(feature = "tauri-runtime")]
pub use system::SystemRenderingSettings;
pub use system::{
AvailableTerminalShells, GitCredentials, GitDetectResult, GitHubAccountsSettings,
GitHubTokenValidation, GitSettings, SystemLanguageSettings, SystemProxySettings,
AvailableTerminalShells, CloseAction, GitCredentials, GitDetectResult, GitHubAccountsSettings,
GitHubTokenValidation, GitSettings, SystemCloseSettings, SystemLanguageSettings, SystemProxySettings,
SystemTerminalSettings, TerminalShellOption,
};
16 changes: 16 additions & 0 deletions src-tauri/src/models/system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,22 @@ pub struct SystemTerminalSettings {
pub default_shell: Option<String>,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum CloseAction {
/// Hide to tray when a tray is available; otherwise exit.
#[default]
HideToTray,
/// Always exit when the main window is closed.
Exit,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(default)]
pub struct SystemCloseSettings {
pub action: CloseAction,
}

/// One row in the "default shell" picker. Backend owns the option list so the
/// frontend doesn't have to know which shells are available on which platform.
/// Labels are not localized server-side: `label_key` points at a frontend i18n
Expand Down
74 changes: 74 additions & 0 deletions src/components/settings/close-behavior-settings.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
import { NextIntlClientProvider } from "next-intl"
import { beforeEach, describe, expect, it, vi } from "vitest"

vi.mock("@/lib/api", () => ({
getSystemCloseSettings: vi.fn(),
updateSystemCloseSettings: vi.fn(),
}))

vi.mock("sonner", () => ({
toast: { error: vi.fn() },
}))

import { CloseBehaviorSettings } from "./close-behavior-settings"
import enMessages from "@/i18n/messages/en.json"
import { getSystemCloseSettings, updateSystemCloseSettings } from "@/lib/api"

const mockGet = vi.mocked(getSystemCloseSettings)
const mockSet = vi.mocked(updateSystemCloseSettings)

function renderWithIntl() {
return render(
<NextIntlClientProvider locale="en" messages={enMessages}>
<CloseBehaviorSettings />
</NextIntlClientProvider>
)
}

beforeEach(() => {
mockGet.mockReset()
mockSet.mockReset()
})

describe("CloseBehaviorSettings", () => {
it("loads the backend default and selects hide-to-tray", async () => {
mockGet.mockResolvedValue({ action: "hide_to_tray" })
renderWithIntl()
const hide = (await screen.findByLabelText(
"Hide to tray (background)"
)) as HTMLInputElement
expect(hide.checked).toBe(true)
})

it("switches to exit and persists the choice", async () => {
mockGet.mockResolvedValue({ action: "hide_to_tray" })
mockSet.mockImplementation(async (next) => next)
renderWithIntl()

const exit = await screen.findByLabelText("Exit application")
fireEvent.click(exit)

await waitFor(() => {
expect(mockSet).toHaveBeenCalledWith({ action: "exit" })
})
expect((exit as HTMLInputElement).checked).toBe(true)
})

it("reverts the radio when saving fails", async () => {
mockGet.mockResolvedValue({ action: "hide_to_tray" })
mockSet.mockRejectedValue(new Error("boom"))
renderWithIntl()

const exit = await screen.findByLabelText("Exit application")
fireEvent.click(exit)

await waitFor(() => {
expect(mockSet).toHaveBeenCalledWith({ action: "exit" })
})
const hide = screen.getByLabelText(
"Hide to tray (background)"
) as HTMLInputElement
expect(hide.checked).toBe(true)
})
})
Loading
Loading