agentmux_launcher/tray/
linux.rs1use 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 start_at_login: Option<bool>,
31}
32
33use agentmux_common::time::now_ms;
34
35fn 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 "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 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
131fn 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
146pub 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
155enum Update {
157 Notify,
158 Running(bool),
159}
160
161pub 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 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; }
220 }
221 })
222 .map_err(|e| e.to_string())?;
223 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 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}