Skip to main content

agentmux_launcher/
background_config.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The tray icon and background-service mode, resolved by the launcher at
5//! startup — `docs/specs/SPEC_OS_NOTIFICATIONS_SYSTEM_2026_09_24.md` §4.1.
6//!
7//! ## Why this module exists
8//!
9//! `tray::start_if_enabled` reads `AGENTMUX_TRAY` + `AGENTMUX_BACKGROUND_SERVICE`
10//! from the **launcher's own** environment. Before this module, nothing ever
11//! put them there:
12//!
13//! - `--background` (what every auto-start artifact passes) was only mapped to
14//!   env on the **host** `Command` (`host_spawn.rs`), so an auto-started
15//!   instance got background semantics in the host but no tray icon — the
16//!   "invisible background service" WS4 exists to prevent.
17//! - There was no setting at all, so the tray was reachable only by exporting
18//!   two env vars before launching.
19//!
20//! Resolving once here, before any thread or child exists, and writing the
21//! result into the launcher's own env fixes both: the tray reads it, the macOS
22//! headless-pump branch in `main` reads it, and every child (host) inherits it.
23//!
24//! ## Two switches, two defaults
25//!
26//! They are separate questions and must not be conflated (#3785 did, and
27//! turned background mode on for everyone; see
28//! `docs/reports/REPORT_TRAY_BACKGROUND_DEFAULT_ON_2026_09_28.md`):
29//!
30//! - **Tray icon** (`"app:showtray"`) — **on by default**. Shown while
31//!   AgentMux runs. Only an explicit `false` hides it.
32//! - **Background mode** (`"app:runinbackground"`) — keep running after the
33//!   last window closes. **Opt-in**: only an explicit `true` turns it on, so a
34//!   fresh install quits on last-window-close.
35//!
36//! Background mode always implies the tray: a resident process must show an
37//! icon, so `app:showtray: false` is overridden while background mode is on.
38//! And if the tray cannot start (e.g. Linux without a StatusNotifier host),
39//! the host is spawned without background mode (`tray::unavailable`), so
40//! closing the last window quits.
41//!
42//! ## Precedence
43//!
44//! Background: `AGENTMUX_BACKGROUND_SERVICE` already set (developer override)
45//! → on; otherwise `--background` → on; otherwise the setting. Tray:
46//! background on, or `AGENTMUX_TRAY` already set → on; otherwise the setting.
47//! A settings file that cannot be read or parsed leaves both at their default
48//! (the user's real choice is unknown): background off, tray on.
49
50use std::path::{Path, PathBuf};
51
52/// Settings keys. Flat `namespace:key`, like every other `settings.json` entry.
53pub const BACKGROUND_KEY: &str = "app:runinbackground";
54pub const TRAY_KEY: &str = "app:showtray";
55
56const ENV_BACKGROUND: &str = "AGENTMUX_BACKGROUND_SERVICE";
57const ENV_TRAY: &str = "AGENTMUX_TRAY";
58
59/// The two settings as stored, with their defaults applied.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub struct Settings {
62    pub run_in_background: bool,
63    pub show_tray: bool,
64}
65
66impl Default for Settings {
67    fn default() -> Self {
68        Settings {
69            run_in_background: false,
70            show_tray: true,
71        }
72    }
73}
74
75/// What the launcher exports.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub struct Resolved {
78    pub background: bool,
79    pub tray: bool,
80}
81
82/// Pure decision, so every input combination is unit-testable.
83pub fn resolve(env_background: bool, env_tray: bool, background_flag: bool, settings: Settings) -> Resolved {
84    let background = env_background || background_flag || settings.run_in_background;
85    let tray = background || env_tray || settings.show_tray;
86    Resolved { background, tray }
87}
88
89/// Read both settings from a settings.json file. Each key keeps its default
90/// unless it holds a bool; a missing or unreadable file yields the defaults.
91pub fn read_settings(settings_path: &Path) -> Settings {
92    let mut s = Settings::default();
93    let Ok(text) = std::fs::read_to_string(settings_path) else {
94        return s;
95    };
96    let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) else {
97        return s;
98    };
99    if let Some(v) = json.get(BACKGROUND_KEY).and_then(|v| v.as_bool()) {
100        s.run_in_background = v;
101    }
102    if let Some(v) = json.get(TRAY_KEY).and_then(|v| v.as_bool()) {
103        s.show_tray = v;
104    }
105    s
106}
107
108/// Resolve both switches and export the ones that are on into the launcher's
109/// own environment.
110///
111/// MUST be called from `main` before any thread is spawned (`set_var` is not
112/// thread-safe on every platform) and before the tray/supervisor start.
113pub fn apply(args: &[String]) -> Resolved {
114    let env_background = std::env::var_os(ENV_BACKGROUND).is_some();
115    let env_tray = std::env::var_os(ENV_TRAY).is_some();
116    let flag = crate::autostart::background_requested(args);
117    let settings = std::panic::catch_unwind(|| {
118        config_dir()
119            .map(|d| read_settings(&d.join("settings.json")))
120            .unwrap_or_default()
121    })
122    .unwrap_or_default();
123
124    let r = resolve(env_background, env_tray, flag, settings);
125    if r.background {
126        std::env::set_var(ENV_BACKGROUND, "1");
127    }
128    if r.tray {
129        std::env::set_var(ENV_TRAY, "1");
130    }
131    r
132}
133
134/// Best-effort config dir (holds settings.json), resolved the same way
135/// `splash_config` does — early and failure-tolerant.
136fn config_dir() -> Option<PathBuf> {
137    let exe = std::env::current_exe().ok()?;
138    let exe_dir = exe.parent()?;
139    let version = env!("CARGO_PKG_VERSION");
140    crate::data_dir::resolve_paths(exe_dir, version)
141        .ok()
142        .map(|p| p.config_dir)
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    fn write_tmp(tag: &str, content: &str) -> PathBuf {
150        let p = std::env::temp_dir().join(format!(
151            "agentmux-bgcfg-{tag}-{}.json",
152            std::process::id()
153        ));
154        std::fs::write(&p, content).unwrap();
155        p
156    }
157
158    fn read(tag: &str, content: &str) -> Settings {
159        let p = write_tmp(tag, content);
160        let s = read_settings(&p);
161        let _ = std::fs::remove_file(&p);
162        s
163    }
164
165    const DEFAULTS: Settings = Settings {
166        run_in_background: false,
167        show_tray: true,
168    };
169
170    /// A fresh install (no settings.json) and one that never touched either
171    /// key: the tray is shown, closing the last window quits.
172    #[test]
173    fn defaults_are_tray_on_background_off() {
174        assert_eq!(read_settings(Path::new("/no/such/agentmux-settings.json")), DEFAULTS);
175        assert_eq!(read("absent", r#"{"term:fontsize": 14}"#), DEFAULTS);
176        assert_eq!(read("empty-obj", "{}"), DEFAULTS);
177        assert_eq!(
178            resolve(false, false, false, DEFAULTS),
179            Resolved { background: false, tray: true }
180        );
181    }
182
183    #[test]
184    fn each_key_is_read_independently() {
185        assert_eq!(
186            read("bg-on", r#"{"app:runinbackground": true}"#),
187            Settings { run_in_background: true, show_tray: true }
188        );
189        assert_eq!(
190            read("tray-off", r#"{"app:showtray": false}"#),
191            Settings { run_in_background: false, show_tray: false }
192        );
193    }
194
195    #[test]
196    fn a_bad_file_or_wrong_type_keeps_the_defaults() {
197        assert_eq!(read("bad", "not json {"), DEFAULTS);
198        // A string "true" is not a bool — must not enable a resident process.
199        assert_eq!(read("str", r#"{"app:runinbackground": "true", "app:showtray": "false"}"#), DEFAULTS);
200    }
201
202    #[test]
203    fn background_is_opt_in_from_any_single_source() {
204        assert!(resolve(true, false, false, DEFAULTS).background, "env override");
205        assert!(resolve(false, false, true, DEFAULTS).background, "--background");
206        let on = Settings { run_in_background: true, ..DEFAULTS };
207        assert!(resolve(false, false, false, on).background, "setting");
208        assert!(!resolve(false, true, false, DEFAULTS).background, "the tray alone never implies background");
209    }
210
211    /// A resident process must always show an icon.
212    #[test]
213    fn background_forces_the_tray_even_when_hidden_in_settings() {
214        let hidden = Settings { run_in_background: false, show_tray: false };
215        assert_eq!(resolve(false, false, false, hidden), Resolved { background: false, tray: false });
216        assert_eq!(resolve(false, false, true, hidden), Resolved { background: true, tray: true });
217        let both = Settings { run_in_background: true, show_tray: false };
218        assert_eq!(resolve(false, false, false, both), Resolved { background: true, tray: true });
219    }
220
221    /// The regression this module exists for: `--background` must make the
222    /// launcher's OWN tray gate pass, not just the host's env.
223    #[test]
224    fn background_flag_reaches_launcher_tray_gate() {
225        let args = vec!["agentmux".to_string(), crate::autostart::BACKGROUND_FLAG.to_string()];
226        // This is the only test in the crate that touches these two vars, so
227        // no cross-test lock is needed; restore them afterwards regardless.
228        let prev_bg = std::env::var_os(ENV_BACKGROUND);
229        let prev_tray = std::env::var_os(ENV_TRAY);
230        std::env::remove_var(ENV_BACKGROUND);
231        std::env::remove_var(ENV_TRAY);
232
233        assert!(apply(&args).background);
234        assert!(crate::tray::should_enable(
235            crate::tray::tray_opt_in_from_env(),
236            crate::tray::background_service_from_env()
237        ));
238
239        match prev_bg {
240            Some(v) => std::env::set_var(ENV_BACKGROUND, v),
241            None => std::env::remove_var(ENV_BACKGROUND),
242        }
243        match prev_tray {
244            Some(v) => std::env::set_var(ENV_TRAY, v),
245            None => std::env::remove_var(ENV_TRAY),
246        }
247    }
248}