1use std::sync::{
17 atomic::{AtomicBool, Ordering},
18 Arc,
19};
20
21use eframe::egui;
22
23use super::tray::{self, TrayVariant};
24
25pub type SetPaused = Arc<dyn Fn(bool) + Send + Sync>;
28
29const TRACE_TARGET: &str = "studio_worker::ui::tray";
32
33#[cfg(any(not(target_os = "linux"), test))]
40fn log_icon_build_failure(op: &'static str, err: &str) {
41 tracing::warn!(
42 target: TRACE_TARGET,
43 op,
44 error = %err,
45 "failed to build tray icon image"
46 );
47}
48
49pub struct TrayHandle {
53 inner: Inner,
54}
55
56impl TrayHandle {
57 pub fn set_variant(&mut self, variant: TrayVariant) {
60 self.inner.set_variant(variant);
61 }
62}
63
64#[cfg(target_os = "linux")]
69struct Inner {
70 tx: tokio::sync::mpsc::UnboundedSender<TrayVariant>,
71 warned: bool,
72}
73
74#[cfg(target_os = "linux")]
75impl Inner {
76 fn set_variant(&mut self, variant: TrayVariant) {
77 if self.tx.send(variant).is_err() && !self.warned {
82 self.warned = true;
83 tracing::warn!(
84 target: TRACE_TARGET,
85 op = "set_variant",
86 "linux tray service is not running; status icon will not update"
87 );
88 }
89 }
90}
91
92#[cfg(target_os = "linux")]
96struct KsniTray {
97 variant: TrayVariant,
98 paused: Arc<AtomicBool>,
99 set_paused: SetPaused,
100 quit: Arc<AtomicBool>,
101 ctx: egui::Context,
102}
103
104#[cfg(target_os = "linux")]
105impl KsniTray {
106 fn show_window(&self) {
107 tracing::info!(target: TRACE_TARGET, "open window requested from tray");
108 self.ctx
109 .send_viewport_cmd(egui::ViewportCommand::Visible(true));
110 self.ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
111 self.ctx.request_repaint();
112 }
113
114 fn toggle_pause(&self) {
115 let paused = !self.paused.load(Ordering::SeqCst);
116 tracing::info!(
117 target: TRACE_TARGET,
118 paused,
119 "pause toggled from tray menu"
120 );
121 (self.set_paused)(paused);
122 self.ctx.request_repaint();
123 }
124
125 fn request_quit(&self) {
126 tracing::info!(
127 target: TRACE_TARGET,
128 "quit requested from tray menu; stopping the daemon"
129 );
130 self.quit.store(true, Ordering::SeqCst);
131 self.ctx.request_repaint();
132 }
133}
134
135#[cfg(target_os = "linux")]
136impl ksni::Tray for KsniTray {
137 fn id(&self) -> String {
138 "studio-worker".into()
139 }
140
141 fn title(&self) -> String {
142 "studio-worker".into()
143 }
144
145 fn icon_pixmap(&self) -> Vec<ksni::Icon> {
146 vec![ksni::Icon {
147 width: 16,
148 height: 16,
149 data: tray::rgba_to_argb32(&self.variant.rgba_16()),
150 }]
151 }
152
153 fn tool_tip(&self) -> ksni::ToolTip {
154 ksni::ToolTip {
155 title: self.variant.tooltip().to_string(),
156 ..Default::default()
157 }
158 }
159
160 fn activate(&mut self, _x: i32, _y: i32) {
161 self.show_window();
162 }
163
164 fn menu(&self) -> Vec<ksni::MenuItem<Self>> {
165 use ksni::menu::StandardItem;
166 let labels = tray::menu_labels(!self.paused.load(Ordering::SeqCst));
168 vec![
169 StandardItem {
170 label: labels.open_window.to_string(),
171 activate: Box::new(|t: &mut Self| t.show_window()),
172 ..Default::default()
173 }
174 .into(),
175 StandardItem {
176 label: labels.toggle_auto.clone(),
177 activate: Box::new(|t: &mut Self| t.toggle_pause()),
178 ..Default::default()
179 }
180 .into(),
181 ksni::MenuItem::Separator,
182 StandardItem {
183 label: labels.quit.to_string(),
184 activate: Box::new(|t: &mut Self| t.request_quit()),
185 ..Default::default()
186 }
187 .into(),
188 ]
189 }
190}
191
192#[cfg(target_os = "linux")]
199pub fn install(
200 ctx: egui::Context,
201 paused: Arc<AtomicBool>,
202 set_paused: SetPaused,
203 quit: Arc<AtomicBool>,
204 tokio: tokio::runtime::Handle,
205 _initial_paused: bool,
206) -> Option<TrayHandle> {
207 use ksni::TrayMethods;
208 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<TrayVariant>();
209 tokio.spawn(async move {
210 let tray = KsniTray {
211 variant: TrayVariant::Disconnected,
212 paused,
213 set_paused,
214 quit,
215 ctx,
216 };
217 let handle = match tray.spawn().await {
218 Ok(h) => {
219 tracing::info!(target: TRACE_TARGET, "linux tray (ksni) started");
220 h
221 }
222 Err(e) => {
223 tracing::warn!(
224 target: TRACE_TARGET,
225 error = %e,
226 "linux tray (ksni) failed to start; running without a tray"
227 );
228 return;
229 }
230 };
231 while let Some(variant) = rx.recv().await {
232 handle
233 .update(move |t: &mut KsniTray| t.variant = variant)
234 .await;
235 }
236 handle.shutdown().await;
238 });
239 Some(TrayHandle {
240 inner: Inner { tx, warned: false },
241 })
242}
243
244#[cfg(not(target_os = "linux"))]
249struct Inner {
250 icon: Option<tray_icon::TrayIcon>,
251}
252
253#[cfg(not(target_os = "linux"))]
254impl Inner {
255 fn set_variant(&mut self, variant: TrayVariant) {
256 let Some(icon) = self.icon.as_ref() else {
257 return;
258 };
259 match tray_icon::Icon::from_rgba(variant.rgba_16(), 16, 16) {
260 Ok(new_icon) => {
261 if let Err(e) = icon.set_icon(Some(new_icon)) {
262 tracing::warn!(
263 target: TRACE_TARGET,
264 op = "set_variant",
265 error = %e,
266 "failed to update tray icon"
267 );
268 }
269 }
270 Err(e) => log_icon_build_failure("set_variant", &e.to_string()),
271 }
272 if let Err(e) = icon.set_tooltip(Some(variant.tooltip())) {
273 tracing::warn!(
274 target: TRACE_TARGET,
275 op = "set_variant",
276 error = %e,
277 "failed to update tray tooltip"
278 );
279 }
280 }
281}
282
283#[cfg(not(target_os = "linux"))]
288pub fn install(
289 ctx: egui::Context,
290 paused: Arc<AtomicBool>,
291 set_paused: SetPaused,
292 quit: Arc<AtomicBool>,
293 _tokio: tokio::runtime::Handle,
294 initial_paused: bool,
295) -> Option<TrayHandle> {
296 use tray_icon::menu::{Menu, MenuEvent, MenuId, MenuItem};
297 use tray_icon::{Icon, TrayIconBuilder};
298
299 let open_id = MenuId::new(tray::menu_ids::OPEN_WINDOW);
300 let toggle_id = MenuId::new(tray::menu_ids::TOGGLE_AUTO);
301 let quit_id = MenuId::new(tray::menu_ids::QUIT);
302
303 let labels = tray::menu_labels(!initial_paused);
305 let menu = Menu::new();
306 let _ = menu.append(&MenuItem::with_id(
307 open_id.clone(),
308 labels.open_window,
309 true,
310 None,
311 ));
312 let _ = menu.append(&MenuItem::with_id(
313 toggle_id.clone(),
314 &labels.toggle_auto,
315 true,
316 None,
317 ));
318 let _ = menu.append(&MenuItem::with_id(quit_id.clone(), labels.quit, true, None));
319
320 let variant = TrayVariant::Disconnected;
321 let icon = match Icon::from_rgba(variant.rgba_16(), 16, 16) {
322 Ok(i) => Some(i),
323 Err(e) => {
324 log_icon_build_failure("install", &e.to_string());
325 None
326 }
327 };
328 let mut builder = TrayIconBuilder::new()
329 .with_tooltip(variant.tooltip())
330 .with_menu(Box::new(menu));
331 if let Some(i) = icon {
332 builder = builder.with_icon(i);
333 }
334 let tray_icon = match builder.build() {
335 Ok(t) => Some(t),
336 Err(e) => {
337 tracing::warn!(
338 target: TRACE_TARGET,
339 error = %e,
340 "tray build failed; running without a tray"
341 );
342 None
343 }
344 };
345
346 std::thread::spawn(move || {
348 let rx = MenuEvent::receiver();
349 while let Ok(event) = rx.recv() {
350 if event.id == open_id {
351 tracing::info!(target: TRACE_TARGET, "open window requested from tray menu");
352 ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true));
353 ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
354 } else if event.id == toggle_id {
355 let now_paused = !paused.load(Ordering::SeqCst);
356 tracing::info!(
357 target: TRACE_TARGET,
358 paused = now_paused,
359 "pause toggled from tray menu"
360 );
361 set_paused(now_paused);
362 } else if event.id == quit_id {
363 tracing::info!(
364 target: TRACE_TARGET,
365 "quit requested from tray menu; stopping the daemon"
366 );
367 quit.store(true, Ordering::SeqCst);
368 }
369 ctx.request_repaint();
370 }
371 });
372
373 Some(TrayHandle {
374 inner: Inner { icon: tray_icon },
375 })
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 #[test]
388 fn icon_build_failure_emits_structured_warn() {
389 let logs = crate::test_support::capture(|| {
390 log_icon_build_failure("install", "bad rgba length");
391 });
392 assert!(logs.contains("WARN"), "expected WARN level, got: {logs}");
393 assert!(
394 logs.contains("studio_worker::ui::tray"),
395 "expected tray target, got: {logs}"
396 );
397 assert!(logs.contains("op=\"install\""), "expected op field: {logs}");
398 assert!(
399 logs.contains("error=bad rgba length"),
400 "expected structured error field, got: {logs}"
401 );
402 assert!(
403 logs.contains("failed to build tray icon image"),
404 "expected build-failure message: {logs}"
405 );
406 }
407
408 #[test]
409 fn icon_build_failure_op_field_tracks_the_call_site() {
410 let logs = crate::test_support::capture(|| {
411 log_icon_build_failure("set_variant", "oops");
412 });
413 assert!(
414 logs.contains("op=\"set_variant\""),
415 "expected set_variant op field: {logs}"
416 );
417 }
418}