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