feat(desktop): add Windows tray lifecycle (#1438)

Windows desktop users can now close the main window without losing
access to
AgentsView. Closing hides the window, the system tray menu restores and
focuses
it, and Quit remains the explicit exit path, matching the existing macOS
behavior.

Windows uses the packaged application icon while macOS keeps its
monochrome
template icon. Close interception is installed only after the tray is
available, so a tray setup failure keeps normal close behavior. Linux
retains
normal close behavior because enabling its tray support would add
separate
AppIndicator packaging requirements.

The shared lifecycle behavior is covered on macOS and Windows. The
Windows
build remains an automated CI check because the local host does not have
a
Windows Rust target installed.

<sup>generated by a clanker</sup>
This commit is contained in:
Marius van Niekerk
2026-08-17 10:41:53 -04:00
committed by GitHub
parent e382902b73
commit 5ae0f872d6
6 changed files with 290 additions and 28 deletions
+2 -2
View File
@@ -17,8 +17,8 @@ import (
)
// Resync may spend up to five seconds draining SQLite connections before a
// swap, particularly on Windows where open handles prevent the rename.
const pricingResyncTestTimeout = 10 * time.Second
// swap, and the surrounding work can take longer on loaded Windows runners.
const pricingResyncTestTimeout = 30 * time.Second
type pricingCatalogTransport struct {
requests chan *http.Request
+3 -2
View File
@@ -9,8 +9,9 @@ The wrapper does not reimplement the web app. Instead, it:
1. Starts it with `serve --background --host 127.0.0.1` on a local port.
1. Loads the local URL in a native webview.
On macOS, the same desktop process also provides a menu-bar status item for
showing the AgentsView window, opening logs, checking for updates, and quitting.
On macOS and Windows, the same desktop process also provides a system tray item
for showing the AgentsView window, opening logs, checking for updates, and
quitting.
## Requirements
+3
View File
@@ -24,3 +24,6 @@ tokio = { version = "1", features = ["time", "sync"] }
[target.'cfg(target_os = "macos")'.dependencies]
tauri = { version = "2", features = ["image-png", "tray-icon"] }
[target.'cfg(target_os = "windows")'.dependencies]
tauri = { version = "2", features = ["tray-icon"] }
+66 -24
View File
@@ -18,7 +18,7 @@ use std::time::{Duration, Instant};
use tauri::async_runtime::Receiver;
use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder};
use tauri::plugin::Builder as PluginBuilder;
#[cfg(target_os = "macos")]
#[cfg(any(target_os = "macos", target_os = "windows"))]
use tauri::tray::TrayIconBuilder;
use tauri::{App, AppHandle, Emitter, Manager, RunEvent, Url, WebviewWindow};
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons};
@@ -157,13 +157,11 @@ pub fn run() {
if let Err(err) = setup_menu(app) {
eprintln!("[agentsview] failed to set up desktop menu: {err}");
}
#[cfg(target_os = "macos")]
if let Err(err) = setup_macos_status_item(app) {
eprintln!("[agentsview] failed to set up macOS status item: {err}");
}
#[cfg(target_os = "macos")]
if let Err(err) = setup_macos_window_lifecycle(app) {
eprintln!("[agentsview] failed to set up macOS window lifecycle: {err}");
#[cfg(any(target_os = "macos", target_os = "windows"))]
if let Err(err) =
setup_close_to_tray_with(app, setup_status_item, setup_window_lifecycle)
{
eprintln!("[agentsview] failed to set up close-to-tray behavior: {err}");
}
match tauri::async_runtime::block_on(run_data_version_preflight(app.handle())) {
Ok(()) => {
@@ -254,6 +252,7 @@ fn show_main_window(handle: &AppHandle) {
}
trait MainWindowVisibility {
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn hide_main_window(&self);
fn show_main_window(&self);
fn unminimize_main_window(&self);
@@ -261,6 +260,7 @@ trait MainWindowVisibility {
}
impl MainWindowVisibility for WebviewWindow {
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn hide_main_window(&self) {
let _ = self.hide();
}
@@ -278,6 +278,7 @@ impl MainWindowVisibility for WebviewWindow {
}
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn hide_main_window_on_close(window: &impl MainWindowVisibility, prevent_close: impl FnOnce()) {
prevent_close();
window.hide_main_window();
@@ -1999,8 +2000,8 @@ fn setup_menu(app: &mut App) -> Result<(), DynError> {
Ok(())
}
#[cfg(target_os = "macos")]
fn setup_macos_status_item(app: &mut App) -> Result<(), DynError> {
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn setup_status_item(app: &mut App) -> Result<(), DynError> {
let show = MenuItemBuilder::with_id(SHOW_MAIN_WINDOW_MENU_ID, "Show AgentsView").build(app)?;
let open_logs =
MenuItemBuilder::with_id(OPEN_LOGS_FOLDER_MENU_ID, "Open Logs Folder").build(app)?;
@@ -2017,18 +2018,38 @@ fn setup_macos_status_item(app: &mut App) -> Result<(), DynError> {
.item(&quit)
.build()?;
let icon = macos_status_item_icon()?;
TrayIconBuilder::with_id("agentsview")
.icon(icon)
.icon_as_template(true)
let builder = TrayIconBuilder::with_id("agentsview")
.tooltip("AgentsView")
.menu(&menu)
.build(app)?;
.menu(&menu);
#[cfg(target_os = "macos")]
let builder = builder
.icon(macos_status_item_icon()?)
.icon_as_template(true);
#[cfg(target_os = "windows")]
let builder = builder.icon(
app.default_window_icon()
.cloned()
.ok_or_else(|| io::Error::other("default window icon is unavailable"))?,
);
builder.build(app)?;
Ok(())
}
#[cfg(target_os = "macos")]
fn setup_macos_window_lifecycle(app: &App) -> Result<(), DynError> {
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn setup_close_to_tray_with<T>(
target: &mut T,
setup_status_item: impl FnOnce(&mut T) -> Result<(), DynError>,
setup_window_lifecycle: impl FnOnce(&T) -> Result<(), DynError>,
) -> Result<(), DynError> {
setup_status_item(target)?;
setup_window_lifecycle(target)
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn setup_window_lifecycle(app: &App) -> Result<(), DynError> {
let window = main_window(app)?;
let close_window = window.clone();
window.on_window_event(move |event| {
@@ -3916,9 +3937,9 @@ agentsview running at http://127.0.0.1:18082
assert!(!is_allowed_external_open_url(&custom));
}
#[cfg(target_os = "macos")]
#[cfg(any(target_os = "macos", target_os = "windows"))]
#[test]
fn macos_status_item_actions_share_desktop_menu_routing() {
fn status_item_actions_share_desktop_menu_routing() {
assert_eq!(
desktop_menu_action(ABOUT_MENU_ID),
Some(DesktopMenuAction::About)
@@ -3942,13 +3963,13 @@ agentsview running at http://127.0.0.1:18082
assert_eq!(desktop_menu_action("unknown"), None);
}
#[cfg(target_os = "macos")]
#[cfg(any(target_os = "macos", target_os = "windows"))]
#[derive(Clone, Default)]
struct FakeMainWindow {
calls: std::sync::Arc<Mutex<Vec<&'static str>>>,
}
#[cfg(target_os = "macos")]
#[cfg(any(target_os = "macos", target_os = "windows"))]
impl MainWindowVisibility for FakeMainWindow {
fn hide_main_window(&self) {
self.calls.lock().expect("lock calls").push("hide");
@@ -3967,9 +3988,9 @@ agentsview running at http://127.0.0.1:18082
}
}
#[cfg(target_os = "macos")]
#[cfg(any(target_os = "macos", target_os = "windows"))]
#[test]
fn macos_close_hides_the_existing_window_and_show_restores_it() {
fn close_hides_the_existing_window_and_show_restores_it() {
let window = FakeMainWindow::default();
let close_calls = window.calls.clone();
@@ -3987,6 +4008,27 @@ agentsview running at http://127.0.0.1:18082
);
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
#[test]
fn tray_setup_failure_does_not_register_window_lifecycle() {
let calls = std::cell::RefCell::new(Vec::new());
let result = setup_close_to_tray_with(
&mut (),
|_| {
calls.borrow_mut().push("tray");
Err(io::Error::other("tray setup failed").into())
},
|_| {
calls.borrow_mut().push("lifecycle");
Ok(())
},
);
assert!(result.is_err());
assert_eq!(*calls.borrow(), vec!["tray"]);
}
#[cfg(target_os = "macos")]
#[test]
fn macos_status_item_icon_is_a_key_only_template() {
@@ -0,0 +1,177 @@
# Windows Tray Lifecycle Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans
> to implement this plan directly in the current agent, task-by-task. Never use
> subagent-driven development. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Give Windows the existing macOS close-to-tray lifecycle while leaving
Linux behavior unchanged.
**Approved spec/design:**
`docs/superpowers/specs/2026-08-16-windows-tray-lifecycle-design.md`
**Architecture:** Compile Tauri's tray support on macOS and Windows, then share
one status-item setup path and one close-handler path across those targets. Keep
icon selection platform-specific: the monochrome template asset on macOS and
Tauri's packaged default window icon on Windows.
**Tech Stack:** Rust 2021, Tauri 2, Cargo target dependencies, Rust unit tests.
## Global Constraints
- Linux keeps normal close behavior and does not gain the `tray-icon` feature.
- The tray menu remains Show AgentsView, Open Logs Folder, Check for Updates,
and Quit.
- Closing hides the main window on macOS and Windows; Quit explicitly exits.
- Preserve non-fatal logging for setup errors, but only register close
interception after tray creation succeeds.
- Do not add dependencies beyond Tauri's existing `tray-icon` feature.
______________________________________________________________________
### Task 1: Share the Tray Lifecycle with Windows
**Files:**
- Modify: `desktop/src-tauri/Cargo.toml`
- Modify: `desktop/src-tauri/src/lib.rs`
- Test: `desktop/src-tauri/src/lib.rs`
- Modify: `desktop/README.md`
**Interfaces:**
- Consumes: Tauri `App::default_window_icon()`, `TrayIconBuilder`, the existing
`desktop_menu_action`, `show_main_window`, and `MainWindowVisibility` APIs.
- Produces: `setup_status_item(app: &mut App) -> Result<(), DynError>` and
`setup_window_lifecycle(app: &App) -> Result<(), DynError>`, compiled only
for macOS and Windows.
- [ ] **Step 1: Extend the lifecycle test target to Windows**
Change the status-item routing test, `FakeMainWindow`, its
`MainWindowVisibility` implementation, and the close-and-restore test from
`#[cfg(target_os = "macos")]` to:
```rust
#[cfg(any(target_os = "macos", target_os = "windows"))]
```
Rename the tests to describe desktop status-item and close behavior rather
than macOS-only behavior:
```rust
fn status_item_actions_share_desktop_menu_routing()
fn close_hides_the_existing_window_and_show_restores_it()
```
- [ ] **Step 2: Verify the Windows-facing test fails before implementation**
Run on a Windows Rust host or CI runner:
```powershell
cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml --lib
```
Expected before implementation: compilation fails because the hide method and
close helper are still macOS-only. On the available macOS host, run the same
command to ensure the expanded test preserves existing behavior; it should
pass there.
- [ ] **Step 3: Enable Windows tray support**
Add the Windows-only target dependency without changing Linux:
```toml
[target.'cfg(target_os = "windows")'.dependencies]
tauri = { version = "2", features = ["tray-icon"] }
```
- [ ] **Step 4: Share tray setup across macOS and Windows**
Compile the import, setup calls, hide method, hide helper, and lifecycle setup
on both supported targets:
```rust
#[cfg(any(target_os = "macos", target_os = "windows"))]
use tauri::tray::TrayIconBuilder;
```
Rename the setup functions and use the same menu on both platforms:
```rust
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn setup_status_item(app: &mut App) -> Result<(), DynError> {
let show = MenuItemBuilder::with_id(SHOW_MAIN_WINDOW_MENU_ID, "Show AgentsView")
.build(app)?;
let open_logs =
MenuItemBuilder::with_id(OPEN_LOGS_FOLDER_MENU_ID, "Open Logs Folder").build(app)?;
let check_updates = MenuItemBuilder::with_id(
CHECK_UPDATES_MENU_ID,
"Check for Updates...",
)
.build(app)?;
let quit = MenuItemBuilder::with_id(QUIT_FROM_STATUS_ITEM_MENU_ID, "Quit AgentsView")
.build(app)?;
let menu = MenuBuilder::new(app)
.item(&show)
.separator()
.item(&open_logs)
.item(&check_updates)
.separator()
.item(&quit)
.build()?;
let builder = TrayIconBuilder::with_id("agentsview")
.tooltip("AgentsView")
.menu(&menu);
#[cfg(target_os = "macos")]
let builder = builder
.icon(macos_status_item_icon()?)
.icon_as_template(true);
#[cfg(target_os = "windows")]
let builder = builder.icon(
app.default_window_icon()
.cloned()
.ok_or_else(|| io::Error::other("default window icon is unavailable"))?,
);
builder.build(app)?;
Ok(())
}
```
Rename `setup_macos_window_lifecycle` to `setup_window_lifecycle`, and keep
`macos_status_item_icon` macOS-only. Sequence tray and lifecycle setup so a
tray error skips close interception, preserving normal close behavior. Use a
platform-neutral startup log message for the combined setup path.
- [ ] **Step 5: Update the desktop documentation**
Change the introductory platform sentence in `desktop/README.md` to state that
macOS and Windows provide a system tray item for showing the window, opening
logs, checking for updates, and quitting.
- [ ] **Step 6: Format and run focused verification**
Use an isolated Cargo target directory and a temporary ignored sidecar
placeholder, then run:
```bash
cargo fmt --manifest-path desktop/src-tauri/Cargo.toml -- --check
cargo check --locked --manifest-path desktop/src-tauri/Cargo.toml
cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml --lib
```
Expected: formatting and check succeed with no warnings; all desktop library
tests pass. Remove the temporary placeholder and isolated target directory.
Windows compilation remains an explicit CI limitation on the macOS host.
- [ ] **Step 7: Review and commit the implementation**
Review `git diff --check` and the complete diff. Stage only
`desktop/src-tauri/Cargo.toml`, `desktop/src-tauri/src/lib.rs`, and
`desktop/README.md`, then commit with the repository's mandatory commit
workflow and hooks.
@@ -0,0 +1,39 @@
# Windows Tray Lifecycle
## Goal
Give the Windows desktop app the same close-to-tray behavior as macOS. Closing
the main window keeps AgentsView available from the system tray, while the Quit
menu action remains the explicit way to exit. Linux behavior does not change.
## Design
Enable Tauri's `tray-icon` feature for Windows and macOS. Use one shared tray
setup path for both platforms, with the existing template image on macOS and the
packaged application icon on Windows. The tray menu continues to provide Show
AgentsView, Open Logs Folder, Check for Updates, and Quit.
Register the close handler on Windows and macOS. A close request is prevented
and the main window is hidden. Show AgentsView restores, unminimizes, and
focuses that same window. The existing desktop menu routing remains the single
action dispatcher for window restore and explicit exit.
Linux retains normal close behavior and does not gain Tauri's tray dependency.
This avoids adding Linux AppIndicator packaging requirements in this change.
## Error Handling
Close-to-tray setup keeps the existing non-fatal startup behavior: setup
failures are logged and do not abort the desktop wrapper. Window-lifecycle setup
only runs after tray creation succeeds, so a tray setup failure preserves normal
close behavior rather than hiding the window without a restore surface.
Platform-specific icon selection occurs during tray setup and propagates errors
through the same setup result.
## Verification
Extend the existing close-and-restore unit test to compile for Windows and
macOS, and keep the macOS template-icon assertions macOS-only. Run Rust
formatting, the desktop crate check, and its library tests on the available
host. Windows compilation and installer behavior remain covered by Windows CI
because the local host does not have the Windows Rust target installed.