Skip to main content

agentmux_launcher/tray/
linux.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Linux tray backend — StatusNotifierItem over D-Bus via `ksni`
5//! (`SPEC_TRAY_OPTIONAL_BACKGROUND_SERVICE_2026_09_04.md` §2, §7.6;
6//! notification hub per `SPEC_OS_NOTIFICATIONS_SYSTEM_2026_09_24.md` §4.2).
7//!
8//! `ksni` rather than `tray-icon`'s GTK path: no second toolkit loop next to
9//! CEF's. The service itself runs on ksni's thread; we add one small thread
10//! to own its blocking handle (see `spawn` for why it can't live in Tokio).
11//!
12//! Host caveats (documented, not fixable in-app): GNOME needs the
13//! "AppIndicator and KStatusNotifierItem Support" extension (Ubuntu ships it
14//! on); some SNI hosts never deliver left-click `activate`, and the GNOME
15//! extension shows no tooltips — so everything important is in the MENU.
16
17use std::sync::mpsc;
18
19use ksni::blocking::TrayMethods;
20use ksni::menu::{CheckmarkItem, MenuItem, StandardItem, SubMenu};
21
22use super::notify_menu::{self, NotifyMenuEntry, NotifyTrayState};
23use super::TrayAction;
24
25struct AgentMuxTray {
26    tx: mpsc::Sender<TrayAction>,
27    running: bool,
28    nstate: NotifyTrayState,
29    /// `app:startatlogin`, refreshed with the notification state.
30    start_at_login: Option<bool>,
31}
32
33use agentmux_common::time::now_ms;
34
35/// SNI menu labels treat `_` as a mnemonic marker (the `&` of muda) —
36/// agent names are user-chosen, so double it. `notify_menu::menu_label`
37/// already escaped `&` for muda; SNI shows `&&` literally, so undo that.
38fn sni_label(s: &str) -> String {
39    s.replace("&&", "&").replace('_', "__")
40}
41
42fn notify_items(entries: Vec<NotifyMenuEntry>) -> Vec<MenuItem<AgentMuxTray>> {
43    entries
44        .into_iter()
45        .map(|e| match e {
46            NotifyMenuEntry::Item { label, action } => StandardItem {
47                label: sni_label(&label),
48                activate: Box::new(move |_: &mut AgentMuxTray| crate::notify::tray_request(action.clone())),
49                ..Default::default()
50            }
51            .into(),
52            NotifyMenuEntry::Submenu { label, items } => SubMenu {
53                label: sni_label(&label),
54                submenu: notify_items(items),
55                ..Default::default()
56            }
57            .into(),
58            NotifyMenuEntry::Separator => MenuItem::Separator,
59        })
60        .collect()
61}
62
63impl ksni::Tray for AgentMuxTray {
64    fn id(&self) -> String {
65        "agentmux".into()
66    }
67
68    fn title(&self) -> String {
69        "AgentMux".into()
70    }
71
72    fn icon_name(&self) -> String {
73        // Installed by the .desktop / AppImage integration; the pixmap below
74        // is the fallback for hosts that can't resolve it.
75        "agentmux".into()
76    }
77
78    fn icon_pixmap(&self) -> Vec<ksni::Icon> {
79        brand_pixmap().into_iter().collect()
80    }
81
82    fn status(&self) -> ksni::Status {
83        if self.nstate.attention.is_empty() {
84            ksni::Status::Active
85        } else {
86            ksni::Status::NeedsAttention
87        }
88    }
89
90    fn tool_tip(&self) -> ksni::ToolTip {
91        ksni::ToolTip {
92            title: super::tooltip(),
93            description: notify_menu::tooltip_suffix(&self.nstate, now_ms()).unwrap_or_default(),
94            ..Default::default()
95        }
96    }
97
98    /// Left-click: the oldest thing that needs you, else a new window.
99    fn activate(&mut self, _x: i32, _y: i32) {
100        match self.nstate.attention.first() {
101            Some(first) => crate::notify::tray_request(notify_menu::NotifyMenuAction::Open(first.id.clone())),
102            None => {
103                let _ = self.tx.send(TrayAction::OpenWindow);
104            }
105        }
106    }
107
108    fn menu(&self) -> Vec<MenuItem<Self>> {
109        let mut items = notify_items(notify_menu::menu(&self.nstate, now_ms()));
110        for entry in super::menu_model(self.running, self.start_at_login) {
111            let action = entry.action;
112            let activate = Box::new(move |t: &mut AgentMuxTray| {
113                let _ = t.tx.send(action);
114            });
115            items.push(match entry.check {
116                Some(check) => CheckmarkItem {
117                    label: sni_label(&entry.label),
118                    enabled: check.enabled,
119                    checked: check.checked,
120                    activate,
121                    ..Default::default()
122                }
123                .into(),
124                None => StandardItem { label: sni_label(&entry.label), activate, ..Default::default() }.into(),
125            });
126        }
127        items
128    }
129}
130
131/// The brand mark as an SNI pixmap (ARGB32, network byte order).
132fn brand_pixmap() -> Option<ksni::Icon> {
133    const PNG: &[u8] = include_bytes!("../../../../assets/favicon-71x71.png");
134    let mut decoder = png::Decoder::new(std::io::Cursor::new(PNG));
135    decoder.set_transformations(png::Transformations::normalize_to_color8() | png::Transformations::ALPHA);
136    let mut reader = decoder.read_info().ok()?;
137    let mut buf = vec![0; reader.output_buffer_size()?];
138    let info = reader.next_frame(&mut buf).ok()?;
139    if info.color_type != png::ColorType::Rgba {
140        return None;
141    }
142    let data = rgba_to_argb(&buf[..(info.width * info.height * 4) as usize]);
143    Some(ksni::Icon { width: info.width as i32, height: info.height as i32, data })
144}
145
146/// RGBA → ARGB (big-endian), as the SNI `IconPixmap` property wants.
147pub fn rgba_to_argb(rgba: &[u8]) -> Vec<u8> {
148    let mut out = Vec::with_capacity(rgba.len());
149    for px in rgba.chunks_exact(4) {
150        out.extend_from_slice(&[px[3], px[0], px[1], px[2]]);
151    }
152    out
153}
154
155/// Updates the ksni thread applies to the live tray.
156enum Update {
157    Notify,
158    Running(bool),
159}
160
161/// ksni's `blocking` API drives its own current-thread Tokio runtime
162/// (`RUNTIME.block_on`), which panics if entered from inside another runtime —
163/// and both our callers are (`run_unix` for spawn, the notify task for
164/// updates). So the tray lives entirely on its own std thread; everyone else
165/// only sends it `Update`s.
166pub fn spawn(data_dir: std::path::PathBuf, dir_hash: String) -> Result<mpsc::Receiver<TrayAction>, String> {
167    let (tx, rx) = mpsc::channel();
168    let (utx, urx) = mpsc::channel::<Update>();
169    let (ready_tx, ready_rx) = mpsc::channel::<Result<(), String>>();
170    let running = super::service_reachable(&data_dir, &dir_hash);
171    let login_start = crate::autostart::login_start();
172    let timeout = super::ready_timeout(login_start);
173
174    std::thread::Builder::new()
175        .name("agentmux-tray".into())
176        .spawn(move || {
177            // A login start retries while the StatusNotifier watcher is not up
178            // yet (SPEC_START_WITH_OS §3.4); any other start fails fast.
179            let deadline = login_start.then(|| std::time::Instant::now() + super::LOGIN_TRAY_WAIT);
180            let mut attempts = 0u32;
181            let handle = loop {
182                attempts += 1;
183                let tray = AgentMuxTray {
184                    tx: tx.clone(),
185                    running,
186                    nstate: notify_menu::get(),
187                    start_at_login: crate::start_at_login::current(),
188                };
189                match tray.spawn() {
190                    Ok(h) => {
191                        if attempts > 1 {
192                            crate::log(&format!("tray: StatusNotifier host appeared (attempt {attempts})"));
193                        }
194                        break h;
195                    }
196                    Err(e) if deadline.is_some_and(|d| std::time::Instant::now() < d) => {
197                        if attempts == 1 {
198                            crate::log(&format!("tray: no StatusNotifier host yet at login ({e}); waiting"));
199                        }
200                        std::thread::sleep(std::time::Duration::from_secs(1));
201                    }
202                    Err(e) => {
203                        let _ = ready_tx.send(Err(format!("ksni spawn: {e}")));
204                        return;
205                    }
206                }
207            };
208            let _ = ready_tx.send(Ok(()));
209            while let Ok(u) = urx.recv() {
210                let applied = match u {
211                    Update::Notify => handle.update(|t: &mut AgentMuxTray| {
212                        t.nstate = notify_menu::get();
213                        t.start_at_login = crate::start_at_login::current();
214                    }),
215                    Update::Running(r) => handle.update(|t: &mut AgentMuxTray| t.running = r),
216                };
217                if applied.is_none() {
218                    return; // tray service gone
219                }
220            }
221        })
222        .map_err(|e| e.to_string())?;
223    // Bounded, like the Windows toast backend: a missing/wedged session bus
224    // must not stall the supervisor.
225    match ready_rx.recv_timeout(timeout) {
226        Ok(Ok(())) => {}
227        Ok(Err(e)) => return Err(e),
228        Err(_) => return Err(format!("tray did not start within {}s", timeout.as_secs())),
229    }
230
231    let wake_tx = utx.clone();
232    notify_menu::set_wake(Box::new(move || {
233        let _ = wake_tx.send(Update::Notify);
234    }));
235
236    // Reachability poller — same contract as the Windows backend.
237    std::thread::Builder::new()
238        .name("agentmux-tray-status".into())
239        .spawn(move || {
240            let mut last = running;
241            loop {
242                std::thread::sleep(super::STATUS_POLL);
243                let now = super::service_reachable(&data_dir, &dir_hash);
244                if now != last {
245                    last = now;
246                    if utx.send(Update::Running(now)).is_err() {
247                        return;
248                    }
249                }
250            }
251        })
252        .map_err(|e| e.to_string())?;
253    Ok(rx)
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn argb_conversion() {
262        assert_eq!(rgba_to_argb(&[1, 2, 3, 4, 5, 6, 7, 8]), vec![4, 1, 2, 3, 8, 5, 6, 7]);
263    }
264
265    #[test]
266    fn sni_labels_escape_underscores_and_unescape_ampersands() {
267        assert_eq!(sni_label("my_agent needs you"), "my__agent needs you");
268        assert_eq!(sni_label("R&&D stopped"), "R&D stopped");
269    }
270
271    #[test]
272    fn brand_pixmap_decodes() {
273        let icon = brand_pixmap().expect("embedded PNG decodes");
274        assert_eq!(icon.data.len(), (icon.width * icon.height * 4) as usize);
275    }
276}