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:
committed by
GitHub
parent
e382902b73
commit
5ae0f872d6
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user