Skip to main content

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}