Skip to content

feat(window): minimize to system tray on close and enforce single-instance focus#397

Open
muhamadgalihsaputra wants to merge 3 commits into
gnoviawan:devfrom
muhamadgalihsaputra:feat/system-tray-minimize
Open

feat(window): minimize to system tray on close and enforce single-instance focus#397
muhamadgalihsaputra wants to merge 3 commits into
gnoviawan:devfrom
muhamadgalihsaputra:feat/system-tray-minimize

Conversation

@muhamadgalihsaputra

@muhamadgalihsaputra muhamadgalihsaputra commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Implements system tray integration with Show and Quit context menu items, intercepts window CloseRequested events to hide the window, and integrates tauri-plugin-single-instance to focus the existing application instance. Closes #235, Closes #390.

Summary by CodeRabbit

  • New Features

    • Added system tray controls to show the app window or quit directly from the tray.
    • Added single-instance behavior so relaunching focuses the existing window instead of opening another copy.
  • Bug Fixes

    • Closing the main window now minimizes it to the tray (non-Windows) rather than exiting.
    • Improved shutdown handling to prevent duplicate cleanup and ensure a clean exit.

@coderabbitai

coderabbitai Bot commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 737516ad-0be9-4541-9325-be265bfa2524

📥 Commits

Reviewing files that changed from the base of the PR and between 98aeb62 and 9df9a2c.

📒 Files selected for processing (2)
  • src-tauri/src/lib.rs
  • tailwind.config.ts
✅ Files skipped from review due to trivial changes (1)
  • tailwind.config.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src-tauri/src/lib.rs

📝 Walkthrough

Walkthrough

Adds tray and single-instance support to the Tauri app. The manifest enables custom-protocol, tray-icon, single-instance, async/process, and Linux GUI dependencies. Runtime startup now focuses an existing main window, tray actions show or quit the app, close requests hide the window, and exit cleanup runs once.

Changes

Tray launch and shutdown coordination

Layer / File(s) Summary
Runtime features and dependencies
src-tauri/Cargo.toml
Adds the custom-protocol feature, enables tray-icon on tauri, adds tauri-plugin-single-instance, async/process crates, Linux GTK/WebKit2GTK targets, and spacing changes in the patch section.
Single-instance and tray controls
src-tauri/src/lib.rs
Adds tray menu identifiers and atomic flags, registers the single-instance handler, creates the tray menu/icon, and hides or focuses the main window on close, show, left-click, or second launch.
Exit cleanup guard
src-tauri/src/lib.rs
Skips repeated exit cleanup once CLEANUP_DONE is set and marks that flag in the PTY, ACP, and fallback shutdown paths.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant Tauri app
  participant tauri_plugin_single_instance
  participant main webview window
  participant tray menu

  Tauri app->>tauri_plugin_single_instance: register single-instance handler
  User->>Tauri app: launch a second instance
  tauri_plugin_single_instance->>main webview window: show, unminimize, focus
  User->>tray menu: select Show Termul
  Tauri app->>main webview window: show, unminimize, focus
  User->>main webview window: click close
  Tauri app->>main webview window: hide unless quitting
  User->>tray menu: select Quit Termul
  Tauri app->>Tauri app: set IS_QUITTING and exit
Loading
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The Tailwind darkMode config tweak is unrelated to tray, close, and shutdown behavior. Remove the Tailwind config change or split it into a separate PR unless it is required for this feature.
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the tray-close and single-instance focus behavior added by the PR.
Linked Issues check ✅ Passed The changes match #235 and #390 by hiding on close, adding tray actions, enforcing single-instance focus, and improving shutdown cleanup.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed. For unrecoverable errors, disable the tool in CodeRabbit configuration.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src-tauri/src/lib.rs`:
- Around line 894-895: The ExitRequested cleanup flow only tracks completion
with CLEANUP_DONE, so a second request can start another cleanup before the
first task marks it done. Add a separate in-progress atomic guard in the same
area as IS_QUITTING and CLEANUP_DONE, set it immediately when entering the async
cleanup path in the ExitRequested handler, and use it to short-circuit any
subsequent requests until the task finishes. Keep the existing
CLEANUP_DONE.store(true, ...) calls at the end of each cleanup task, and update
the cleanup-start checks in the referenced cleanup blocks so kill_all(),
destroy_all(), and exit(0) can only run once.
- Around line 934-943: The window-restore flow currently focuses before
restoring, which can leave the app visible but not foregrounded on some
platforms. Update the single-instance handler in
builder.plugin(tauri_plugin_single_instance::init(...)) and the tray action
paths that use window.show()/set_focus()/unminimize() so that unminimize()
happens before set_focus(), keeping the same behavior elsewhere.
- Around line 1136-1148: The window close handler in lib.rs currently prevents
close and hides the main window for every platform, which conflicts with the
Windows shutdown requirement. Update the CloseRequested handling around
app.get_webview_window("main"), window.on_window_event, and IS_QUITTING so the
hide-on-close path is only used on non-Windows platforms, while Windows allows
the app to fully exit when the user closes the window; if needed, align the
acceptance criteria with this platform-specific behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0e479205-6669-4e3d-8bbb-21ff20ac3386

