agentmux_launcher/tray/mod.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Optional system-tray / menu-bar icon — issue #2977 Workstream 1,
5//! `docs/specs/SPEC_TRAY_OPTIONAL_BACKGROUND_SERVICE_2026_09_04.md`.
6//!
7//! ## Why this lives in the launcher, not the host
8//!
9//! §2 of the spec puts tray ownership in `agentmux-launcher` deliberately: the
10//! icon must survive `host` crashes and restarts, and the launcher is the
11//! process that outlives them. It is also the process that already knows how
12//! to ask for a new window (`second_instance::forward_open_new_window`).
13//!
14//! ## The event-loop problem (§7.5)
15//!
16//! The spec originally assumed the tray would "coexist with whatever message
17//! pump the launcher already runs for its splash". **There is no such pump on
18//! Windows** — the splash creates a real window but registers `DefWindowProcW`
19//! and polls, which works only because it is layered, non-activating, and
20//! takes no input. The launcher's real loop is Tokio. So the Windows backend
21//! here *introduces* a message pump on a dedicated thread; see `windows.rs`.
22//!
23//! macOS is the opposite: AppKit insists the status item be created *and*
24//! driven on the main thread, and the launcher's main thread already pumps
25//! `NSApplication` for the process lifetime. So the macOS backend does not
26//! own a thread at all — it queues a request the main-thread pump services,
27//! see `macos.rs`. Linux uses `ksni`, which is pure D-Bus and needs no
28//! toolkit loop at all.
29//!
30//! ## What is shared
31//!
32//! Everything downstream of "the user picked something" is platform-neutral
33//! and lives here: the menu model, the reachability probe that drives the
34//! icon's state, the retrying forwarder to the host, and the action loop the
35//! supervisor spawns. A new backend only has to turn native events into
36//! `TrayAction`s and call `spawn_action_loop` from its supervisor.
37//!
38//! ## When it runs
39//!
40//! When `AGENTMUX_TRAY` or `AGENTMUX_BACKGROUND_SERVICE` is set
41//! (presence-based, matching the `AGENTMUX_DEV` idiom). `background_config`
42//! sets them from two separate settings: `app:showtray` (the icon, **on by
43//! default**) and `app:runinbackground` (keep running after the last window
44//! closes, **opt-in**). Background mode forces the icon (`should_enable`), and
45//! the converse holds too: when the tray was requested but failed to start,
46//! [`unavailable`] tells the host spawn to drop background mode, so there is
47//! never a resident process without an icon. See
48//! `docs/reports/REPORT_TRAY_BACKGROUND_DEFAULT_ON_2026_09_28.md` for why the
49//! two are kept apart.
50
51use std::sync::atomic::{AtomicBool, Ordering};
52use std::sync::mpsc;
53
54/// How long a backend waits for its icon to actually exist before `spawn`
55/// reports success. Every backend builds the icon on another thread (Windows:
56/// its pump thread; macOS: the AppKit main thread; Linux: the ksni thread), and
57/// `start_if_enabled` must not report success for an icon that was only
58/// *requested*: `unavailable` depends on it (ReAgent P1s on #3785).
59pub(crate) const READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
60
61/// How long a login start keeps trying for a tray host that is not up yet
62/// (`SPEC_START_WITH_OS_2026_09_25.md` §3.4). At login the Linux StatusNotifier
63/// watcher can register after the autostart entry runs; nobody is looking at a
64/// login start yet, and a late tray beats a window nobody asked for.
65#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
66pub(crate) const LOGIN_TRAY_WAIT: std::time::Duration = std::time::Duration::from_secs(45);
67
68/// The Linux backend's ready timeout: long enough to cover the retries a
69/// login start makes there. Windows and macOS do not retry (their icon does
70/// not depend on another process being up), so they keep `READY_TIMEOUT` and
71/// a failed start still falls back fast.
72#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
73pub(crate) fn ready_timeout(login_start: bool) -> std::time::Duration {
74 if login_start {
75 LOGIN_TRAY_WAIT + READY_TIMEOUT
76 } else {
77 READY_TIMEOUT
78 }
79}
80
81/// Set when the tray was requested but could not start. Read by the host spawn.
82static TRAY_UNAVAILABLE: AtomicBool = AtomicBool::new(false);
83
84/// The tray was requested but failed to start, so background mode must not be
85/// handed to the host: closing the last window would otherwise leave a
86/// resident process with no icon and no visible way to quit it.
87pub fn unavailable() -> bool {
88 TRAY_UNAVAILABLE.load(Ordering::SeqCst)
89}
90
91// No macOS menu-bar backend yet (SPEC_OS_NOTIFICATIONS_SYSTEM_2026_09_24.md §6.2).
92#[cfg_attr(target_os = "macos", allow(dead_code))]
93pub(crate) mod notify_menu;
94#[cfg(target_os = "windows")]
95mod windows;
96#[cfg(target_os = "macos")]
97pub(crate) mod macos;
98#[cfg(target_os = "linux")]
99mod linux;
100
101/// What the user picked from the tray menu. Platform backends translate their
102/// native click/menu events into these; everything downstream is
103/// platform-neutral, so the handling logic is written and tested once.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum TrayAction {
106 /// "New Window" (or "Start AgentMux" when the service is down) — also a
107 /// left-click / double-click on the icon itself.
108 /// Reuses the existing `open_new_window` forward rather than introducing a
109 /// second way to make a window (see §7.5.1: the reopen path already exists
110 /// and is what the macOS reopen delegate uses).
111 OpenWindow,
112 /// "Quit AgentMux" — a genuine, user-intended full shutdown, distinct from
113 /// closing the last window while background-service mode is on.
114 Quit,
115 /// The "Start at login" check item: flips `app:startatlogin`
116 /// (`crate::start_at_login`). Handled in the launcher, not the host.
117 ToggleStartAtLogin,
118}
119
120impl TrayAction {
121 /// The host IPC command this action forwards, if it goes to the host at
122 /// all. Kept next to the enum so every backend maps the same way.
123 pub fn host_cmd(self) -> Option<&'static str> {
124 Some(match self {
125 // Exactly the path a second launch / the macOS reopen delegate
126 // uses — see design doc §7.5.1.
127 TrayAction::OpenWindow => "open_new_window",
128 // Graceful: `quit_app` drains with LauncherRequested so srv still
129 // gets its per-window cleanup. Killing the process tree from the
130 // launcher instead would leak srv rows and resurrect ghost windows
131 // on next launch.
132 TrayAction::Quit => "quit_app",
133 TrayAction::ToggleStartAtLogin => return None,
134 })
135 }
136}
137
138/// How often backends re-check whether the background service is actually
139/// reachable. Cheap (a loopback connect with a short timeout) and slow enough
140/// to be invisible; the icon is a status indicator, not a monitor.
141pub const STATUS_POLL: std::time::Duration = std::time::Duration::from_secs(5);
142
143/// Is the background service actually reachable *right now*?
144///
145/// Deliberately the same question `forward_host_cmd` has to answer — port file
146/// readable AND the host's IPC port accepting a connection — because that is
147/// what the indicator should mean: "would Open work if you clicked it?".
148/// Checking only that the port file exists would keep claiming "running" after
149/// a host crash, since the file outlives a hard exit (see `lib.rs`, which
150/// removes it only on a clean `run_message_loop` return).
151pub fn service_reachable(data_dir: &std::path::Path, dir_hash: &str) -> bool {
152 let port_file = data_dir.join(format!("ipc-port-{}", dir_hash));
153 let Ok(contents) = std::fs::read_to_string(&port_file) else {
154 return false;
155 };
156 let Some((port_str, _token)) = contents.trim().split_once(':') else {
157 return false;
158 };
159 let Ok(port) = port_str.parse::<u16>() else {
160 return false;
161 };
162 let addr: std::net::SocketAddr = ([127, 0, 0, 1], port).into();
163 std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(400)).is_ok()
164}
165
166/// Forward a tray action to the host, retrying while the failure is
167/// `Transient`.
168///
169/// The tray starts as soon as the single-instance pipe/socket is claimed —
170/// before `srv`/`host` are spawned, and well before the host publishes
171/// `ipc-port-<hash>`. So a user who clicks Quit (or Open) during startup hits
172/// exactly the `Transient` case `ForwardError` already distinguishes: "the
173/// instance is alive but not serving IPC yet". Dropping that request meant a
174/// startup-time Quit silently did nothing and the app carried on booting
175/// (Codex P2 on PR #2996).
176///
177/// Retries only `Transient`. `Fatal` means the port file was readable but the
178/// HTTP path failed — a hung or genuinely broken host, where retrying just
179/// hides the problem, so it is logged once and dropped.
180///
181/// The budget covers a slow cold start (first-run Defender scan, CEF init)
182/// without leaving a click queued forever: a request the user made a minute
183/// ago is no longer one they want acted on.
184pub fn forward_tray_cmd(data_dir: &std::path::Path, dir_hash: &str, cmd: &str) {
185 use crate::second_instance::{forward_host_cmd, ForwardError};
186
187 const MAX_ATTEMPTS: u32 = 30;
188 const DELAY: std::time::Duration = std::time::Duration::from_secs(1);
189
190 for attempt in 1..=MAX_ATTEMPTS {
191 match forward_host_cmd(data_dir, dir_hash, cmd) {
192 Ok(()) => {
193 crate::log(&format!("tray: {} forwarded (attempt {})", cmd, attempt));
194 return;
195 }
196 Err(ForwardError::Transient(e)) => {
197 if attempt == MAX_ATTEMPTS {
198 crate::log(&format!(
199 "tray: {} gave up after {} transient failures ({}) — host never became reachable",
200 cmd, MAX_ATTEMPTS, e
201 ));
202 return;
203 }
204 std::thread::sleep(DELAY);
205 }
206 Err(ForwardError::Fatal(e)) => {
207 crate::log(&format!("tray: {} failed fatally, not retrying: {}", cmd, e));
208 return;
209 }
210 }
211 }
212}
213
214/// Drain user actions from a backend and forward each to the host.
215///
216/// Called by every supervisor right after `start_if_enabled` returns a
217/// receiver. Detached on purpose: it blocks on `recv` and ends when the
218/// backend drops its sender (i.e. at process exit). Its only contact with the
219/// supervisor is the channel — no shared locks — so a wedged forward can stall
220/// nothing but itself.
221pub fn spawn_action_loop(
222 rx: mpsc::Receiver<TrayAction>,
223 data_dir: std::path::PathBuf,
224 dir_hash: String,
225) {
226 std::thread::Builder::new()
227 .name("agentmux-tray-actions".into())
228 .spawn(move || {
229 while let Ok(action) = rx.recv() {
230 match action.host_cmd() {
231 Some(cmd) => forward_tray_cmd(&data_dir, &dir_hash, cmd),
232 None => crate::start_at_login::toggle(),
233 }
234 }
235 crate::log("tray: action loop ended");
236 })
237 .ok();
238}
239
240/// A menu entry, resolved from the platform-neutral model below. Kept as data
241/// (rather than built directly against `muda`/`ksni` types) so the menu's
242/// composition is unit-testable without a display server.
243#[derive(Debug, Clone, PartialEq, Eq)]
244pub struct MenuItem {
245 pub label: String,
246 pub action: TrayAction,
247 /// `Some` for a check item.
248 pub check: Option<Check>,
249}
250
251/// A check item's state.
252#[derive(Debug, Clone, Copy, PartialEq, Eq)]
253pub struct Check {
254 pub checked: bool,
255 /// False until the value is known; clicking an unknown state would guess.
256 pub enabled: bool,
257}
258
259/// The tray menu, as data.
260///
261/// Deliberately tiny. The spec's §2 rejects a native OS menu as the *panel*
262/// ("too limited for agent chat" — that is Workstream 3, a real CEF window),
263/// so this menu is only a launcher: open a window, start at login, or quit.
264/// Anything richer belongs in the panel, not here.
265///
266/// `running` drives the first item's label so the icon doubles as an honest
267/// status readout, which Workstream 4 requires of it ("the tray icon itself
268/// must be a reliable 'is it actually running' indicator").
269///
270/// `start_at_login` is `app:startatlogin` as srv last reported it (`None`
271/// until known), shown as a check item. The Settings toggle writes the same
272/// setting, so the two always agree (`SPEC_START_WITH_OS_2026_09_25.md` §3.9).
273pub fn menu_model(running: bool, start_at_login: Option<bool>) -> Vec<MenuItem> {
274 vec![
275 MenuItem {
276 // "New Window" rather than "Open AgentMux": the icon's presence
277 // already says AgentMux is up, so "Open" read as a no-op for a
278 // state the user can see is true. When the service is NOT
279 // reachable the wording still has to change — "New Window" would
280 // promise something that cannot happen — hence "Start AgentMux".
281 label: if running {
282 "New Window".to_string()
283 } else {
284 "Start AgentMux".to_string()
285 },
286 action: TrayAction::OpenWindow,
287 check: None,
288 },
289 MenuItem {
290 label: "Start at login".to_string(),
291 action: TrayAction::ToggleStartAtLogin,
292 check: Some(Check {
293 checked: start_at_login.unwrap_or(false),
294 enabled: start_at_login.is_some(),
295 }),
296 },
297 MenuItem {
298 label: "Quit AgentMux".to_string(),
299 action: TrayAction::Quit,
300 check: None,
301 },
302 ]
303}
304
305/// Tooltip shown on hover: just the name and version. The running state is
306/// reported by the menu's first item ("New Window" / "Start AgentMux").
307pub fn tooltip() -> String {
308 format!("AgentMux v{}", env!("CARGO_PKG_VERSION"))
309}
310
311/// Should the tray be started at all?
312///
313/// When the tray is on (`app:showtray`, on by default), or whenever
314/// background-service mode is on: a resident process must always show an
315/// icon, so background mode forces it. Without background mode the icon
316/// simply lives as long as the app does: closing the last window quits, and
317/// the icon goes with the process.
318pub fn should_enable(tray: bool, background_service: bool) -> bool {
319 tray || background_service
320}
321
322/// Read the tray switch from the environment (`background_config` sets it
323/// from `app:showtray`). Presence-based, matching `AGENTMUX_DEV` and
324/// `AGENTMUX_BACKGROUND_SERVICE`.
325pub fn tray_opt_in_from_env() -> bool {
326 std::env::var("AGENTMUX_TRAY").is_ok()
327}
328
329/// Background-service mode, as the host reads it. The launcher needs its own
330/// read because it decides whether to show the icon before/independently of
331/// the host reporting in.
332pub fn background_service_from_env() -> bool {
333 std::env::var("AGENTMUX_BACKGROUND_SERVICE").is_ok()
334}
335
336/// Start the tray if enabled, returning the receiver the caller polls for
337/// user actions. `None` when the tray is disabled or unsupported on this
338/// platform — callers treat that as "no tray", never as an error.
339///
340/// Non-fatal by construction: a tray that fails to start must never take the
341/// app down with it. The launcher supervises `srv` and `host`; a cosmetic
342/// icon is the least important thing it owns.
343pub fn start_if_enabled(
344 _data_dir: std::path::PathBuf,
345 _dir_hash: String,
346) -> Option<mpsc::Receiver<TrayAction>> {
347 if !should_enable(tray_opt_in_from_env(), background_service_from_env()) {
348 return None;
349 }
350 #[cfg(target_os = "windows")]
351 let started = windows::spawn(_data_dir, _dir_hash).map(|rx| ("windows", rx));
352 #[cfg(target_os = "macos")]
353 let started = macos::spawn(_data_dir, _dir_hash).map(|rx| ("macos", rx));
354 #[cfg(target_os = "linux")]
355 let started = linux::spawn(_data_dir, _dir_hash).map(|rx| ("linux", rx));
356 #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
357 let started: Result<(&str, mpsc::Receiver<TrayAction>), String> =
358 Err("no backend on this platform yet".to_string());
359
360 match started {
361 Ok((platform, rx)) => {
362 crate::log(&format!("tray: started ({})", platform));
363 Some(rx)
364 }
365 Err(e) => {
366 TRAY_UNAVAILABLE.store(true, Ordering::SeqCst);
367 crate::log(&format!(
368 "tray: failed to start, continuing without it and without background mode: {}",
369 e
370 ));
371 None
372 }
373 }
374}
375
376#[cfg(test)]
377mod tray_model_tests {
378 use super::*;
379
380 #[test]
381 fn tray_shows_when_on_and_always_in_background_mode() {
382 assert!(should_enable(true, true));
383 // The default: an icon while the app runs, quit on last-window-close.
384 assert!(should_enable(true, false));
385 // A resident process must never be without an icon.
386 assert!(should_enable(false, true));
387 // `app:showtray: false` without background mode: no icon.
388 assert!(!should_enable(false, false));
389 }
390
391 #[test]
392 fn menu_offers_new_window_start_at_login_and_quit_in_that_order() {
393 // The panel entry was removed: `open_panel` still exists as a host IPC
394 // command (issue #2977 WS3, PR #3002), it is simply not reachable from
395 // the tray for now. Start at login is the one setting here, because it
396 // is about the tray's own process (SPEC_START_WITH_OS §3.9).
397 let m = menu_model(true, Some(false));
398 assert_eq!(m.len(), 3, "menu is deliberately minimal");
399 assert_eq!(m[0].action, TrayAction::OpenWindow);
400 assert_eq!(m[1].action, TrayAction::ToggleStartAtLogin);
401 assert_eq!(m[2].action, TrayAction::Quit);
402 }
403
404 #[test]
405 fn start_at_login_is_a_check_item_that_mirrors_the_setting() {
406 let item = |v| menu_model(true, v).into_iter().find(|i| i.action == TrayAction::ToggleStartAtLogin).unwrap();
407 assert_eq!(item(Some(true)).check, Some(Check { checked: true, enabled: true }));
408 assert_eq!(item(Some(false)).check, Some(Check { checked: false, enabled: true }));
409 // Not reported by srv yet: shown unchecked and disabled, never guessed.
410 assert_eq!(item(None).check, Some(Check { checked: false, enabled: false }));
411 assert!(menu_model(true, None).iter().filter(|i| i.action != TrayAction::ToggleStartAtLogin).all(|i| i.check.is_none()));
412 }
413
414 #[test]
415 fn menu_reports_running_state_honestly() {
416 // WS4: the icon must be a reliable "is it actually running"
417 // indicator, so the first menu item changes with the state.
418 assert_eq!(menu_model(true, Some(false))[0].label, "New Window");
419 assert_eq!(menu_model(false, Some(false))[0].label, "Start AgentMux");
420 }
421
422 #[test]
423 fn tooltip_is_just_the_name_and_version() {
424 assert_eq!(tooltip(), format!("AgentMux v{}", env!("CARGO_PKG_VERSION")));
425 }
426
427 #[test]
428 fn actions_map_to_the_existing_host_commands() {
429 // Both are pre-existing host IPC verbs: `open_new_window` is the
430 // second-launch / reopen path, `quit_app` the graceful drain. The
431 // tray must not grow a third way to do either.
432 assert_eq!(TrayAction::OpenWindow.host_cmd(), Some("open_new_window"));
433 assert_eq!(TrayAction::Quit.host_cmd(), Some("quit_app"));
434 assert_eq!(TrayAction::ToggleStartAtLogin.host_cmd(), None, "handled in the launcher");
435 }
436
437 #[test]
438 fn quit_is_always_offered_regardless_of_running_state() {
439 // A user must always be able to get rid of a background process from
440 // the icon that represents it — including when it is wedged enough
441 // that `running` reads false.
442 for running in [true, false] {
443 assert!(
444 menu_model(running, None).iter().any(|i| i.action == TrayAction::Quit),
445 "quit missing when running={}",
446 running
447 );
448 }
449 }
450}