Skip to main content

studio_worker/ui/
tray_host.rs

1//! Cross-platform system-tray host.
2//!
3//! The tray is best-effort on every OS: the window UI works without it.
4//!
5//! * **Linux** uses [`ksni`] — a pure-Rust StatusNotifierItem service
6//!   over zbus — so the build pulls in no GTK / cairo / appindicator and
7//!   `cargo install studio-worker` works on a bare box with no
8//!   `pkg-config` / `-dev` packages.
9//! * **macOS / Windows** use [`tray-icon`], which talks to the native
10//!   tray APIs and needs no extra system libraries.
11//!
12//! Both backends expose the same [`TrayHandle`] surface: build it once
13//! when the window opens, then call [`TrayHandle::set_variant`] whenever
14//! the worker's health (idle / busy / disconnected) changes.
15
16use std::sync::{
17    atomic::{AtomicBool, Ordering},
18    Arc,
19};
20
21use eframe::egui;
22
23use super::tray::{self, TrayVariant};
24
25/// Tracing target for tray lifecycle + click events.  Stable so
26/// operators can filter with `RUST_LOG=studio_worker::ui::tray=debug`.
27const TRACE_TARGET: &str = "studio_worker::ui::tray";
28
29/// Emit a structured breadcrumb when the native tray icon image fails to
30/// build from its RGBA buffer.  Shared by both build sites — `install`
31/// (startup) and `set_variant` (per health change) — so a failure is
32/// never swallowed and the breadcrumb is unit-testable on the Linux CI
33/// box even though the call sites are `#[cfg(not(target_os = "linux"))]`.
34/// `op` names the lifecycle phase that hit the failure.
35#[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
45/// Handle the [`App`](crate::ui::app::App) keeps for the lifetime of the
46/// window; dropping it tears the tray down.  `set_variant` pushes a new
47/// health colour + tooltip when the worker's state changes.
48pub struct TrayHandle {
49    inner: Inner,
50}
51
52impl TrayHandle {
53    /// Push a new health variant to the OS tray.  Best-effort: any
54    /// failure is logged once, never panics.
55    pub fn set_variant(&mut self, variant: TrayVariant) {
56        self.inner.set_variant(variant);
57    }
58}
59
60// ---------------------------------------------------------------------------
61// Linux backend — ksni (pure Rust, no GTK).
62// ---------------------------------------------------------------------------
63
64#[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        // The ksni service runs on the tokio runtime; forward the new
74        // variant to it.  A send error means the service never started
75        // (or already shut down) — warn once so the operator knows the
76        // status icon is stale rather than silently swallowing it.
77        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/// The ksni `Tray` model.  Holds the shared runtime flags so menu
89/// activations (and a left-click) drive the same actions the in-window
90/// controls do.
91#[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        // `menu_labels` flips Pause/Resume on `auto_enabled` (= not paused).
161        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/// Spawn the ksni tray on the tokio runtime and return a handle that
187/// forwards variant updates to it.  The service is set up
188/// asynchronously; variant updates sent before it is ready are buffered
189/// and applied once it starts.  Returns `Some` immediately (the channel
190/// always exists); the icon itself appears only if a StatusNotifier host
191/// is present (KDE, GNOME-with-AppIndicator, etc.).
192#[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        // All senders dropped (window closed) — tear the tray down.
229        handle.shutdown().await;
230    });
231    Some(TrayHandle {
232        inner: Inner { tx, warned: false },
233    })
234}
235
236// ---------------------------------------------------------------------------
237// macOS / Windows backend — tray-icon (native).
238// ---------------------------------------------------------------------------
239
240#[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/// Build the native tray icon + menu (on the current — main — thread,
276/// as tray-icon requires) and spawn a thread that routes menu clicks to
277/// the shared runtime flags.  Returns `None` only when the platform tray
278/// host rejects the icon; the window UI keeps working regardless.
279#[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    // `menu_labels` flips Pause/Resume on `auto_enabled` (= not paused).
295    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    // Route muda menu events to the shared flags on a background thread.
338    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    // The mac/win tray builds an icon image from a static RGBA buffer in
373    // two places (`install` at startup, `set_variant` on every health
374    // change).  Both must surface a build failure rather than swallow it,
375    // so the breadcrumb is exercised here on the Linux CI box even though
376    // the call sites themselves are `#[cfg(not(target_os = "linux"))]`.
377    #[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}