📥 Commits

Reviewing files that changed from the base of the PR and between 83d3aac and 98aeb62.

⛔ Files ignored due to path filters (1)
  • src-tauri/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (2)
  • src-tauri/Cargo.toml
  • src-tauri/src/lib.rs

Comment thread src-tauri/src/lib.rs
Comment on lines +894 to +895
static IS_QUITTING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
static CLEANUP_DONE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard cleanup while it is in progress, not only after it finishes.

CLEANUP_DONE is set inside the async cleanup task. If another ExitRequested arrives before that store, this code starts another cleanup task and may run kill_all(), destroy_all(), and exit(0) multiple times. Add a separate in-progress guard.

Proposed fix
 static IS_QUITTING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
+static CLEANUP_IN_PROGRESS: std::sync::atomic::AtomicBool =
+    std::sync::atomic::AtomicBool::new(false);
 static CLEANUP_DONE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
 if let RunEvent::ExitRequested { api, .. } = event {
     if CLEANUP_DONE.load(std::sync::atomic::Ordering::SeqCst) {
         return;
     }
     // Prevent the default exit behavior so we can cleanup first
     api.prevent_exit();
+    if CLEANUP_IN_PROGRESS.swap(true, std::sync::atomic::Ordering::SeqCst) {
+        return;
+    }

Keep the existing CLEANUP_DONE.store(true, ...) calls at the end of each cleanup task.

Also applies to: 1315-1319, 1356-1383

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src-tauri/src/lib.rs` around lines 894 - 895, The ExitRequested cleanup flow
only tracks completion with CLEANUP_DONE, so a second request can start another
cleanup before the first task marks it done. Add a separate in-progress atomic
guard in the same area as IS_QUITTING and CLEANUP_DONE, set it immediately when
entering the async cleanup path in the ExitRequested handler, and use it to
short-circuit any subsequent requests until the task finishes. Keep the existing
CLEANUP_DONE.store(true, ...) calls at the end of each cleanup task, and update
the cleanup-start checks in the referenced cleanup blocks so kill_all(),
destroy_all(), and exit(0) can only run once.

Comment thread src-tauri/src/lib.rs
Comment on lines +934 to +943
// Single-instance guard: jika user buka termul lagi padahal sudah jalan,
// cukup fokuskan window yang ada — jangan spawn instance baru.
builder = builder.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
let _ = window.unminimize();
}
}));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

set -euo pipefail

# Map relevant Rust files and docs first
git ls-files 'src-tauri/src/lib.rs' 'docs/architecture.md' 'docs/api-contracts.md' 'docs/source-tree-analysis.md'

# Inspect the relevant code ranges around the cited lines and tray handlers
sed -n '900,965p' src-tauri/src/lib.rs
printf '\n---\n'
sed -n '1088,1145p' src-tauri/src/lib.rs

# Check for other focus/restore usages
rg -n "set_focus\\(|unminimize\\(|show\\(" src-tauri/src/lib.rs

# Read the required Tauri docs if present
for f in docs/architecture.md docs/api-contracts.md docs/source-tree-analysis.md; do
  if [ -f "$f" ]; then
    printf '\n=== %s ===\n' "$f"
    sed -n '1,220p' "$f"
  fi
done

Repository: gnoviawan/termul

Length of output: 27745


🌐 Web query:

Tauri 2 window unminimize set_focus order minimized window focus restore platform docs

💡 Result:

In Tauri 2, calling set_focus on a window that is currently minimized will not automatically restore it to a visible state [1][2]. To successfully bring a minimized window to the front and ensure it is focused, you must explicitly unminimize the window before or in sequence with the focus command [1][2]. Recommended Approach To restore and focus a window reliably across platforms, follow this operational order [2][3]: 1. Unminimize: Call the unminimize method [1][2]. 2. Focus: Call the set_focus method [1][2]. If you encounter issues where this sequence is insufficient—particularly on certain Linux desktop environments—some developers successfully use a workaround by hiding the window, unminimizing it, showing it, and then focusing it [3]: hide -> unminimize -> show -> set_focus [3] API Reference Both the JavaScript API and the Rust backend provide these methods for window management: - JavaScript: Use window.unminimize and window.setFocus [4][5]. - Rust: Use window.unminimize and window.set_focus [6][7]. Note that behavior can depend on the underlying operating system's window manager, which Tauri does not control directly [3]. Always ensure that the window has the necessary permissions defined in your application's capabilities configuration to perform these actions [8].

Citations:


Restore before focusing. Move set_focus() after unminimize() in the single-instance, tray “Show”, and left-click paths; focusing first can leave a minimized window restored but not foregrounded on some platforms.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src-tauri/src/lib.rs` around lines 934 - 943, The window-restore flow
currently focuses before restoring, which can leave the app visible but not
foregrounded on some platforms. Update the single-instance handler in
builder.plugin(tauri_plugin_single_instance::init(...)) and the tray action
paths that use window.show()/set_focus()/unminimize() so that unminimize()
happens before set_focus(), keeping the same behavior elsewhere.

Comment thread src-tauri/src/lib.rs
Comment on lines +1136 to +1148
// Intercept window close (X button) → sembunyikan ke tray,
// bukan terminate proses. User bisa quit lewat menu tray.
if let Some(window) = app.get_webview_window("main") {
let app_handle_for_close = handle.clone();
window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
if IS_QUITTING.load(std::sync::atomic::Ordering::SeqCst) {
// Allow close to proceed and terminate the app
} else {
api.prevent_close();
if let Some(w) = app_handle_for_close.get_webview_window("main") {
let _ = w.hide();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Don’t hide-on-close on Windows if #390 requires shutdown.

This handler prevents close and hides the window on every OS. That satisfies the macOS background behavior, but conflicts with the stated Windows objective: closing should not leave the app running in Task Manager. Scope hide-on-close away from Windows, or explicitly update the acceptance criteria.

Possible platform-scoped fix
 if let tauri::WindowEvent::CloseRequested { api, .. } = event {
     if IS_QUITTING.load(std::sync::atomic::Ordering::SeqCst) {
         // Allow close to proceed and terminate the app
     } else {
+        #[cfg(target_os = "windows")]
+        {
+            api.prevent_close();
+            IS_QUITTING.store(true, std::sync::atomic::Ordering::SeqCst);
+            app_handle_for_close.exit(0);
+        }
+
+        #[cfg(not(target_os = "windows"))]
+        {
-        api.prevent_close();
-        if let Some(w) = app_handle_for_close.get_webview_window("main") {
-            let _ = w.hide();
+            api.prevent_close();
+            if let Some(w) = app_handle_for_close.get_webview_window("main") {
+                let _ = w.hide();
+            }
         }
     }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Intercept window close (X button) → sembunyikan ke tray,
// bukan terminate proses. User bisa quit lewat menu tray.
if let Some(window) = app.get_webview_window("main") {
let app_handle_for_close = handle.clone();
window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
if IS_QUITTING.load(std::sync::atomic::Ordering::SeqCst) {
// Allow close to proceed and terminate the app
} else {
api.prevent_close();
if let Some(w) = app_handle_for_close.get_webview_window("main") {
let _ = w.hide();
}
// Intercept window close (X button) → sembunyikan ke tray,
// bukan terminate proses. User bisa quit lewat menu tray.
if let Some(window) = app.get_webview_window("main") {
let app_handle_for_close = handle.clone();
window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
if IS_QUITTING.load(std::sync::atomic::Ordering::SeqCst) {
// Allow close to proceed and terminate the app
} else {
#[cfg(target_os = "windows")]
{
api.prevent_close();
IS_QUITTING.store(true, std::sync::atomic::Ordering::SeqCst);
app_handle_for_close.exit(0);
}
#[cfg(not(target_os = "windows"))]
{
api.prevent_close();
if let Some(w) = app_handle_for_close.get_webview_window("main") {
let _ = w.hide();
}
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src-tauri/src/lib.rs` around lines 1136 - 1148, The window close handler in
lib.rs currently prevents close and hides the main window for every platform,
which conflicts with the Windows shutdown requirement. Update the CloseRequested
handling around app.get_webview_window("main"), window.on_window_event, and
IS_QUITTING so the hide-on-close path is only used on non-Windows platforms,
while Windows allows the app to fully exit when the user closes the window; if
needed, align the acceptance criteria with this platform-specific behavior.

gnoviawan added 2 commits July 1, 2026 14:50
The single-element array form ['class'] is a type error under tailwindcss
v4 (DarkModeStrategy expects ['class', string]); the string form 'class'
is valid in both v3 and v4. Syncs the config with dev (v4) and pre-resolves
the merge conflict when this branch lands on dev.
…avior

- Add CLEANUP_IN_PROGRESS guard (compare_exchange) at the top of the
  ExitRequested handler so a re-entrant exit request cannot spawn a
  duplicate cleanup task racing kill_all()/destroy_all()/exit(0).
  CLEANUP_DONE still marks final completion.
- Restore windows with show() -> unminimize() -> set_focus() in the
  single-instance, tray Show, and tray click paths so the restored
  window is reliably foregrounded on every platform.
- Gate hide-on-close to non-Windows; on Windows let the window close so
  the app exits cleanly (issue gnoviawan#390: no lingering process in Task
  Manager) and does not block OS shutdown/logoff (WM_QUERYENDSESSION).
  macOS/Linux still hide to the tray (issue gnoviawan#235).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants